Files
90CNServer/server/internal/services/dnfbridge/raid_phase_runtime.go
T
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

1451 lines
63 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package dnfbridge
import (
"context"
"fmt"
"math"
"sort"
"time"
"longheng.io/server/internal/modules/dnf/dnfenum"
"longheng.io/server/internal/modules/dnf/raid"
)
const (
runtimeRaidPrepareState byte = 1
runtimeRaidPhaseStartState byte = 2
runtimeRaidTopTimerKey uint32 = 0
// runtimeRaidFailedResultWindow is how long a failed raid keeps its terminal
// object. 86JP: "Preserve the terminal result long enough for the client result
// UI and reconnect snapshot, then release the roster exactly once"
// (RemoveFailedRaidAfterResultDelayAsync awaits Task.Delay(10000)).
runtimeRaidFailedResultWindow = 10 * time.Second
)
func (s *Service) publishRuntimeRaidReady(state runtimeRaidState) error {
if state.Lifecycle.Status != runtimeRaidLifecyclePreparing || state.Lifecycle.ReadyDeadline.IsZero() {
return errRuntimeRaidLifecycleState
}
// 超时空:待命阶段额外推一帧 1453{1,0}。原生端在开战前用这一帧把战况表从
// "准备中"切到"已就绪",缺了它表会一直停在准备态。
if state.RaidMode == runtimeRaidOwnerFiendWar {
if err := s.broadcastRuntimeRaidWireSequence(state, runtimeRaidWireNotification{msgID: 1453, body: []byte{1, 0}}); err != nil {
return err
}
}
duration := state.Lifecycle.ReadyDeadline.Sub(state.Lifecycle.StartedAt)
if duration < 0 {
duration = 0
}
ready := raid.RaidTimer{
TimerType: 0,
Value0: runtimeRaidTopTimerKey,
Value1: uint32(state.Lifecycle.ReadyDeadline.Unix()),
Value2: runtimeRaidDurationSeconds(duration),
}
if err := s.broadcastRuntimeRaidWireSequence(state,
// Current EXE sub_1D50040 records the preparation lifecycle first;
// op596/op597 then stage the deadline and remaining seconds. The
// bodyless op594 handler sub_1D67770 is the final materialization edge:
// it calls sub_1C5DE30 and constructs the native visible 3,2,1 panel
// from the already-populated timer state.
runtimeRaidWireNotification{
msgID: raid.RaidStateMsgID,
body: raid.BuildRaidStateBody(raid.RaidState{
State: runtimeRaidPrepareState,
Argument: int8(state.Lifecycle.Phase - 1),
}),
},
runtimeRaidWireNotification{msgID: raid.RaidTimerMsgID, body: raid.BuildRaidTimerBody(ready)},
runtimeRaidWireNotification{
msgID: raid.RaidRemainTimeMsgID,
body: raid.BuildRaidRemainTimeBody(raid.RaidRemainTime{
TimerType: 0,
Seconds: ready.Value2,
}),
},
runtimeRaidWireNotification{msgID: raid.RaidPrepareStartMsgID},
); err != nil {
return err
}
return s.scheduleRuntimeRaidReady(state)
}
func (s *Service) scheduleRuntimeRaidReady(state runtimeRaidState) error {
queue := s.ensureGameplayTimeQueue()
if queue == nil {
return errGameplayTimeQueueUnavailable
}
delay := state.Lifecycle.ReadyDeadline.Sub(queue.Now())
if delay < 0 {
delay = 0
}
name := fmt.Sprintf("raid-ready:%d:%d", state.RaidKey, state.StartAttempt)
return queue.ScheduleAfter(name, delay, func(due time.Time) {
// 走 activateRuntimeRaidReadyPhase:没有调试标记文件时它与原来那一行
// (直接 activateRuntimeRaidPhase(..., phaseOne, due, false))逐字同义,
// 只是多了一层 runtime/state/raid-skip-* 标记检查。见 raid_debug_phase_skip.go。
if err := s.activateRuntimeRaidReadyPhase(state.RaidKey, due); err != nil {
s.logPacketEvent("game-raid-ready-activation-failed", "raid_key", state.RaidKey, "error", err)
}
})
}
func (s *Service) activateRuntimeRaidPhase(raidKey uint32, phase byte, now time.Time, allowEarly bool) (runtimeRaidState, error) {
now = runtimeRaidNormalizeTime(now)
s.raidMu.Lock()
state := s.raids[raidKey]
if state == nil {
s.raidMu.Unlock()
return runtimeRaidState{}, errRuntimeRaidNotFound
}
config := runtimeRaidBaseConfig(*state)
if config == nil {
s.raidMu.Unlock()
return runtimeRaidState{}, errRuntimeRaidLifecycleState
}
if state.Lifecycle.Status == runtimeRaidLifecyclePhaseActive && state.Lifecycle.Phase == phase {
active := cloneRuntimeRaidState(*state)
s.raidMu.Unlock()
return s.publishRuntimeRaidPhaseActivation(active, now)
}
if state.TailFlag == 1 && phase != runtimeRaidDungeonPhaseOne {
s.raidMu.Unlock()
return runtimeRaidState{}, errRuntimeRaidPhaseMismatch
}
var phaseTwoArmed []runtimeRaidMechanismTimer
switch phase {
case runtimeRaidDungeonPhaseOne:
if state.Lifecycle.Status != runtimeRaidLifecyclePreparing || state.Lifecycle.Phase != phase ||
(!allowEarly && now.Before(state.Lifecycle.ReadyDeadline)) {
s.raidMu.Unlock()
return runtimeRaidState{}, errRuntimeRaidLifecycleState
}
// 卢克一阶段:把一阶段的门状态与符号初始化成 luke.etc 的首态。
// 安徒恩状态(owner != Luke)与单人模式(TailFlag == 1)都不进这里。
if state.TailFlag != 1 && runtimeRaidTeamGraphOwner(*state) == runtimeRaidOwnerLuke {
initRuntimeRaidLukePhaseOneLocked(state)
}
case runtimeRaidDungeonPhaseTwo:
if state.Lifecycle.Status != runtimeRaidLifecycleStandby || state.Lifecycle.Phase != runtimeRaidDungeonPhaseOne ||
(!allowEarly && now.Before(state.Lifecycle.StandbyDeadline)) {
s.raidMu.Unlock()
return runtimeRaidState{}, errRuntimeRaidLifecycleState
}
if err := initializeRuntimeRaidPhaseObjectives(&state.Lifecycle, config, phase); err != nil {
s.raidMu.Unlock()
return runtimeRaidState{}, err
}
switch {
case runtimeRaidUsesAntonTeamGraph(*state):
// Anton's siege state only. Seeding it for a Luke raid filled in a
// full black-volcano shield and armed a hatchery-open timer on
// Anton's map 219, none of which exists in Luke's phase two.
// 安徒恩语义与改动前逐字一致:原来的条件是 state.TailFlag != 1,
// 而 runtimeRaidUsesAntonTeamGraph = TailFlag != 1 && owner == Anton,
// 对安徒恩与单人模式求值结果完全相同。
initRuntimeRaidPhaseTwoLocked(state, now)
case state.TailFlag != 1 && runtimeRaidTeamGraphOwner(*state) == runtimeRaidOwnerLuke:
phaseTwoArmed = initRuntimeRaidLukePhaseTwoLocked(state, now)
}
default:
s.raidMu.Unlock()
return runtimeRaidState{}, errRuntimeRaidPhaseMismatch
}
_, timeoutSeconds, ok := runtimeRaidPhaseConfig(*state, phase)
if !ok || timeoutSeconds <= 0 {
s.raidMu.Unlock()
return runtimeRaidState{}, errRuntimeRaidPhaseMismatch
}
state.Lifecycle.Status = runtimeRaidLifecyclePhaseActive
// 超时空:阶段激活是战场的第一次求值 —— 空事件会跑一遍 PVF 规则对,把
// fiendwar init 立起来、把 SET TIMER 那批计时器全部武装(敌军游走、雷达、
// 电池、商人、区域恢复)。syncRuntimeFiendWarObjectives 立刻把战场的区域状态
// 翻译成战况表的行。安东/卢克没有战场,这段不执行。
if state.Lifecycle.FiendWar != nil {
if err := state.Lifecycle.FiendWar.Apply(raid.FiendWarEvent{}, now); err != nil {
s.raidMu.Unlock()
return runtimeRaidState{}, err
}
syncRuntimeFiendWarObjectives(state)
}
state.Lifecycle.Phase = phase
// 新阶段的复活币计数归零(只有声明了 [PHASE COIN LIMIT] 的阶段用得上:
// 卢克二阶段 20 次,全队共享,重新进图不重置)。见 raid_phase_coin.go。
state.Lifecycle.PhaseCoinsUsed = 0
state.Lifecycle.ReadyDeadline = time.Time{}
state.Lifecycle.StandbyStartedAt = time.Time{}
state.Lifecycle.StandbyDeadline = time.Time{}
state.Lifecycle.PhaseStartedAt = now
state.Lifecycle.PhaseDeadline = now.Add(time.Duration(timeoutSeconds) * time.Second)
state.Lifecycle.MovieOpenedAt = time.Time{}
clear(state.Lifecycle.MovieConfirmed)
// A new phase starts with every member's clear registration at zero (86JP
// RaidManager clears all personal counters when the next phase begins), and
// with every gate's one-clear-per-run ledger gone: the next phase's runs are
// new party clears even on a map id both phases carry.
clear(state.Lifecycle.PhaseClearCounts)
clear(state.Lifecycle.RunClearCredits)
state.Lifecycle.RewardToken = 0
state.Lifecycle.RewardOpenedAt = time.Time{}
clear(state.Lifecycle.RewardSelections)
// A new phase re-decides who is skipped: presence is read again from the
// sockets that exist now, not from the previous phase's settlement.
state.Lifecycle.RewardSkippedMembers = make(map[uint16]bool)
state.Lifecycle.RewardRosterFrozen = false
state.Lifecycle.RewardDelivery = nil
state.Lifecycle.RewardDeliveryCommitted = false
state.Lifecycle.RewardDeliveryCommittedAt = time.Time{}
state.Lifecycle.Revision++
active := cloneRuntimeRaidState(*state)
s.raidMu.Unlock()
// 卢克二阶段激活时上弦的机制计时器(能源控制室 45/90/120 秒、储藏室 150 秒等)。
// 安徒恩这条 phaseTwoArmed 恒为 nil,与改动前完全不做事一致。
if err := s.scheduleRuntimeRaidMechanismTimers(raidKey, phaseTwoArmed); err != nil {
s.logPacketEvent("game-raid-luke-phase-two-timer-failed", "raid_key", raidKey, "error", err)
}
return s.publishRuntimeRaidPhaseActivation(active, now)
}
func (s *Service) publishRuntimeRaidPhaseActivation(state runtimeRaidState, now time.Time) (runtimeRaidState, error) {
// The current client creates the attack-party panel from the mode=0 raid
// object. That object is sent when the party is created, but the client
// tears down the selection view while the ready timer is running. Re-send
// the complete object and its buff state at phase activation so the native
// panel is materialized before state=2 and the map snapshot arrive.
if err := s.broadcastRuntimeRaidCreate(state); err != nil {
return runtimeRaidState{}, err
}
// Do NOT push the 0x024F mode3 attack-party roster from here. It looked like
// the fix for "战况表按钮不出现" (the clear-registration path sends mode0+mode3,
// and entering the dungeon-select view once made the panel appear), but the
// client's mode3 branch builds a fresh 0x98-byte roster object and inserts it
// into the raid manager, so every extra send left another stale row behind:
// live 2026-09-17T07:5xZ the board showed two identical squad rows for one
// occupied squad and never cleared the previous one ("进一次出现一次,之前的不清空").
// The panel entry needs a non-duplicating trigger instead.
if err := s.broadcastRuntimeRaidPhaseSnapshot(state, true, now); err != nil {
return runtimeRaidState{}, err
}
if state.TailFlag != 1 {
// A new phase is exactly where the two squads stop moving together: one
// may already be inside its gate while the other is still in the waiting
// area. Re-asserting the squad parties here keeps the party HUD, the
// 战况�?grid and the standby room resolving the same roster. A single
// raid owns no squad, so its trigger points stay where they were.
if err := s.applyRuntimeRaidParties(state); err != nil {
s.logGameEvent(nil, "game-raid-squad-party-projection-failed",
"raid_key", state.RaidKey,
"stage", "phase_activation",
"error", err)
}
// Activation is the other moment the two squads are guaranteed to be in
// different scenes, so every pair of members needs the other's actor record
// before any raid window is drawn.
s.syncRuntimeRaidMateActorsAll(state, "raid_phase_activation_raid_mate_actors")
// This sweep is the measured source of the phase-start freeze (live
// 2026-09-29: all eight loops ~1.0s with mode1, ~0.37s without), so the
// per-raid cost summary is emitted right here, covering the phase that just
// ended -- see raid_cost_summary.go. It is a synchronous one-line record and
// deliberately schedules no timer (the scheduling below is pinned by tests).
s.publishRuntimeRaidCostSummary(state.RaidKey, "phase_activation_previous_phase_window")
}
if err := s.scheduleRuntimeRaidPhaseExpiry(state); err != nil {
return runtimeRaidState{}, err
}
// Mechanism timers armed during activation (phase two arms the hatchery
// opening) are queued here, outside the raid lock.
if err := s.scheduleRuntimeRaidMechanismTimers(state.RaidKey, sortedRuntimeRaidMechanismTimers(state)); err != nil {
return runtimeRaidState{}, err
}
// 超时空的战场 tick:每秒一次,让敌军在地图上移动、计时器到点、商人出现。
// 它是自续期的(每次 tick 结束时重新排下一次),所以在攻坚进行期间一直跑。
if state.RaidMode == runtimeRaidOwnerFiendWar {
if err := s.scheduleRuntimeFiendWarTick(state); err != nil {
return runtimeRaidState{}, err
}
}
return state, nil
}
func (s *Service) broadcastRuntimeRaidPhaseSnapshot(state runtimeRaidState, announce bool, now time.Time) error {
// 超时空的战况表是它自己的一族包 (1452..1460),与安徒恩/卢克的 0x0247/0x0249
// 完全不同构,所以整条快照路径在入口处分流。
if state.RaidMode == runtimeRaidOwnerFiendWar {
return s.broadcastRuntimeFiendWarBoard(state, announce, now)
}
symbolBody, dungeonBody, dungeonRows, err := runtimeRaidPhaseSnapshotBodies(state)
if err != nil {
return err
}
seconds := runtimeRaidDurationSeconds(runtimeRaidPhaseSnapshotRemaining(state, now))
phaseDuration := state.Lifecycle.PhaseDeadline.Sub(state.Lifecycle.PhaseStartedAt)
if phaseDuration < 0 {
phaseDuration = 0
}
// The trailer of the 0x0249 this snapshot is about to send, in the one form the
// client resolves the infection from (see runtimeRaidInfectionMapIDForWire).
infection := runtimeRaidInfectionMapIDForWire(state)
notifications := make([]runtimeRaidWireNotification, 0, 7)
// The 0x0249 dungeon-state table goes out in full exactly once per phase, at its
// activation. Every row carries its gate's wire state and a CLEARED gate keeps
// reading 3 for the rest of the phase, so re-sending the table re-announces
// finished dungeons to the client -- which answers each cleared row with the
// 「已通关[%s]地下城」 toast (string 646). Measured on the live server 2026-09-19
// 14:23-14:46Z, raid 1507338: 618 sends of 0x0249 in 23 minutes, re-announcing
// 第一孵化中心 (221) 104 times, 第二孵化中心 (222) 52 times and 第四孵化中心 (224)
// 196 times -- the operator's "每个阶段只要是通关过的就会一直弹". After activation only
// the rows that actually moved travel, each as a one-row 0x024A change, which is
// what 86JP does (Progression.cs:681-685 for the table; Runtime.cs:369-403 for
// every later change). Symbol rows travel with the same snapshot and are
// unchanged by this: a client that opens the selector, rejoins or re-activates
// the phase still receives the complete table from sendRuntimeRaidSelectorSnapshot
// / the rejoin admission, and the change rows are absolute, not deltas.
notifications = append(notifications,
runtimeRaidWireNotification{msgID: raid.RaidSymbolMsgID, body: symbolBody},
)
tableNotification := runtimeRaidWireNotification{msgID: raid.RaidDungeonStateMsgID, body: dungeonBody}
if announce {
s.recordRuntimeRaidDungeonStateRows(state.RaidKey, state.Lifecycle.Phase, dungeonRows, infection)
notifications = append(notifications, tableNotification)
} else if changed, fullTable, infectionChanged := s.claimRuntimeRaidDungeonStateRows(state.RaidKey, state.Lifecycle.Phase, dungeonRows, infection); fullTable {
notifications = append(notifications, tableNotification)
} else {
for _, row := range changed {
notifications = append(notifications, runtimeRaidWireNotification{
msgID: raid.RaidDungeonChangeMsgID,
body: raid.BuildRaidDungeonChangeBody(raid.RaidDungeonChange{
DungeonID: row.DungeonID,
State: byte(row.State),
Argument: byte(row.State),
}),
})
}
if infectionChanged {
// The infection moved without the whole table going out, so ship the
// trailer on its own: a zero-row 0x0249 is legal for this client
// (sub_1D39B50 loops `count` times and always reads the dword after the
// rows), it inserts and overwrites no gate, and it therefore cannot
// re-announce a cleared gate the way a whole table would. It follows the
// 0x0247 above in the same sequence, which matters for symbol 7: the
// trailer only ever SETS it (cmp [esi+0x150],0 / je), so the symbol table
// is what clears it on the suppression edge.
tailOnly, err := raid.BuildRaidDungeonStateBody(raid.RaidDungeonStateSnapshot{TrailingValue: infection})
if err != nil {
return err
}
notifications = append(notifications, runtimeRaidWireNotification{
msgID: raid.RaidDungeonStateMsgID,
body: tailOnly,
})
}
}
if announce {
// Current EXE sub_F176A0 initializes the Anton selector while processing
// op587 state=2, and sub_F163C0 reads every dungeon state during that
// initialization. op585's observer does not request a full redraw, so the
// symbols and rows must already exist before state=2 materializes the view.
notifications = append(notifications, runtimeRaidWireNotification{
msgID: raid.RaidStateMsgID,
body: raid.BuildRaidStateBody(raid.RaidState{
State: runtimeRaidPhaseStartState,
Argument: int8(state.Lifecycle.Phase - 1),
}),
})
singleBootstrap, err := runtimeRaidSinglePhaseBootstrapNotifications(state)
if err != nil {
return err
}
notifications = append(notifications, singleBootstrap...)
}
notifications = append(notifications,
runtimeRaidWireNotification{
msgID: raid.RaidTimerMsgID,
body: raid.BuildRaidTimerBody(raid.RaidTimer{
TimerType: 0,
Value0: runtimeRaidTopTimerKey,
Value1: uint32(state.Lifecycle.PhaseDeadline.Unix()),
Value2: runtimeRaidDurationSeconds(phaseDuration),
}),
},
runtimeRaidWireNotification{
msgID: raid.RaidRemainTimeMsgID,
body: raid.BuildRaidRemainTimeBody(raid.RaidRemainTime{TimerType: 0, Seconds: seconds}),
},
)
notifications = append(notifications, runtimeRaidMechanismTimerNotifications(state, now)...)
// op775:小队已用复活币数。有复活币总额的小队,客户端会把它当"已用"读,所以它
// 跟着每次快照走 —— 激活时清零,重连与后进队的人靠它追上(raid_phase_coin.go)。
//
// ⚠️ 上游对「所有非单人模式」都发这一条;本树只对卢克发:我们这条线自己有一套
// 安徒恩复活币账本(clearRuntimeRaidReviveCoinLedgerForGateLocked 等),
// 给安徒恩快照再加一条 op775 会改动那条已经验收过的路径。卢克二阶段才声明
// [PHASE COIN LIMIT],所以只有它需要这一格。
if state.TailFlag != 1 && runtimeRaidTeamGraphOwner(state) == runtimeRaidOwnerLuke {
notifications = append(notifications, runtimeRaidSquadCoinUsedNotification(state))
}
// 卢克二阶段一旦有成员被路线锁绑住,0x0249 表就得按成员重建:被绑到另一条路线的
// 成员看那条路线的门应当画成 close(见 runtimeRaidViewerObjectiveWireState)。
// 安徒恩与单人模式不进这里,走下面原路。
if state.TailFlag != 1 && runtimeRaidTeamGraphOwner(state) == runtimeRaidOwnerLuke &&
len(state.Lifecycle.LukeRouteLocks) != 0 {
return s.broadcastRuntimeRaidWireSequencePerViewer(state, notifications,
func(viewer uint16) (runtimeRaidWireNotification, bool) {
viewerDungeonBody, buildErr := runtimeRaidPhaseSnapshotViewerDungeonBody(state, viewer)
if buildErr != nil {
s.logPacketEvent("game-raid-luke-viewer-snapshot-failed",
"raid_key", state.RaidKey, "viewer", viewer, "error", buildErr)
return runtimeRaidWireNotification{}, false
}
return runtimeRaidWireNotification{msgID: raid.RaidDungeonStateMsgID, body: viewerDungeonBody}, true
})
}
return s.broadcastRuntimeRaidWireSequence(state, notifications...)
}
// claimRuntimeRaidDungeonStateRows records the (gate -> wire state) table this raid
// is about to put on the wire and reports which rows actually moved since the last
// broadcast of the same phase. Callers send the whole table only when fullTable is
// true; otherwise they send the returned rows as one-row changes, so a snapshot
// that changes one gate no longer re-announces every gate the phase already
// finished. It returns fullTable=true when the caller has no row breakdown (the
// single-mode table) or when this is the first broadcast of the phase.
//
// The infected-hatchery trailer is claimed with the rows even though it is not a
// row: 0x024A carries no trailer, so an infection edge that moved no gate (a
// suppression or a recovery whose row was about to move anyway) would otherwise
// never reach the client. When it moved, the caller ships it as a zero-row 0x0249
// -- the whole table would re-announce every cleared gate. infectionChanged is
// always false when fullTable is true, because that table already carries the
// trailer.
func (s *Service) claimRuntimeRaidDungeonStateRows(
raidKey uint32,
phase byte,
rows []raid.RaidDungeonStateRow,
infection uint32,
) ([]raid.RaidDungeonStateRow, bool, bool) {
if s == nil || raidKey == 0 || len(rows) == 0 {
return nil, true, false
}
s.raidMu.Lock()
defer s.raidMu.Unlock()
state := s.raids[raidKey]
if state == nil {
return nil, true, false
}
previous := state.Lifecycle.DungeonStates
samePhase := state.Lifecycle.DungeonStatePhase == phase && len(previous) > 0
changed := make([]raid.RaidDungeonStateRow, 0, len(rows))
next := make(map[uint32]uint32, len(rows))
for _, row := range rows {
next[row.DungeonID] = row.State
if !samePhase || previous[row.DungeonID] != row.State {
changed = append(changed, row)
}
}
infectionChanged := state.Lifecycle.DungeonStateInfection != infection
state.Lifecycle.DungeonStatePhase = phase
state.Lifecycle.DungeonStates = next
state.Lifecycle.DungeonStateInfection = infection
if !samePhase {
// The first table of a phase is always complete, however it was reached:
// a caller that only sent the changed rows here would leave every gate the
// client had not seen yet at its stale value.
return nil, true, false
}
return changed, false, infectionChanged
}
// recordRuntimeRaidDungeonStateRows stores the table a full-table broadcast has just
// delivered, so the next snapshot can send only the rows that move. Callers that
// broadcast to every member use this; a per-session replay
// (sendRuntimeRaidSelectorSnapshot) deliberately does not, because the other members
// did not receive it.
func (s *Service) recordRuntimeRaidDungeonStateRows(raidKey uint32, phase byte, rows []raid.RaidDungeonStateRow, infection uint32) {
if s == nil || raidKey == 0 || len(rows) == 0 {
return
}
next := make(map[uint32]uint32, len(rows))
for _, row := range rows {
next[row.DungeonID] = row.State
}
s.raidMu.Lock()
defer s.raidMu.Unlock()
if state := s.raids[raidKey]; state != nil {
state.Lifecycle.DungeonStatePhase = phase
state.Lifecycle.DungeonStates = next
state.Lifecycle.DungeonStateInfection = infection
}
}
// runtimeRaidSinglePhaseBootstrapNotifications refreshes the current-client
// single-mode start node after the complete 0x0249 phase snapshot and state=2
// activation have been delivered. Participation op584 is deliberately absent:
// its op=1 row means that the member has entered the dungeon and is owned by the
// first real FINISH_LOADING notification, never phase activation.
func runtimeRaidSinglePhaseBootstrapNotifications(state runtimeRaidState) ([]runtimeRaidWireNotification, error) {
if state.TailFlag != 1 || state.Lifecycle.Phase != runtimeRaidDungeonPhaseOne {
return nil, nil
}
openObjectives := make([]runtimeRaidDungeonObjective, 0, 1)
for _, objective := range state.Lifecycle.Objectives {
if objective.Phase == state.Lifecycle.Phase && objective.State == raid.AntonDungeonOpen {
openObjectives = append(openObjectives, objective)
}
}
if len(openObjectives) != 1 {
return nil, errRuntimeRaidPhaseMismatch
}
sort.Slice(openObjectives, func(i, j int) bool { return openObjectives[i].MapID < openObjectives[j].MapID })
objective := openObjectives[0]
clientDungeonID, ok := runtimeRaidSingleSelectorMapIDForState(state, objective.MapID)
if !ok {
return nil, errRuntimeRaidPhaseMismatch
}
stateValue := runtimeRaidObjectiveWireState(objective.State)
return []runtimeRaidWireNotification{
{
msgID: raid.RaidDungeonChangeMsgID,
body: raid.BuildRaidDungeonChangeBody(raid.RaidDungeonChange{
DungeonID: uint32(clientDungeonID),
State: byte(stateValue),
Argument: byte(stateValue),
}),
},
}, nil
}
func runtimeRaidPhaseSnapshotRemaining(state runtimeRaidState, now time.Time) time.Duration {
startedAt := state.Lifecycle.PhaseStartedAt
deadline := state.Lifecycle.PhaseDeadline
if startedAt.IsZero() || deadline.IsZero() || !deadline.After(startedAt) {
return 0
}
duration := deadline.Sub(startedAt)
elapsed := runtimeRaidPhaseElapsed(state, now)
if elapsed >= duration {
return 0
}
return duration - elapsed
}
// runtimeRaidPhaseSnapshotBodies builds the symbol table and the 0x0249
// dungeon-state table of one phase snapshot, plus the row breakdown of that table
// when the caller can diff it. The single-mode table (TailFlag 1) carries a native
// alias row per gate and therefore reports no breakdown: its caller always sends the
// whole table.
func runtimeRaidPhaseSnapshotBodies(state runtimeRaidState) ([]byte, []byte, []raid.RaidDungeonStateRow, error) {
// viewer=0 表示「共享视图」:不做任何按成员改画,行为与改动前逐字一致。
return runtimeRaidPhaseSnapshotBodiesForViewer(state, 0)
}
// runtimeRaidPhaseSnapshotBodiesForViewer 是 runtimeRaidPhaseSnapshotBodies 的
// 「按某一名成员所见」版本。
//
// 只有 0x0249/0x024A 每行的状态会变:卢克二阶段的路线锁要按成员可见 —— 被绑到另一条
// 路线的成员,看那条路线的门应当画成 close,否则界面把它画成 open、玩家点了却被拒,
// 屏幕上什么解释都没有。权威 objective 的状态不变,真正拒绝入场仍然是
// validateAntonRaidDungeonEntry。viewer=0 或非队伍卢克时逐字走原路径。
func runtimeRaidPhaseSnapshotBodiesForViewer(state runtimeRaidState, viewer uint16) ([]byte, []byte, []raid.RaidDungeonStateRow, error) {
if state.TailFlag == 1 {
symbolBody, dungeonBody, err := runtimeRaidSingleSelectorSnapshotBodies(state)
return symbolBody, dungeonBody, nil, err
}
objectives := make([]runtimeRaidDungeonObjective, 0, len(state.Lifecycle.Objectives))
for _, objective := range state.Lifecycle.Objectives {
if objective.Phase == state.Lifecycle.Phase {
objectives = append(objectives, objective)
}
}
sort.Slice(objectives, func(i, j int) bool { return objectives[i].MapID < objectives[j].MapID })
symbols := make([]raid.RaidSymbolRow, 0, len(objectives))
dungeons := make([]raid.RaidDungeonStateRow, 0, len(objectives))
for _, objective := range objectives {
// 副本行先出,再查符号:选图页与战况表是按副本行构建的,所以一个门不管
// 符号表认不认识它的通关计数键,都必须出现在那里。上游把两者绑在一起,
// 结果把每个卢克队伍攻坚都清空了 —— 它所有关卡都没有符号项,循环跳过它们,
// op585 发出 0 行。安徒恩每个门都有符号项,所以行为不变。
dungeons = append(dungeons, raid.RaidDungeonStateRow{
DungeonID: uint32(objective.MapID),
State: runtimeRaidViewerObjectiveWireState(state, viewer, objective),
})
key, ok := runtimeRaidObjectiveSymbolKey(objective.MapID)
if !ok {
// A gate with no symbol row must be skipped, never fatal: this error
// travels back through publishRuntimeRaidPhaseActivation after
// activateRuntimeRaidPhase has already committed Status=PhaseActive and
// the new PhaseDeadline, so returning it leaves the raid in phase two
// with no snapshot and no expiry timer armed -- a permanent stall. The
// symbol table (etc/raid/anton.etc.symbol) defines 50..56 for 210..216,
// 57..63 for 218..224 and 64..84 for the single-raid selectors; 217 has
// no entry at all and is legitimately absent from both sides.
continue
}
symbols = append(symbols, raid.RaidSymbolRow{Key: key, Value: uint32(objective.ClearCount)})
}
symbols = append(symbols, runtimeRaidPhaseTwoSymbolRows(state)...)
// 卢克团队图的持久符号(祭坛/神殿计数、两个能源房效果、二阶段路线标记)。
// 它们不是一次性脉冲,所以随每次快照重发,重连的成员看到同样的进度;
// 安徒恩的符号表在 luke.etc.symbol 里什么也匹配不到,返回空。
symbols = append(symbols, runtimeRaidLukeSymbolRows(state)...)
symbolBody, err := raid.BuildRaidSymbolBody(symbols)
if err != nil {
return nil, nil, nil, err
}
// The trailing dword of 0x0249 is the infected-hatchery map id the client's
// 战况表 colours from, not padding: the current EXE reads it and stores it at
// manager+0x150, and re-derives symbols 8 and 7 from it (see
// runtimeRaidInfectionMapIDForWire). It was left at its zero value here, so
// every board this server ever drew had no 感染孵化场 marker while the client
// also had symbol 8 silently cleared one packet later.
//
// Deliberately only the VALUE changes: the table is still sent on exactly the
// same occasions as before (announce, or the 695 战况表 refresh / rejoin /
// phase activation). Forcing an extra full table on the infection edge would
// re-announce every already-cleared gate, which this client answers with the
// 「已通关[%s]地下城」 toast (see the broadcast policy above).
dungeonBody, err := raid.BuildRaidDungeonStateBody(raid.RaidDungeonStateSnapshot{
Rows: dungeons,
TrailingValue: runtimeRaidInfectionMapIDForWire(state),
})
if err != nil {
return nil, nil, nil, err
}
return symbolBody, dungeonBody, dungeons, nil
}
// runtimeRaidMovieSymbolChanges lists the cutscene markers whose value moved, so a
// caller can pulse exactly those keys after a state change. They are deliberately
// NOT part of the periodic snapshot: the client's 0x0247 handler feeds
// SetRaidSymbol, whose value change drives the video, so replaying a marker with
// every snapshot (each clear, each timer, every reconnect) would restart the clip.
// The ids live in etc/raid/anton.etc.symbol and the client resolves the
// Video/AntonCutScene file itself -- the server never names a file.
func runtimeRaidMovieSymbolChanges(before, after map[uint32]uint32) []raid.RaidSymbolRow {
if len(after) == 0 {
return nil
}
keys := make([]uint32, 0, len(after))
for key, value := range after {
if previous, ok := before[key]; ok && previous == value {
continue
}
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
rows := make([]raid.RaidSymbolRow, 0, len(keys))
for _, key := range keys {
rows = append(rows, raid.RaidSymbolRow{Key: key, Value: after[key]})
}
return rows
}
// filterRuntimeRaidMovieSymbolRows drops the given keys from a marker pulse, for
// edges another packet already carries.
func filterRuntimeRaidMovieSymbolRows(rows []raid.RaidSymbolRow, excluded ...uint32) []raid.RaidSymbolRow {
if len(rows) == 0 || len(excluded) == 0 {
return rows
}
drop := make(map[uint32]struct{}, len(excluded))
for _, key := range excluded {
drop[key] = struct{}{}
}
out := make([]raid.RaidSymbolRow, 0, len(rows))
for _, row := range rows {
if _, skip := drop[row.Key]; skip {
continue
}
out = append(out, row)
}
return out
}
// cloneRuntimeRaidMovieSymbols snapshots the markers so a caller can diff them.
func cloneRuntimeRaidMovieSymbols(state *runtimeRaidState) map[uint32]uint32 {
if state == nil || len(state.Lifecycle.MovieSymbols) == 0 {
return nil
}
out := make(map[uint32]uint32, len(state.Lifecycle.MovieSymbols))
for key, value := range state.Lifecycle.MovieSymbols {
out[key] = value
}
return out
}
// setRuntimeRaidMovieSymbolLocked pulses one cutscene marker. Caller holds raidMu
// and must broadcast runtimeRaidMovieSymbolChanges afterwards, otherwise the client
// never learns about the edge.
func (state *runtimeRaidState) setRuntimeRaidMovieSymbolLocked(key, value uint32) {
if state == nil {
return
}
state.ensureRuntimeRaidLifecycleMapsLocked()
// An absent key reads as 0, but an explicit 0 (the reset half of an edge set,
// e.g. 127=0 alongside the break) still has to be recorded and announced --
// only a repeat of the same stored value is a no-op.
if previous, ok := state.Lifecycle.MovieSymbols[key]; ok && previous == value {
return
}
state.Lifecycle.MovieSymbols[key] = value
state.Lifecycle.Revision++
}
func runtimeRaidSingleSelectorSnapshotBodies(state runtimeRaidState) ([]byte, []byte, error) {
if state.Lifecycle.Phase != runtimeRaidDungeonPhaseOne {
return nil, nil, errRuntimeRaidPhaseMismatch
}
if state.RaidMode == runtimeRaidOwnerLuke {
return runtimeRaidLukeSingleSelectorSnapshotBodies(state)
}
selectorIDs := []int{210, 211, 212, 213, 214, 215, 216, 218, 219, 220, 221, 222, 223, 224}
wireStates := make(map[int]uint32, len(selectorIDs))
clearCounts := make(map[int]uint32, len(selectorIDs))
nativeObjectives := make([]runtimeRaidDungeonObjective, 0, len(state.Lifecycle.Objectives))
for _, selectorID := range selectorIDs {
wireStates[selectorID] = runtimeRaidObjectiveWireState(raid.AntonDungeonHide)
}
for _, objective := range state.Lifecycle.Objectives {
if objective.Phase != runtimeRaidDungeonPhaseOne {
continue
}
nativeObjectives = append(nativeObjectives, objective)
selectorID, ok := runtimeRaidSingleSelectorMapID(state.SingleRaidType, objective.MapID)
if !ok {
continue
}
wireStates[selectorID] = runtimeRaidObjectiveWireState(objective.State)
clearCounts[selectorID] = uint32(objective.ClearCount)
}
symbols := make([]raid.RaidSymbolRow, 0, len(selectorIDs))
dungeons := make([]raid.RaidDungeonStateRow, 0, len(selectorIDs))
for _, selectorID := range selectorIDs {
key, ok := runtimeRaidObjectiveSymbolKey(selectorID)
if !ok {
return nil, nil, errRuntimeRaidPhaseMismatch
}
symbols = append(symbols, raid.RaidSymbolRow{Key: key, Value: clearCounts[selectorID]})
dungeons = append(dungeons, raid.RaidDungeonStateRow{
DungeonID: uint32(selectorID),
State: wireStates[selectorID],
})
}
// This EXE has two simultaneous identities for a single-Anton dungeon.
// The selector and Insert status UI render only the legacy 210..224 rows,
// while the dedicated 5314..5322 identity from op28 is used by the Raid
// objective lookup that builds op695 monster-HP rows. Keep both identities
// in op585: omitting the native alias makes that lookup return an empty
// vector and the client retires every room actor immediately; omitting the
// legacy row leaves the selector blank.
sort.Slice(nativeObjectives, func(i, j int) bool { return nativeObjectives[i].MapID < nativeObjectives[j].MapID })
for _, objective := range nativeObjectives {
dungeons = append(dungeons, raid.RaidDungeonStateRow{
DungeonID: uint32(objective.MapID),
State: runtimeRaidObjectiveWireState(objective.State),
})
}
symbolBody, err := raid.BuildRaidSymbolBody(symbols)
if err != nil {
return nil, nil, err
}
dungeonBody, err := raid.BuildRaidDungeonStateBody(raid.RaidDungeonStateSnapshot{Rows: dungeons})
if err != nil {
return nil, nil, err
}
return symbolBody, dungeonBody, nil
}
func runtimeRaidLukeSingleSelectorSnapshotBodies(state runtimeRaidState) ([]byte, []byte, error) {
selectorIDs := make([]int, 0, len(state.Lifecycle.Objectives))
for mapID, objective := range state.Lifecycle.Objectives {
if objective.Phase == runtimeRaidDungeonPhaseOne {
selectorIDs = append(selectorIDs, mapID)
}
}
sort.Ints(selectorIDs)
symbols := make([]raid.RaidSymbolRow, 0, len(selectorIDs))
dungeons := make([]raid.RaidDungeonStateRow, 0, len(selectorIDs))
for _, selectorID := range selectorIDs {
objective := state.Lifecycle.Objectives[selectorID]
key, ok := runtimeRaidObjectiveSymbolKey(selectorID)
if !ok {
return nil, nil, errRuntimeRaidPhaseMismatch
}
symbols = append(symbols, raid.RaidSymbolRow{Key: key, Value: uint32(objective.ClearCount)})
dungeons = append(dungeons, raid.RaidDungeonStateRow{
DungeonID: uint32(selectorID),
State: runtimeRaidObjectiveWireState(objective.State),
})
}
symbolBody, err := raid.BuildRaidSymbolBody(symbols)
if err != nil {
return nil, nil, err
}
dungeonBody, err := raid.BuildRaidDungeonStateBody(raid.RaidDungeonStateSnapshot{Rows: dungeons})
if err != nil {
return nil, nil, err
}
return symbolBody, dungeonBody, nil
}
// runtimeRaidLukeObjectiveSymbolKeys 是 etc/raid/luke/luke.etc.symbol 的
// "<id> DGN HP" 表里**队伍攻坚**那些门,逐字抄录而不是算出来的:这些号不连续
// (72、77..81、83 空着),而且队伍门也不是按 map id 排序的,任何算术形式都是猜。
//
// 上游这张表还包含 5323..5334 → 89..100(单人卢克的 12 关)。本树**有意省略那一段**:
// 我们这条线的单人卢克已经上线在用,走下面原有的 `73 + (mapID-5323)` 算术分支;
// 上游把这一段重编成 89..100,动它会改到已经跑通的单人卢克显示,所以这里只补
// 队伍攻坚缺失的门(df0b093:卢克队伍门以前在这张表里一个都没有,快照构建器会把
// 没有符号的门整行跳过,op585 就发出 0 行,战况表与选图页什么都画不出来)。
var runtimeRaidLukeObjectiveSymbolKeys = map[int]uint32{
// 一阶段三个圣所。
5054: 67, 5055: 68, 5056: 69,
// 二阶段:两个能源房、两个祭坛、两个王座。
5057: 70, 5058: 71, 5059: 73, 5060: 74, 5061: 75, 5062: 76,
// 二阶段:两个祭坛入口与两个共用房间。
5311: 82, 5313: 84, 5063: 85, 5064: 86, 5065: 87, 5066: 88,
}
func runtimeRaidObjectiveSymbolKey(mapID int) (uint32, bool) {
if key, ok := runtimeRaidLukeObjectiveSymbolKeys[mapID]; ok {
return key, true
}
switch {
case mapID >= 5314 && mapID <= 5322:
return uint32(64 + mapID - 5314), true
case mapID >= 5323 && mapID <= 5334:
return uint32(73 + mapID - 5323), true
case mapID >= 210 && mapID <= 216:
return uint32(50 + mapID - 210), true
case mapID >= 218 && mapID <= 224:
return uint32(57 + mapID - 218), true
default:
return 0, false
}
}
// runtimeRaidObjectiveWireState projects one gate state onto the 0x0249/0x024A
// state byte.
//
// The current EXE hands the byte straight to sub_17C13C0(manager, dungeonId,
// state) and every reader branches on -1 / 0 / 1 / 2 / 3 (getter sub_17C1420 has
// 43 consumers: 0x00F142C9 tests 0/1/3 explicitly and defaults 2, while
// 0x00F16987, 0x015CA923 and 0x015CBCCF compare against -1, 2, 1 and 3 in turn).
// etc/raid/anton.etc only ever spells "open" and "hide" -- verified against the
// real Script.pvf -- and the 86JP reference server sends 0 for those and 2 for a
// gate it has closed again, 3 for one whose clears are done. So:
//
// open -> 0, hide -> 2, cleared -> 3
//
// "close" is a PVF word this raid never uses (the Luke catalogs do); it keeps its
// old value because nothing pins what a closed-but-listed gate should look like.
func runtimeRaidObjectiveWireState(state raid.AntonDungeonState) uint32 {
switch state {
case raid.AntonDungeonOpen:
return 0
case raid.AntonDungeonCleared:
return 3
case raid.AntonDungeonClosed:
return 1
case raid.AntonDungeonHide:
return 2
default:
return 2
}
}
// runtimeRaidViewerObjectiveWireState 把一行的状态投影成**某一名成员**看到的字节。
//
// 卢克二阶段有两条互斥路线:被绑到一条的成员,看另一条路线的门应当画成 close,
// 否则界面把它画成 open、玩家点了却被拒,屏幕上什么解释都没有。
//
// 只有呈现会变:权威 objective 保留自己的状态,真正拒绝入场的是
// validateAntonRaidDungeonEntry。viewer=0 或非队伍卢克时逐字返回原值。
//
// 移植自上游 raid_phase_runtime.go(bfb2098 / ede0bf5)。
func runtimeRaidViewerObjectiveWireState(
state runtimeRaidState,
viewer uint16,
objective runtimeRaidDungeonObjective,
) uint32 {
wire := runtimeRaidObjectiveWireState(objective.State)
if viewer == 0 || objective.State != raid.AntonDungeonOpen ||
state.TailFlag == 1 || runtimeRaidTeamGraphOwner(state) != runtimeRaidOwnerLuke {
return wire
}
if runtimeRaidLukeRouteEntryAllowedLocked(&state, viewer, objective.MapID) {
return wire
}
return runtimeRaidObjectiveWireState(raid.AntonDungeonClosed)
}
// runtimeRaidPhaseSnapshotViewerDungeonBody 只重建某一名成员的 0x024A 行,
// 共享的符号/计时器通知仍然只编码一次。
func runtimeRaidPhaseSnapshotViewerDungeonBody(state runtimeRaidState, viewer uint16) ([]byte, error) {
_, dungeonBody, _, err := runtimeRaidPhaseSnapshotBodiesForViewer(state, viewer)
if err != nil {
return nil, err
}
return dungeonBody, nil
}
func (s *Service) publishRuntimeRaidObjectiveProgress(raidKey uint32, phase byte) error {
// A member whose client froze inside a gate never reports abandoned or
// completed, so its record and its squad's occupancy would survive every later
// completion and keep openRuntimeRaidPhaseMovie closed forever. Reconcile the
// open gates against the live sessions before this progress is judged.
if released := s.releaseRuntimeRaidStaleGateOccupancy(raidKey); released > 0 {
s.logPacketEvent("game-raid-objective-progress-stale-gates-released",
"raid_key", raidKey, "raid_phase", phase, "released", released)
}
s.raidMu.Lock()
state := s.raids[raidKey]
if state == nil {
s.raidMu.Unlock()
return errRuntimeRaidNotFound
}
if state.Lifecycle.Phase != phase || len(state.Lifecycle.Objectives) == 0 {
s.raidMu.Unlock()
return nil
}
progress := cloneRuntimeRaidState(*state)
complete := runtimeRaidPhaseObjectivesComplete(progress, phase)
s.raidMu.Unlock()
now := time.Now().UTC()
if err := s.broadcastRuntimeRaidPhaseSnapshot(progress, false, now); err != nil {
return err
}
if !complete {
return nil
}
// The tail is handed to the gameplay timer queue instead of running on the
// caller's stack.
//
// The caller here is the completing member's packet goroutine, and it holds
// that member's own session.dungeon.mu (completeCurrentDungeonBossDieCheckLocked
// is the entry point). The tail force-returns every teammate still inside a gate
// of the phase, and that return takes the teammate's session lock
// (sendCurrentFullDungeonReturnToTown, dungeon_return_town.go:1000). With two
// members both inside their own packet path -- the normal case for a party
// clearing 擎天 A/B or 火山 -- each goroutine holds its own lock and then waits
// for the other's: a permanent deadlock in which the settlement, the movie and
// every later packet never happen. Live 2026-09-20 08:32:02Z, raid 1507329: op115
// went out, the participation rows and the volcano cutscene marker went out, and
// then that connection was silent for 71.2 s until the client's socket died --
// no event=completed, no phase-clear registration, no settlement entry.
// Reproduced deterministically (before this change) by
// TestRuntimeRaidPhaseCompletionNeverWaitsOnAMateSessionLock.
//
// What the old comment here called "safe" was only the TryLock *probe*: the probe
// skips a busy session, but between the probe and the actual return the teammate's
// own goroutine can take its lock, and then the return blocks on it. 86JP has the
// same ordering discipline this restores: the phase-break presentation is awaited
// outside every manager and session critical section (RaidHandler.Progression.cs
// :998-1015), and the displaced groups are returned per session, each in its own
// try/catch, never from the clearing member's critical section.
//
// Ordering is unchanged -- the forced returns still run before the settlement and
// the movie, because finishRuntimeRaidPhase does them in that order -- it simply
// no longer runs inside the completing member's session lock.
s.scheduleRuntimeRaidPhaseCompletion(raidKey, phase)
return nil
}
// finishRuntimeRaidPhase is the completed phase's tail: force the parties still
// inside the phase's gates back to town, announce the clear, and publish the
// settlement movie.
func (s *Service) finishRuntimeRaidPhase(raidKey uint32, phase byte) {
if s == nil || raidKey == 0 {
return
}
// The gates first: the movie refuses to open while any gate is still occupied
// (openRuntimeRaidPhaseMovie requires len(GroupOccupancy) == 0), and the forced
// return is what releases them.
if returned := s.forceReturnRuntimeRaidPhaseGroups(raidKey, phase, "raid_phase_objective_completed"); returned > 0 {
s.logPacketEvent("game-raid-phase-groups-force-returned",
"raid_key", raidKey,
"raid_phase", phase,
"returned_sessions", returned,
"reason", "phase_objectives_complete_closes_every_gate_of_the_phase")
}
s.raidMu.Lock()
state := s.raids[raidKey]
if state == nil || state.Lifecycle.Phase != phase ||
state.Lifecycle.Status != runtimeRaidLifecyclePhaseActive {
// The phase already settled (a second member's completion scheduled the same
// tail) or the raid is gone: the settlement below must not run twice.
s.raidMu.Unlock()
return
}
settling := cloneRuntimeRaidState(*state)
s.raidMu.Unlock()
// 门还没全关上时**不能**往下走:openRuntimeRaidPhaseMovie 要求 GroupOccupancy 为空,
// 于是电影打不开 → 结算不发 → 客户端既没有结算页也没有翻牌,通报也一起丢。
//
// 真机 2026-10-01 20:32:37/38(两人几乎同时打死罗什):两台会话各自占着自己的
// session.dungeon 锁去登记通关,forceReturnRuntimeRaidPhaseGroups 逐个 TryLock
// 时**两个都被跳过**(game-raid-phase-force-return-skipped-busy-session ×2),
// 于是这里没有重试、整场卡住三分钟,直到玩家自己按"返回城镇"。
// 现在改为有界重试:等他们真的回城了再把结算放出去。
if len(settling.Lifecycle.GroupOccupancy) != 0 {
if s.scheduleRuntimeRaidPhaseCompletionRetry(raidKey, phase) {
s.logPacketEvent("game-raid-phase-completion-awaits-free-gates",
"raid_key", raidKey,
"raid_phase", phase,
"occupied_gates", len(settling.Lifecycle.GroupOccupancy),
"reason", "phase_tail_retries_until_every_gate_is_released")
}
return
}
s.clearRuntimeRaidPhaseCompletionRetries(raidKey)
// The raid is over the moment its last phase completes: announce it on the
// channel while the settlement movie is still to come.
s.announceRuntimeRaidClearIfNeeded(settling, phase)
wirePhase, err := runtimeRaidSettlementWirePhase(phase)
if err != nil {
s.logPacketEvent("game-raid-phase-completion-wire-phase-failed",
"raid_key", raidKey, "raid_phase", phase, "error", err)
return
}
if err := s.publishRuntimeRaidPhaseSettlement(raidKey, phase, raid.RaidSettlementResult{
ResultType: 0,
PhaseIndex: wirePhase,
ClearSeconds: runtimeRaidDurationSeconds(runtimeRaidPhaseElapsed(settling, time.Now().UTC())),
}, time.Now().UTC()); err != nil {
s.logPacketEvent("game-raid-phase-completion-settlement-failed",
"raid_key", raidKey, "raid_phase", phase, "error", err)
}
}
// runtimeRaidPhaseCompletionRetryDelay / runtimeRaidPhaseCompletionRetryLimit bound the
// "wait for every gate to be released" retry of the completed phase's tail.
//
// Two seconds is longer than a packet path takes to release its session lock and shorter
// than the client's own patience; 15 attempts is 30 seconds, which is plenty for a forced
// return to land and small enough that a genuinely stuck gate cannot spin forever.
const (
runtimeRaidPhaseCompletionRetryDelay = 2 * time.Second
runtimeRaidPhaseCompletionRetryLimit = 15
)
// scheduleRuntimeRaidPhaseCompletionRetry re-runs the completed phase's tail while some
// gate is still occupied, so the settlement movie is not lost when every forced return
// hit a busy session lock. Bounded: runtimeRaidPhaseCompletionRetryLimit attempts.
//
// Returns false when the budget is spent (the tail then simply does not settle, exactly
// as before this change, and the log says so).
func (s *Service) scheduleRuntimeRaidPhaseCompletionRetry(raidKey uint32, phase byte) bool {
if s == nil || raidKey == 0 {
return false
}
s.raidPhaseCompletionMu.Lock()
if s.raidPhaseCompletionRetries == nil {
s.raidPhaseCompletionRetries = make(map[uint32]int)
}
attempts := s.raidPhaseCompletionRetries[raidKey]
if attempts >= runtimeRaidPhaseCompletionRetryLimit {
s.raidPhaseCompletionMu.Unlock()
s.logPacketEvent("game-raid-phase-completion-retry-exhausted",
"raid_key", raidKey, "raid_phase", phase, "attempts", attempts,
"reason", "gates_stayed_occupied_until_the_retry_budget_ran_out")
return false
}
s.raidPhaseCompletionRetries[raidKey] = attempts + 1
s.raidPhaseCompletionMu.Unlock()
retry := func(time.Time) { s.finishRuntimeRaidPhase(raidKey, phase) }
queue := s.ensureGameplayTimeQueue()
if queue == nil {
go retry(time.Time{})
return true
}
name := fmt.Sprintf("raid-phase-complete-retry:%d:%d:%d", raidKey, phase, attempts)
if err := queue.ScheduleAfter(name, runtimeRaidPhaseCompletionRetryDelay, retry); err != nil {
s.logPacketEvent("game-raid-phase-completion-retry-schedule-failed",
"raid_key", raidKey, "raid_phase", phase, "error", err)
go retry(time.Time{})
}
return true
}
// clearRuntimeRaidPhaseCompletionRetries drops the retry budget once the tail settled.
func (s *Service) clearRuntimeRaidPhaseCompletionRetries(raidKey uint32) {
if s == nil || raidKey == 0 {
return
}
s.raidPhaseCompletionMu.Lock()
delete(s.raidPhaseCompletionRetries, raidKey)
s.raidPhaseCompletionMu.Unlock()
}
// scheduleRuntimeRaidPhaseCompletion is kept for the callers that must not run the
// tail on their own stack (a raid phase that expired from a timer callback, and the
// objective-completion path, whose caller is a packet goroutine holding its own
// session lock). It is the one entry point for the tail from any such caller.
func (s *Service) scheduleRuntimeRaidPhaseCompletion(raidKey uint32, phase byte) {
if s == nil || raidKey == 0 {
return
}
queue := s.ensureGameplayTimeQueue()
if queue == nil {
go s.finishRuntimeRaidPhase(raidKey, phase)
return
}
name := fmt.Sprintf("raid-phase-complete:%d:%d", raidKey, phase)
if err := queue.ScheduleAfter(name, 0, func(time.Time) {
s.finishRuntimeRaidPhase(raidKey, phase)
}); err != nil {
s.logPacketEvent("game-raid-phase-completion-schedule-failed",
"raid_key", raidKey, "raid_phase", phase, "error", err)
go s.finishRuntimeRaidPhase(raidKey, phase)
}
}
// scheduleRuntimeRaidForcedGroupReturn hands one forced-return sweep to the gameplay
// timer queue.
//
// The sweep itself is synchronous and correct; what must never happen is running it
// while the caller holds a session lock. Its callers are packet paths (the
// completing member's own goroutine, which holds that member's session.dungeon.mu),
// and the sweep takes each targeted teammate's session lock, so two members each
// inside their own packet path would wait for each other forever. The reference
// server has the same rule from the other side: its phase-break presentation and its
// displaced-group returns are awaited outside every manager lock
// (RaidHandler.Progression.cs:992-1015, and "锁内绝不 await" in
// DungeonCombatHandler.cs:2164).
//
// logKind, when non-empty, is emitted with report(returned) after the sweep.
// It falls back to a bare goroutine when the queue is unavailable or refuses the
// task, because losing the sweep would leave the gate occupied and stall the phase.
func (s *Service) scheduleRuntimeRaidForcedGroupReturn(
raidKey uint32,
phase byte,
source string,
logKind string,
report func(returned int) []any,
match func(mapID int) bool,
) {
if s == nil || raidKey == 0 {
return
}
run := func(time.Time) {
returned := s.forceReturnRuntimeRaidGroupsMatching(raidKey, phase, source, match)
if returned > 0 && logKind != "" && report != nil {
s.logPacketEvent(logKind, report(returned)...)
}
}
queue := s.ensureGameplayTimeQueue()
if queue == nil {
go run(time.Time{})
return
}
name := fmt.Sprintf("raid-phase-return-groups:%d:%d:%s", raidKey, phase, source)
if err := queue.ScheduleAfter(name, 0, run); err != nil {
s.logPacketEvent("game-raid-phase-group-return-schedule-failed",
"raid_key", raidKey,
"raid_phase", phase,
"source", source,
"error", err)
go run(time.Time{})
}
}
// forceReturnRuntimeRaidPhaseGroups sends every member still inside a gate of the
// completed phase back to town, through the same abandon route the client's own
// 返回城镇 uses, and reports how many sessions were returned.
//
// It is the current-server shape of 86JP's
// ReturnRaidDungeonGroupsClosedByObjectiveAsync: the gate is closed by the
// objective, so the run cannot be finished and the member must not be left in a
// dungeon the raid no longer owns. Retiring the run also publishes the raid
// abandon (sendCurrentActiveDungeonReturnToTown -> notifyRuntimeRaidDungeonAbandonedLocked),
// which releases the squad's gate occupancy and lets the phase movie open.
func (s *Service) forceReturnRuntimeRaidPhaseGroups(raidKey uint32, phase byte, source string) int {
return s.forceReturnRuntimeRaidGroupsMatching(raidKey, phase, source, nil)
}
// forceReturnRuntimeRaidGroupsMatching is the same forced return narrowed to the
// gates the caller names: the volcano's shield coming back closes only the black
// volcano (219), while the volcano falling closes 218/219 and every hatchery
// (86JP TryInvalidateDungeonRuns([218,219,221..224])). A nil match means every gate
// of the phase.
func (s *Service) forceReturnRuntimeRaidGroupsMatching(
raidKey uint32,
phase byte,
source string,
match func(mapID int) bool,
) int {
if s == nil || raidKey == 0 {
return 0
}
s.raidMu.Lock()
state := s.raids[raidKey]
if state == nil || state.Lifecycle.Phase != phase {
s.raidMu.Unlock()
return 0
}
seen := make(map[uint16]bool, len(state.FrozenMembers))
candidates := make([]uint16, 0, len(state.FrozenMembers))
for _, member := range state.FrozenMembers {
if member.CharID == 0 || seen[member.CharID] {
continue
}
seen[member.CharID] = true
candidates = append(candidates, member.CharID)
}
s.raidMu.Unlock()
type phaseRunTarget struct {
session *gameSession
runtime *runtimeDungeonState
}
targets := make([]phaseRunTarget, 0, len(candidates))
for _, characterID := range candidates {
session, ok := s.onlineGameSession(characterID)
if !ok || session == nil {
continue
}
// TryLock, never Lock. This runs from the completing member's own packet path,
// which already holds that member's session.dungeon.mu, and Go mutexes are not
// reentrant: a plain Lock on that session parked its goroutine forever, and the
// phase never settled (live 2026-09-19 16:37:45Z, raid 1507329: closed gate
// table and snapshot out, then nothing at all -- no settlement, no error, "一阶段
// 打完了 不结束"). A session whose lock is busy is skipped, never waited on: the
// member that is holding it is the one that just cleared the gate, whose client
// is on its own settlement page, and every teammate still inside a closed gate
// is returned below.
if !session.dungeon.mu.TryLock() {
s.logPacketEvent("game-raid-phase-force-return-skipped-busy-session",
"raid_key", raidKey,
"raid_phase", phase,
"char_id", characterID,
"reason", "session_lock_held_by_the_member_that_completed_the_phase")
continue
}
runtime := session.dungeon.runtime
insidePhase := runtime != nil &&
runtime.raidDungeon.RaidKey == raidKey &&
runtime.raidDungeon.Phase == phase &&
dungeonRuntimeOwnsCharacter(runtime, characterID)
if insidePhase && match != nil {
insidePhase = match(runtime.raidDungeon.MapID)
}
session.dungeon.mu.Unlock()
if insidePhase {
targets = append(targets, phaseRunTarget{session: session, runtime: runtime})
}
}
returned := 0
for _, target := range targets {
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
transition, err := s.prepareCurrentDungeonTownTransitionForSession(ctx, target.session, target.session.selectedCharacterID)
cancel()
if err != nil {
s.logGameEvent(target.session, "game-raid-phase-force-return-blocked",
"raid_key", raidKey,
"raid_phase", phase,
"char_id", target.session.selectedCharacterID,
"dungeon_id", target.runtime.Dungeon.ID,
"reason", "town_transition_unavailable_for_completed_phase",
"error", err)
continue
}
if err := s.sendCurrentActiveDungeonReturnToTownKeepingParty(
target.session,
target.runtime,
transition,
uint16(dnfenum.CmdPacketGiveupGame),
source+"_force_town_return",
); err != nil {
s.logGameEvent(target.session, "game-raid-phase-force-return-blocked",
"raid_key", raidKey,
"raid_phase", phase,
"char_id", target.session.selectedCharacterID,
"dungeon_id", target.runtime.Dungeon.ID,
"reason", "force_town_return_failed",
"error", err)
continue
}
returned++
s.logGameEvent(target.session, "game-raid-phase-group-returned",
"raid_key", raidKey,
"raid_phase", phase,
"char_id", target.session.selectedCharacterID,
"dungeon_id", target.runtime.Dungeon.ID,
"source", source,
"reason", "the_phase_completed_and_closed_every_gate_of_this_phase")
}
return returned
}
func (s *Service) scheduleRuntimeRaidPhaseExpiry(state runtimeRaidState) error {
queue := s.ensureGameplayTimeQueue()
if queue == nil {
return errGameplayTimeQueueUnavailable
}
delay := state.Lifecycle.PhaseDeadline.Sub(queue.Now())
if delay < 0 {
delay = 0
}
name := fmt.Sprintf("raid-phase:%d:%d:%d", state.RaidKey, state.Lifecycle.Phase, state.StartAttempt)
return queue.ScheduleAfter(name, delay, func(due time.Time) {
failed, err := s.expireRuntimeRaidPhase(state.RaidKey, state.Lifecycle.Phase, due)
if err != nil {
s.logPacketEvent("game-raid-phase-expiry-ignored", "raid_key", state.RaidKey, "raid_phase", state.Lifecycle.Phase, "error", err)
return
}
// 超时空的过期与安徒恩/卢克走的不是同一条收尾:它要把整张地图上的成员全部
// 送回**待机区** (98/2,不是频道门口 98/0)、退掉重试券、发 587{6,1}+601 这一
// 对失败演出,并在保持 5 秒后放回 587{0} 让攻坚队还能再开一次。
// 关键区别:**不删攻坚队行、不发 op665** —— op665 就是客户端那句
// 「已经强制退出攻坚队。」,也是服主报的"攻坚队被解散"。见 publishFailedFiendWar。
if failed.RaidMode == runtimeRaidOwnerFiendWar {
if err := s.grantRuntimeFiendWarRetry(failed, due); err != nil {
s.logPacketEvent("game-fiendwar-retry-ticket-grant-failed", "raid_key", failed.RaidKey, "error", err)
s.scheduleRuntimeFiendWarRetry(failed, due)
}
// expireRuntimeRaidPhase 把行留在表里 (Status=Failed) 并在这里交给
// 超时空自己的收尾;它既不删行也不排 scheduleRuntimeRaidFailedRemoval
// —— 那条十秒清除器会把刚保住的攻坚队又删掉。
if err := s.publishFailedFiendWar(failed); err != nil {
s.logPacketEvent("game-fiendwar-expiry-publish-failed", "raid_key", failed.RaidKey, "error", err)
}
// 攻坚失败返还入场次数(+ 入场材料,超时空那一份由上面的重试券链路独占,
// 见 runtimeRaidEntryMaterialRefundOwnedByRetry)。放在失败演出之后:返还的
// 收尾是重推 op1303/op1206,那两包不能挤进横幅序列里。
s.refundFailedRuntimeRaidEntry(failed, due)
return
}
// 86JP publishes the failure, then hands the terminal object to a ten-second
// remover (RaidHandler.Runtime.cs:639-644, :730-762). The raid stays in the
// raid table in between so a reconnecting client still reads the result.
if err := s.publishFailedRuntimeRaid(failed); err != nil {
s.logPacketEvent("game-raid-phase-timeout-publish-failed", "raid_key", failed.RaidKey, "raid_phase", failed.Lifecycle.Phase, "tail_flag", failed.TailFlag, "error", err)
}
// 攻坚失败返还:安徒恩/卢克的唯一失败形态就是这条倒计时到点,所以这里是它们
// 全部的次数返还点。中途放弃(abandonRuntimeRaid)不走这里,也就不返还。
s.refundFailedRuntimeRaidEntry(failed, due)
s.scheduleRuntimeRaidFailedRemoval(failed.RaidKey, failed.Lifecycle.Phase)
})
}
// scheduleRuntimeRaidFailedRemoval arms 86JP's delayed teardown of a failed raid
// (RemoveFailedRaidAfterResultDelayAsync, RaidHandler.Runtime.cs:730-762). It is
// armed before the failure presentation is written, so a failed raid can never
// outlive its window even if a socket write fails.
func (s *Service) scheduleRuntimeRaidFailedRemoval(raidKey uint32, phase byte) {
if s == nil || raidKey == 0 {
return
}
queue := s.ensureGameplayTimeQueue()
if queue == nil {
// Without a timer queue the failed raid would stay in the table forever, so
// release it inline rather than leaving every later admission blocked.
s.removeRuntimeRaidFailedPhase(raidKey, phase)
return
}
name := fmt.Sprintf("raid-fail-remove:%d:%d", raidKey, phase)
if err := queue.ScheduleAfter(name, runtimeRaidFailedResultWindow, func(time.Time) {
s.removeRuntimeRaidFailedPhase(raidKey, phase)
}); err != nil {
s.logPacketEvent("game-raid-failed-raid-removal-schedule-failed",
"raid_key", raidKey, "raid_phase", phase, "error", err)
s.removeRuntimeRaidFailedPhase(raidKey, phase)
}
}
func (s *Service) enterRuntimeRaidStandby(raidKey uint32, phase byte, now time.Time) (runtimeRaidState, raid.RaidTimer, error) {
now = runtimeRaidNormalizeTime(now)
s.raidMu.Lock()
state := s.raids[raidKey]
if state == nil {
s.raidMu.Unlock()
return runtimeRaidState{}, raid.RaidTimer{}, errRuntimeRaidNotFound
}
if state.Lifecycle.Status == runtimeRaidLifecycleStandby && state.Lifecycle.Phase == phase {
standby := cloneRuntimeRaidState(*state)
s.raidMu.Unlock()
return standby, runtimeRaidStandbyTimer(standby), nil
}
if phase != runtimeRaidDungeonPhaseOne || state.Lifecycle.Phase != phase ||
state.Lifecycle.Status != runtimeRaidLifecycleAwaitingRewards || !state.Lifecycle.RewardDeliveryCommitted {
s.raidMu.Unlock()
return runtimeRaidState{}, raid.RaidTimer{}, errRuntimeRaidRewardNotReady
}
state.Lifecycle.Status = runtimeRaidLifecycleStandby
state.Lifecycle.StandbyStartedAt = now
config := runtimeRaidBaseConfig(*state)
if config == nil {
s.raidMu.Unlock()
return runtimeRaidState{}, raid.RaidTimer{}, errRuntimeRaidLifecycleState
}
state.Lifecycle.StandbyDeadline = now.Add(time.Duration(config.PhaseBreakSeconds) * time.Second)
state.Lifecycle.Revision++
previousRevision := state.Lifecycle.Revision - 1
standby := cloneRuntimeRaidState(*state)
s.raidMu.Unlock()
if err := s.scheduleRuntimeRaidStandby(standby); err != nil {
s.raidMu.Lock()
if current := s.raids[raidKey]; current != nil &&
current.Lifecycle.Status == runtimeRaidLifecycleStandby &&
current.Lifecycle.Phase == phase &&
current.Lifecycle.Revision == standby.Lifecycle.Revision {
current.Lifecycle.Status = runtimeRaidLifecycleAwaitingRewards
current.Lifecycle.StandbyStartedAt = time.Time{}
current.Lifecycle.StandbyDeadline = time.Time{}
current.Lifecycle.Revision = previousRevision
}
s.raidMu.Unlock()
return runtimeRaidState{}, raid.RaidTimer{}, err
}
return standby, runtimeRaidStandbyTimer(standby), nil
}
func runtimeRaidStandbyTimer(state runtimeRaidState) raid.RaidTimer {
return raid.RaidTimer{
TimerType: 1,
Value0: state.RaidKey,
Value1: uint32(state.Lifecycle.StandbyDeadline.Unix()),
Value2: runtimeRaidDurationSeconds(state.Lifecycle.StandbyDeadline.Sub(state.Lifecycle.StandbyStartedAt)),
}
}
func (s *Service) scheduleRuntimeRaidStandby(state runtimeRaidState) error {
queue := s.ensureGameplayTimeQueue()
if queue == nil {
return errGameplayTimeQueueUnavailable
}
delay := state.Lifecycle.StandbyDeadline.Sub(queue.Now())
if delay < 0 {
delay = 0
}
name := fmt.Sprintf("raid-standby:%d:%d", state.RaidKey, state.StartAttempt)
return queue.ScheduleAfter(name, delay, func(due time.Time) {
if _, err := s.activateRuntimeRaidPhase(state.RaidKey, runtimeRaidDungeonPhaseTwo, due, false); err != nil {
s.logPacketEvent("game-raid-standby-activation-failed", "raid_key", state.RaidKey, "error", err)
}
})
}
func runtimeRaidDurationSeconds(duration time.Duration) uint32 {
if duration <= 0 {
return 0
}
seconds := uint64(duration / time.Second)
if duration%time.Second != 0 {
seconds++
}
if seconds > math.MaxUint32 {
return math.MaxUint32
}
return uint32(seconds)
}