服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
431 lines
19 KiB
Go
431 lines
19 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
|
|
"longheng.io/server/internal/modules/dnf/dnfenum"
|
|
"longheng.io/server/internal/modules/dnf/worldmap"
|
|
)
|
|
|
|
const currentDungeonCharacterStatisticBodySize = 24
|
|
|
|
// handleDungeonCharacterStatistic records the six-u32 telemetry packet emitted
|
|
// by the current EXE's completed tutorial/settlement manager. The values never
|
|
// become authoritative rewards or assets. A single-floor tower has already
|
|
// committed its reward inventory and next floor while building op34/op35; this
|
|
// client-owned barrier only publishes that committed dedicated result.
|
|
func (s *Service) handleDungeonCharacterStatistic(session *gameSession, body []byte) error {
|
|
if session == nil {
|
|
return nil
|
|
}
|
|
if len(body) != currentDungeonCharacterStatisticBodySize {
|
|
s.logDungeonStallBlocked(session, dungeonStallKindSettlement,
|
|
"game-dungeon-character-statistic-blocked", uint32(session.selectedCharacterID),
|
|
"body_len", len(body),
|
|
"expected_body_len", currentDungeonCharacterStatisticBodySize,
|
|
"reason", "current_exe_op123_six_u32_boundary_mismatch")
|
|
return nil
|
|
}
|
|
|
|
session.dungeon.mu.Lock()
|
|
runtime := session.dungeon.runtime
|
|
if !completedDungeonSettlementEntryReady(runtime, session.selectedCharacterID) {
|
|
session.dungeon.mu.Unlock()
|
|
s.logDungeonStallBlocked(session, dungeonStallKindSettlement,
|
|
"game-dungeon-character-statistic-blocked", uint32(session.selectedCharacterID),
|
|
"char_id", session.selectedCharacterID,
|
|
"body_len", len(body),
|
|
"reason", "completed_settlement_entry_owner_not_ready")
|
|
return nil
|
|
}
|
|
if runtime.settlementStatisticReceived {
|
|
if bytes.Equal(runtime.settlementStatisticBody, body) {
|
|
s.logGameEvent(session, "game-dungeon-character-statistic-replay",
|
|
"char_id", session.selectedCharacterID,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"body_len", len(body),
|
|
"reason", "exact_replay_is_idempotent")
|
|
} else {
|
|
session.dungeon.mu.Unlock()
|
|
s.logDungeonStallBlocked(session, dungeonStallKindSettlement,
|
|
"game-dungeon-character-statistic-blocked", uint32(session.selectedCharacterID),
|
|
"char_id", session.selectedCharacterID,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"body_len", len(body),
|
|
"reason", "conflicting_replay_after_first_capture")
|
|
return nil
|
|
}
|
|
} else {
|
|
runtime.settlementStatisticReceived = true
|
|
runtime.settlementStatisticBody = append([]byte(nil), body...)
|
|
s.logGameEvent(session, "game-dungeon-character-statistic-captured",
|
|
"char_id", session.selectedCharacterID,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"body_len", len(body),
|
|
"value0", binary.LittleEndian.Uint32(body[0:4]),
|
|
"value1", binary.LittleEndian.Uint32(body[4:8]),
|
|
"value2", binary.LittleEndian.Uint32(body[8:12]),
|
|
"value3", binary.LittleEndian.Uint32(body[12:16]),
|
|
"value4", binary.LittleEndian.Uint32(body[16:20]),
|
|
"value5", binary.LittleEndian.Uint32(body[20:24]),
|
|
"authoritative_for_rewards", false,
|
|
"source", "current_cn_six_u32_settlement_writer")
|
|
}
|
|
|
|
// A tower never runs the ordinary card settlement: its clear window is the
|
|
// native op348/op1263 notification published by the frozen settlement plan.
|
|
if currentDungeonTowerRewardMsgID(runtime) == 0 {
|
|
session.dungeon.mu.Unlock()
|
|
return nil
|
|
}
|
|
session.dungeon.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// currentDungeonSingleFloorTowerSettlement reports whether this run is a tower
|
|
// clear and which family it belongs to. The family name is kept for logging;
|
|
// the native message ID (op348/op1263) is what actually drives the window.
|
|
func (s *Service) currentDungeonSingleFloorTowerSettlement(runtime *runtimeDungeonState) (string, bool) {
|
|
kind := currentDungeonTowerKind(runtime)
|
|
if kind == "" {
|
|
return "", false
|
|
}
|
|
return "tower_of_" + string(kind), true
|
|
}
|
|
|
|
// handleDungeonGiveupGame closes both current EXE op42 meanings without
|
|
// conflating them: an active run remains a give-up request, while a completed
|
|
// run is accepted only after the server's settlement-entry op31, current EXE
|
|
// op46 play-result request, and post-settlement op123 statistic barrier. The
|
|
// client owns the wait before it emits op42; the server must not replace that
|
|
// lifecycle with an unrelated timer. An active-run typed op24 remains pending
|
|
// until positive town-side scene evidence commits it. A completed tutorial's
|
|
// first op42 instead uses the already accepted full completed-town route and
|
|
// detaches only after that route writes its typed op24.
|
|
func (s *Service) handleDungeonGiveupGame(session *gameSession, body []byte) error {
|
|
if session == nil {
|
|
return nil
|
|
}
|
|
if len(body) != 0 {
|
|
s.logGameEvent(session, "game-dungeon-giveup-game-blocked",
|
|
"body_len", len(body),
|
|
"reason", "current_exe_op42_body_must_be_empty")
|
|
return nil
|
|
}
|
|
if session.selectedCharacterID == 0 {
|
|
s.logGameEvent(session, "game-dungeon-giveup-game-blocked",
|
|
"body_len", len(body),
|
|
"reason", "selected_character_missing")
|
|
return nil
|
|
}
|
|
if _, err := s.persistCurrentActiveTutorialExit(
|
|
session,
|
|
session.selectedCharacterID,
|
|
"confirmed_mid_tutorial_bodyless_op42_active_run_giveup",
|
|
); err != nil {
|
|
s.logGameEvent(session, "game-dungeon-giveup-game-blocked",
|
|
"char_id", session.selectedCharacterID,
|
|
"reason", "confirmed_tutorial_abandonment_persistence_failed",
|
|
"error", err)
|
|
return nil
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
transition, err := s.prepareCurrentDungeonTownTransitionForSession(ctx, session, session.selectedCharacterID)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-dungeon-giveup-game-blocked",
|
|
"char_id", session.selectedCharacterID,
|
|
"reason", "persisted_town_transition_unavailable",
|
|
"error", err)
|
|
return nil
|
|
}
|
|
|
|
session.dungeon.mu.Lock()
|
|
runtime := session.dungeon.runtime
|
|
if runtime == nil || runtime.Session == nil || !dungeonRuntimeOwnsCharacter(runtime, session.selectedCharacterID) {
|
|
session.dungeon.mu.Unlock()
|
|
s.logGameEvent(session, "game-dungeon-giveup-game-blocked",
|
|
"char_id", session.selectedCharacterID,
|
|
"reason", "active_dungeon_runtime_owner_missing")
|
|
return nil
|
|
}
|
|
snapshot := runtime.Session.Snapshot()
|
|
// A cleared TEAM raid gate has no card flip at all: its reward is the phase
|
|
// settlement the raid owner publishes once the phase completes, not a
|
|
// per-dungeon card row. The ordinary completed-run chain therefore never runs
|
|
// for one, and every barrier that waits for it -- the settlement entry, the
|
|
// play-result capture, the statistic, the card layout/selection/reward commit --
|
|
// would wait forever. Live incident 2026-09-16 18:43: both members of a team
|
|
// raid cleared gate 210, each sent op42 twice, and every request was answered
|
|
// with "completed_settlement_entry_owner_not_ready" or
|
|
// "exit-deferred-before-card-reward"; both clients sat on the clear screen with
|
|
// neither 再次挑战 nor 返回城镇 working.
|
|
cardlessRaid := s.completedCurrentTeamRaidDungeonExitReady(runtime, session.selectedCharacterID)
|
|
if snapshot.Run.Status == worldmap.DungeonRunCompleted &&
|
|
!cardlessRaid &&
|
|
!completedDungeonSettlementResultReady(runtime, session.selectedCharacterID) {
|
|
session.dungeon.mu.Unlock()
|
|
s.logGameEvent(session, "game-dungeon-giveup-game-blocked",
|
|
"char_id", session.selectedCharacterID,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"run_status", snapshot.Run.Status,
|
|
"settlement_entry_sent", runtime.settlementEntrySent,
|
|
"settlement_play_result_received", runtime.settlementPlayResultReceived,
|
|
"settlement_statistic_received", runtime.settlementStatisticReceived,
|
|
"reason", "completed_run_missing_play_result_or_statistic_barrier")
|
|
return nil
|
|
}
|
|
if snapshot.Run.Status != worldmap.DungeonRunActive && snapshot.Run.Status != worldmap.DungeonRunCompleted {
|
|
session.dungeon.mu.Unlock()
|
|
s.logGameEvent(session, "game-dungeon-giveup-game-blocked",
|
|
"char_id", session.selectedCharacterID,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"run_status", snapshot.Run.Status,
|
|
"reason", "dungeon_run_not_active_or_completed")
|
|
return nil
|
|
}
|
|
if runtime.townReturnPending && snapshot.Run.Status != worldmap.DungeonRunCompleted {
|
|
session.dungeon.mu.Unlock()
|
|
s.logGameEvent(session, "game-dungeon-giveup-game-replay",
|
|
"char_id", session.selectedCharacterID,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"run_status", snapshot.Run.Status,
|
|
"op24_sent", runtime.townReturnOp24Sent,
|
|
"reason", "town_transition_already_pending")
|
|
return nil
|
|
}
|
|
|
|
source := "current_exe_bodyless_op42_active_run_giveup"
|
|
if snapshot.Run.Status == worldmap.DungeonRunCompleted {
|
|
source = "current_exe_op31_completed_tutorial_or_settlement_timed_op42"
|
|
completedTutorial := isPVFTutorialDungeon(runtime)
|
|
if completedTutorial {
|
|
source = "current_exe_completed_pvf_tutorial_first_op42_return_to_town"
|
|
}
|
|
if currentDungeonRuntimeIsPractice(runtime) {
|
|
runtime.advanceSettlementPhase(currentDungeonSettlementPhaseEnding)
|
|
session.dungeon.mu.Unlock()
|
|
return s.sendCurrentCompletedDungeonReturnToTown(
|
|
session,
|
|
runtime,
|
|
transition,
|
|
uint16(dnfenum.CmdPacketGiveupGame),
|
|
"current_exe_op255_practice_direct_return_to_town",
|
|
// op42 is its own handler and holds no world lock.
|
|
false,
|
|
)
|
|
}
|
|
if cardlessRaid {
|
|
// A raid gate is done the moment its objective is committed; there is no
|
|
// card row to wait for, so the exit goes straight back to the raid town.
|
|
runtime.advanceSettlementPhase(currentDungeonSettlementPhaseEnding)
|
|
session.dungeon.mu.Unlock()
|
|
return s.sendCurrentCompletedDungeonReturnToTown(
|
|
session,
|
|
runtime,
|
|
transition,
|
|
uint16(dnfenum.CmdPacketGiveupGame),
|
|
"current_exe_team_raid_completed_direct_return_to_town",
|
|
false,
|
|
)
|
|
}
|
|
cardlessTower := completedCurrentSingleFloorTowerSettlementExitReady(runtime, session.selectedCharacterID)
|
|
if !completedTutorial && !cardlessTower && !cardlessRaid && runtime.settlementCardLayoutSent &&
|
|
!runtime.settlementCardSideSelectionSent[dungeonCardSideFree] {
|
|
if err := s.selectCurrentDungeonCardLocked(
|
|
session,
|
|
runtime,
|
|
dungeonCardSideFree,
|
|
runtimePartyDungeonCardMemberIndex(runtime),
|
|
"current_exe_completed_op42_auto_flip_free_row_before_exit",
|
|
); err != nil {
|
|
s.logGameEvent(session, "game-dungeon-giveup-game-completed-auto-flip-blocked",
|
|
"char_id", session.selectedCharacterID,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"request_msg_id", uint16(dnfenum.CmdPacketGiveupGame),
|
|
"error", err)
|
|
}
|
|
}
|
|
if !completedTutorial && !cardlessTower && !cardlessRaid && runtime.settlementCardSelectionSent && !runtime.settlementCardRewardCommitted {
|
|
if err := s.commitCurrentDungeonCardRewardLocked(session, runtime, "current_exe_completed_op42_reward_retry_before_exit"); err != nil {
|
|
s.logGameEvent(session, "game-dungeon-giveup-game-completed-exit-reward-retry-blocked",
|
|
"char_id", session.selectedCharacterID,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"request_msg_id", uint16(dnfenum.CmdPacketGiveupGame),
|
|
"error", err)
|
|
}
|
|
}
|
|
if !completedTutorial && !cardlessTower && !cardlessRaid && (!runtime.settlementCardLayoutSent ||
|
|
!runtime.settlementCardSelectionSent || !runtime.settlementCardRewardCommitted ||
|
|
runtime.settlementPhase < currentDungeonSettlementPhaseRewardCommitted) {
|
|
s.logGameEvent(session, "game-dungeon-giveup-game-completed-exit-deferred-before-card-reward",
|
|
"char_id", session.selectedCharacterID,
|
|
"dungeon_id", runtime.Dungeon.ID,
|
|
"request_msg_id", uint16(dnfenum.CmdPacketGiveupGame),
|
|
"layout_sent", runtime.settlementCardLayoutSent,
|
|
"selection_sent", runtime.settlementCardSelectionSent,
|
|
"reward_committed", runtime.settlementCardRewardCommitted,
|
|
"settlement_phase", runtime.settlementPhase.String(),
|
|
"reason", "current_exe_card_request_chain_not_finished")
|
|
session.dungeon.mu.Unlock()
|
|
return nil
|
|
}
|
|
runtime.advanceSettlementPhase(currentDungeonSettlementPhaseEnding)
|
|
session.dungeon.mu.Unlock()
|
|
return s.sendCurrentCompletedDungeonReturnToTown(
|
|
session,
|
|
runtime,
|
|
transition,
|
|
uint16(dnfenum.CmdPacketGiveupGame),
|
|
source,
|
|
false,
|
|
)
|
|
}
|
|
// 超时空:**队长**按下撤退(无包体 op42)时,这支临时队伍一起回城。
|
|
// 判据必须在队长自己那一趟回城**之前**抓下来 —— 那一趟会把 runtime 上的攻坚绑定
|
|
// 清掉(真机 2026-10-02 10:29:10:只有队长一个人回了城,同队的 char 21 留在格里,
|
|
// 23 秒后连点 op72 被 settlement_result_not_ready 拒了 119 次才自己按了 op42)。
|
|
// 只影响超时空;安东/卢克这里拿到的永远是零值 plan。
|
|
retreatPlan := s.runtimeFiendWarPartyRetreatPlanFor(session, runtime)
|
|
session.dungeon.mu.Unlock()
|
|
if err := s.sendCurrentActiveDungeonReturnToTown(
|
|
session,
|
|
runtime,
|
|
transition,
|
|
uint16(dnfenum.CmdPacketGiveupGame),
|
|
source,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
s.runRuntimeFiendWarPartyRetreat(retreatPlan, source)
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) completedDungeonSettlementExitReady(runtime *runtimeDungeonState, characterID uint16) bool {
|
|
if completedCurrentDungeonAltarSettlementExitReady(runtime, characterID) {
|
|
return true
|
|
}
|
|
if completedRuntimeRaidDungeonExitReady(runtime, characterID) {
|
|
return true
|
|
}
|
|
if s.currentRaidGateCardlessExitReady(runtime, characterID) {
|
|
return true
|
|
}
|
|
if !completedDungeonSettlementResultReady(runtime, characterID) {
|
|
return false
|
|
}
|
|
if completedCurrentSingleFloorTowerSettlementExitReady(runtime, characterID) {
|
|
return true
|
|
}
|
|
if isPVFTutorialDungeon(runtime) {
|
|
return true
|
|
}
|
|
if runtime.tournament != nil {
|
|
return runtime.tournament.RewardSelectionComplete()
|
|
}
|
|
if currentDungeonRuntimeIsPractice(runtime) {
|
|
return true
|
|
}
|
|
return runtime.settlementCardLayoutSent && runtime.settlementCardSelectionSent &&
|
|
runtime.settlementCardRewardCommitted &&
|
|
runtime.settlementPhase >= currentDungeonSettlementPhaseRewardCommitted
|
|
}
|
|
|
|
// completedCurrentSingleFloorTowerSettlementExitReady reports whether a tower
|
|
// clear may return to town: its own native window (op348/op1263) must have been
|
|
// published, and the ordinary result statistics are not its ACK.
|
|
func completedCurrentSingleFloorTowerSettlementExitReady(runtime *runtimeDungeonState, characterID uint16) bool {
|
|
return completedCurrentDungeonTowerSettlementExitReady(runtime, characterID)
|
|
}
|
|
|
|
// completedRuntimeRaidDungeonExitReady separates a completed Raid objective
|
|
// from the ordinary dungeon card owner. Anton advances and rewards the whole
|
|
// raid through its own lifecycle; each intermediate dungeon must therefore be
|
|
// allowed to return to the private waiting selector after op31/op46/op123/op35
|
|
// without inventing a per-map card selection. The final single-Raid native
|
|
// movie/card path never sends that ordinary settlement sequence; state=6 owns
|
|
// a separate completion flag and only needs the authoritative completed run.
|
|
func completedRuntimeRaidDungeonExitReady(runtime *runtimeDungeonState, characterID uint16) bool {
|
|
if runtime == nil || !runtime.raidDungeon.completedNotified ||
|
|
runtime.raidDungeon.abandonedNotified || runtime.raidDungeon.RaidKey == 0 {
|
|
return false
|
|
}
|
|
if runtime.raidNativeSettlementFinished {
|
|
return runtime.Session != nil && dungeonRuntimeOwnsCharacter(runtime, characterID) &&
|
|
runtime.Session.Snapshot().Run.Status == worldmap.DungeonRunCompleted
|
|
}
|
|
return completedDungeonSettlementResultReady(runtime, characterID)
|
|
}
|
|
|
|
func completedTournamentSettlementExitReady(runtime *runtimeDungeonState, characterID uint16) bool {
|
|
return runtime != nil && runtime.tournament != nil &&
|
|
runtime.tournament.RewardSelectionComplete() &&
|
|
completedDungeonSettlementResultReady(runtime, characterID)
|
|
}
|
|
|
|
// currentDungeonRuntimeIsRaidGate reports a run inside a raid gate.
|
|
//
|
|
// A raid gate drops nothing at all: its reward is the phase settlement the raid
|
|
// owner publishes, not loot, so both the weighted monster pool (which carries the
|
|
// gold entries and the regional materials) and the quest-item table stay switched
|
|
// off for it.
|
|
func (s *Service) currentDungeonRuntimeIsRaidGate(runtime *runtimeDungeonState) bool {
|
|
return runtime != nil && runtime.raidDungeon.RaidKey != 0
|
|
}
|
|
|
|
// completedCurrentTeamRaidDungeonExitReady reports a cleared TEAM raid gate,
|
|
// which has no card flip and therefore no ordinary settlement chain to wait for.
|
|
//
|
|
// A single raid keeps the native settlement -- the raid owner drives the result
|
|
// movie and the card rows (runtimeRaidCompletedDungeonOwnsNativeSettlement) -- so
|
|
// only the team case is exempt.
|
|
func (s *Service) completedCurrentTeamRaidDungeonExitReady(runtime *runtimeDungeonState, characterID uint16) bool {
|
|
if s == nil || runtime == nil || characterID == 0 || runtime.raidDungeon.RaidKey == 0 {
|
|
return false
|
|
}
|
|
return !s.runtimeRaidCompletedDungeonOwnsNativeSettlement(characterID, runtime.raidDungeon)
|
|
}
|
|
|
|
// currentRaidGateCardlessExitReady reports a cleared raid gate whose settlement
|
|
// result screen is already up.
|
|
//
|
|
// A raid gate never owns an ordinary per-map card row: its reward is the phase
|
|
// settlement the raid owner publishes. The opener's session proves that through
|
|
// its completed raid binding, but a squadmate that adopted the gate's binding
|
|
// carries no local completed flag of its own, and a failed completion publish is
|
|
// deliberately kept non-fatal instead of disconnecting the player. Both cases
|
|
// used to leave the client on a card screen that can never be filled -- live
|
|
// report 2026-09-16 11:14Z, the leader's 返回城镇 was answered with
|
|
// op72_cannot_cross_unfinished_card_or_reward_phase while the raid was cleared.
|
|
// Every other exit owner still requires its own terminal state; this one only
|
|
// fires for a completed raid gate with a fully shown result.
|
|
func (s *Service) currentRaidGateCardlessExitReady(runtime *runtimeDungeonState, characterID uint16) bool {
|
|
return s.currentDungeonRuntimeIsRaidGate(runtime) &&
|
|
completedDungeonSettlementResultReady(runtime, characterID)
|
|
}
|
|
|
|
func completedDungeonSettlementResultReady(runtime *runtimeDungeonState, characterID uint16) bool {
|
|
if !completedDungeonSettlementEntryReady(runtime, characterID) ||
|
|
!runtime.settlementPlayResultReceived || !runtime.settlementClearRewardSent ||
|
|
runtime.settlementPhase < currentDungeonSettlementPhaseResultShown {
|
|
return false
|
|
}
|
|
if currentDungeonTowerRewardMsgID(runtime) != 0 {
|
|
// Tower notifications set their own native clear flag and show the
|
|
// dedicated window. The ordinary result statistics are not its ACK.
|
|
return runtime.settlementTowerRewardSent
|
|
}
|
|
return currentDungeonRuntimeIsPractice(runtime) || runtime.settlementStatisticReceived
|
|
}
|
|
|
|
func completedDungeonSettlementEntryReady(runtime *runtimeDungeonState, characterID uint16) bool {
|
|
if runtime == nil || runtime.Session == nil || !dungeonRuntimeOwnsCharacter(runtime, characterID) ||
|
|
!runtime.settlementEntrySent {
|
|
return false
|
|
}
|
|
return runtime.Session.Snapshot().Run.Status == worldmap.DungeonRunCompleted
|
|
}
|