服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
1214 lines
50 KiB
Go
1214 lines
50 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"longheng.io/server/internal/modules/dnf/dnfenum"
|
|
"longheng.io/server/internal/modules/dnf/dungeoncmd"
|
|
dnfhellparty "longheng.io/server/internal/modules/dnf/hellparty"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
"longheng.io/server/internal/modules/dnf/worldmap"
|
|
)
|
|
|
|
// Friendly PVF APCs can follow the player into later rooms while retaining
|
|
// the object key announced by their original room.
|
|
func dungeonRuntimeContainsDeathOwnerObjectKey(runtime *runtimeDungeonState, objectKey uint32) bool {
|
|
if runtime == nil || runtime.Room == nil || objectKey == 0 {
|
|
return false
|
|
}
|
|
if runtime.Room.ContainsAnnouncedActorObjectKey(objectKey) {
|
|
return true
|
|
}
|
|
for _, visit := range runtime.Rooms {
|
|
if visit == nil || visit.Room == nil || visit.Room == runtime.Room {
|
|
continue
|
|
}
|
|
if visit.Room.ContainsAnnouncedNonHostileAICharacterObjectKey(objectKey) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isHostileAnnouncedKiller reports whether the killer object key names a
|
|
// hostile actor of the current room (an ordinary monster or an extended actor
|
|
// carrying a hostile reference). The current EXE reports summons, pets and
|
|
// creatures with dynamic keys the server never announced; those are not
|
|
// hostile and must not be mistaken for a forged killer.
|
|
func isHostileAnnouncedKiller(room *runtimeDungeonRoom, objectKey uint32) bool {
|
|
if room == nil || objectKey == 0 {
|
|
return false
|
|
}
|
|
room.mu.RLock()
|
|
defer room.mu.RUnlock()
|
|
if _, ok := room.byObjectKey[objectKey]; ok {
|
|
return true
|
|
}
|
|
if index, ok := room.extendedByObjectKey[objectKey]; ok && index >= 0 && index < len(room.extendedActors) {
|
|
if room.extendedActors[index].HostileReference != nil {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *Service) handleDungeonMonsterDeath(session *gameSession, body []byte) error {
|
|
return s.executeRuntimePartyDungeonMonsterDeath(session, append([]byte(nil), body...))
|
|
}
|
|
|
|
func (s *Service) handleDungeonMonsterDeathLocal(session *gameSession, body []byte) error {
|
|
request, err := dungeoncmd.DecodeDieMonsterRequest(body)
|
|
if err != nil {
|
|
s.logDungeonStallBlocked(session, dungeonStallKindMonsterDeath,
|
|
"game-dungeon-monster-death-blocked", 0,
|
|
"body_len", len(body),
|
|
"reason", "current_exe_op39_request_malformed",
|
|
"error", err)
|
|
return nil
|
|
}
|
|
if len(request.OpaqueTail) != 0 {
|
|
s.logDungeonStallBlocked(session, dungeonStallKindMonsterDeath,
|
|
"game-dungeon-monster-death-blocked", request.RuntimeObjectKey,
|
|
"object_key", request.RuntimeObjectKey,
|
|
"body_len", len(body),
|
|
"tail_len", len(request.OpaqueTail),
|
|
"reason", "current_exe_op39_request_tail_unproven")
|
|
return nil
|
|
}
|
|
if session == nil {
|
|
return nil
|
|
}
|
|
session.dungeon.mu.Lock()
|
|
defer session.dungeon.mu.Unlock()
|
|
runtime := session.dungeon.runtime
|
|
if runtime == nil || runtime.Session == nil || runtime.Room == nil {
|
|
s.logDungeonStallBlocked(session, dungeonStallKindMonsterDeath,
|
|
"game-dungeon-monster-death-blocked", request.RuntimeObjectKey,
|
|
"object_key", request.RuntimeObjectKey,
|
|
"reason", "dungeon_runtime_missing")
|
|
return nil
|
|
}
|
|
scene, hasScene := runtime.Session.Scene()
|
|
// The current EXE's altar controller uses the variable op39 writer for
|
|
// dynamic actors, but its combat-row count and owner vary with the native
|
|
// controller path. The decoded boundary, empty opaque tail (checked above),
|
|
// and an exact current-room altar sequence are the fail-closed authority.
|
|
if hasScene && request.Layout == dungeoncmd.DieMonsterRequestLayoutVariableCombat {
|
|
if sequence, altarOwned := currentDungeonAltarDynamicDeathSequenceLocked(
|
|
runtime,
|
|
scene,
|
|
request.RuntimeObjectKey,
|
|
); altarOwned {
|
|
return s.handleCurrentDungeonAltarDeathsLocked(
|
|
session,
|
|
runtime,
|
|
scene,
|
|
[]uint16{sequence},
|
|
"client_op39_dynamic_death",
|
|
true,
|
|
)
|
|
}
|
|
}
|
|
if runtime.HellParty != nil && runtime.HellParty.Runtime != nil && hasScene &&
|
|
scene.Coordinate == runtime.HellParty.Room.Coordinate && scene.Map.Map.ID == runtime.HellParty.Room.HellMap.ID {
|
|
actor, known := runtime.HellParty.Actors[request.RuntimeObjectKey]
|
|
if known {
|
|
if cached := runtime.HellParty.DeathResponses[request.RuntimeObjectKey]; len(cached) != 0 {
|
|
return s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketNotifyDieMonster), append([]byte(nil), cached...), 0)
|
|
}
|
|
playerObjectKey := currentSceneActorObjectKey(session.selectedCharacterID)
|
|
if request.OwnerObjectKey != playerObjectKey && request.OwnerObjectKey != ^uint16(0) &&
|
|
!dungeonRuntimeContainsDeathOwnerObjectKey(runtime, uint32(request.OwnerObjectKey)) &&
|
|
!runtimePartyDungeonContainsActor(runtime, request.OwnerObjectKey) {
|
|
return nil
|
|
}
|
|
if !runtime.Room.ContainsAnnouncedActorObjectKey(request.RuntimeObjectKey) {
|
|
return nil
|
|
}
|
|
if runtime.HellParty.Runtime.Phase() == dnfhellparty.PhaseWaitingStart {
|
|
if err := runtime.HellParty.Runtime.Start(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
result, err := runtime.HellParty.Runtime.RecordDeath(actor.Identity)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
target, matched, err := runtime.Room.RetireHellPartyActor(request.RuntimeObjectKey)
|
|
if err != nil || !matched {
|
|
return err
|
|
}
|
|
var drops []currentDungeonDeathDropWire
|
|
var selected []currentDungeonHellPartySelectedDrop
|
|
if result.GroupCompleted && !result.RewardSuppressed && result.RewardRollCount > 0 {
|
|
drops, selected, err = s.planCurrentDungeonHellPartyDrops(runtime, actor, playerObjectKey, result.RewardRollCount)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-dungeon-hell-party-drop-plan-failed",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"room", scene.Coordinate.String(),
|
|
"map_id", scene.Map.Map.ID,
|
|
"object_key", request.RuntimeObjectKey,
|
|
"actor_code", actor.Code,
|
|
"actor_level", actor.Level,
|
|
"reward_roll_count", result.RewardRollCount,
|
|
"error", err)
|
|
drops, selected = nil, nil
|
|
}
|
|
}
|
|
response, err := buildCurrentDungeonDeathNotificationBodyWithDrops(target.ObjectKey, target.ResponseKind, drops)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
runtime.HellParty.DeathResponses[request.RuntimeObjectKey] = append([]byte(nil), response...)
|
|
if err := runtime.cacheCurrentDungeonRoom(); err != nil {
|
|
return err
|
|
}
|
|
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketNotifyDieMonster), response, 0); err != nil {
|
|
return err
|
|
}
|
|
s.logGameEvent(session, "game-dungeon-hell-party-actor-death-accepted",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"room", scene.Coordinate.String(),
|
|
"map_id", scene.Map.Map.ID,
|
|
"object_key", request.RuntimeObjectKey,
|
|
"actor_code", actor.Code,
|
|
"group_completed", result.GroupCompleted,
|
|
"all_complete", result.AllComplete,
|
|
"reward_roll_count", result.RewardRollCount,
|
|
"reward_suppressed", result.RewardSuppressed,
|
|
"drop_count", len(drops),
|
|
"selected_drop_count", len(selected))
|
|
return nil
|
|
}
|
|
}
|
|
if runtime.tournament != nil && hasScene {
|
|
if request.RuntimeObjectKey == 0 || request.RuntimeObjectKey > uint32(^uint16(0)) {
|
|
s.logGameEvent(session, "game-tournament-actor-death-blocked",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"object_key", request.RuntimeObjectKey,
|
|
"reason", "tournament_actor_object_key_out_of_range")
|
|
return nil
|
|
}
|
|
actor, actorKnown := runtime.tournament.ActorForSequence(uint16(request.RuntimeObjectKey))
|
|
if actorKnown {
|
|
playerObjectKey := currentSceneActorObjectKey(session.selectedCharacterID)
|
|
if request.OwnerObjectKey != playerObjectKey {
|
|
s.logGameEvent(session, "game-tournament-actor-death-blocked",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"maze_index", runtime.MazeIndex,
|
|
"room", scene.Coordinate.String(),
|
|
"map_id", scene.Map.Map.ID,
|
|
"object_key", request.RuntimeObjectKey,
|
|
"related_actor_object_key", request.OwnerObjectKey,
|
|
"player_object_key", playerObjectKey,
|
|
"reason", "tournament_owner_must_be_current_player")
|
|
return nil
|
|
}
|
|
if !runtime.Room.ContainsAnnouncedActorObjectKey(request.RuntimeObjectKey) {
|
|
s.logGameEvent(session, "game-tournament-actor-death-blocked",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"object_key", request.RuntimeObjectKey,
|
|
"reason", "tournament_actor_not_announced")
|
|
return nil
|
|
}
|
|
eventID := fmt.Sprintf("tournament:%d:%d:%d:%d:%d:%d:%d",
|
|
session.selectedCharacterID, runtime.Dungeon.ID, runtime.MazeIndex,
|
|
scene.Coordinate.X, scene.Coordinate.Y, runtime.Seed, request.RuntimeObjectKey)
|
|
transition := runtime.tournament.ApplyActorDeath(eventID, uint16(request.RuntimeObjectKey))
|
|
if transition.Disposition == worldmap.TournamentDeathDuplicate {
|
|
s.logGameEvent(session, "game-tournament-actor-death-duplicate",
|
|
"dungeon_id", runtime.Dungeon.ID, "object_key", request.RuntimeObjectKey, "event_id", eventID)
|
|
return nil
|
|
}
|
|
if transition.Disposition == worldmap.TournamentDeathRejected {
|
|
s.logGameEvent(session, "game-tournament-actor-death-blocked",
|
|
"dungeon_id", runtime.Dungeon.ID, "object_key", request.RuntimeObjectKey,
|
|
"current_round", transition.CurrentRound, "reason", "tournament_runtime_rejected_death", "event_id", eventID)
|
|
return nil
|
|
}
|
|
responseKind := currentTournamentDeathResponseKind(actor)
|
|
target, retireErr := runtime.Room.RetireTournamentActor(request.RuntimeObjectKey, responseKind)
|
|
if retireErr != nil {
|
|
s.logGameEvent(session, "game-tournament-actor-death-blocked",
|
|
"dungeon_id", runtime.Dungeon.ID, "object_key", request.RuntimeObjectKey,
|
|
"reason", "tournament_room_actor_retirement_failed", "error", retireErr, "event_id", eventID)
|
|
return nil
|
|
}
|
|
responseBody, buildErr := buildCurrentDungeonDeathNotificationBody(target.ObjectKey, target.ResponseKind)
|
|
if buildErr != nil {
|
|
return buildErr
|
|
}
|
|
if sendErr := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketNotifyDieMonster), responseBody, 0); sendErr != nil {
|
|
return sendErr
|
|
}
|
|
if cacheErr := runtime.cacheCurrentDungeonRoom(); cacheErr != nil {
|
|
return fmt.Errorf("cache tournament actor death: %w", cacheErr)
|
|
}
|
|
if transition.Disposition == worldmap.TournamentCompleted {
|
|
if rewardErr := s.sendCurrentTournamentClearRewardLocked(session, runtime); rewardErr != nil {
|
|
return fmt.Errorf("send tournament clear reward: %w", rewardErr)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
if hasScene {
|
|
handled, handleErr := s.handleCurrentFruzStationNarkeObstacleDeathLocked(
|
|
session,
|
|
runtime,
|
|
scene,
|
|
request,
|
|
)
|
|
if handleErr != nil {
|
|
return handleErr
|
|
}
|
|
if handled {
|
|
return nil
|
|
}
|
|
}
|
|
ownerIsSentinel := request.OwnerObjectKey == ^uint16(0)
|
|
var scriptedTutorialMonster runtimeDungeonMonster
|
|
var scriptedTutorialEvidence dungeonTutorialScriptEvidence
|
|
scriptedTutorialDeath := false
|
|
ownerSentinelOrdinaryTarget := false
|
|
ownerSentinelHostileAITarget := false
|
|
ownerSentinelBlockingClearTarget := false
|
|
ownerSentinelBlockingClearSource := ""
|
|
ownerSentinelConsumedLayerBaseBossTarget := false
|
|
ownerSentinelConsumedLayerBaseBossSource := ""
|
|
if ownerIsSentinel {
|
|
if request.Layout != dungeoncmd.DieMonsterRequestLayoutVariableCombat {
|
|
s.logDungeonStallBlocked(session, dungeonStallKindMonsterDeath,
|
|
"game-dungeon-monster-death-blocked", request.RuntimeObjectKey,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"maze_index", runtime.MazeIndex,
|
|
"object_key", request.RuntimeObjectKey,
|
|
"related_actor_object_key", request.OwnerObjectKey,
|
|
"request_layout", request.Layout,
|
|
"combat_entry_count", request.CombatEntryCount,
|
|
"reason", "owner_sentinel_request_layout_unproven")
|
|
return nil
|
|
}
|
|
if hasScene {
|
|
scriptedTutorialMonster, scriptedTutorialEvidence, scriptedTutorialDeath = s.isPVFTutorialScriptedMonsterDeath(
|
|
runtime,
|
|
scene,
|
|
request.RuntimeObjectKey,
|
|
)
|
|
if !scriptedTutorialDeath {
|
|
_, ownerSentinelOrdinaryTarget = currentDungeonOwnerSentinelAnnouncedOrdinaryMonster(
|
|
runtime,
|
|
scene,
|
|
request.RuntimeObjectKey,
|
|
)
|
|
_, ownerSentinelBlockingClearSource, ownerSentinelBlockingClearTarget =
|
|
currentDungeonOwnerSentinelBlockingClearTarget(runtime, scene, request.RuntimeObjectKey)
|
|
if !ownerSentinelBlockingClearTarget {
|
|
_, ownerSentinelConsumedLayerBaseBossSource, ownerSentinelConsumedLayerBaseBossTarget =
|
|
currentDungeonOwnerSentinelConsumedLayerBaseBossTarget(runtime, scene, request.RuntimeObjectKey)
|
|
if !ownerSentinelConsumedLayerBaseBossTarget && !ownerSentinelOrdinaryTarget {
|
|
ownerSentinelHostileAITarget = currentDungeonOwnerSentinelAnnouncedHostileAI(
|
|
runtime,
|
|
scene,
|
|
request.RuntimeObjectKey,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
combatShapeProven := request.CombatEntryCount == 0 ||
|
|
(request.CombatEntryCount == 1 && (ownerSentinelOrdinaryTarget || ownerSentinelHostileAITarget))
|
|
if !combatShapeProven {
|
|
s.logDungeonStallBlocked(session, dungeonStallKindMonsterDeath,
|
|
"game-dungeon-monster-death-blocked", request.RuntimeObjectKey,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"maze_index", runtime.MazeIndex,
|
|
"object_key", request.RuntimeObjectKey,
|
|
"related_actor_object_key", request.OwnerObjectKey,
|
|
"request_layout", request.Layout,
|
|
"combat_entry_count", request.CombatEntryCount,
|
|
"owner_sentinel_ordinary_target", ownerSentinelOrdinaryTarget,
|
|
"owner_sentinel_hostile_ai_target", ownerSentinelHostileAITarget,
|
|
"reason", "owner_sentinel_combat_shape_unproven")
|
|
return nil
|
|
}
|
|
}
|
|
if ownerIsSentinel && !scriptedTutorialDeath && !ownerSentinelBlockingClearTarget &&
|
|
!ownerSentinelConsumedLayerBaseBossTarget && !ownerSentinelOrdinaryTarget && !ownerSentinelHostileAITarget {
|
|
target, retirementErr := runtime.Room.RetireNonHostileAIActor(request.RuntimeObjectKey)
|
|
if retirementErr != nil {
|
|
if hasScene {
|
|
handled, clearErr := s.handleCurrentDungeonDestroyObjectClearConditionDeathLocked(
|
|
session,
|
|
runtime,
|
|
scene,
|
|
request.RuntimeObjectKey,
|
|
)
|
|
if clearErr != nil {
|
|
return clearErr
|
|
}
|
|
if handled {
|
|
s.cancelCurrentDungeonReturnAfterDungeonEvidenceLocked(
|
|
session,
|
|
runtime,
|
|
"accepted_destroy_object_clear_condition_op39_after_pending_op24",
|
|
)
|
|
return nil
|
|
}
|
|
handled, rewardErr := s.handleCurrentDungeonQuestPassiveObjectRewardDeathLocked(
|
|
session,
|
|
runtime,
|
|
scene,
|
|
request.RuntimeObjectKey,
|
|
)
|
|
if rewardErr != nil {
|
|
s.logGameEvent(session, "game-dungeon-quest-passive-object-reward-deferred",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"maze_index", runtime.MazeIndex,
|
|
"object_key", request.RuntimeObjectKey,
|
|
"related_actor_object_key", request.OwnerObjectKey,
|
|
"reason", "active_pvf_enemy_reward_item_type3_failed",
|
|
"error", rewardErr)
|
|
return nil
|
|
}
|
|
if handled {
|
|
s.cancelCurrentDungeonReturnAfterDungeonEvidenceLocked(
|
|
session,
|
|
runtime,
|
|
"accepted_quest_passive_object_reward_op39_after_pending_op24",
|
|
)
|
|
return nil
|
|
}
|
|
handled, passiveErr := s.handleCurrentDungeonQuestPassiveObjectDeathLocked(
|
|
session,
|
|
runtime,
|
|
scene,
|
|
request.RuntimeObjectKey,
|
|
)
|
|
if passiveErr != nil {
|
|
s.logGameEvent(session, "game-dungeon-quest-passive-object-death-deferred",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"maze_index", runtime.MazeIndex,
|
|
"object_key", request.RuntimeObjectKey,
|
|
"related_actor_object_key", request.OwnerObjectKey,
|
|
"reason", "unique_active_pvf_passive_target_owner_failed",
|
|
"error", passiveErr)
|
|
return nil
|
|
}
|
|
if handled {
|
|
s.cancelCurrentDungeonReturnAfterDungeonEvidenceLocked(
|
|
session,
|
|
runtime,
|
|
"accepted_quest_passive_object_op39_after_pending_op24",
|
|
)
|
|
return nil
|
|
}
|
|
}
|
|
s.logDungeonStallBlocked(session, dungeonStallKindMonsterDeath,
|
|
"game-dungeon-monster-death-blocked", request.RuntimeObjectKey,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"maze_index", runtime.MazeIndex,
|
|
"object_key", request.RuntimeObjectKey,
|
|
"related_actor_object_key", request.OwnerObjectKey,
|
|
"request_layout", request.Layout,
|
|
"reason", "non_hostile_ai_retirement_rejected",
|
|
"error", retirementErr)
|
|
return nil
|
|
}
|
|
responseBody, err := buildCurrentDungeonDeathNotificationBody(
|
|
target.ObjectKey,
|
|
target.ResponseKind,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketNotifyDieMonster), responseBody, 0); err != nil {
|
|
return err
|
|
}
|
|
scene, _ = runtime.Session.Scene()
|
|
s.logGameEvent(session, "game-dungeon-non-hostile-ai-retired",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"maze_index", runtime.MazeIndex,
|
|
"room", scene.Coordinate.String(),
|
|
"map_id", scene.Map.Map.ID,
|
|
"object_key", request.RuntimeObjectKey,
|
|
"related_actor_object_key", request.OwnerObjectKey,
|
|
"request_layout", request.Layout,
|
|
"combat_entry_count", request.CombatEntryCount,
|
|
"response_kind", target.ResponseKind,
|
|
"response_opcode", uint16(dnfenum.CmdPacketNotifyDieMonster),
|
|
"response_body_len", len(responseBody),
|
|
"room_cleared", scene.Cleared,
|
|
"boss_room", scene.Boss,
|
|
"reward_assets", "unchanged",
|
|
"response", "current_exe_op38_non_hostile_ai_retirement_ack")
|
|
s.cancelCurrentDungeonReturnAfterDungeonEvidenceLocked(
|
|
session,
|
|
runtime,
|
|
"accepted_non_hostile_ai_op39_after_pending_op24",
|
|
)
|
|
return nil
|
|
}
|
|
if ownerSentinelConsumedLayerBaseBossTarget {
|
|
target, _, reportErr := runtime.Room.CommitActorDeathReport(request.RuntimeObjectKey, runtime.Session)
|
|
if reportErr != nil {
|
|
s.logDungeonStallBlocked(session, dungeonStallKindMonsterDeath,
|
|
"game-dungeon-monster-death-blocked", request.RuntimeObjectKey,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"maze_index", runtime.MazeIndex,
|
|
"object_key", request.RuntimeObjectKey,
|
|
"reason", "consumed_layer_base_boss_retirement_rejected",
|
|
"error", reportErr)
|
|
return nil
|
|
}
|
|
responseBody, buildErr := buildCurrentDungeonDeathNotificationBody(target.ObjectKey, target.ResponseKind)
|
|
if buildErr != nil {
|
|
return buildErr
|
|
}
|
|
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketNotifyDieMonster), responseBody, 0); err != nil {
|
|
return err
|
|
}
|
|
if err := runtime.cacheCurrentDungeonRoom(); err != nil {
|
|
return fmt.Errorf("cache consumed layered base room after dummy boss retirement: %w", err)
|
|
}
|
|
scene, _ = runtime.Session.Scene()
|
|
s.logGameEvent(session, "game-dungeon-consumed-layer-base-boss-retired",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"maze_index", runtime.MazeIndex,
|
|
"room", scene.Coordinate.String(),
|
|
"map_id", scene.Map.Map.ID,
|
|
"object_key", request.RuntimeObjectKey,
|
|
"related_actor_object_key", request.OwnerObjectKey,
|
|
"request_layout", request.Layout,
|
|
"combat_entry_count", request.CombatEntryCount,
|
|
"completion_source", ownerSentinelConsumedLayerBaseBossSource,
|
|
"room_cleared", scene.Cleared,
|
|
"reward_assets", "unchanged",
|
|
"response_opcode", uint16(dnfenum.CmdPacketNotifyDieMonster),
|
|
"response_body_len", len(responseBody),
|
|
"next_stage", "ordinary_final_room_settlement")
|
|
if !scene.Cleared {
|
|
return nil
|
|
}
|
|
if err := s.completeCurrentDungeonOrdinaryFinalRoomAfterDeathLocked(
|
|
session,
|
|
runtime,
|
|
scene,
|
|
request.RuntimeObjectKey,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
s.cancelCurrentDungeonReturnAfterDungeonEvidenceLocked(
|
|
session,
|
|
runtime,
|
|
"accepted_consumed_layer_base_boss_op39_after_pending_op24",
|
|
)
|
|
return nil
|
|
}
|
|
playerObjectKey := currentSceneActorObjectKey(session.selectedCharacterID)
|
|
// 支援兵/队友击杀时客户端上报的 owner 是 APC/队友对象;怪物死亡响应应
|
|
// 与玩家本人击杀使用同一条 owner 路径,否则客户端可能只进入白影受击态。
|
|
// 保留 0xffff 教程/非敌对 NPC 哨兵和真正的敌对 AI 击杀判定。
|
|
if request.OwnerObjectKey != 0 && request.OwnerObjectKey != 0xffff &&
|
|
request.OwnerObjectKey != playerObjectKey &&
|
|
!isHostileAnnouncedKiller(runtime.Room, uint32(request.OwnerObjectKey)) {
|
|
originalOwner := request.OwnerObjectKey
|
|
request.OwnerObjectKey = playerObjectKey
|
|
s.logGameEvent(session, "game-dungeon-monster-death-owner-normalized",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"maze_index", runtime.MazeIndex,
|
|
"object_key", request.RuntimeObjectKey,
|
|
"original_owner_object_key", originalOwner,
|
|
"owner_object_key", request.OwnerObjectKey,
|
|
"reason", "companion_or_party_kill_uses_player_owner")
|
|
}
|
|
// Male Priest tutorial 7113 proves a second native non-hostile APC death
|
|
// shape: variable op39, one combat row, and owner=current player. The older
|
|
// tutorial captures used owner=0xffff and are handled above. Keep this
|
|
// compatibility path limited to the currently owned PVF tutorial scene and
|
|
// to the exact player-owned request shape; RetireNonHostileAIActor then
|
|
// proves that the target is an announced/retired, non-blocking character AI
|
|
// with no hostile reference. It only retires the visual APC and never grants
|
|
// EXP/drops or changes hostile room-clear state.
|
|
if hasScene &&
|
|
isPVFTutorialDungeonScene(runtime, scene) &&
|
|
request.Layout == dungeoncmd.DieMonsterRequestLayoutVariableCombat &&
|
|
request.CombatEntryCount == 1 &&
|
|
request.OwnerObjectKey == playerObjectKey {
|
|
target, retirementErr := runtime.Room.RetireNonHostileAIActor(request.RuntimeObjectKey)
|
|
if retirementErr == nil {
|
|
responseBody, buildErr := buildCurrentDungeonDeathNotificationBody(
|
|
target.ObjectKey,
|
|
target.ResponseKind,
|
|
)
|
|
if buildErr != nil {
|
|
return buildErr
|
|
}
|
|
if sendErr := s.sendGameUpperRawClass(
|
|
session,
|
|
uint16(dnfenum.CmdPacketNotifyDieMonster),
|
|
responseBody,
|
|
0,
|
|
); sendErr != nil {
|
|
return sendErr
|
|
}
|
|
scene, _ = runtime.Session.Scene()
|
|
s.logGameEvent(session, "game-dungeon-non-hostile-ai-retired",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"maze_index", runtime.MazeIndex,
|
|
"room", scene.Coordinate.String(),
|
|
"map_id", scene.Map.Map.ID,
|
|
"object_key", request.RuntimeObjectKey,
|
|
"related_actor_object_key", request.OwnerObjectKey,
|
|
"request_layout", request.Layout,
|
|
"combat_entry_count", request.CombatEntryCount,
|
|
"response_kind", target.ResponseKind,
|
|
"response_opcode", uint16(dnfenum.CmdPacketNotifyDieMonster),
|
|
"response_body_len", len(responseBody),
|
|
"room_cleared", scene.Cleared,
|
|
"boss_room", scene.Boss,
|
|
"reward_assets", "unchanged",
|
|
"response", "current_exe_op38_player_owned_tutorial_ai_retirement_ack")
|
|
s.cancelCurrentDungeonReturnAfterDungeonEvidenceLocked(
|
|
session,
|
|
runtime,
|
|
"accepted_player_owned_tutorial_ai_op39_after_pending_op24",
|
|
)
|
|
return nil
|
|
}
|
|
}
|
|
if !scriptedTutorialDeath && !ownerSentinelBlockingClearTarget && !ownerSentinelOrdinaryTarget &&
|
|
!ownerSentinelHostileAITarget &&
|
|
request.OwnerObjectKey != playerObjectKey {
|
|
if isHostileAnnouncedKiller(runtime.Room, uint32(request.OwnerObjectKey)) {
|
|
// A hostile room actor can never be the killer of another monster.
|
|
s.logDungeonStallBlocked(session, dungeonStallKindMonsterDeath,
|
|
"game-dungeon-monster-death-blocked", request.RuntimeObjectKey,
|
|
"object_key", request.RuntimeObjectKey,
|
|
"related_actor_object_key", request.OwnerObjectKey,
|
|
"player_object_key", playerObjectKey,
|
|
"request_layout", request.Layout,
|
|
"reason", "room_related_actor_object_key_mismatch",
|
|
"error", errDungeonMonsterOwnerMismatch)
|
|
return nil
|
|
}
|
|
if !dungeonRuntimeContainsDeathOwnerObjectKey(runtime, uint32(request.OwnerObjectKey)) &&
|
|
!runtimePartyDungeonContainsActor(runtime, request.OwnerObjectKey) {
|
|
// Friendly APCs that followed from an earlier room and party members
|
|
// are known killers and pass silently above. Everything else follows
|
|
// the 90US alignment: the victim is the authority (CommitActorDeathReport);
|
|
// the killer key is display-only. Summons, pets, creatures and quest APC
|
|
// dynamic keys (even far keys) are reported here by the current EXE and
|
|
// were never announced by the server. usdof_v54 opcode0027 die_monster
|
|
// (seq-gated) and the 86JP DungeonCombatHandler / DungeonKillApplicationService
|
|
// never validate the killer, so any non-hostile killer is allowed.
|
|
s.logGameEvent(session, "game-dungeon-monster-death-killer-allowed-non-hostile",
|
|
"object_key", request.RuntimeObjectKey,
|
|
"related_actor_object_key", request.OwnerObjectKey,
|
|
"player_object_key", playerObjectKey,
|
|
"request_layout", request.Layout,
|
|
"reason", "killer_not_player_nor_friendly_apc_allowed")
|
|
}
|
|
}
|
|
if target, matchedSpecialMonster, retirementErr := runtime.Room.RetireSpecialMonsterActor(request.RuntimeObjectKey); matchedSpecialMonster {
|
|
if retirementErr != nil {
|
|
s.logDungeonStallBlocked(session, dungeonStallKindMonsterDeath,
|
|
"game-dungeon-monster-death-blocked", request.RuntimeObjectKey,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"maze_index", runtime.MazeIndex,
|
|
"object_key", request.RuntimeObjectKey,
|
|
"related_actor_object_key", request.OwnerObjectKey,
|
|
"request_layout", request.Layout,
|
|
"combat_entry_count", request.CombatEntryCount,
|
|
"reason", "special_monster_retirement_rejected",
|
|
"error", retirementErr)
|
|
return nil
|
|
}
|
|
if err := s.completeCurrentSuspiciousVillageElevatorLocked(session, runtime, target.ObjectKey); err != nil {
|
|
return err
|
|
}
|
|
responseBody, err := buildCurrentDungeonDeathNotificationBody(
|
|
target.ObjectKey,
|
|
target.ResponseKind,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketNotifyDieMonster), responseBody, 0); err != nil {
|
|
return err
|
|
}
|
|
if err := runtime.cacheCurrentDungeonRoom(); err != nil {
|
|
return fmt.Errorf("cache dungeon room after special monster retirement: %w", err)
|
|
}
|
|
scene, _ = runtime.Session.Scene()
|
|
s.logGameEvent(session, "game-dungeon-special-monster-retired",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"maze_index", runtime.MazeIndex,
|
|
"room", scene.Coordinate.String(),
|
|
"map_id", scene.Map.Map.ID,
|
|
"object_key", request.RuntimeObjectKey,
|
|
"related_actor_object_key", request.OwnerObjectKey,
|
|
"request_layout", request.Layout,
|
|
"combat_entry_count", request.CombatEntryCount,
|
|
"response_kind", target.ResponseKind,
|
|
"response_opcode", uint16(dnfenum.CmdPacketNotifyDieMonster),
|
|
"response_body_len", len(responseBody),
|
|
"room_cleared", scene.Cleared,
|
|
"boss_room", scene.Boss,
|
|
"reward_assets", "unchanged",
|
|
"response", "current_exe_op38_special_monster_retirement_ack")
|
|
s.cancelCurrentDungeonReturnAfterDungeonEvidenceLocked(
|
|
session,
|
|
runtime,
|
|
"accepted_special_monster_op39_after_pending_op24",
|
|
)
|
|
return nil
|
|
}
|
|
dropMonster, ordinaryDropCandidate := runtime.Room.AnnouncedMonster(request.RuntimeObjectKey)
|
|
questAICharacterCode, questAICharacterDropCandidate := runtime.Room.AnnouncedHostileAICharacterCode(
|
|
request.RuntimeObjectKey,
|
|
)
|
|
bossActorDeath, bossActorType, bossActorSource := currentDungeonRuntimeObjectBossActor(
|
|
runtime,
|
|
request.RuntimeObjectKey,
|
|
runtimeDungeonMonsterAnnounced,
|
|
)
|
|
clearConditionActorDeath := false
|
|
clearConditionSource := ""
|
|
if ordinaryDropCandidate {
|
|
clearConditionSource = currentDungeonClearConditionSource(runtime, dropMonster)
|
|
clearConditionActorDeath = clearConditionSource != ""
|
|
if clearConditionActorDeath && !bossActorDeath {
|
|
bossActorDeath = true
|
|
bossActorType = 3
|
|
bossActorSource = clearConditionSource
|
|
}
|
|
}
|
|
target, cleared, reportErr := runtime.Room.CommitActorDeathReport(request.RuntimeObjectKey, runtime.Session)
|
|
if reportErr != nil {
|
|
s.logDungeonStallBlocked(session, dungeonStallKindMonsterDeath,
|
|
"game-dungeon-monster-death-blocked", request.RuntimeObjectKey,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"maze_index", runtime.MazeIndex,
|
|
"object_key", request.RuntimeObjectKey,
|
|
"reason", "runtime_actor_owner_rejected",
|
|
"error", reportErr)
|
|
return nil
|
|
}
|
|
scene, _ = runtime.Session.Scene()
|
|
storyAIBossGate, storyAIBossGateActive := currentDungeonStoryAIBossDeathGate(runtime, scene)
|
|
pvfMultiBossGate, pvfMultiBossGateActive := currentDungeonPVFMultiBossDeathGate(runtime, scene)
|
|
pvfMultiBossPending := pvfMultiBossGateActive && !pvfMultiBossGate.Ready
|
|
rewardMonster := dropMonster
|
|
rewardMonsterCandidate := ordinaryDropCandidate
|
|
if !rewardMonsterCandidate {
|
|
if monster, ok := runtime.Room.MonsterByReference(
|
|
target.Reference,
|
|
runtimeDungeonMonsterAnnounced,
|
|
runtimeDungeonMonsterDefeated,
|
|
); ok {
|
|
rewardMonster = monster
|
|
rewardMonsterCandidate = true
|
|
}
|
|
}
|
|
if rewardMonsterCandidate && !clearConditionActorDeath {
|
|
clearConditionSource = currentDungeonClearConditionSource(runtime, rewardMonster)
|
|
clearConditionActorDeath = clearConditionSource != ""
|
|
if clearConditionActorDeath && !bossActorDeath {
|
|
bossActorDeath = true
|
|
bossActorType = 3
|
|
bossActorSource = clearConditionSource
|
|
}
|
|
}
|
|
// The clear-card gold formula is driven by the run's weighted kill count,
|
|
// so tally the death on the same gate that already decided it earns
|
|
// rewards. Practice runs stay isolated from every reward source.
|
|
if rewardMonsterCandidate && !currentDungeonRuntimeIsPractice(runtime) {
|
|
runtime.recordCurrentDungeonClearKill(
|
|
currentDungeonClearKillActorType(rewardMonster, bossActorDeath, bossActorType),
|
|
)
|
|
}
|
|
var deathDrops []currentDungeonDeathDropWire
|
|
dropSource := "none"
|
|
if currentDungeonRuntimeIsPractice(runtime) {
|
|
dropSource = "practice_zero_growth_isolation"
|
|
} else if rewardMonsterCandidate {
|
|
deathDrops, err = s.planCurrentDungeonMonsterDrops(runtime, rewardMonster, playerObjectKey)
|
|
if err != nil {
|
|
reason := "runtime_pvf_csharp_formula_drop_plan_failed_zero_drop_fallback"
|
|
dropSource = "zero_drop_fallback"
|
|
s.logGameEvent(session, "game-dungeon-monster-drop-deferred",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"maze_index", runtime.MazeIndex,
|
|
"room", scene.Coordinate.String(),
|
|
"map_id", scene.Map.Map.ID,
|
|
"monster_id", rewardMonster.Spawn.MonsterID,
|
|
"monster_object_key", request.RuntimeObjectKey,
|
|
"logical_monster_object_key", rewardMonster.ObjectKey,
|
|
"map_random_drop_count", rewardMonster.Spawn.RandomDropCount,
|
|
"map_offset7_specified_pool_count", rewardMonster.Spawn.FixedDropCount,
|
|
"reason", reason,
|
|
"error", err)
|
|
deathDrops = nil
|
|
} else if len(deathDrops) != 0 {
|
|
dropSource = "runtime_pvf_csharp_drop_formula_standard_item_branch"
|
|
} else {
|
|
dropSource = "runtime_pvf_csharp_drop_formula_no_standard_item"
|
|
}
|
|
if err == nil && currentDungeonStandaloneRiftRuntime(runtime) {
|
|
riftDrops, selectedRiftDrops, riftDropErr := s.planCurrentDungeonStandaloneRiftBossDrops(
|
|
runtime,
|
|
rewardMonster,
|
|
playerObjectKey,
|
|
)
|
|
if riftDropErr != nil {
|
|
s.logGameEvent(session, "game-dungeon-standalone-rift-boss-drop-deferred",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"maze_index", runtime.MazeIndex,
|
|
"room", scene.Coordinate.String(),
|
|
"map_id", scene.Map.Map.ID,
|
|
"monster_id", rewardMonster.Spawn.MonsterID,
|
|
"monster_rank", rewardMonster.Spawn.Rank,
|
|
"reason", "pvf_hell_drop_plan_failed_standard_drops_retained",
|
|
"error", riftDropErr)
|
|
} else if len(riftDrops) != 0 {
|
|
deathDrops = append(deathDrops, riftDrops...)
|
|
dropSource = "runtime_pvf_standard_plus_standalone_rift_boss"
|
|
s.logGameEvent(session, "game-dungeon-standalone-rift-boss-drop-planned",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"maze_index", runtime.MazeIndex,
|
|
"room", scene.Coordinate.String(),
|
|
"map_id", scene.Map.Map.ID,
|
|
"monster_id", rewardMonster.Spawn.MonsterID,
|
|
"monster_rank", rewardMonster.Spawn.Rank,
|
|
"drop_count", len(riftDrops),
|
|
"selected_drops", selectedRiftDrops,
|
|
"source", "pvf_inferno_or_particular_hell_plus_itemdropinfo_monster_hell")
|
|
}
|
|
}
|
|
}
|
|
var monsterExperience currentDungeonMonsterExperienceCommitResult
|
|
var monsterExperienceBody []byte
|
|
// monsterExperienceOp37Total is the value actually written into op37's
|
|
// +0x01 field. At the level cap it collapses onto the durable EXP (see
|
|
// currentMonsterKillOp37TotalExperience) so the client difference is zero
|
|
// and it stops announcing a level-cap kill award.
|
|
var monsterExperienceOp37Total uint32
|
|
forcedBossClearCount := 0
|
|
forcedBossVisualDeathCount := 0
|
|
var stagedStoryForcedCombatEnd *currentDungeonForcedCombatEnd
|
|
nextStoryCoordinate, nextStoryStageIndex, pendingStoryAdvance := currentDungeonPendingStoryAdvance(runtime, scene)
|
|
if pendingStoryAdvance && bossActorDeath && !cleared && !storyAIBossGateActive && !pvfMultiBossPending &&
|
|
!isPVFTutorialDungeonScene(runtime, scene) {
|
|
prepared, prepareErr := s.prepareCurrentDungeonForcedCombatEndLocked(runtime, request.RuntimeObjectKey)
|
|
if prepareErr != nil {
|
|
s.logGameEvent(session, "game-dungeon-story-stage-force-clear-deferred",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"maze_index", runtime.MazeIndex,
|
|
"room", scene.Coordinate.String(),
|
|
"map_id", scene.Map.Map.ID,
|
|
"story_stage_index", runtime.StoryStageIndex,
|
|
"next_story_stage_index", nextStoryStageIndex,
|
|
"next_story_room", nextStoryCoordinate.String(),
|
|
"boss_object_key", request.RuntimeObjectKey,
|
|
"reason", "story_stage_boss_forced_clear_prepare_failed",
|
|
"error", prepareErr)
|
|
} else {
|
|
stagedStoryForcedCombatEnd = &prepared
|
|
forcedBossClearCount = len(prepared.Targets)
|
|
cleared = cleared || prepared.Cleared
|
|
if prepared.SceneOK {
|
|
scene = prepared.Scene
|
|
}
|
|
}
|
|
}
|
|
var optionalStoryRoom []worldmap.RoomCoordinate
|
|
if pendingStoryAdvance && cleared && (!storyAIBossGateActive || storyAIBossGate.Ready) {
|
|
optionalStoryRoom = append(optionalStoryRoom, nextStoryCoordinate)
|
|
}
|
|
responseBody, err := buildCurrentDungeonDeathNotificationBodyWithDrops(
|
|
target.ObjectKey,
|
|
target.ResponseKind,
|
|
deathDrops,
|
|
optionalStoryRoom...,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketNotifyDieMonster), responseBody, 0); err != nil {
|
|
return err
|
|
}
|
|
if err := s.publishCurrentDungeonBossEntranceGateAfterDeathLocked(session, runtime); err != nil {
|
|
return err
|
|
}
|
|
// A death can satisfy a passive object's live-monster check (能源储藏室's
|
|
// cylinders count their room's monster down to zero and open the boss door).
|
|
// (上游 b402882 的物件脚本子系统)
|
|
s.runCurrentDungeonActionObjectsLocked(session, runtime, scene, runtimeDungeonActionObjectInRoom, "monster_death")
|
|
// The current client keeps a defeated actor in its white-hit state until
|
|
// op38 arrives. Send that authoritative visual retirement before the
|
|
// durable experience transaction so SQLite contention can never delay the
|
|
// visible death or its ground-drop projection.
|
|
if rewardMonsterCandidate && !currentDungeonRuntimeIsPractice(runtime) &&
|
|
!s.currentDungeonRuntimeIsRaidGate(runtime) {
|
|
// A raid gate grants no experience at all: live 2026-09-16 20:28Z showed
|
|
// per-kill 怪物经验 rows inside gate 215 plus a 20.3M clear-exp package.
|
|
// The gate's reward is the raid phase settlement, not character growth.
|
|
experienceContext, cancelExperience := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
monsterExperience, err = s.awardCurrentDungeonMonsterExperience(
|
|
experienceContext,
|
|
session,
|
|
runtime,
|
|
rewardMonster,
|
|
)
|
|
cancelExperience()
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-dungeon-monster-experience-deferred",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"maze_index", runtime.MazeIndex,
|
|
"room", scene.Coordinate.String(),
|
|
"map_id", scene.Map.Map.ID,
|
|
"monster_id", rewardMonster.Spawn.MonsterID,
|
|
"monster_object_key", request.RuntimeObjectKey,
|
|
"logical_monster_object_key", rewardMonster.ObjectKey,
|
|
"reason", "runtime_pvf_csharp_domain_formula_or_atomic_progression_unavailable",
|
|
"error", err)
|
|
} else {
|
|
recordCurrentDungeonMonsterExperience(runtime, rewardMonster, monsterExperience)
|
|
if monsterExperience.Award.Gain > 0 {
|
|
runtime.Character = dnfrepo.CloneCharacter(monsterExperience.Character)
|
|
// 满级(90)角色:op37 的累计经验压回持久化值,客户端差值为 0
|
|
// 就不再弹"获得X(基础…)"。判定与理由见
|
|
// currentMonsterKillOp37TotalExperience。
|
|
monsterExperienceOp37Total = currentMonsterKillOp37TotalExperience(monsterExperience)
|
|
monsterExperienceBody = buildCurrentFinishLoadingCharacterStateBodyWithPresentation(
|
|
monsterExperience.Character,
|
|
monsterExperience.Skill.Points,
|
|
¤tFinishLoadingExperiencePresentation{
|
|
GrowthContractBonus: monsterExperience.GrowthContractBonus,
|
|
FatigueBatteryBonus: monsterExperience.FatigueBatteryBonus,
|
|
WelcomeBonus: monsterExperience.WelcomeBonus,
|
|
TotalExperience: &monsterExperienceOp37Total,
|
|
},
|
|
)
|
|
if len(monsterExperienceBody) != currentFinishLoadingCharacterStateBodySize {
|
|
s.logGameEvent(session, "game-dungeon-monster-experience-deferred",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"monster_id", dropMonster.Spawn.MonsterID,
|
|
"monster_object_key", request.RuntimeObjectKey,
|
|
"reason", "current_exe_op37_body_shape_invalid_after_atomic_commit",
|
|
"body_len", len(monsterExperienceBody))
|
|
monsterExperienceBody = nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if stagedStoryForcedCombatEnd != nil {
|
|
forcedVisual, forcedCleared, flushErr := s.flushCurrentDungeonForcedCombatEndLocked(
|
|
session,
|
|
runtime,
|
|
request.RuntimeObjectKey,
|
|
"op39_story_stage_boss_actor_death_after_source_coordinate_response",
|
|
*stagedStoryForcedCombatEnd,
|
|
)
|
|
if flushErr != nil {
|
|
return flushErr
|
|
}
|
|
forcedBossVisualDeathCount = forcedVisual
|
|
cleared = cleared || forcedCleared
|
|
}
|
|
if len(monsterExperienceBody) != 0 {
|
|
if err := s.sendGameUpperRawClass(session, currentDungeonCharacterStateMsgID, monsterExperienceBody, 0); err != nil {
|
|
return err
|
|
}
|
|
if monsterExperience.Character.Level != monsterExperience.PreviousLevel {
|
|
// 副本内打怪升级:op37 已经让客户端看到新等级,这里必须把
|
|
// 剧情摘要基线(op1378)同步到同一等级。否则客户端内部
|
|
// last_level 仍停在旧段,下一次场景 finalizer 会判定"段差"
|
|
// 并自动补播章节动画/剧情摘要。
|
|
if err := s.sendCurrentStoryDigestLastLevelForLevelSync(
|
|
session,
|
|
uint32(monsterExperience.Character.Level),
|
|
"dungeon_monster_level_up_story_digest_sync",
|
|
); err != nil {
|
|
return err
|
|
}
|
|
if err := s.sendCurrentDungeonLevelUpSkillRuntimeRefresh(
|
|
session,
|
|
monsterExperience.Character,
|
|
monsterExperience.Skill,
|
|
"current_exe_op38_op37_committed_level_change_skill_runtime_before_quest_refresh",
|
|
); err != nil {
|
|
return err
|
|
}
|
|
if err := s.sendCurrentAcceptableQuestListForSession(
|
|
session,
|
|
"current_exe_op38_op37_op19_after_atomic_monster_level_change",
|
|
); err != nil {
|
|
return err
|
|
}
|
|
s.notifyCurrentUnitedFriendPresence(
|
|
monsterExperience.Character.Name,
|
|
currentFriendPresence{ChannelID: uint16(session.channel.ID), Busy: true},
|
|
"dungeon_level_up_friend_absolute_refresh",
|
|
)
|
|
}
|
|
s.logGameEvent(session, "game-dungeon-monster-experience-awarded",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"maze_index", runtime.MazeIndex,
|
|
"room", scene.Coordinate.String(),
|
|
"map_id", scene.Map.Map.ID,
|
|
"monster_id", monsterExperience.Award.MonsterID,
|
|
"monster_object_key", request.RuntimeObjectKey,
|
|
"logical_monster_object_key", rewardMonster.ObjectKey,
|
|
"monster_level", monsterExperience.Award.MonsterLevel,
|
|
"monster_type", monsterExperience.Award.MonsterType,
|
|
"named_monster", monsterExperience.Award.NamedMonster,
|
|
"monster_table_exp", monsterExperience.Award.MonsterTableEXP,
|
|
"dungeon_weight", monsterExperience.Award.DungeonWeight,
|
|
"difficulty_rate", monsterExperience.Award.DifficultyRate,
|
|
"monster_type_rate", monsterExperience.Award.MonsterTypeRate,
|
|
"level_penalty", monsterExperience.Award.LevelPenalty,
|
|
"pre_penalty_gain", monsterExperience.Award.PrePenaltyGain,
|
|
"experience_gain", monsterExperience.Award.Gain,
|
|
"growth_contract_bonus", monsterExperience.GrowthContractBonus,
|
|
"fatigue_battery_bonus", monsterExperience.FatigueBatteryBonus,
|
|
"equipment_bonus", monsterExperience.EquipmentBonus,
|
|
"attendance_marble_bonus", monsterExperience.AttendanceMarbleBonus,
|
|
"committed_experience_gain", uint64(monsterExperience.Award.Gain)+uint64(monsterExperience.GrowthContractBonus)+uint64(monsterExperience.EquipmentBonus)+uint64(monsterExperience.AttendanceMarbleBonus),
|
|
"honor_expert_gain", monsterExperience.HonorExpertGain,
|
|
"honor_expert_level", numericCharacterStatValue(monsterExperience.Character, currentHonorExpertLevelStatKey),
|
|
"honor_expert_progress", numericCharacterStatValue(monsterExperience.Character, currentHonorExpertProgressExperienceStatKey),
|
|
// 同一笔经验现在也镜像进账号冒险团:这两个字段是现场核对
|
|
// "打怪到底有没有在攒结晶"的唯一凭据。credit = 进骁勇结晶的
|
|
// 经验(任意等级),maximum = 其中的满级溢出部分(进胶囊槽/名誉)。
|
|
"adventure_group_credit", monsterExperience.AdventureGroupCredit,
|
|
"adventure_group_maximum", monsterExperience.AdventureGroupMaximum,
|
|
"adventure_group_crystals", monsterExperience.AdventureGroupCrystals,
|
|
"sp_gain", monsterExperience.SPGain,
|
|
"tp_gain", monsterExperience.TPGain,
|
|
"remaining_sp", monsterExperience.Skill.Points.RemainingSP,
|
|
"remaining_tp", monsterExperience.Skill.Points.RemainingTP,
|
|
"new_level", monsterExperience.Character.Level,
|
|
"new_total_exp", statU32(monsterExperience.Character, "exp", 0),
|
|
"presented_total_exp", monsterExperience.PresentedTotalExperience,
|
|
// op37_total_exp 是真正写进包里的 +0x01;满级时它等于持久化经验,
|
|
// 客户端差值为 0,因此不会弹"获得X(基础…)"提示。
|
|
"op37_total_exp", monsterExperienceOp37Total,
|
|
"exp_notice_suppressed", monsterExperienceOp37Total != monsterExperience.PresentedTotalExperience,
|
|
"op38_then_op37", true,
|
|
"op37_body_len", len(monsterExperienceBody),
|
|
"formula_source", "runtime_pvf_monsterexp_table_formula",
|
|
"packet_source", "current_exe_sub_1D78240_full_character_state")
|
|
}
|
|
if rewardMonsterCandidate && !currentDungeonRuntimeIsPractice(runtime) {
|
|
// Ordinary hunt objectives are owned by the current client's native
|
|
// op33 SET_TRIGGER. Applying the same death here races that write and
|
|
// double-decrements packed progress. Passive/room-object objectives,
|
|
// which have no client owner, retain the explicit server path.
|
|
s.checkCurrentDungeonQuestMonsterDrops(session, runtime, rewardMonster.Spawn.MonsterID)
|
|
} else if questAICharacterDropCandidate && !currentDungeonRuntimeIsPractice(runtime) {
|
|
// enemy_type=2 is the PVF domain for hostile AI/APC actors. Their op39
|
|
// carries only the room object key, so resolve and freeze the AI code
|
|
// before CommitActorDeathReport changes the actor state, then grant the
|
|
// matching quest item after the authoritative op38 death notification.
|
|
s.checkCurrentDungeonQuestEnemyDrops(
|
|
session,
|
|
runtime,
|
|
questAICharacterCode,
|
|
currentDungeonQuestEnemyTypeAICharacter,
|
|
false,
|
|
)
|
|
}
|
|
if bossActorDeath && !storyAIBossGateActive && !pvfMultiBossPending && !isPVFTutorialDungeonScene(runtime, scene) &&
|
|
stagedStoryForcedCombatEnd == nil {
|
|
forcedDefeated, forcedVisual, forcedCleared, forceErr := s.forceCurrentDungeonRemainingHostilesForCombatEndLocked(
|
|
session,
|
|
runtime,
|
|
request.RuntimeObjectKey,
|
|
"op39_boss_actor_death",
|
|
)
|
|
if forceErr != nil {
|
|
s.logGameEvent(session, "game-dungeon-boss-forced-clear-deferred",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"maze_index", runtime.MazeIndex,
|
|
"room", scene.Coordinate.String(),
|
|
"map_id", scene.Map.Map.ID,
|
|
"boss_object_key", request.RuntimeObjectKey,
|
|
"monster_id", rewardMonster.Spawn.MonsterID,
|
|
"boss_rank", rewardMonster.Spawn.Rank,
|
|
"boss_actor_type", bossActorType,
|
|
"boss_actor_source", bossActorSource,
|
|
"forced_defeated_count", forcedDefeated,
|
|
"reason", "boss_death_forced_clear_commit_failed",
|
|
"error", forceErr)
|
|
} else if forcedDefeated > 0 || forcedCleared {
|
|
forcedBossClearCount = forcedDefeated
|
|
forcedBossVisualDeathCount = forcedVisual
|
|
cleared = cleared || forcedCleared
|
|
scene, _ = runtime.Session.Scene()
|
|
s.logGameEvent(session, "game-dungeon-boss-forced-room-clear",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"maze_index", runtime.MazeIndex,
|
|
"room", scene.Coordinate.String(),
|
|
"map_id", scene.Map.Map.ID,
|
|
"boss_object_key", request.RuntimeObjectKey,
|
|
"monster_id", rewardMonster.Spawn.MonsterID,
|
|
"boss_rank", rewardMonster.Spawn.Rank,
|
|
"boss_actor_type", bossActorType,
|
|
"boss_actor_source", bossActorSource,
|
|
"forced_defeated_count", forcedDefeated,
|
|
"forced_visual_death_count", forcedBossVisualDeathCount,
|
|
"defeated_actor_count", len(scene.DefeatedObjects),
|
|
"tracked_hostile_count", len(scene.ExpectedHostiles),
|
|
"blocking_hostile_count", len(scene.BlockingHostiles),
|
|
"room_cleared", scene.Cleared,
|
|
"flow_source", "boss_death_combat_end_policy")
|
|
}
|
|
}
|
|
if err := runtime.cacheCurrentDungeonRoom(); err != nil {
|
|
return fmt.Errorf("cache dungeon room after actor death: %w", err)
|
|
}
|
|
scene, _ = runtime.Session.Scene()
|
|
s.logGameEvent(session, "game-dungeon-monster-death-accepted",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"maze_index", runtime.MazeIndex,
|
|
"room", scene.Coordinate.String(),
|
|
"map_id", scene.Map.Map.ID,
|
|
"object_key", request.RuntimeObjectKey,
|
|
"related_actor_object_key", request.OwnerObjectKey,
|
|
"request_layout", request.Layout,
|
|
"combat_entry_count", request.CombatEntryCount,
|
|
"actor_kind", target.Reference.Kind,
|
|
"scripted_tutorial_death", scriptedTutorialDeath,
|
|
"scripted_tutorial_monster_id", scriptedTutorialMonster.Spawn.MonsterID,
|
|
"scripted_tutorial_rank", scriptedTutorialMonster.Spawn.Rank,
|
|
"scripted_tutorial_monster_index", scriptedTutorialEvidence.MonsterIndex,
|
|
"scripted_tutorial_cinematic_id", scriptedTutorialEvidence.CinematicID,
|
|
"scripted_tutorial_cinematic_path", scriptedTutorialEvidence.CinematicPath,
|
|
"owner_sentinel_blocking_clear_target", ownerSentinelBlockingClearTarget,
|
|
"owner_sentinel_blocking_clear_source", ownerSentinelBlockingClearSource,
|
|
"owner_sentinel_ordinary_target", ownerSentinelOrdinaryTarget,
|
|
"owner_sentinel_hostile_ai_target", ownerSentinelHostileAITarget,
|
|
"response_kind", target.ResponseKind,
|
|
"response_opcode", uint16(dnfenum.CmdPacketNotifyDieMonster),
|
|
"response_body_len", len(responseBody),
|
|
"story_stage_index", runtime.StoryStageIndex,
|
|
"next_story_stage_index", nextStoryStageIndex,
|
|
"story_stage_advance", len(optionalStoryRoom) == 1,
|
|
"story_stage_next_room", nextStoryCoordinate.String(),
|
|
"defeated_actor_count", len(scene.DefeatedObjects),
|
|
"tracked_hostile_count", len(scene.ExpectedHostiles),
|
|
"blocking_hostile_count", len(scene.BlockingHostiles),
|
|
"room_cleared", cleared,
|
|
"boss_actor_death", bossActorDeath,
|
|
"boss_actor_type", bossActorType,
|
|
"boss_actor_source", bossActorSource,
|
|
"clear_condition_actor_death", clearConditionActorDeath,
|
|
"clear_condition_source", clearConditionSource,
|
|
"logical_monster_resolved", rewardMonsterCandidate,
|
|
"logical_monster_id", rewardMonster.Spawn.MonsterID,
|
|
"logical_monster_object_key", rewardMonster.ObjectKey,
|
|
"boss_forced_defeated_count", forcedBossClearCount,
|
|
"boss_forced_visual_death_count", forcedBossVisualDeathCount,
|
|
"story_ai_boss_dual_death_gate", storyAIBossGateActive,
|
|
"story_ai_boss_dual_death_ready", storyAIBossGate.Ready,
|
|
"story_dummy_boss_object_keys", storyAIBossGate.DummyBossObjectKeys,
|
|
"story_ai_boss_object_keys", storyAIBossGate.AIBossObjectKeys,
|
|
"pvf_multi_boss_gate", pvfMultiBossGateActive,
|
|
"pvf_multi_boss_ready", pvfMultiBossGate.Ready,
|
|
"pvf_multi_boss_object_keys", pvfMultiBossGate.BossObjectKeys,
|
|
"boss_room", scene.Boss,
|
|
"drop_count", len(deathDrops),
|
|
"drop_source", dropSource,
|
|
"monster_experience_gain", monsterExperience.Award.Gain,
|
|
"monster_experience_state_sent", len(monsterExperienceBody) != 0,
|
|
"response", "current_exe_op38_actor_death_notification")
|
|
if runtime.bossDieCheckPending &&
|
|
uint32(runtime.bossDieCheckPendingRequest.TargetObjectKey) == request.RuntimeObjectKey {
|
|
pendingRequest := runtime.bossDieCheckPendingRequest
|
|
s.logGameEvent(session, "game-dungeon-boss-die-check-resume-after-death",
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"maze_index", runtime.MazeIndex,
|
|
"room", scene.Coordinate.String(),
|
|
"map_id", scene.Map.Map.ID,
|
|
"related_actor_object_key", pendingRequest.RelatedActorObjectKey,
|
|
"target_object_key", pendingRequest.TargetObjectKey,
|
|
"reason", "same_target_authoritative_op39_committed")
|
|
return s.handleDungeonBossDieCheckLocked(session, runtime, pendingRequest, true)
|
|
}
|
|
// A boss actor dying inside a room that is already Cleared is the cinematic
|
|
// dummy-boss case (quest 3599 / dungeon 96 map 76114): every spawn is on
|
|
// team 0, so the room never blocked and the death itself is the only
|
|
// settlement signal the client will send.
|
|
if cleared || (storyAIBossGateActive && storyAIBossGate.Ready && scene.Cleared) || (bossActorDeath && scene.Cleared) {
|
|
if visit, visitErr := runtime.currentDungeonRoomVisit(scene); visitErr == nil {
|
|
s.awardCurrentPetRoomClearLocked(
|
|
session,
|
|
runtime,
|
|
scene,
|
|
int(visit.FatigueConsumedAmount),
|
|
"authoritative_monster_room_clear",
|
|
)
|
|
} else {
|
|
s.logGameEvent(session, "game-pet-room-clear-growth-deferred",
|
|
"char_id", session.selectedCharacterID,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"room", scene.Coordinate.String(),
|
|
"source", "authoritative_monster_room_clear",
|
|
"reason", "room_visit_unavailable",
|
|
"error", visitErr)
|
|
}
|
|
if err := s.completeCurrentDungeonOrdinaryFinalRoomAfterDeathLocked(session, runtime, scene, request.RuntimeObjectKey); err != nil {
|
|
return err
|
|
}
|
|
if err := s.completePVFTutorialBosslessFinalRoomAfterDeathLocked(session, runtime, scene); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
s.cancelCurrentDungeonReturnAfterDungeonEvidenceLocked(
|
|
session,
|
|
runtime,
|
|
"accepted_hostile_op39_after_pending_op24",
|
|
)
|
|
return nil
|
|
}
|
|
|
|
func (runtime *runtimeDungeonState) accumulateCurrentDungeonSettlementMonsterExperience(
|
|
monsterType byte,
|
|
gain uint32,
|
|
) {
|
|
if runtime == nil || gain == 0 {
|
|
return
|
|
}
|
|
runtime.settlementMonsterExperienceTotal = saturatingCurrentDungeonUint32Add(
|
|
runtime.settlementMonsterExperienceTotal,
|
|
gain,
|
|
)
|
|
switch monsterType {
|
|
case 1:
|
|
runtime.settlementChampionExperience = saturatingCurrentDungeonUint32Add(
|
|
runtime.settlementChampionExperience,
|
|
gain,
|
|
)
|
|
case 2:
|
|
runtime.settlementSuperChampionExperience = saturatingCurrentDungeonUint32Add(
|
|
runtime.settlementSuperChampionExperience,
|
|
gain,
|
|
)
|
|
case 3:
|
|
runtime.settlementBossExperience = saturatingCurrentDungeonUint32Add(
|
|
runtime.settlementBossExperience,
|
|
gain,
|
|
)
|
|
}
|
|
}
|
|
|
|
func saturatingCurrentDungeonUint32Add(current uint32, delta uint32) uint32 {
|
|
if ^uint32(0)-current < delta {
|
|
return ^uint32(0)
|
|
}
|
|
return current + delta
|
|
}
|