服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
222 lines
10 KiB
Go
222 lines
10 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"context"
|
|
"longheng.io/server/internal/modules/dnf/dnfenum"
|
|
"longheng.io/server/internal/modules/dnf/worldmap"
|
|
)
|
|
|
|
// completeCurrentDungeonBossDieCheckLocked closes the current-EXE final CMT
|
|
// request/response path and enters the independently observed normal settlement
|
|
// handshake. For an ordinary dungeon, persist the clear-map quest marker,
|
|
// publish its trigger-zero op574 snapshot, reply with op115, then send the
|
|
// bodyless op31 that can make the current EXE construct op46. Town transition
|
|
// and rewards do not occur at this stage.
|
|
func (s *Service) completeCurrentDungeonBossDieCheckLocked(
|
|
session *gameSession,
|
|
runtime *runtimeDungeonState,
|
|
source string,
|
|
) error {
|
|
if session == nil || runtime == nil || runtime.Session == nil || session.dungeon.runtime != runtime {
|
|
return nil
|
|
}
|
|
if !dungeonRuntimeOwnsCharacter(runtime, session.selectedCharacterID) {
|
|
s.logGameEvent(session, "game-dungeon-boss-die-check-completion-blocked",
|
|
"char_id", session.selectedCharacterID,
|
|
"runtime_char_id", runtime.Character.CharacterID,
|
|
"source", source,
|
|
"reason", "active_dungeon_runtime_character_owner_mismatch")
|
|
return nil
|
|
}
|
|
snapshot := runtime.Session.Snapshot()
|
|
if !runtime.bossDieCheckAccepted || snapshot.Run.Status != worldmap.DungeonRunCompleted {
|
|
return nil
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
tutorialDungeon := isPVFTutorialDungeon(runtime)
|
|
if tutorialDungeon && !runtime.tutorialCompletionPersisted {
|
|
previousFlag, err := s.persistCurrentDungeonTutorialCompletion(ctx, session.selectedCharacterID)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-dungeon-boss-die-check-completion-blocked",
|
|
"char_id", session.selectedCharacterID,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"previous_tutorial_completed", previousFlag,
|
|
"source", source,
|
|
"reason", "tutorial_completion_persistence_failed",
|
|
"error", err)
|
|
return nil
|
|
}
|
|
runtime.tutorialCompletionPersisted = true
|
|
applyCurrentDungeonTutorialCompletionStats(&runtime.Character)
|
|
s.logGameEvent(session, "game-dungeon-tutorial-completion-persisted",
|
|
"char_id", session.selectedCharacterID,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"previous_tutorial_completed", previousFlag,
|
|
"tutorial_completed", currentDungeonTutorialCompleteFlag,
|
|
"select_tutorial_index_count", 0,
|
|
"source", "validated_final_op117_after_current_exe_cmt_on_end")
|
|
// 教学副本的通关记录。上面这个分支对每个 runtime 只会进入一次。
|
|
s.recordDungeonClear(session, runtime, "tutorial_completion_persisted")
|
|
}
|
|
if !tutorialDungeon && !runtime.tutorialCompletionPersisted {
|
|
// The settlement owner predates ordinary-dungeon completion and uses
|
|
// this historical flag as its persistence barrier. Ordinary dungeons
|
|
// have no tutorial row to persist: satisfy only that in-memory barrier;
|
|
// Character.Stats and the character repository remain unchanged.
|
|
runtime.tutorialCompletionPersisted = true
|
|
s.logGameEvent(session, "game-dungeon-ordinary-completion-validated",
|
|
"char_id", session.selectedCharacterID,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"tutorial_completion_database_mutated", false,
|
|
"tutorial_completion_character_snapshot_mutated", false,
|
|
"settlement_persistence_barrier_ready", true,
|
|
"source", "owned_cleared_runtime_boss_op117")
|
|
// 普通副本的通关记录:这里是"已通关"第一次被确认的位置(bossDieCheckAccepted
|
|
// 且 Run.Status == DungeonRunCompleted),同样每个 runtime 只进入一次。
|
|
s.recordDungeonClear(session, runtime, "ordinary_completion_validated")
|
|
}
|
|
if !currentDungeonRuntimeIsPractice(runtime) && !runtime.clearMapCompletionPhaseAPersisted {
|
|
if err := s.persistCurrentDungeonClearMapCompletionPhaseA(ctx, session, runtime, snapshot); err != nil {
|
|
s.logGameEvent(session, "game-dungeon-boss-die-check-completion-quest-sync-nonblocking",
|
|
"char_id", session.selectedCharacterID,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"maze_index", runtime.MazeIndex,
|
|
"map_id", snapshot.Scene.Map.Map.ID,
|
|
"completion_key", runtime.clearMapCompletionKey,
|
|
"source", source,
|
|
"reason", "clear_map_phase_a_persistence_failed",
|
|
"op115_sent", runtime.bossDieCheckResponseSent,
|
|
"settlement_entry_sent", runtime.settlementEntrySent,
|
|
"error", err)
|
|
// 86JP treats clear-map quest synchronization as a side effect of
|
|
// ClearDungeon, not as the owner of the result screen. A quest/PVF
|
|
// snapshot failure must not strand an already-completed dungeon in
|
|
// the cleared-but-no-score state: continue to op115/op31 and let the
|
|
// quest owner repair/refresh the task state independently.
|
|
s.logGameEvent(session, "game-dungeon-clear-map-phase-a-deferred-nonblocking",
|
|
"char_id", session.selectedCharacterID,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"maze_index", runtime.MazeIndex,
|
|
"map_id", snapshot.Scene.Map.Map.ID,
|
|
"completion_key", runtime.clearMapCompletionKey,
|
|
"source", source,
|
|
"reason", "86jp_clear_dungeon_result_not_blocked_by_quest_sync",
|
|
"error", err)
|
|
}
|
|
}
|
|
if !currentDungeonRuntimeIsPractice(runtime) && !tutorialDungeon && runtime.clearMapCompletionPhaseAPersisted {
|
|
if err := s.sendCurrentDungeonClearMapCompletionNotificationLocked(ctx, session, runtime); err != nil {
|
|
s.logGameEvent(session, "game-dungeon-boss-die-check-completion-active-snapshot-nonblocking",
|
|
"char_id", session.selectedCharacterID,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"maze_index", runtime.MazeIndex,
|
|
"map_id", snapshot.Scene.Map.Map.ID,
|
|
"completion_key", runtime.clearMapCompletionKey,
|
|
"source", source,
|
|
"reason", "clear_map_active_quest_snapshot_failed",
|
|
"notification_closed", runtime.clearMapCompletionNotificationClosed,
|
|
"op115_sent", runtime.bossDieCheckResponseSent,
|
|
"settlement_entry_sent", runtime.settlementEntrySent,
|
|
"error", err)
|
|
// Same as Phase-A above: the active-task snapshot is useful for UI,
|
|
// but it cannot own the dungeon score/card lifecycle once combat is
|
|
// authoritatively complete.
|
|
s.logGameEvent(session, "game-dungeon-clear-map-active-quest-snapshot-deferred-nonblocking",
|
|
"char_id", session.selectedCharacterID,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"maze_index", runtime.MazeIndex,
|
|
"map_id", snapshot.Scene.Map.Map.ID,
|
|
"completion_key", runtime.clearMapCompletionKey,
|
|
"source", source,
|
|
"reason", "86jp_clear_dungeon_result_not_blocked_by_task_snapshot",
|
|
"error", err)
|
|
}
|
|
} else if !tutorialDungeon && !currentDungeonRuntimeIsPractice(runtime) {
|
|
s.logGameEvent(session, "game-dungeon-clear-map-active-quest-snapshot-skipped",
|
|
"char_id", session.selectedCharacterID,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"maze_index", runtime.MazeIndex,
|
|
"map_id", snapshot.Scene.Map.Map.ID,
|
|
"completion_key", runtime.clearMapCompletionKey,
|
|
"reason", "phase_a_not_persisted_but_result_flow_continues",
|
|
"reward_granted", false)
|
|
}
|
|
|
|
if !runtime.bossDieCheckResponseSent {
|
|
responseBody := buildCurrentDungeonBossDieCheckResponse(runtime.bossDieCheckTargetObjectKey)
|
|
s.logGameEvent(session, "game-dungeon-boss-die-check-op115-send",
|
|
"request_msg_id", uint16(dnfenum.CmdPacketBossDieCheck),
|
|
"response_msg_id", uint16(dnfenum.CmdPacketNotifyBossDieCheck),
|
|
"classification", 0,
|
|
"char_id", session.selectedCharacterID,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"maze_index", runtime.MazeIndex,
|
|
"room", snapshot.Run.Current.String(),
|
|
"related_actor_object_key", runtime.bossDieCheckRelatedActorObjectKey,
|
|
"target_object_key", runtime.bossDieCheckTargetObjectKey,
|
|
"body_len", len(responseBody),
|
|
"ordering", "ordinary_phase_a_op574_then_validated_op117_response_before_bodyless_settlement_op31",
|
|
"body_source", "current_exe_sub_1D3F4D0_status1_complete1_real_target_key")
|
|
if err := s.sendGameUpperRawClass(
|
|
session,
|
|
uint16(dnfenum.CmdPacketNotifyBossDieCheck),
|
|
responseBody,
|
|
0,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
runtime.bossDieCheckResponseSent = true
|
|
}
|
|
// The EXE first closes the dungeon's final CMT path with op115, then accepts
|
|
// the Raid participation/result sequence. Publishing the Raid result before
|
|
// op115 leaves its state-3 result UI racing an unfinished dungeon and was
|
|
// observed live as a stuck/incorrect final banner. Commit and publish the
|
|
// terminal Raid event here instead; its frozen lifecycle token keeps retries
|
|
// idempotent when op115 was already written.
|
|
if err := s.notifyRuntimeRaidDungeonCompletedLocked(session, runtime); err != nil {
|
|
// The client's own op39/op117 already proved the boss died and this run is
|
|
// authoritatively completed, so failing the Raid bookkeeping step must not
|
|
// abort the inbound packet. Inbound packets are fatal async events, so
|
|
// returning here closed the socket: live incident 2026-09-16 11:14:18Z, the
|
|
// raid member saw 网络连接中断 at the exact moment its gate cleared while the
|
|
// leader kept playing. Report the failure and continue on this run's own
|
|
// settlement path instead.
|
|
s.logGameEvent(session, "game-raid-dungeon-completion-publish-failed",
|
|
"char_id", session.selectedCharacterID,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"op115_sent", runtime.bossDieCheckResponseSent,
|
|
"source", source,
|
|
"error", err)
|
|
s.logGameEvent(session, "game-raid-dungeon-completion-publish-deferred",
|
|
"char_id", session.selectedCharacterID,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"raid_key", runtime.raidDungeon.RaidKey,
|
|
"raid_phase", runtime.raidDungeon.Phase,
|
|
"raid_map_id", runtime.raidDungeon.MapID,
|
|
"session_kept_alive", true,
|
|
"reason", "authoritative_clear_continues_without_raid_bookkeeping")
|
|
}
|
|
nativeRaidSettlement := s.runtimeRaidCompletedDungeonOwnsNativeSettlement(
|
|
session.selectedCharacterID,
|
|
runtime.raidDungeon,
|
|
)
|
|
if nativeRaidSettlement {
|
|
s.logGameEvent(session, "game-dungeon-native-raid-settlement-owned",
|
|
"char_id", session.selectedCharacterID,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"raid_key", runtime.raidDungeon.RaidKey,
|
|
"raid_phase", runtime.raidDungeon.Phase,
|
|
"raid_map_id", runtime.raidDungeon.MapID,
|
|
"ordinary_op31_sent", false,
|
|
"owner", "current_exe_native_raid_result_movie_card_flow")
|
|
return nil
|
|
}
|
|
|
|
return s.sendCurrentDungeonSettlementEntryLocked(
|
|
session,
|
|
runtime,
|
|
"current_exe_final_cmt_op117_op115_bodyless_op31_"+source,
|
|
)
|
|
}
|