Files
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

2939 lines
131 KiB
Go
Raw Permalink 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 (
"errors"
"fmt"
"sort"
"time"
"longheng.io/server/internal/modules/dnf/raid"
"longheng.io/server/internal/modules/dnf/worldmap"
)
// runtimeRaidLifecycleStatus describes server-owned progression only. Wire
// values are deliberately kept in the protocol package and are not mirrored
// here.
type runtimeRaidLifecycleStatus byte
const (
runtimeRaidLifecycleCreated runtimeRaidLifecycleStatus = iota
runtimeRaidLifecyclePreparing
runtimeRaidLifecyclePhaseActive
runtimeRaidLifecycleAwaitingMovie
runtimeRaidLifecycleAwaitingRewards
runtimeRaidLifecycleStandby
runtimeRaidLifecycleFinished
runtimeRaidLifecycleAbandoned
// runtimeRaidLifecycleFailed is the attack-window timeout: the raid is over
// (86JP RaidManager.TryFailPhase sets state 4 with argument 1) but the object
// stays alive for the ten-second result/reconnect window before the delayed
// remover releases the roster (RunAttackTimeoutAsync +
// RemoveFailedRaidAfterResultDelayAsync, RaidHandler.Runtime.cs:592-762).
runtimeRaidLifecycleFailed
)
type runtimeRaidDungeonProgressStatus byte
const (
runtimeRaidDungeonProgressSelected runtimeRaidDungeonProgressStatus = iota + 1
runtimeRaidDungeonProgressLoaded
runtimeRaidDungeonProgressCompleted
)
// runtimeRaidDungeonSeat names who holds a raid gate: the attack group of the member
// that opened the run, or -- when that member belongs to no attack group -- the member
// itself.
//
// A squad fights one gate at a time, so its members share one seat: the second one to
// walk in adopts the run the first one opened. A member nobody has assigned to a squad
// belongs to no group, so it gets its own seat instead of squatting on squad 1's:
// without that, two unassigned members could not be inside two different gates at the
// same time (the second entry was refused with errRuntimeRaidGroupOccupied), and an
// unassigned member standing in one gate blocked squad 1 from entering another.
// Live report 2026-09-17 21:5x, the same session in which an unassigned member's 战况表
// row turned out to be drawn as squad 1's.
type runtimeRaidDungeonSeat struct {
Group byte
CharacterID uint16
}
// runtimeRaidSeatFor is the seat of one roster member.
func runtimeRaidSeatFor(member runtimeRaidMemberState) runtimeRaidDungeonSeat {
if squad := runtimeRaidMemberSquad(member.GroupIndex); squad != 0 {
return runtimeRaidDungeonSeat{Group: squad}
}
return runtimeRaidDungeonSeat{CharacterID: member.CharID}
}
// runtimeRaidDungeonSeatFor resolves the seat one run is keyed by.
//
// 安东/卢克:座位就是小队(或未编队时的个人),逐字沿用 runtimeRaidSeatFor。
//
// 超时空:区域是**共享战场**,不是小队的格子。客户端点进已有同伴的区域时自己弹的
// 是「移动后,会与处于当前城镇的同伴一同开始战斗。确认要移动吗?」;PVF 的
// [AREA INFO] 也只有 [MEMBER MAX](每区域 4 人),没有"归属小队"。
//
// 所以超时空的座位按**区域**算:同一张图(一张图只属于一个区域)永远只有一条 run,
// 后到的人沿用先到者的座位,于是命中下面 beginRuntimeRaidDungeon 里现成的 adopt
// 分支(同座位 + 同 MapID → 沿用对方 binding 与 LifecycleToken,一场只报一次)。
//
// 注意**不能**用一个固定的零值座位:那张表的 key 只有座位,全团共用一个座位会让
// 分处两个区域的两个人互相顶掉对方的占位。
func runtimeRaidDungeonSeatFor(state runtimeRaidState, member runtimeRaidMemberState, mapID int) runtimeRaidDungeonSeat {
seat := runtimeRaidSeatFor(member)
if state.Lifecycle.FiendWar == nil {
return seat
}
for occupiedSeat, occupied := range state.Lifecycle.GroupOccupancy {
if occupied.MapID == mapID {
return occupiedSeat
}
}
return seat
}
// runtimeRaidDungeonRunKey identifies one gate run: one entry per (dungeon, seat), so
// two squads -- or two unassigned members -- may fight the same map at the same time
// (86JP RaidManager.cs:731-792 TryEnterDungeon refuses only a duplicate userId, and
// :1342-1349 keeps a list of participations).
type runtimeRaidDungeonRunKey struct {
MapID int
Seat runtimeRaidDungeonSeat
}
type runtimeRaidDungeonAttemptKey struct {
CharacterID uint16
LifecycleKey uint64
}
type runtimeRaidDungeonProgress struct {
Phase byte
MapID int
Seat runtimeRaidDungeonSeat
CharacterID uint16
LifecycleToken uint64
Status runtimeRaidDungeonProgressStatus
SelectedAt time.Time
LoadedAt time.Time
CompletedAt time.Time
// CoinsUsed counts the revive coins spent in this one entry, shared by the
// squad that holds it. The record is dropped when the run is completed or
// abandoned, so a retreat and re-entry starts again at zero -- which is the
// dungeon's own [coin limit] rule (Luke phase one: 8 per sanctum entry).
CoinsUsed int
}
type runtimeRaidDungeonTerminal struct {
Phase byte
MapID int
Kind runtimeRaidDungeonLifecycleKind
}
type runtimeRaidDungeonObjective struct {
Phase byte
MapID int
State raid.AntonDungeonState
MaxHP int
RemainingHP int
ClearCount int
PartyMax int
}
type runtimeRaidRewardSelection struct {
Mode byte
Index byte
ConfirmedAt time.Time
}
type runtimeRaidLifecycleState struct {
Status runtimeRaidLifecycleStatus
Phase byte
Revision uint64
StartedAt time.Time
ReadyDeadline time.Time
PhaseStartedAt time.Time
PhaseDeadline time.Time
StandbyStartedAt time.Time
StandbyDeadline time.Time
MovieOpenedAt time.Time
MovieConfirmed map[uint16]bool
Objectives map[int]runtimeRaidDungeonObjective
Dungeons map[runtimeRaidDungeonRunKey]runtimeRaidDungeonProgress
// PhaseClearCounts is the per-member clear registration of the active phase.
// The current client's 地下城通关 column renders the member record's clear byte
// as "<cleared>/<required>", and the phase settlement only rewards members it
// can prove cleared a gate, so this is the only place that fact lives. 86JP
// (RaidManager.TryClearDungeon) increments every member of the clearing squad
// and clears all counters when the next phase starts; the count therefore
// survives a disconnect and a rejoin, which is the same lifetime as the raid
// aggregate here.
PhaseClearCounts map[uint16]uint32
// RunClearCredits is the one-clear-per-run ledger of the active phase: it
// remembers, per gate run, which run token already spent that gate's point.
//
// 黑雾之源 (210) needs four clears, and a clear is a PARTY clear: one entry
// into the gate by one squad counts once, no matter how many of its members
// walked in. This server gives every member its own instance of the room
// (different seeds), so without this ledger a four-member squad took the whole
// gate down in a single run -- four member clears, four points -- and a
// two-member squad needed two runs instead of four (operator report
// 2026-09-19). 86JP counts the same way: one RaidDungeonParticipation per
// (dungeon, party) and one scalar clear count per (raid, dungeon)
// (RaidManager.cs, TryClearDungeon).
//
// The run key is reused by the squad's next run, so opening a new run clears
// its credit (see beginRuntimeRaidDungeon); a run whose record is already gone
// (its owner finished first) keeps its credit until then, which is what makes
// the late squadmate completions free.
RunClearCredits map[runtimeRaidDungeonRunKey]uint64
// DungeonStates is the last (gate -> wire state) table this raid broadcast for
// DungeonStatePhase. A cleared gate keeps reading 3 for the rest of the phase, so
// re-sending an unchanged row re-announces a finished dungeon to the client,
// which answers each cleared row with the 「已通关[%s]地下城」 toast (string 646).
// The live server was sending 618 whole tables in 23 minutes (2026-09-19
// 14:23-14:46Z, raid 1507338), and every one of them re-announced 第一孵化中心
// (221) 104 times, 第二孵化中心 (222) 52 times and 第四孵化中心 (224) 196 times.
//
// Only the changed rows travel after the phase's one full table (86JP:
// Progression.cs:681-685 sends the table once at activation and every later
// change goes out as RAID_CHANGE_DUNGEON_STATE, Runtime.cs:369-403). See
// claimRuntimeRaidDungeonStateRows.
DungeonStates map[uint32]uint32
DungeonStatePhase byte
// DungeonStateInfection is the trailing dword of that same 0x0249: the infected
// hatchery's map id, or 0 while none is coloured. It needs its own memory
// because the client reads the infection from that trailer and from nowhere
// else -- sub_1D39B50 hands it to sub_17B87B0, the only writer of
// manager+0x150, which every 战况表 renderer compares each gate against
// (sub_1CF26F0, and sub_17C29F0/sub_17C2B80 under the board init sub_F176A0).
//
// A row-only change travels as 0x024A, which has no trailer, so a gate moving
// never carried the infection with it: the board kept the value it last saw
// until the next WHOLE table happened to be drawn, which is why the operator
// saw no infection until an op15/ENTER_SELECT_DUNGEON table arrived (live
// 2026-09-20, raid 1507329: the tail only ever moved at 02:13:02Z and 02:19:13Z,
// both op15-driven, never on the 02:13:02Z open / 02:14:35Z suppress /
// 02:19:13Z recovery edges themselves). See claimRuntimeRaidDungeonStateRows.
DungeonStateInfection uint32
GroupOccupancy map[runtimeRaidDungeonSeat]runtimeRaidDungeonBinding
ClosedDungeonAttempts map[runtimeRaidDungeonAttemptKey]runtimeRaidDungeonTerminal
RewardToken uint64
RewardOpenedAt time.Time
RewardSelections map[uint16]runtimeRaidRewardSelection
RewardDelivery *runtimeRaidRewardDeliveryCache
RewardDeliveryCommitted bool
RewardDeliveryCommittedAt time.Time
FinishedAt time.Time
AbandonedAt time.Time
// FailedAt is when the attack window expired. The failed raid stays in the
// raid table until the ten-second removal timer fires.
FailedAt time.Time
// Mechanisms holds the armed Anton mechanism timers of the active phase.
// MechanismGeneration grows on every arm/clear so a dequeued callback can
// prove it still owns its slot; PhaseCycle grows on every mechanism reset
// so callbacks from a failed cycle cannot touch the next attempt.
Mechanisms map[runtimeRaidMechanismKey]runtimeRaidMechanismTimer
MechanismGeneration uint64
PhaseCycle uint64
// Phase-two barrier state. Barrier is the black-volcano shield (symbol
// 110); VolcanoOpen mirrors symbol 111 and is the dungeon-entry authority
// for 219; InfectionMapID is the infected hatchery (symbol 8, 0 = none) and
// InfectionSuppressed mirrors the clearing of symbol 7: the infection marker is
// on whenever a hatchery is infected and that hatchery is still up, and it is
// switched off while the infected hatchery is down (86JP writes symbol 7 to 0
// from ClearHatcheryAsync and back to 1 from TryRecoverHatcheryAsync).
// BarrierRequests counts accepted client shield requests per dungeon run
// so a run cannot report the same transmission twice.
Barrier uint32
VolcanoOpen bool
InfectionMapID int
InfectionSuppressed bool
BarrierRequests map[runtimeRaidBarrierRequestKey]uint32
// LukeGeneratorReports are the op662 generator reports of Luke's phase
// one, per sanctum run and room (raid_luke_generator.go). Created lazily.
LukeGeneratorReports map[runtimeRaidLukeGeneratorReportKey]runtimeRaidLukeGeneratorRoomReports
// LukeSanctumMazeGroups is the [CARD SET MAP INFO] group each phase-one
// sanctum walks, drawn on its first entry (raid_luke_sanctum_maze.go).
LukeSanctumMazeGroups map[int]int
// MovieSymbols are the one-shot 0x0247 markers the raid pushes so the client
// plays its Anton cutscenes and barrier animations. etc/raid/anton.etc.symbol
// owns the mapping (120 [DGN SMOKE OPEN], 122 [MOVIE SMOKE OUT], 126 [MOVIE
// BARRIER BREAK], 127 [MOVIE BARRIER RECOVER], 128 [MOVIE HATCHERY OPEN]); a
// value of 1 pulses the marker and it is replayed with every phase snapshot so
// a reconnecting member keeps the same state. Live 2026-09-16: the server had
// never sent a single 121..128 key in the whole log, which is why clearing
// 榛戦浘涔嬫簮 four times played no video.
MovieSymbols map[uint32]uint32
// LukeSymbols holds the Luke team graph's counters and effect flags from
// etc/raid/luke/luke.etc.symbol (the altar/temple counters, the two energy
// room effects, the phase-two route marker). Unlike MovieSymbols these are
// durable phase state rather than one-shot pulses, so the periodic snapshot
// carries them and a reconnecting member sees the same progress. Luke's
// symbol namespace is its own; nothing here is comparable to an Anton id.
LukeSymbols map[uint32]uint32
// LukeRouteLocks records which phase-two route each member is committed to,
// keyed by character id and holding the gate's own [LOCKOUT TYPE] value
// (4 light, 5 dark). A member is bound by the first route gate it enters and
// cannot cross to the other side for the rest of the phase. The two shared
// rooms declare 0 and never bind or block anyone.
LukeRouteLocks map[uint16]byte
// PhaseCoinsUsed counts the revive coins the whole roster has spent in the
// active phase. It only matters for a phase that declares
// [PHASE COIN LIMIT] (Luke phase two: 20, shared, not reset by re-entry);
// activation starts it at zero. See raid_phase_coin.go.
PhaseCoinsUsed int
// ClearAnnounced is the one-shot guard of the channel-wide clear
// announcement, keyed by the phase that cleared the raid. The announcement is
// raised on the objective transition, so anything that keeps the lifecycle on
// PhaseActive (a gate still occupied by a member whose client froze) re-runs
// that transition on every further clear and would repeat the banner: live
// 2026-09-17 07:26:44Z pushed msg 0x0272 to both members twice, 92ms apart.
// Claiming the phase once makes the banner a property of the raid, not of the
// number of times its completion was observed.
ClearAnnounced map[byte]bool
// RewardSkippedMembers is the set of frozen members this phase's settlement
// has given up on. A member that drops before it confirms the phase movie or
// picks its card can never satisfy either roster gate again, and the gate is
// what stands between the members that ARE there and their cards: live
// 2026-09-17, character 1's client crashed at 08:52:12Z, character 22
// confirmed the phase-two movie at 08:54:16Z, and the raid then sat in
// AwaitingMovie until the log ended -- one absent member swallowed the whole
// flip. A dropped socket is exactly "offline and only a reconnect can bring it
// back", which is the same fact raid_disconnect.go already records.
//
// The record is monotonic per phase: once a member is listed it never leaves
// the set, so a late reconnect can neither be paid (its row is not in the
// committed receipt) nor pull the settlement back into the waiting state (the
// gates stay satisfied).
RewardSkippedMembers map[uint16]bool
// RewardRosterFrozen pins RewardSkippedMembers at the instant the settlement's
// eligible roster is materialised, i.e. when the delivery token is minted.
// Until then the set is recomputed from live presence, so a member that
// reconnects inside its grace window is simply not skipped; from then on the
// set and the receipt that was planned from it can no longer drift apart.
RewardRosterFrozen bool
// FiendWar is the 超时空攻坚战 persistent battle: the 30-area map, the
// roaming enemy/monster units, the area timers, radar and battery, the
// merchant, and the two bosses' four-mechanism cycle. Non-nil only while
// RaidMode == runtimeRaidOwnerFiendWar.
//
// It coexists with Objectives rather than replacing it: the PVF rule pairs
// evaluated here are the authority, and syncRuntimeFiendWarObjectives derives
// the Objectives rows the client's battle-status table reads from them.
FiendWar *raid.FiendWarBattle
// FiendWarClearCounts is how many areas each member has cleared. Fiend War
// declares [MINIMAL DUNGEON CLEAR COUNT] 1, and the settlement pays only the
// members that reached it (runtimeFiendWarRewardEligible), so this counter
// exists for reward eligibility and nothing else.
FiendWarClearCounts map[uint16]int
// FiendWarRetreats records the areas the battle decided must retreat: area ->
// the dungeon attempt that was holding it. The battle writes it, the bridge
// consumes it and clears it after each member has actually been moved back to
// the waiting room (captureRuntimeFiendWarRetreats).
FiendWarRetreats map[int]runtimeRaidDungeonProgress
}
var (
errRuntimeRaidNotFound = errors.New("runtime raid not found")
errRuntimeRaidNotMember = errors.New("character is not a frozen raid member")
errRuntimeRaidNotStarted = errors.New("runtime raid has not started")
errRuntimeRaidPhaseMismatch = errors.New("runtime raid phase mismatch")
errRuntimeRaidLifecycleState = errors.New("runtime raid lifecycle state rejects transition")
errRuntimeRaidDungeonToken = errors.New("runtime raid dungeon lifecycle token is invalid")
errRuntimeRaidDungeonOccupied = errors.New("runtime raid dungeon is occupied")
errRuntimeRaidGroupOccupied = errors.New("runtime raid group already occupies a dungeon")
errRuntimeRaidDungeonCompleted = errors.New("runtime raid dungeon is already completed")
errRuntimeRaidDungeonUnavailable = errors.New("runtime raid dungeon objective is not open")
errRuntimeRaidDungeonEventOrder = errors.New("runtime raid dungeon event is out of order")
errRuntimeRaidRewardToken = errors.New("runtime raid reward token is invalid")
errRuntimeRaidRewardSelectionFrozen = errors.New("runtime raid reward selection is already frozen")
errRuntimeRaidRewardSelectionOrder = errors.New("runtime raid reward card must confirm mode1 before mode0")
errRuntimeRaidRewardRosterPending = errors.New("runtime raid reward selection is not confirmed by the frozen roster")
errRuntimeRaidRewardNotReady = errors.New("runtime raid reward is not ready for final cleanup")
)
func newRuntimeRaidLifecycleState(now time.Time) runtimeRaidLifecycleState {
now = runtimeRaidNormalizeTime(now)
return runtimeRaidLifecycleState{
Status: runtimeRaidLifecyclePhaseActive,
Phase: runtimeRaidDungeonPhaseOne,
Revision: 1,
StartedAt: now,
PhaseStartedAt: now,
MovieConfirmed: make(map[uint16]bool),
Objectives: make(map[int]runtimeRaidDungeonObjective),
Dungeons: make(map[runtimeRaidDungeonRunKey]runtimeRaidDungeonProgress),
GroupOccupancy: make(map[runtimeRaidDungeonSeat]runtimeRaidDungeonBinding),
RunClearCredits: make(map[runtimeRaidDungeonRunKey]uint64),
ClosedDungeonAttempts: make(map[runtimeRaidDungeonAttemptKey]runtimeRaidDungeonTerminal),
RewardSelections: make(map[uint16]runtimeRaidRewardSelection),
RewardSkippedMembers: make(map[uint16]bool),
Mechanisms: make(map[runtimeRaidMechanismKey]runtimeRaidMechanismTimer),
BarrierRequests: make(map[runtimeRaidBarrierRequestKey]uint32),
ClearAnnounced: make(map[byte]bool),
}
}
func newRuntimeRaidLifecycleStateFromAnton(config *raid.AntonConfig, now time.Time) (runtimeRaidLifecycleState, error) {
return newRuntimeRaidLifecycleStateFromAntonMode(config, now, false)
}
func newRuntimeRaidLifecycleStateFromAntonMode(config *raid.AntonConfig, now time.Time, single bool) (runtimeRaidLifecycleState, error) {
return newRuntimeRaidLifecycleStateFromAntonModeAndType(config, now, single, 0)
}
func newRuntimeRaidLifecycleStateFromAntonModeAndType(config *raid.AntonConfig, now time.Time, single bool, singleRaidType byte) (runtimeRaidLifecycleState, error) {
return newRuntimeRaidLifecycleStateFromModeAndType(config, runtimeRaidOwnerAnton, now, single, singleRaidType)
}
// runtimeRaidPhaseCountMatchesOwner states each raid owner's phase count
// exactly. Anton and Luke are two-phase raids and a document that declares
// anything else is still rejected; Fiend War declares [PHASE MAX] 1 and
// parseFiendWarBattle synthesises exactly one phase from it. Keeping the check
// per owner -- rather than relaxing it to a range -- is what guarantees an
// Anton or Luke document cannot start a raid with the wrong shape.
func runtimeRaidPhaseCountMatchesOwner(config *raid.AntonConfig, raidMode byte) bool {
if config == nil {
return false
}
if raidMode == runtimeRaidOwnerFiendWar {
return len(config.Phases) == 1
}
return len(config.Phases) == 2
}
func newRuntimeRaidLifecycleStateFromModeAndType(config *raid.AntonConfig, raidMode byte, now time.Time, single bool, singleRaidType byte) (runtimeRaidLifecycleState, error) {
if config == nil || config.StartDelaySeconds <= 0 {
return runtimeRaidLifecycleState{}, errRuntimeRaidLifecycleState
}
if single {
if config.SingleMode == nil || config.SingleMode.TimeLimitSeconds <= 0 || len(config.SingleMode.Phase.Dungeons) == 0 {
return runtimeRaidLifecycleState{}, errRuntimeRaidLifecycleState
}
} else if !runtimeRaidPhaseCountMatchesOwner(config, raidMode) {
return runtimeRaidLifecycleState{}, errRuntimeRaidLifecycleState
}
now = runtimeRaidNormalizeTime(now)
state := newRuntimeRaidLifecycleState(now)
state.Status = runtimeRaidLifecyclePreparing
state.PhaseStartedAt = time.Time{}
state.ReadyDeadline = now.Add(time.Duration(config.StartDelaySeconds) * time.Second)
var err error
if single {
err = initializeRuntimeRaidSinglePhaseObjectivesForOwner(&state, config, raidMode, singleRaidType)
} else {
err = initializeRuntimeRaidPhaseObjectives(&state, config, runtimeRaidDungeonPhaseOne)
}
if err != nil {
return runtimeRaidLifecycleState{}, err
}
return state, nil
}
func initializeRuntimeRaidSinglePhaseObjectives(state *runtimeRaidLifecycleState, config *raid.AntonConfig) error {
return initializeRuntimeRaidSinglePhaseObjectivesWithType(state, config, 0)
}
func initializeRuntimeRaidSinglePhaseObjectivesWithType(state *runtimeRaidLifecycleState, config *raid.AntonConfig, singleRaidType byte) error {
return initializeRuntimeRaidSinglePhaseObjectivesForOwner(state, config, runtimeRaidOwnerAnton, singleRaidType)
}
func initializeRuntimeRaidSinglePhaseObjectivesForOwner(state *runtimeRaidLifecycleState, config *raid.AntonConfig, raidMode byte, singleRaidType byte) error {
if state == nil || config == nil || config.SingleMode == nil || len(config.SingleMode.Phase.Dungeons) == 0 {
return errRuntimeRaidPhaseMismatch
}
state.Objectives = make(map[int]runtimeRaidDungeonObjective, len(config.SingleMode.Phase.Dungeons))
state.Dungeons = make(map[runtimeRaidDungeonRunKey]runtimeRaidDungeonProgress)
state.GroupOccupancy = make(map[runtimeRaidDungeonSeat]runtimeRaidDungeonBinding)
state.RunClearCredits = make(map[runtimeRaidDungeonRunKey]uint64)
for _, dungeon := range config.SingleMode.Phase.Dungeons {
mapID := int(dungeon.Index)
state.Objectives[mapID] = runtimeRaidDungeonObjective{
Phase: runtimeRaidDungeonPhaseOne,
MapID: mapID,
State: dungeon.InitState,
MaxHP: dungeon.HP,
RemainingHP: dungeon.HP,
PartyMax: dungeon.PartyMax,
}
}
switch raidMode {
case runtimeRaidOwnerAnton:
if singleRaidType != 0 && singleRaidType != 3 && singleRaidType != 6 {
return errRuntimeRaidPhaseMismatch
}
case runtimeRaidOwnerLuke:
if singleRaidType != runtimeRaidLukeLightRoute && singleRaidType != runtimeRaidLukeDarkRoute {
return errRuntimeRaidPhaseMismatch
}
firstID := 5323
secondID := 5324
if singleRaidType == runtimeRaidLukeDarkRoute {
firstID = 5329
secondID = 5330
}
for mapID, objective := range state.Objectives {
objective.State = raid.AntonDungeonHide
switch mapID {
case firstID:
objective.State = raid.AntonDungeonOpen
case secondID:
objective.State = raid.AntonDungeonClosed
}
state.Objectives[mapID] = objective
}
default:
return errRuntimeRaidPhaseMismatch
}
return nil
}
func initializeRuntimeRaidPhaseObjectives(state *runtimeRaidLifecycleState, config *raid.AntonConfig, phase byte) error {
phaseConfig, ok := runtimeRaidAntonPhase(config, phase)
if state == nil || !ok || len(phaseConfig.Dungeons) == 0 {
return errRuntimeRaidPhaseMismatch
}
state.Objectives = make(map[int]runtimeRaidDungeonObjective, len(phaseConfig.Dungeons))
state.Dungeons = make(map[runtimeRaidDungeonRunKey]runtimeRaidDungeonProgress)
state.GroupOccupancy = make(map[runtimeRaidDungeonSeat]runtimeRaidDungeonBinding)
state.RunClearCredits = make(map[runtimeRaidDungeonRunKey]uint64)
// A new phase starts with no mechanism timer; anything still queued from
// the previous phase must find a foreign generation and stop.
state.Mechanisms = make(map[runtimeRaidMechanismKey]runtimeRaidMechanismTimer)
state.MechanismGeneration++
for _, dungeon := range phaseConfig.Dungeons {
mapID := int(dungeon.Index)
state.Objectives[mapID] = runtimeRaidDungeonObjective{
Phase: phase,
MapID: mapID,
State: dungeon.InitState,
MaxHP: dungeon.HP,
RemainingHP: dungeon.HP,
PartyMax: dungeon.PartyMax,
}
}
return nil
}
func runtimeRaidNormalizeTime(now time.Time) time.Time {
if now.IsZero() {
return time.Now().UTC()
}
return now.UTC()
}
// runtimeRaidGatePartyMaxReachedLocked 报告这个门是不是已经容下了它的 [PARTY MAX]
// 允许的小队数(只数除调用者自己座位以外的不同座位)。调用者持有 raidMu。
//
// 上限为 0 或缺失时一律放行 —— 没声明上限的门行为与改动前完全一致。
//
// 移植自上游 raid_lifecycle.go(ede0bf5)。
func runtimeRaidGatePartyMaxReachedLocked(
state *runtimeRaidState,
seat runtimeRaidDungeonSeat,
mapID int,
) (int, int, bool) {
if state == nil {
return 0, 0, false
}
objective, exists := state.Lifecycle.Objectives[mapID]
if !exists || objective.PartyMax <= 0 {
return 0, 0, false
}
holders := make(map[runtimeRaidDungeonSeat]struct{})
for otherKey, other := range state.Lifecycle.Dungeons {
if otherKey.MapID != mapID || otherKey.Seat == seat ||
other.Status == runtimeRaidDungeonProgressCompleted {
continue
}
holders[otherKey.Seat] = struct{}{}
}
return len(holders), objective.PartyMax, len(holders) >= objective.PartyMax
}
func (state *runtimeRaidState) ensureRuntimeRaidLifecycleMapsLocked() {
if state.Lifecycle.MovieConfirmed == nil {
state.Lifecycle.MovieConfirmed = make(map[uint16]bool)
}
if state.Lifecycle.MovieSymbols == nil {
state.Lifecycle.MovieSymbols = make(map[uint32]uint32)
}
// 卢克的持久符号表与路线锁在这里初始化(与上游一致);LukeGeneratorReports 与
// LukeSanctumMazeGroups 由各自的 setter 懒建。
if state.Lifecycle.LukeSymbols == nil {
state.Lifecycle.LukeSymbols = make(map[uint32]uint32)
}
if state.Lifecycle.LukeRouteLocks == nil {
state.Lifecycle.LukeRouteLocks = make(map[uint16]byte)
}
if state.Lifecycle.Objectives == nil {
state.Lifecycle.Objectives = make(map[int]runtimeRaidDungeonObjective)
}
if state.Lifecycle.Dungeons == nil {
state.Lifecycle.Dungeons = make(map[runtimeRaidDungeonRunKey]runtimeRaidDungeonProgress)
}
if state.Lifecycle.GroupOccupancy == nil {
state.Lifecycle.GroupOccupancy = make(map[runtimeRaidDungeonSeat]runtimeRaidDungeonBinding)
}
if state.Lifecycle.RunClearCredits == nil {
state.Lifecycle.RunClearCredits = make(map[runtimeRaidDungeonRunKey]uint64)
}
if state.Lifecycle.ClosedDungeonAttempts == nil {
state.Lifecycle.ClosedDungeonAttempts = make(map[runtimeRaidDungeonAttemptKey]runtimeRaidDungeonTerminal)
}
if state.Lifecycle.RewardSelections == nil {
state.Lifecycle.RewardSelections = make(map[uint16]runtimeRaidRewardSelection)
}
if state.Lifecycle.RewardSkippedMembers == nil {
state.Lifecycle.RewardSkippedMembers = make(map[uint16]bool)
}
if state.Lifecycle.Mechanisms == nil {
state.Lifecycle.Mechanisms = make(map[runtimeRaidMechanismKey]runtimeRaidMechanismTimer)
}
if state.Lifecycle.BarrierRequests == nil {
state.Lifecycle.BarrierRequests = make(map[runtimeRaidBarrierRequestKey]uint32)
}
if state.Lifecycle.ClearAnnounced == nil {
state.Lifecycle.ClearAnnounced = make(map[byte]bool)
}
// 超时空的两张表懒建。FiendWar 本身是 *raid.FiendWarBattle,由
// newRuntimeRaidLifecycleStateFromModeAndType 之后的 resolve 路径按需挂上,
// 不在这里造 —— 一个 nil 战场对安东/卢克是正常状态。
if state.Lifecycle.FiendWarClearCounts == nil {
state.Lifecycle.FiendWarClearCounts = make(map[uint16]int)
}
if state.Lifecycle.FiendWarRetreats == nil {
state.Lifecycle.FiendWarRetreats = make(map[int]runtimeRaidDungeonProgress)
}
}
// claimRuntimeRaidClearAnnouncement returns true exactly once per raid and phase,
// for the caller that is about to broadcast the clear banner.
func (s *Service) claimRuntimeRaidClearAnnouncement(raidKey uint32, phase byte) bool {
if s == nil || raidKey == 0 {
return true
}
s.raidMu.Lock()
defer s.raidMu.Unlock()
state := s.raids[raidKey]
if state == nil {
// The raid is no longer registered: the status guard already decided, and
// there is no record left to hold the claim.
return true
}
state.ensureRuntimeRaidLifecycleMapsLocked()
if state.Lifecycle.ClearAnnounced[phase] {
return false
}
state.Lifecycle.ClearAnnounced[phase] = true
state.Lifecycle.Revision++
return true
}
// beginRuntimeRaidDungeon atomically reserves one current-phase map for the
// frozen member's attack-party group. Ordinary maps intentionally return a
// zero binding so their established dungeon lifecycle stays untouched.
func (s *Service) beginRuntimeRaidDungeon(characterID uint16, mapID int, lifecycleToken uint64) (runtimeRaidDungeonBinding, error) {
phase, raidMap := runtimeRaidDungeonPhaseForMap(mapID)
if !raidMap {
return runtimeRaidDungeonBinding{}, nil
}
if lifecycleToken == 0 {
return runtimeRaidDungeonBinding{}, errRuntimeRaidDungeonToken
}
s.raidMu.Lock()
defer s.raidMu.Unlock()
state := s.runtimeRaidByMemberLocked(characterID)
if state == nil {
return runtimeRaidDungeonBinding{}, errRuntimeRaidNotFound
}
if !state.Started || !state.EntryCostPaid || len(state.FrozenMembers) == 0 {
return runtimeRaidDungeonBinding{}, errRuntimeRaidNotStarted
}
if state.Lifecycle.Status != runtimeRaidLifecyclePhaseActive || state.Lifecycle.Phase != phase {
return runtimeRaidDungeonBinding{}, fmt.Errorf("%w: active=%d requested=%d status=%d", errRuntimeRaidPhaseMismatch, state.Lifecycle.Phase, phase, state.Lifecycle.Status)
}
member, ok := runtimeRaidFrozenMember(*state, characterID)
if !ok {
return runtimeRaidDungeonBinding{}, errRuntimeRaidNotMember
}
state.ensureRuntimeRaidLifecycleMapsLocked()
if len(state.Lifecycle.Objectives) != 0 {
objective, exists := state.Lifecycle.Objectives[mapID]
if !exists || objective.Phase != phase || objective.RemainingHP <= 0 || objective.State != raid.AntonDungeonOpen {
return runtimeRaidDungeonBinding{}, fmt.Errorf("%w: phase=%d map=%d", errRuntimeRaidDungeonUnavailable, phase, mapID)
}
if !runtimeRaidAntonEntryAllowedByBarrier(*state, mapID) {
return runtimeRaidDungeonBinding{}, fmt.Errorf("%w: map=%d barrier=%d", errRuntimeRaidDungeonUnavailable, mapID, state.Lifecycle.Barrier)
}
}
attemptKey := runtimeRaidDungeonAttemptKey{CharacterID: characterID, LifecycleKey: lifecycleToken}
if terminal, exists := state.Lifecycle.ClosedDungeonAttempts[attemptKey]; exists {
return runtimeRaidDungeonBinding{}, fmt.Errorf("%w: token already closed as %s phase=%d map=%d", errRuntimeRaidDungeonToken, terminal.Kind, terminal.Phase, terminal.MapID)
}
binding := runtimeRaidDungeonBinding{
RaidKey: state.RaidKey,
Phase: phase,
MapID: mapID,
LifecycleToken: lifecycleToken,
}
seat := runtimeRaidDungeonSeatFor(*state, member, mapID)
runKey := runtimeRaidDungeonRunKey{MapID: mapID, Seat: seat}
// A warp out of the energy-interception gate (218) into a hatchery the phase
// has opened is an in-run transfer, not a second gate: the member leaves its own
// 218 run and enters the hatchery, so the squad keeps exactly one gate and its
// occupancy moves with the member. Without this the transfer was refused with
// "runtime raid group already occupies a dungeon: group=1 map=218" -- live
// 2026-09-17 07:25:12Z, both op16 attempts of the 222 warp -- which is what left
// the hatcheries unreachable and the member's session without a run at all.
if runtimeRaidHatcheryWarpApplies(state, seat, characterID, phase, mapID) {
s.releaseRuntimeRaidRunAtSeatLocked(state, seat)
}
if occupied, exists := state.Lifecycle.GroupOccupancy[seat]; exists {
if runtimeRaidDungeonBindingMatches(occupied, binding) {
progress, progressExists := state.Lifecycle.Dungeons[runKey]
if progressExists && runtimeRaidDungeonProgressMatches(progress, characterID, binding) {
return occupied, nil
}
}
if occupied.Phase == phase && occupied.MapID == mapID {
// The seat is already inside this gate. One squad fights one run, so a
// SQUADMATE arriving second adopts that binding instead of opening a
// rival one -- refusing here broke every party entry into a raid gate,
// because the roster's members share a squad and each of their runtimes
// carries its own lifecycle token, so the second freeze could never
// match the first binding.
//
// The adopter keeps the owner's token, which is what keeps the run
// single-counted: notifyRuntimeRaidDungeonLifecycleLocked only publishes
// events whose token equals the session's own, so the run is reported
// once by the opener -- and the adopter's own clear still spends one
// point of the gate (applyRuntimeRaidJoinedSquadClear).
//
// The same character re-beginning with a new token is NOT an adoption:
// that is a racing duplicate of its own request and stays refused.
if progress, exists := state.Lifecycle.Dungeons[runKey]; exists && progress.CharacterID != characterID {
return occupied, nil
}
}
return runtimeRaidDungeonBinding{}, fmt.Errorf("%w: group=%d char=%d map=%d", errRuntimeRaidGroupOccupied, seat.Group, seat.CharacterID, occupied.MapID)
}
// [PARTY MAX] 是「一个门同时能容几支小队」,以前一处都没执行过 —— 这个字段被
// PVF 解析、存到 objective 上,然后只被读出来看过。上面那段占位检查是**反过来的**
// 规则(一支小队一次只打一个门),不是这一条(一个门只容一支小队),所以第二支
// 小队会直接走进别人正在打的同一个门。
//
// 卢克每个门都声明 1(同一圣所同时只容一支队伍);安徒恩的门声明 5,而安徒恩
// 最多 4 支小队,所以对安徒恩这条上限永远触发不到 —— 行为不变。
if state.Lifecycle.FiendWar == nil {
// 超时空的区域是共享战场:一个区域同时站着几个人是正常的,这一格不按小队计数。
// 安东/卢克逐字保留原来的 [PARTY MAX] 判定。
if holders, partyMax, full := runtimeRaidGatePartyMaxReachedLocked(state, seat, mapID); full {
return runtimeRaidDungeonBinding{}, fmt.Errorf("%w: map=%d squads=%d party_max=%d",
errRuntimeRaidDungeonOccupied, mapID, holders, partyMax)
}
}
// A cleared gate stays cleared for every squad until the phase resets it.
// Without an objective table the completed record survives its own run (see
// the completed branch of applyRuntimeRaidDungeonLifecycleEvent), so the same
// map must still be refused here even though the run key belongs to another
// squad.
for otherKey, other := range state.Lifecycle.Dungeons {
if otherKey.MapID == mapID && other.Status == runtimeRaidDungeonProgressCompleted {
return runtimeRaidDungeonBinding{}, fmt.Errorf("%w: map=%d", errRuntimeRaidDungeonCompleted, mapID)
}
}
if progress, exists := state.Lifecycle.Dungeons[runKey]; exists {
if runtimeRaidDungeonProgressMatches(progress, characterID, binding) {
state.Lifecycle.GroupOccupancy[seat] = binding
return binding, nil
}
// Only this seat is blocked by its own unclaimed run. A run owned by
// another seat is a different record and must not stop this one: that
// cross-squad refusal is exactly what kept a second squad out of a gate.
return runtimeRaidDungeonBinding{}, fmt.Errorf("%w: map=%d group=%d char=%d", errRuntimeRaidDungeonOccupied, mapID, progress.Seat.Group, progress.Seat.CharacterID)
}
now := time.Now().UTC()
state.Lifecycle.Dungeons[runKey] = runtimeRaidDungeonProgress{
Phase: phase,
MapID: mapID,
Seat: seat,
CharacterID: characterID,
LifecycleToken: lifecycleToken,
Status: runtimeRaidDungeonProgressSelected,
SelectedAt: now,
}
// A new run of this gate starts with its clear unspent. The run key (gate +
// seat) is reused by the squad's next run, so the previous run's credit -- which
// has to outlive that run's record, because a squadmate may report its own
// completion after the opener closed the run -- is dropped here rather than
// when the run ends. Adopting squadmates never reach this line: they keep the
// opener's binding, which is what keeps one run one clear.
delete(state.Lifecycle.RunClearCredits, runKey)
state.Lifecycle.GroupOccupancy[seat] = binding
state.Lifecycle.Revision++
return binding, nil
}
// releaseRuntimeRaidRunsOwnedByLocked drops every dungeon run a departing
// character owns.
//
// A run lives inside that character's session: once the session is gone the run
// can neither be completed nor abandoned, so its gate would stay occupied and the
// whole squad would be locked out of it until the process restarts. Live evidence
// 2026-09-16: the first entry (09:33:19 loaded map 210) ended in a client crash
// with no abandoned event, and every later select of that gate was answered with
// "runtime raid group already occupies a dungeon: group=1 map=210". Caller holds
// raidMu.
func (s *Service) releaseRuntimeRaidRunsOwnedByLocked(state *runtimeRaidState, characterID uint16) {
if state == nil || characterID == 0 || len(state.Lifecycle.Dungeons) == 0 {
return
}
state.ensureRuntimeRaidLifecycleMapsLocked()
for runKey, progress := range state.Lifecycle.Dungeons {
if progress.CharacterID != characterID || progress.Status == runtimeRaidDungeonProgressCompleted {
continue
}
delete(state.Lifecycle.Dungeons, runKey)
// Only this run's own squad is released: another squad may be holding the
// same gate under a different run key and must keep its occupancy.
delete(state.Lifecycle.GroupOccupancy, progress.Seat)
state.Lifecycle.ClosedDungeonAttempts[runtimeRaidDungeonAttemptKey{
CharacterID: characterID,
LifecycleKey: progress.LifecycleToken,
}] = runtimeRaidDungeonTerminal{
Phase: progress.Phase,
MapID: runKey.MapID,
Kind: runtimeRaidDungeonAbandoned,
}
state.Lifecycle.Revision++
}
}
// releaseRuntimeRaidRunAtSeatLocked drops the run a squad holds under one seat.
//
// This is the in-run transfer half of releaseRuntimeRaidRunsOwnedByLocked: a warp
// that moves the squad's single gate from 218 into a hatchery has to retire the run
// the squad held, and that run may have been opened by a squadmate rather than by
// the member doing the warping, so it cannot be found by owner. Caller holds raidMu.
func (s *Service) releaseRuntimeRaidRunAtSeatLocked(state *runtimeRaidState, seat runtimeRaidDungeonSeat) {
if state == nil || len(state.Lifecycle.Dungeons) == 0 {
return
}
state.ensureRuntimeRaidLifecycleMapsLocked()
for runKey, progress := range state.Lifecycle.Dungeons {
if runKey.Seat != seat || progress.Status == runtimeRaidDungeonProgressCompleted {
continue
}
delete(state.Lifecycle.Dungeons, runKey)
delete(state.Lifecycle.GroupOccupancy, progress.Seat)
state.Lifecycle.ClosedDungeonAttempts[runtimeRaidDungeonAttemptKey{
CharacterID: progress.CharacterID,
LifecycleKey: progress.LifecycleToken,
}] = runtimeRaidDungeonTerminal{
Phase: progress.Phase,
MapID: runKey.MapID,
Kind: runtimeRaidDungeonAbandoned,
}
state.Lifecycle.Revision++
}
}
// releaseRuntimeRaidStaleGateOccupancy drops every open gate whose owning session
// no longer holds the run it was opened for.
//
// A run lives inside its owner's session: only that session can report its loaded,
// completed or abandoned event. A client that froze inside a gate -- a live socket
// that stopped answering -- never reports any of them, so its record and its
// squad's GroupOccupancy stay forever and every later completion of the phase runs
// into openRuntimeRaidPhaseMovie's "len(GroupOccupancy) == 0" gate. Live
// 2026-09-17: character 1 loaded 218 at 07:24:57Z, its client froze at 07:25:12Z
// while warping to hatchery 222 (dungeon_runtime_commit_failed), and its session
// was then left with no runtime at all (game-dungeon-move-blocked
// reason=dungeon_runtime_missing). Character 22 cleared the heart (220) five times
// between 07:26:23Z and 07:26:44Z and the raid never ended, because that one
// orphaned occupancy survived all five.
//
// This never touches a live squad: a record is released only when its owner has no
// bound session, no committed runtime, a runtime that owns another character, a
// runtime bound to a different raid/map/token, or a run that is no longer active.
// It returns the number of released records.
func (s *Service) releaseRuntimeRaidStaleGateOccupancy(raidKey uint32) int {
if s == nil || raidKey == 0 {
return 0
}
type runtimeRaidGateRecord struct {
key runtimeRaidDungeonRunKey
progress runtimeRaidDungeonProgress
}
s.raidMu.Lock()
state := s.raids[raidKey]
if state == nil || len(state.Lifecycle.Dungeons) == 0 {
s.raidMu.Unlock()
return 0
}
open := make([]runtimeRaidGateRecord, 0, len(state.Lifecycle.Dungeons))
for key, progress := range state.Lifecycle.Dungeons {
if progress.Status == runtimeRaidDungeonProgressCompleted {
continue
}
open = append(open, runtimeRaidGateRecord{key: key, progress: progress})
}
s.raidMu.Unlock()
if len(open) == 0 {
return 0
}
stale := make([]runtimeRaidGateRecord, 0, len(open))
for _, record := range open {
if s.runtimeRaidDungeonRunLive(raidKey, record.key, record.progress) {
continue
}
stale = append(stale, record)
}
if len(stale) == 0 {
return 0
}
s.raidMu.Lock()
defer s.raidMu.Unlock()
state = s.raids[raidKey]
if state == nil {
return 0
}
released := 0
for _, record := range stale {
current, exists := state.Lifecycle.Dungeons[record.key]
if !exists ||
current.LifecycleToken != record.progress.LifecycleToken ||
current.Status == runtimeRaidDungeonProgressCompleted {
continue
}
delete(state.Lifecycle.Dungeons, record.key)
// Only this run's own seat is released, and only while its occupancy still
// points at this exact run: a squad that has meanwhile entered another gate
// keeps that one.
if occupied, ok := state.Lifecycle.GroupOccupancy[record.key.Seat]; ok &&
occupied.MapID == record.key.MapID &&
occupied.LifecycleToken == current.LifecycleToken {
delete(state.Lifecycle.GroupOccupancy, record.key.Seat)
}
state.Lifecycle.ClosedDungeonAttempts[runtimeRaidDungeonAttemptKey{
CharacterID: current.CharacterID,
LifecycleKey: current.LifecycleToken,
}] = runtimeRaidDungeonTerminal{
Phase: current.Phase,
MapID: record.key.MapID,
Kind: runtimeRaidDungeonAbandoned,
}
state.Lifecycle.Revision++
released++
s.logPacketEvent("game-raid-stale-gate-occupancy-released",
"raid_key", raidKey,
"char_id", current.CharacterID,
"raid_phase", current.Phase,
"raid_map_id", record.key.MapID,
"group", record.key.Seat.Group,
"seat_char_id", record.key.Seat.CharacterID,
"lifecycle_token", current.LifecycleToken,
"reason", "owning_session_no_longer_holds_the_run")
}
return released
}
// runtimeRaidDungeonRunLive reports whether the record's owner still holds exactly
// this run. It reads the bound session and that session's own dungeon runtime, so
// it must be called without raidMu held.
func (s *Service) runtimeRaidDungeonRunLive(raidKey uint32, key runtimeRaidDungeonRunKey, progress runtimeRaidDungeonProgress) bool {
if s == nil || progress.CharacterID == 0 {
return false
}
identity, online := s.onlineGameSessionCharacterSnapshot(progress.CharacterID)
if !online || identity.session == nil {
return false
}
session := identity.session
// TryLock, never Lock: this runs from publishRuntimeRaidObjectiveProgress, which
// the dungeon-completion path reaches already holding the completing member's own
// session.dungeon.mu. Go mutexes are not reentrant, so a plain Lock on that
// member's own session parked its packet goroutine forever the moment it had any
// open gate at all (the completion of a gate whose record is still open is
// exactly when this is asked). A session whose lock is busy is reported as live,
// which is the conservative answer: it is never released on a guess.
if !session.dungeon.mu.TryLock() {
return true
}
defer session.dungeon.mu.Unlock()
runtime := session.dungeon.runtime
if runtime == nil || runtime.Session == nil ||
!dungeonRuntimeOwnsCharacter(runtime, progress.CharacterID) {
return false
}
if runtime.raidDungeon.RaidKey != raidKey ||
runtime.raidDungeon.Phase != progress.Phase ||
runtime.raidDungeon.MapID != key.MapID ||
runtime.raidDungeon.LifecycleToken != progress.LifecycleToken {
return false
}
return runtime.Session.Snapshot().Run.Status == worldmap.DungeonRunActive
}
// applyRuntimeRaidDungeonLifecycleEvent accepts only the binding returned by
// beginRuntimeRaidDungeon. It never performs network I/O and never acquires a
// dungeon/session lock; callers already own that lifecycle boundary.
func (s *Service) applyRuntimeRaidDungeonLifecycleEvent(event runtimeRaidDungeonLifecycleEvent) error {
if event.RaidKey == 0 || event.CharacterID == 0 || event.LifecycleToken == 0 {
return errRuntimeRaidDungeonToken
}
s.raidMu.Lock()
defer s.raidMu.Unlock()
state := s.raids[event.RaidKey]
if state == nil {
return errRuntimeRaidNotFound
}
if !state.Started || !state.EntryCostPaid {
return errRuntimeRaidNotStarted
}
if _, ok := runtimeRaidFrozenMember(*state, event.CharacterID); !ok {
return errRuntimeRaidNotMember
}
// The run this event belongs to is the one its own seat OPENED. That seat is
// frozen into the run record and must never be recomputed from the live roster
// here: a squad relabel during the run (a party change while the member stands
// inside the gate, see unassignRuntimeRaidSquadOnPartyLeave) would move the key,
// the lookup below would miss, and the member's completion would be dropped as
// errRuntimeRaidDungeonToken while the old seat's GroupOccupancy stayed behind as
// an orphan that blocks every later entry of that squad. Live 2026-09-19, raid
// 1507338: 14:24:07 and 14:24:16 unassigned two members who were inside 218.
runKey, progress, exists := runtimeRaidRunKeyForAttemptLocked(
state, event.CharacterID, event.MapID, event.LifecycleToken)
state.ensureRuntimeRaidLifecycleMapsLocked()
attemptKey := runtimeRaidDungeonAttemptKey{CharacterID: event.CharacterID, LifecycleKey: event.LifecycleToken}
if terminal, closed := state.Lifecycle.ClosedDungeonAttempts[attemptKey]; closed {
if terminal.Phase == event.Phase && terminal.MapID == event.MapID && terminal.Kind == event.Kind {
return nil
}
return fmt.Errorf("%w: closed event=%s received=%s", errRuntimeRaidDungeonEventOrder, terminal.Kind, event.Kind)
}
if !runtimeRaidDungeonLifecycleReportAccepted(state, runKey, progress, exists, event) {
return fmt.Errorf("%w: raid=%d phase=%d map=%d token=%d", errRuntimeRaidDungeonToken, event.RaidKey, event.Phase, event.MapID, event.LifecycleToken)
}
now := time.Now().UTC()
switch event.Kind {
case runtimeRaidDungeonSelected:
if progress.Status < runtimeRaidDungeonProgressSelected {
return errRuntimeRaidDungeonEventOrder
}
return nil
case runtimeRaidDungeonLoaded:
if progress.Status >= runtimeRaidDungeonProgressLoaded {
return nil
}
if progress.Status != runtimeRaidDungeonProgressSelected {
return errRuntimeRaidDungeonEventOrder
}
progress.Status = runtimeRaidDungeonProgressLoaded
progress.LoadedAt = now
state.Lifecycle.Dungeons[runKey] = progress
// 超时空:进图要告诉战场"这个小队占住了这个区域",它会启动该区域的战斗
// 计时器与 BOSS 机制。未编队成员 (Seat.Group == 0) 在这里被 battle.Enter
// 拒绝 —— 这是刻意的:超时空的战场模型只有"小队占区域",没有个人占区域。
//
// 区域由**副本**决定,不由报告者的实时格子决定。
//
// 原来这里写 `battle.GroupAreas[seat.Group] = battle.MemberArea(报告者, seat.Group)`,
// 在"报告者就是按下进图的那个人"时是对的(他刚被 town_move.go 挪进目标区域,
// MemberAreas 就是目标区域)—— 单人进图一直是这条形状,所以从来没暴露。
//
// 但**报告者不一定是按下进图的人**:超时空一个区域共用一条 run,
// 而 prepareRuntimePartyDungeonEntry 先备队友,所以经常是**队友**把这条 run 开出来。
// 真机 2026-10-01 23:22:38:game-raid-dungeon-lifecycle char_id=22 event=selected
// dungeon_lifecycle_token=1 —— 队友 char 22 是开局人,驱动者 char 1 是 adopt 的
// (binding token=1,本地 token=2,所以他一条都不发布)。而队友按设计**留在原格**:
// MemberAreas 钉在他按下进图时站的格子,于是那一行把 GroupAreas[小队] 覆盖成**原格**,
// 紧接着 Enter 就失败 ——
//
// 15:22:40.369 game-raid-dungeon-loaded-publish-deferred char_id=22 party_id=4
// loading_cycle=1 raid_key=12058625
// reason=party_loading_release_continues
// error=fiendwar dungeon admission rejected
//
// 战场里这一格因此永远没被标记成"被占",玩家能把图清完,但通关时 Clear 报
//
// 15:23:05.986 game-raid-dungeon-completion-publish-failed char_id=22
// dungeon_id=4956 op115_sent=true
// error=fiendwar dungeon not occupied
//
// 结算推进不下去,于是"清完自动回城"整段判定永远不成立 —— 现象就是
// 「刷完没反应」。单人时一直正常,因为那时 MemberArea 恰好等于目标区域。
if state.Lifecycle.FiendWar != nil {
battle := state.Lifecycle.FiendWar.Clone()
if area, ok := battle.AreaForDungeon(event.MapID); ok {
battle.GroupAreas[progress.Seat.Group] = area.Index
} else {
// 副本不在任何区域名下(不该发生):退回原来的写法,至少不改变行为。
battle.GroupAreas[progress.Seat.Group] = battle.MemberArea(event.CharacterID, progress.Seat.Group)
}
if err := battle.Enter(progress.Seat.Group, event.MapID, now); err != nil {
return err
}
state.Lifecycle.FiendWar = battle
// 战况表的目标状态必须**在这一次边界广播之前**就位。
//
// publishRuntimeRaidDungeonParticipation 的超时空分支会把整张战况表
// 立刻重发一遍(raid_dungeon_lifecycle.go 的 broadcastRuntimeFiendWarBoard),
// 而 585 的门状态与 0x0254 的副本倒计时都是从 Objectives 上取的。
// 以前这里不同步,只有 1 Hz 心跳里的 syncRuntimeFiendWarObjectives 会跑,
// 于是**进图那一帧**广播出去的是:
// 585 -> 这一格还是 hide(2)
// 0x0254 -> 状态不是 0/1/3,按客户端规则"不画",于是整条不发
// 真机 2026-10-02 16:28–16:36(raid 12058625)实测:每一次进图,
// 4 个成员的 0x0254 都要等**下一个心跳**才发,比进图晚 1.4–3.1 秒 ——
// 操作员截图里的"进副本后显示的还是攻坚倒计时 00:58:28"就是这段窗口。
// 同步一次只把心跳本来就会做的那次计算提前到这个边界,行为等价。
syncRuntimeFiendWarObjectives(state)
}
if state.TailFlag != 1 && runtimeRaidTeamGraphOwner(*state) == runtimeRaidOwnerLuke {
// Luke's [ON ENTER DUNGEON] behaviors fire once the scene is real,
// which is this boundary and not `selected`. [ON ENTER MAP] is a room
// edge and runs from the room move (observeRuntimeRaidLukeEnterMapLocked).
runtimeRaidLukeEnterDungeonLocked(state, event.MapID)
if route, bound := bindRuntimeRaidLukeRouteLocked(state, event.CharacterID, event.MapID); bound {
s.logPacketEvent("game-raid-luke-route-bound",
"raid_key", event.RaidKey, "char_id", event.CharacterID,
"raid_map_id", event.MapID, "route", route)
}
}
state.Lifecycle.Revision++
return nil
case runtimeRaidDungeonCompleted:
if progress.Status == runtimeRaidDungeonProgressCompleted {
return nil
}
if progress.Status != runtimeRaidDungeonProgressLoaded {
return errRuntimeRaidDungeonEventOrder
}
progress.Status = runtimeRaidDungeonProgressCompleted
progress.CompletedAt = now
// 超时空:清区域先让战场结算(BOSS 机制必须已完成,否则 Clear 返回错误,
// 这一笔通关就不会被承认)。随后记清数 —— 超时空的
// [MINIMAL DUNGEON CLEAR COUNT] 是 1,结算只给达到它的成员发奖。
//
// 记账口径与本服务端其它地方一致:一次副本 = 一个小队的一次通关,所以整队
// 一起记,而不是只记报告的那个人。未编队 (Seat.Group == 0) 才记到个人头上。
// One run is one party clear, and **an adopting squadmate may already have
// booked it** (applyRuntimeRaidJoinedSquadClear credits the run and spends the
// gate's point). Computed before the Fiend War block below because that block
// needs it, and reused further down by the generic bookkeeping.
runCredited := runtimeRaidRunClearCreditedLocked(state, runKey)
if state.Lifecycle.FiendWar != nil {
battle := state.Lifecycle.FiendWar.Clone()
// BOSS 房用 ClearClientBossRoom:本客户端不发 op1511 的 kind=1 机制上报,
// 机制状态机走不到 9,用 Clear 会把这一笔通关整个丢掉(真机现象:
// 「超时空通关后没反应」,见 raid.FiendWarBattle.ClearClientBossRoom 的注释)。
if err := battle.ClearClientBossRoom(event.MapID, now); err != nil {
return err
}
// 这一格的目标必须**还开着** —— 除非这条 run 的通关已经被队友记过账了。
//
// 一个区域共用一条 run:adopt 进来的队友先报 completed 时走的是
// applyRuntimeRaidJoinedSquadClear,它会把这一格的那一点 HP 花掉,
// 并调用 runtimeRaidCreditRunClearLocked 把这条 run 记成"已记账"
// ("一条 run 一次通关")。于是随后**开局人**再报 completed 时
// objective.RemainingHP 已经是 0。
//
// 原来这里无条件要求 RemainingHP > 0 && State == AntonDungeonOpen,
// 开局人这一笔就被判成 errRuntimeRaidDungeonUnavailable —— 真机
// 2026-10-01 23:38(raid 12058625,分片 035):
//
// 15:38:57.846 game-raid-joined-clear-booked char_id=1
// booked=true remaining_hp_before=1 remaining_hp_after=0
// 15:38:57.860 game-raid-dungeon-completion-publish-failed char_id=22
// dungeon_id=4954 error=runtime raid dungeon objective is not open
// 15:38:58.751 game-fiendwar-clear-auto-return char_id=1 ...
// (char 22 是开局人,此后再也没有回城)
//
// 注意**只有开局人那一笔**会把这条 run 标成 completed(队友那一笔只记
// 本地、ClosedDungeonAttempts 也挂在他自己名下),所以这一拒等于这条 run
// 永远关不掉、开局人永远不自动回城 —— 现象就是「队长回城了、队员没回去」。
//
// 已经被记过账的 run 只关 run、不再动目标;清数(FiendWarClearCounts)
// 也**不再加一次**:一次副本 = 一个小队的一次通关。
if !runCredited {
objective, ok := state.Lifecycle.Objectives[event.MapID]
if !ok || objective.Phase != event.Phase || objective.RemainingHP <= 0 || objective.State != raid.AntonDungeonOpen {
return errRuntimeRaidDungeonUnavailable
}
state.Lifecycle.FiendWar = battle
if state.Lifecycle.FiendWarClearCounts == nil {
state.Lifecycle.FiendWarClearCounts = make(map[uint16]int)
}
if squad := runtimeRaidMembersInSquad(*state, progress.Seat.Group); len(squad) > 0 {
for _, squadMember := range squad {
state.Lifecycle.FiendWarClearCounts[squadMember]++
}
} else {
state.Lifecycle.FiendWarClearCounts[progress.CharacterID]++
}
} else {
// 战场状态照样要落地(ClearClientBossRoom 已经把这一格放开了),
// 只是不再动目标、不再加清数。
state.Lifecycle.FiendWar = battle
}
}
delete(state.Lifecycle.GroupOccupancy, progress.Seat)
state.Lifecycle.ClosedDungeonAttempts[attemptKey] = runtimeRaidDungeonTerminal{
Phase: event.Phase,
MapID: event.MapID,
Kind: runtimeRaidDungeonCompleted,
}
clearCountBefore := 0
if objective, ok := state.Lifecycle.Objectives[event.MapID]; ok {
clearCountBefore = objective.ClearCount
}
if len(state.Lifecycle.Objectives) == 0 {
state.Lifecycle.Dungeons[runKey] = progress
} else {
delete(state.Lifecycle.Dungeons, runKey)
// One run is one party clear. An adopting squadmate may already have
// reported its own instance of this very run, in which case the gate's
// point is spent and the owner's completion only closes the run.
if !runCredited {
if err := advanceRuntimeRaidObjectiveLocked(state, event.Phase, event.MapID); err != nil {
return err
}
}
}
// Only a clear that actually advanced the gate registers for its squad: a
// completion reported after the gate was closed or reset earns nothing, and
// neither does a second member's completion of an already credited run.
objective, ok := state.Lifecycle.Objectives[event.MapID]
if ok && objective.ClearCount > clearCountBefore {
runtimeRaidCreditRunClearLocked(state, runKey, progress.LifecycleToken)
runtimeRaidNotePhaseClearLocked(state, event.CharacterID, event.Phase)
} else if !ok {
runtimeRaidNotePhaseClearLocked(state, event.CharacterID, event.Phase)
} else if runCredited {
// 这一条 run 的那一点 HP 已经被队友花掉了("一条 run 一次通关"),
// 所以**不再动目标、不再花第二点**;但**本人这一笔通关要记在自己头上**。
//
// 原来这里只写一行日志、什么都不记。两人**同一小队**时看不出问题 ——
// 队友记账那一下 runtimeRaidNotePhaseClearLocked 已经把整队都记上了。
// 但超时空的进图单位是**区域名单(规格 D:与小队编制无关)**,
// 两个人常常不在同一小队,于是记账那一方只记了自己那一队:
//
// 真机 2026-10-02 00:05:22(raid 12058625,分片 20261002-002)
// game-raid-joined-clear-booked char_id=1 booked=true
// game-raid-run-clear-already-credited char_id=22 group=2
// reason=one_run_is_one_party_clear
// game-raid-phase-clear-registration char_id=22
// member_clear_counts=1:1 ← 只有 char 1 记到了
//
// 于是**开局人(队员)的"地下城通关"一直是 0** —— 操作员 2026-10-02:
// 「通关以后 队友的通关次数居然没计数」。
//
// 作用域刻意只加**超时空**:它的 [MINIMAL DUNGEON CLEAR COUNT] 是 1,
// runtimeRaidNotePhaseClearLocked 自己会按 limit 封顶,所以即使同一小队
// 被记两次也只停在 1;安东/卢克的门可能要求多次通关,重复记会提前把
// 那一列填满,所以它们一个字不改。
if state.Lifecycle.FiendWar != nil {
// 两本账一起记:战况表那一列(PhaseClearCounts)+ 发奖门槛
// (FiendWarClearCounts)。只记前者会出现"显示 1/1 却拿不到奖励"。
runtimeRaidNoteFiendWarMemberClearLocked(state, event.CharacterID, event.Phase)
}
s.logPacketEvent("game-raid-run-clear-already-credited",
"raid_key", event.RaidKey,
"raid_phase", event.Phase,
"raid_map_id", event.MapID,
"char_id", event.CharacterID,
"group", progress.Seat.Group,
"seat_char_id", progress.Seat.CharacterID,
"run_lifecycle_token", progress.LifecycleToken,
"reason", "one_run_is_one_party_clear")
}
state.Lifecycle.Revision++
return nil
case runtimeRaidDungeonAbandoned:
if progress.Status == runtimeRaidDungeonProgressCompleted {
return errRuntimeRaidDungeonEventOrder
}
delete(state.Lifecycle.Dungeons, runKey)
delete(state.Lifecycle.GroupOccupancy, progress.Seat)
state.Lifecycle.ClosedDungeonAttempts[attemptKey] = runtimeRaidDungeonTerminal{
Phase: event.Phase,
MapID: event.MapID,
Kind: runtimeRaidDungeonAbandoned,
}
// 超时空:放弃区域要让战场把小队从区域里放出来、并按 PVF 规则决定要不要
// 整队撤退 (双 BOSS 任一边放弃时两边一起退)。captureRuntimeFiendWarRetreats
// 把战场写下的撤退标记转成桥上可消费的副本尝试;syncRuntimeFiendWarObjectives
// 立刻把战况表的行更新成战场的判断。
if state.Lifecycle.FiendWar != nil {
battle := state.Lifecycle.FiendWar.Clone()
if err := battle.Abandon(event.MapID, now); err != nil {
return err
}
captureRuntimeFiendWarRetreats(state, battle)
state.Lifecycle.FiendWar = battle
syncRuntimeFiendWarObjectives(state)
}
if state.TailFlag != 1 && runtimeRaidTeamGraphOwner(*state) == runtimeRaidOwnerLuke {
// [ON GIVEUP DUNGEON]: a sanctum's object counter resets, a throne
// closes the energy save room again.
runtimeRaidLukeGiveUpDungeonLocked(state, event.MapID)
}
state.Lifecycle.Revision++
return nil
default:
return errRuntimeRaidDungeonEventOrder
}
}
// applyRuntimeRaidJoinedSquadClear books one adopting squadmate's own completion of
// the gate its squad already holds.
//
// A squad fights one raid gate at a time, so the second member to walk in keeps
// the binding the first one opened (see the adoption branch of
// beginRuntimeRaidDungeon) and its lifecycle events are not published as a run --
// that is what keeps the squad's gate *bookkeeping* single: one run record, one
// occupancy, one 战况表 row.
//
// The gate's HP is spent once per RUN, not once per member. This server gives every
// member its own dungeon runtime and its own copy of the room -- the two sessions
// move into the same gate with different `seed`s (live 2026-09-17T12:45:46Z, raid
// 1507329: character 1 seed=2820159178, character 22 seed=3502334619) -- so every
// member kills its own boss and reports its own clear, but those reports describe
// ONE party clear of the gate and the gate must lose exactly one point for them.
//
// Live 2026-09-17T12:45-12:46Z is what the run ledger preserves: characters 1 and 22
// cleared the heart together twice (12:45:47 and 12:45:54) and character 1 cleared
// it three more times alone. That is five party runs, and the gate booked five --
// two together-runs plus three solo runs. Counting each member's own instance
// instead (what this path used to do) would have charged the pair four points for
// those two together-runs and taken a four-clear gate down in two runs, or in ONE
// run for a four-member squad (operator report 2026-09-19: 两人组队通关两把就进
// 下一阶段;四人组队一把就过). One run, one point, however many members walked it.
//
// localToken is the joined session's own lifecycle token, never the owner's, so a
// replayed op39/op117 cannot book the same run twice.
// runtimeRaidNoteFiendWarMemberClearLocked 记"这个人清过这一格",**两本账一起记**。
//
// 为什么要两本:
//
// - `PhaseClearCounts`(经 runtimeRaidPhaseClearCount 进 mode=3 名册)是**战况表上
// 「地下城通关」那一列**:客户端刷新名册时从成员记录里读,操作员就是照这一列
// 说「后两个不给算通关」的;
// - `FiendWarClearCounts` 是**发奖门槛**(runtimeFiendWarRewardEligible ≥
// [MINIMAL DUNGEON CLEAR COUNT],超时空是 1)。
//
// 只记前者会出现"战况表 1/1 但拿不到奖励",只记后者则相反。两本都记才自洽。
//
// 幂等与上限:调用点由 ClosedDungeonAttempts 保证"每人每本地 token 只报一次";
// 超时空的这一格是 1 点 HP、门槛也是 1,多记一次不改变任何判定(安东/卢克不走这里)。
//
// 真机 2026-10-02 11:32:16(raid 12058646,分片 009,四人一起进 4955)就是这一列:
//
// joined-clear-booked char_id=22 booked=true hp 1 -> 0
// run-clear-already-credited char_id=21
// phase-clear-registration char_id=21 member_clear_counts=21:1,22:1
// joined-clear-booked char_id=1 booked=false hp 0 -> 0 ← 一条不记
// joined-clear-booked char_id=20 booked=false hp 0 -> 0 ← 一条不记
//
// 操作员截图:编制 1、2 是 1/1,3、4 还是 0/1。调用者持 raidMu。
func runtimeRaidNoteFiendWarMemberClearLocked(state *runtimeRaidState, characterID uint16, phase byte) {
if state == nil || characterID == 0 {
return
}
runtimeRaidNotePhaseClearLocked(state, characterID, phase)
if state.Lifecycle.FiendWarClearCounts == nil {
state.Lifecycle.FiendWarClearCounts = make(map[uint16]int)
}
state.Lifecycle.FiendWarClearCounts[characterID]++
}
// runtimeRaidJoinedSquadClearIsAlreadySpentLocked 报告"这一格对本阶段已经不可打了":
// 目标属于本阶段、而且那一点 HP 已经被花掉(谁花的都算)。
//
// 这是 applyRuntimeRaidJoinedSquadClear 判定"本人这一笔要不要记"的依据。
// **不能只看 RunClearCredits**:那张表是按 (map, seat) 键的,而座位是从**还在账本里的
// 那条 run** 反查出来的 —— 生命周期那条路处理完成时会把 run 从账本里删掉,于是后报的人
// 反查不到 owner token,runKey 退化成"他自己小队的座位",runCredited 就成了 false,
// 本人这一笔就被整脚丢掉(真机 11:32:16 的 char 1 / char 20 正是如此)。
func runtimeRaidJoinedSquadClearIsAlreadySpentLocked(state *runtimeRaidState, mapID int, phase byte) bool {
if state == nil {
return false
}
objective, ok := state.Lifecycle.Objectives[mapID]
if !ok || objective.Phase != phase {
return false
}
return objective.RemainingHP <= 0
}
func (s *Service) applyRuntimeRaidJoinedSquadClear(
session *gameSession,
binding runtimeRaidDungeonBinding,
localToken uint64,
) {
if s == nil || session == nil || binding.RaidKey == 0 || binding.MapID == 0 || localToken == 0 {
return
}
characterID := session.selectedCharacterID
if characterID == 0 {
return
}
s.raidMu.Lock()
state := s.raids[binding.RaidKey]
if state == nil || !state.Started || !state.EntryCostPaid ||
state.Lifecycle.Phase != binding.Phase ||
state.Lifecycle.Status != runtimeRaidLifecyclePhaseActive {
s.raidMu.Unlock()
return
}
member, ok := runtimeRaidFrozenMember(*state, characterID)
if !ok {
s.raidMu.Unlock()
return
}
state.ensureRuntimeRaidLifecycleMapsLocked()
// This member's own run of the gate shares its squad's seat and its opener's
// token, which is the same key the owner's own completion uses: whichever of
// them reports first spends the gate's point for that run. The seat is read back
// from the run record itself, so a squad re-label during the run (an ordinary
// party change, a leader re-group) cannot move the key out from under the credit
// and leave the run's occupancy orphaned.
runKey := runtimeRaidDungeonRunKey{
MapID: binding.MapID,
Seat: runtimeRaidRunSeatForTokenLocked(
state, binding.MapID, binding.LifecycleToken, runtimeRaidSeatFor(member)),
}
attemptKey := runtimeRaidDungeonAttemptKey{CharacterID: characterID, LifecycleKey: localToken}
if _, closed := state.Lifecycle.ClosedDungeonAttempts[attemptKey]; closed {
s.raidMu.Unlock()
return
}
// The clear is booked whether or not the gate had a point left to spend: a
// gate that closed or reset while this member was inside earns nothing, which
// is the same rule the owning path applies in advanceRuntimeRaidObjectiveLocked.
// Recording it keeps a retried report a no-op either way.
state.Lifecycle.ClosedDungeonAttempts[attemptKey] = runtimeRaidDungeonTerminal{
Phase: binding.Phase,
MapID: binding.MapID,
Kind: runtimeRaidDungeonCompleted,
}
runCredited := runtimeRaidRunClearCreditedLocked(state, runKey)
booked := false
remainingBefore := 0
if objective, ok := state.Lifecycle.Objectives[binding.MapID]; ok &&
objective.Phase == binding.Phase && objective.RemainingHP > 0 &&
objective.State == raid.AntonDungeonOpen && !runCredited {
remainingBefore = objective.RemainingHP
if err := advanceRuntimeRaidObjectiveLocked(state, binding.Phase, binding.MapID); err != nil {
delete(state.Lifecycle.ClosedDungeonAttempts, attemptKey)
} else {
booked = true
runtimeRaidCreditRunClearLocked(state, runKey, binding.LifecycleToken)
// 两本账一起记(战况表那一列 + 发奖门槛),见函数注释。
runtimeRaidNoteFiendWarMemberClearLocked(state, characterID, binding.Phase)
}
}
// 这一条 run 的那一点 HP 已经被别人花掉了("一条 run 一次通关"):**不再动目标**,
// 但**本人这一笔通关要记在自己头上**。
//
// 这是 applyRuntimeRaidDungeonLifecycleEvent 里同一处修复的**另一条路径** ——
// 那次只补了生命周期的 completed 分支,而队友(adopting squadmate)的通关报告
// 走的是这一条(applyRuntimeRaidJoinedSquadClear),于是同一个洞在四个人进图时
// 一次又一次地露出来:
//
// 真机 2026-10-02 10:20:33(raid 12058625,分片 006,四人一起进 4955)
// joined-clear-booked char_id=1 booked=true hp 1 -> 0
// joined-clear-booked char_id=21 booked=false hp 0 -> 0 ← 一条不记
// joined-clear-booked char_id=20 booked=false hp 0 -> 0 ← 一条不记
// run-clear-already-credited char_id=22 (走生命周期那条)
// phase-clear-registration char_id=22 member_clear_counts=1:1,22:1
//
// 真机 2026-10-02 11:32:16(raid 12058646,分片 009,**修过一版之后**的四人局)
// joined-clear-booked char_id=22 booked=true hp 1 -> 0
// run-clear-already-credited char_id=21
// phase-clear-registration char_id=21 member_clear_counts=21:1,22:1
// joined-clear-booked char_id=1 booked=false hp 0 -> 0 ← 还是不记
// joined-clear-booked char_id=20 booked=false hp 0 -> 0 ← 还是不记
// 操作员截图:编制 1、2 是 1/1,3、4 是 0/1。
//
// 上一版只判 `runCredited`,那一版漏在**账本已经被清掉**的情形上:生命周期那条路
// 处理完成时会把 run 从 Lifecycle.Dungeons 里删掉,后报的人于是反查不到 owner
// token,runKey 退化成"他自己小队的座位",runCredited 就成了 false —— 判据失效。
// 现在改成看**这一格的目标**:属于本阶段、HP 已经没了(谁花的都算)。
//
// 为什么两人时看不出来、四人时露馅:runtimeRaidNotePhaseClearLocked 记的是
// **记账人所在小队**。超时空的进图单位是区域名单(规格 D:与编制无关),而且
// 操作员 2026-10-02 定案「超时空每个小队编制仅可放 1 人」—— 于是"记整队"在
// 超时空就等于"只记他自己",同格另外三个人一个都不会被记到。
//
// 作用域刻意只加**超时空**:它的 [MINIMAL DUNGEON CLEAR COUNT] 是 1,
// runtimeRaidNotePhaseClearLocked 自己按 limit 封顶,重复记也停在 1;
// 安东/卢克的门可能要求多次通关(而且 limit 可能为 0 即不封顶),重复记会把
// 那一列提前填满,所以它们一个字不改。
//
// 幂等:同一个 (角色, 本地 token) 的报告在函数开头就写进 ClosedDungeonAttempts,
// 重放会直接返回,所以这里不会给同一个人记第二次。
countedWithoutBooking := false
if !booked && state.Lifecycle.FiendWar != nil &&
(runCredited || runtimeRaidJoinedSquadClearIsAlreadySpentLocked(state, binding.MapID, binding.Phase)) {
runtimeRaidNoteFiendWarMemberClearLocked(state, characterID, binding.Phase)
countedWithoutBooking = true
}
state.Lifecycle.Revision++
raidKey := state.RaidKey
phase := state.Lifecycle.Phase
remainingAfter := 0
if next, ok := state.Lifecycle.Objectives[binding.MapID]; ok {
remainingAfter = next.RemainingHP
}
snapshot := cloneRuntimeRaidState(*state)
s.raidMu.Unlock()
s.logGameEvent(session, "game-raid-joined-clear-booked",
"raid_key", raidKey,
"raid_phase", phase,
"raid_map_id", binding.MapID,
"char_id", characterID,
"dungeon_lifecycle_token", localToken,
"owner_lifecycle_token", binding.LifecycleToken,
"booked", booked,
"counted", booked || countedWithoutBooking,
"remaining_hp_before", remainingBefore,
"remaining_hp_after", remainingAfter)
if !booked {
if !countedWithoutBooking {
return
}
// 记上了就必须**让客户端看见**:战况表那一列读的是 mode=3 名册里的成员记录。
// 这一笔没有动目标,所以只补名册刷新,机制/进度那两步不归它。
if err := s.broadcastRuntimeRaidPhaseClearRegistration(snapshot); err != nil {
s.logPacketEvent("game-raid-joined-clear-registration-failed",
"raid_key", raidKey, "raid_map_id", binding.MapID, "error", err)
}
s.logPacketEvent("game-raid-joined-clear-counted-without-booking",
"raid_key", raidKey,
"raid_phase", phase,
"raid_map_id", binding.MapID,
"char_id", characterID,
"reason", "the_gate_point_was_already_spent_but_this_members_clear_still_counts")
return
}
// The owning path's follow-ups, in the same order: mechanisms first, then the
// progress snapshot every member's client redraws from, then the roster refresh
// that carries the clear registration.
if err := s.afterRuntimeRaidObjectiveAdvance(raidKey, phase, binding.MapID); err != nil {
s.logGameEvent(session, "game-raid-anton-mechanism-arm-failed",
"raid_key", raidKey, "raid_map_id", binding.MapID, "error", err)
}
if err := s.publishRuntimeRaidObjectiveProgress(raidKey, phase); err != nil {
s.logPacketEvent("game-raid-joined-clear-progress-failed",
"raid_key", raidKey, "raid_map_id", binding.MapID, "error", err)
}
if err := s.broadcastRuntimeRaidPhaseClearRegistration(snapshot); err != nil {
s.logPacketEvent("game-raid-joined-clear-registration-failed",
"raid_key", raidKey, "raid_map_id", binding.MapID, "error", err)
}
}
func runtimeRaidDungeonBindingMatches(left runtimeRaidDungeonBinding, right runtimeRaidDungeonBinding) bool {
return left.RaidKey == right.RaidKey &&
left.Phase == right.Phase &&
left.MapID == right.MapID &&
left.LifecycleToken == right.LifecycleToken
}
// runtimeRaidRunKeyForAttemptLocked resolves the run record one lifecycle event
// belongs to, from the event's own identity (character + map + the run's opener
// token) instead of from the live roster.
//
// The run key carries the seat the run was OPENED under, and that seat is frozen
// into the record. Recomputing it from the current roster (runtimeRaidSeatFor)
// would move the key whenever a member's squad label changed during the run -- a
// party change or a leader re-group -- which silently dropped that member's
// completion and left the old seat's GroupOccupancy orphaned. Fall back to the
// roster seat only while no record exists yet (the "selected" event that creates
// it). Callers hold raidMu.
func runtimeRaidRunKeyForAttemptLocked(
state *runtimeRaidState,
characterID uint16,
mapID int,
token uint64,
) (runtimeRaidDungeonRunKey, runtimeRaidDungeonProgress, bool) {
if state == nil || characterID == 0 || mapID == 0 || token == 0 {
return runtimeRaidDungeonRunKey{}, runtimeRaidDungeonProgress{}, false
}
for runKey, progress := range state.Lifecycle.Dungeons {
if runKey.MapID != mapID {
continue
}
if progress.CharacterID == characterID && progress.LifecycleToken == token {
return runKey, progress, true
}
}
member, ok := runtimeRaidFrozenMember(*state, characterID)
if !ok {
return runtimeRaidDungeonRunKey{}, runtimeRaidDungeonProgress{}, false
}
runKey := runtimeRaidDungeonRunKey{MapID: mapID, Seat: runtimeRaidSeatFor(member)}
progress, exists := state.Lifecycle.Dungeons[runKey]
return runKey, progress, exists
}
// runtimeRaidRunSeatForTokenLocked resolves the seat a run was opened under, by
// its opener token: a joined squadmate's report must be credited to the run's own
// seat, not to the seat its roster label happens to name now. Callers hold raidMu.
func runtimeRaidRunSeatForTokenLocked(
state *runtimeRaidState,
mapID int,
token uint64,
fallback runtimeRaidDungeonSeat,
) runtimeRaidDungeonSeat {
if state == nil || mapID == 0 || token == 0 {
return fallback
}
for runKey, progress := range state.Lifecycle.Dungeons {
if runKey.MapID == mapID && progress.LifecycleToken == token {
return runKey.Seat
}
}
return fallback
}
func runtimeRaidDungeonProgressMatches(progress runtimeRaidDungeonProgress, characterID uint16, binding runtimeRaidDungeonBinding) bool {
return progress.CharacterID == characterID &&
progress.Phase == binding.Phase &&
progress.MapID == binding.MapID &&
progress.LifecycleToken == binding.LifecycleToken
}
// runtimeFiendWarClearReturnBindingAccepts 报告"本人手上这份 raidDungeon 绑定还能不能用",
// 也就是"清完自动回城"里除了"这一局的结算已经做完"之外的那半条。
//
// 操作员 2026-10-01 定案(原话):
//
// 「不管是谁 谁是驱动者 谁收尾 这都不重要 通关直接回城就是了
// 当然了 这些逻辑只适用于超时空」
//
// 所以这里**只**回答"这份绑定描述的是不是我此刻真的在打的那张图",
// 一切与"谁"有关的判断都不许出现在这条路上:
//
// - 不看 binding token 等不等于本人本地 token —— 那只有**开局的人**成立;
// 超时空一个区域共用一条 run,而 prepareRuntimePartyDungeonEntry **先备队友**,
// 所以常常是队友开局、驱动者 adopt,驱动者手上那份 binding 带的是开局人的 token。
// - 不看攻坚账本里还留不留着这条 run —— 通关处理本身就会
// `delete(state.Lifecycle.Dungeons, runKey)`,等该回城时账本已经空了。
// 2026-10-01 真机把这两条各演示了一遍:23:38 开局人收尾**失败**(账本还在)
// ⇒ 驱动者回了城("队长自动回城了 队员没回去");23:54 开局人收尾**成功**
// (账本被删)⇒ 同一个驱动者又回不去("队员回了 队长没回去")。
//
// 剩下这条是"我与我自己"的关系,不是"谁":一个会话不可能拿着 A 图的绑定站在 B 图里
// (runtimeRaidCurrentRaidDungeonRun 用的是同一条不变式)。
//
// 作用域:本函数只被超时空那条清完回城的判定调用(tryRuntimeFiendWarEncounterPlanned,
// 入口已经用 isCurrentFiendWarRaidChannel + RaidMode==runtimeRaidOwnerFiendWar 挡过),
// 安东/卢克一个字都不改。
func runtimeFiendWarClearReturnBindingAccepts(runtime *runtimeDungeonState) bool {
if runtime == nil || runtime.raidDungeon.RaidKey == 0 || runtime.lifecycleToken == 0 {
return false
}
return runtime.raidDungeon.MapID == int(runtime.Dungeon.ID)
}
// runtimeRaidDungeonLifecycleReportAccepted 是 notifyRuntimeRaidDungeonLifecycleLocked
// 的准入判定:这一条 lifecycle 事件能不能落到 run 上。
//
// 常规那半条是逐字保留的「本人 + 同 token」。
//
// 超时空那半条是 2026-10-01 真机事故的修复:一个区域共用一条 run,**先开的人持有
// 它的 token**。后到的那位手里是自己那份 token(他这一趟从没开过 run),但这一格
// 确实已经有本小队的 run 在打 —— 按设计"一场只报一次、由 owner 报"
// (见 beginRuntimeRaidDungeonForRuntime 里 binding.LifecycleToken !=
// runtime.lifecycleToken 那条 return nil),这里必须是**静默接受**。
//
// 真机 21:27:11 把它当成了非法 token:队友先被 prepare 开出了这一格的 run
// (token=1,owner=char 20),驱动者随后带着自己那份 token=2 来报,于是
//
// game-dungeon-select-blocked reason=dungeon_runtime_commit_failed
// error=runtime raid dungeon lifecycle token is invalid:
// raid=12058625 phase=1 map=4960 token=2
//
// 整场进图被拆("A party that cannot be prepared is REFUSED, never split"),
// 之后心跳永远重试 game-fiendwar-encounter-peer-not-ready —— 谁都进不去。
//
// 命中条件刻意收得很紧:
// - exists 为真:说明**本成员座位下**这一格的 run 确实在
// (runtimeRaidRunKeyForAttemptLocked 的兜底用 runKey{MapID, runtimeRaidSeatFor});
// - runKey.MapID / progress.MapID / progress.Phase 与事件一致;
// - owner 非零且不是本人 —— 也就是"这是我队友开的、我们一起打的那一场"。
//
// 本人用新 token 重开自己的局(racing duplicate)owner 就是自己,仍然被拒。
//
// 安东/卢克逐字不变:它们的座位恒等于 runtimeRaidSeatFor,后到者在
// beginRuntimeRaidDungeon 就 adopt 了 owner 的 token,走不到这里;而且这半条被
// state.Lifecycle.FiendWar != nil 挡住。
func runtimeRaidDungeonLifecycleReportAccepted(
state *runtimeRaidState,
runKey runtimeRaidDungeonRunKey,
progress runtimeRaidDungeonProgress,
exists bool,
event runtimeRaidDungeonLifecycleEvent,
) bool {
if !exists {
return false
}
if runtimeRaidDungeonProgressMatches(progress, event.CharacterID, runtimeRaidDungeonBinding{
RaidKey: event.RaidKey,
Phase: event.Phase,
MapID: event.MapID,
LifecycleToken: event.LifecycleToken,
}) {
return true
}
return state != nil && state.Lifecycle.FiendWar != nil &&
runKey.MapID == event.MapID &&
progress.MapID == event.MapID &&
progress.Phase == event.Phase &&
progress.CharacterID != 0 &&
progress.CharacterID != event.CharacterID
}
// runtimeRaidRunClearCreditedLocked reports whether this gate run already spent its
// one clear. One run is one party clear: the opener's completion and every adopting
// squadmate's own completion all name the same run, so only the first of them may
// take a point off the gate. Callers hold raidMu.
func runtimeRaidRunClearCreditedLocked(state *runtimeRaidState, runKey runtimeRaidDungeonRunKey) bool {
if state == nil || state.Lifecycle.RunClearCredits == nil {
return false
}
_, credited := state.Lifecycle.RunClearCredits[runKey]
return credited
}
// runtimeRaidCreditRunClearLocked records that this run has spent its one clear.
// Callers hold raidMu and have just advanced the objective.
func runtimeRaidCreditRunClearLocked(state *runtimeRaidState, runKey runtimeRaidDungeonRunKey, token uint64) {
if state == nil {
return
}
if state.Lifecycle.RunClearCredits == nil {
state.Lifecycle.RunClearCredits = make(map[runtimeRaidDungeonRunKey]uint64)
}
state.Lifecycle.RunClearCredits[runKey] = token
}
// runtimeRaidNotePhaseClearLocked registers one cleared gate for every member of
// the clearing squad, and only for the clearing member itself when that member
// belongs to no squad. The current client reads the result from the member record
// it already holds (see runtimeRaidMemberRecord), so the counter has to live on
// the raid state and be capped at the phase's own requirement: 86JP caps the
// same value at the gate's maximum clear count, and the 地下城通关 column must
// never read higher than the requirement it is measured against.
//
// An unassigned member stands alone: 86JP's BuildSituationGroups gives PartyIndex 0
// its own singleton group (RaidManager.cs:339-344), so its clear must not fill the
// 地下城通关 column of squad 1, which is what folding it onto the normalized group
// used to do.
//
// Callers hold raidMu and have just advanced the objective.
func runtimeRaidNotePhaseClearLocked(state *runtimeRaidState, characterID uint16, phase byte) {
if state == nil || characterID == 0 {
return
}
member, ok := runtimeRaidFrozenMember(*state, characterID)
if !ok {
// A session that reports a clear but is not (or no longer) a frozen
// member registered nothing: the run itself still settles normally.
return
}
group := runtimeRaidMemberSquad(member.GroupIndex)
limit := uint32(runtimeRaidPhaseMinimalClearCount(*state, phase))
if state.Lifecycle.PhaseClearCounts == nil {
state.Lifecycle.PhaseClearCounts = make(map[uint16]uint32)
}
register := func(candidate uint16) {
next := state.Lifecycle.PhaseClearCounts[candidate] + 1
if limit > 0 && next > limit {
next = limit
}
state.Lifecycle.PhaseClearCounts[candidate] = next
}
if group == 0 {
register(characterID)
return
}
for _, squadmate := range state.FrozenMembers {
if runtimeRaidMemberSquad(squadmate.GroupIndex) != group {
continue
}
register(squadmate.CharID)
}
}
// runtimeRaidPhaseMinimalClearCount is the active phase's
// [minimal dungeon clear count] from the native PVF, or 0 when the config does
// not carry one (the callers then leave the registration uncapped).
//
// It is the *display* cap for the 地下城通关 column and keeps its historical
// shape on purpose: it indexes config.Phases for every raid, so single-mode
// Luke (whose team phase one declares 0) stays uncapped. The *reward*
// requirement is a different question with a different answer for that case --
// use runtimeRaidPhaseMinimalClearRequirement for it.
func runtimeRaidPhaseMinimalClearCount(state runtimeRaidState, phase byte) int {
if phase == 0 {
return 0
}
config := state.AntonConfig
if state.RaidMode == runtimeRaidOwnerLuke && state.LukeConfig != nil {
config = state.LukeConfig.Raid
}
if config == nil {
return 0
}
index := int(phase) - 1
if index < 0 || index >= len(config.Phases) {
return 0
}
return config.Phases[index].MinimalDungeonClearCount
}
// runtimeRaidPhaseClearCount projects one member's phase clear registration onto
// the single byte the current client's member record carries.
func runtimeRaidPhaseClearCount(state runtimeRaidState, characterID uint16) byte {
value := state.Lifecycle.PhaseClearCounts[characterID]
if value > 0xff {
value = 0xff
}
return byte(value)
}
func advanceRuntimeRaidObjectiveLocked(state *runtimeRaidState, phase byte, mapID int) error {
if state == nil || state.Lifecycle.Phase != phase {
return errRuntimeRaidPhaseMismatch
}
objective, ok := state.Lifecycle.Objectives[mapID]
if !ok || objective.Phase != phase {
return errRuntimeRaidDungeonUnavailable
}
if objective.RemainingHP <= 0 || objective.State != raid.AntonDungeonOpen {
// The gate closed while this run was still inside: a fourth black-fog
// clear hides 210 for the groups that had not finished, and a mechanism
// reset reinitializes every gate. Such a clear earns nothing, but the run
// itself must still settle normally, so this is not an error.
return nil
}
objective.RemainingHP--
objective.ClearCount++
if objective.RemainingHP == 0 {
// A finished gate reads as cleared (wire 3), not as merely hidden: the
// client keeps drawing it while the phase runs, which is how the legs and
// the hatcheries stay visible as "done" until the phase ends.
objective.State = raid.AntonDungeonCleared
}
state.Lifecycle.Objectives[mapID] = objective
if state.TailFlag == 1 {
if phase != runtimeRaidDungeonPhaseOne {
return errRuntimeRaidPhaseMismatch
}
advanceRuntimeRaidSingleObjectiveLocked(state, mapID, objective.RemainingHP == 0)
if runtimeRaidPhaseObjectivesComplete(*state, phase) {
for id, next := range state.Lifecycle.Objectives {
if next.Phase == phase {
next.State = raid.AntonDungeonHide
state.Lifecycle.Objectives[id] = next
}
}
}
return nil
}
// 队伍卢克:它的副本图是自己那套。一阶段的门图在这里走;二阶段的图由
// afterRuntimeRaidObjectiveAdvance 的已清关钩子驱动,因为那边要上弦机制计时器,
// 而这个函数不能调度(上游 0c79ba6 / bb52082)。
// 安徒恩状态求值为 false,下面那一段与改动前逐字相同。
if state.TailFlag != 1 && runtimeRaidTeamGraphOwner(*state) == runtimeRaidOwnerLuke {
if phase == runtimeRaidDungeonPhaseOne {
applyRuntimeRaidLukePhaseOneClearLocked(state, mapID)
}
return nil
}
switch phase {
case runtimeRaidDungeonPhaseOne:
if mapID == antonBlackFogSourceMapID && objective.RemainingHP == 0 {
// The black-fog source is spent: the two quakes and the two legs open
// now. The regenerated fog (211) and the naval cannon (216) stay
// hidden until their own mechanism timers reopen them.
objective.State = raid.AntonDungeonHide
state.Lifecycle.Objectives[mapID] = objective
// The regenerated fog carries the respawn countdown the client draws.
// Its gate timer already exists (0x0254 timerType 2, map 211, 300s, armed
// by applyRuntimeRaidAntonPhaseOneClearLocked), but the client only draws
// a type-2 countdown for a gate whose state is 1 or 3 and skips state 2
// entirely: 0x00F142C9 reads the state, sends state 0 to the type-1 key
// and state 1/3 to the type-2 key, and every other value falls through to
// the do-not-draw path. The PVF leaves 211 at hide, so nothing was ever
// rendered and the reported "黑雾之源重生倒计时没�? followed. 86JP's
// AntonFirstPhaseSmokeClearedStates writes 211 -> 3 here for the same
// reason.
if fog, exists := state.Lifecycle.Objectives[antonBlackFogMapID]; exists && fog.State == raid.AntonDungeonHide {
fog.State = raid.AntonDungeonCleared
state.Lifecycle.Objectives[antonBlackFogMapID] = fog
}
for id, next := range state.Lifecycle.Objectives {
if next.Phase == phase && next.RemainingHP > 0 && antonPhaseOneOpensWithBlackFogSource(id) {
next.State = raid.AntonDungeonOpen
state.Lifecycle.Objectives[id] = next
}
}
}
case runtimeRaidDungeonPhaseTwo:
if mapID == antonBlackVolcanoMapID && objective.RemainingHP == 0 {
// The volcano falls: energy interception, the volcano itself and every
// hatchery close (state 2, not the cleared marker -- the 86JP reference
// overrides its own "cleared" write the same way in
// ClearBlackVolcanoAsync), and only the heart remains open.
for _, id := range []int{antonEnergyInterceptionMapID, antonBlackVolcanoMapID, 221, 222, 223, 224} {
next, exists := state.Lifecycle.Objectives[id]
if exists {
next.State = raid.AntonDungeonHide
state.Lifecycle.Objectives[id] = next
}
}
if heart, exists := state.Lifecycle.Objectives[antonHeartMapID]; exists && heart.RemainingHP > 0 {
heart.State = raid.AntonDungeonOpen
state.Lifecycle.Objectives[antonHeartMapID] = heart
}
}
default:
return errRuntimeRaidPhaseMismatch
}
if runtimeRaidPhaseObjectivesComplete(*state, phase) {
for id, next := range state.Lifecycle.Objectives {
if next.Phase == phase {
next.State = raid.AntonDungeonHide
state.Lifecycle.Objectives[id] = next
}
}
}
return nil
}
func advanceRuntimeRaidSingleObjectiveLocked(state *runtimeRaidState, mapID int, completed bool) {
if state == nil || !completed {
return
}
if state.RaidMode == runtimeRaidOwnerLuke {
route := runtimeRaidLukeSingleRoute(state.SingleRaidType)
completedIndex := -1
for index, routeMapID := range route {
if routeMapID == mapID {
completedIndex = index
break
}
}
if completedIndex < 0 {
return
}
for index, routeMapID := range route {
objective, exists := state.Lifecycle.Objectives[routeMapID]
if !exists || objective.Phase != runtimeRaidDungeonPhaseOne {
continue
}
switch {
case index <= completedIndex:
objective.State = raid.AntonDungeonHide
case index == completedIndex+1:
objective.State = raid.AntonDungeonOpen
case index == completedIndex+2:
objective.State = raid.AntonDungeonClosed
default:
objective.State = raid.AntonDungeonHide
}
state.Lifecycle.Objectives[routeMapID] = objective
}
return
}
nextIDs := runtimeRaidSingleNextMapIDs(state.RaidMode, state.SingleRaidType, mapID)
for _, nextID := range nextIDs {
next, exists := state.Lifecycle.Objectives[nextID]
if exists && next.Phase == runtimeRaidDungeonPhaseOne && next.RemainingHP > 0 {
next.State = raid.AntonDungeonOpen
state.Lifecycle.Objectives[nextID] = next
}
}
}
func runtimeRaidLukeSingleRoute(singleRaidType byte) []int {
switch singleRaidType {
case runtimeRaidLukeLightRoute:
return []int{5323, 5324, 5325, 5326, 5327, 5328}
case runtimeRaidLukeDarkRoute:
return []int{5329, 5330, 5331, 5332, 5333, 5334}
default:
return nil
}
}
func runtimeRaidSingleNextMapIDs(raidMode, singleRaidType byte, mapID int) []int {
if raidMode == runtimeRaidOwnerLuke {
switch mapID {
case 5323:
return []int{5324}
case 5324:
return []int{5325}
case 5325:
return []int{5326}
case 5326:
return []int{5327}
case 5327:
return []int{5328}
case 5329:
return []int{5330}
case 5330:
return []int{5331}
case 5331:
return []int{5332}
case 5332:
return []int{5333}
case 5333:
return []int{5334}
default:
return nil
}
}
switch mapID {
case 5314:
switch singleRaidType {
case 3:
return []int{5315}
case 6:
return []int{5316}
default:
return []int{5317}
}
case 5315:
return []int{5318}
case 5316:
return []int{5321}
case 5317:
return []int{5322}
case 5318, 5321, 5322:
return []int{5319}
case 5319:
return []int{5320}
default:
return nil
}
}
func runtimeRaidPhaseObjectivesComplete(state runtimeRaidState, phase byte) bool {
// 超时空的"阶段完成"不是数副本 HP,而是战场自己判的:PVF 的 [CLEAR PHASE] 规则
// 一旦求值到就把 battle.Complete 立起来。它只有一阶段,所以 phase 必须是 1。
// 这条不接的话整团永远进不了结算 —— 战场会把 Complete 置位,但没人问它。
if state.RaidMode == runtimeRaidOwnerFiendWar {
return phase == runtimeRaidDungeonPhaseOne && state.Lifecycle.FiendWar != nil && state.Lifecycle.FiendWar.Complete
}
remaining := func(mapID int) int {
objective, ok := state.Lifecycle.Objectives[mapID]
if !ok || objective.Phase != phase {
return -1
}
return objective.RemainingHP
}
switch phase {
case runtimeRaidDungeonPhaseOne:
if state.TailFlag == 1 {
if state.RaidMode == runtimeRaidOwnerLuke {
if state.SingleRaidType == runtimeRaidLukeLightRoute {
return remaining(5328) == 0
}
if state.SingleRaidType == runtimeRaidLukeDarkRoute {
return remaining(5334) == 0
}
return false
}
return remaining(5320) == 0
}
// 卢克一阶段:三个圣所全部 clear 才算完(etc/raid/luke/luke.etc [1PHASE SUCCESS])。
// 安徒恩(owner != Anton 时才会进这里,即 owner == Luke)以外的路径逐字不变。
if runtimeRaidTeamGraphOwner(state) == runtimeRaidOwnerLuke {
return runtimeRaidLukePhaseObjectivesComplete(state, phase)
}
return remaining(213) == 0 && remaining(215) == 0
case runtimeRaidDungeonPhaseTwo:
if runtimeRaidTeamGraphOwner(state) == runtimeRaidOwnerLuke {
return runtimeRaidLukePhaseObjectivesComplete(state, phase)
}
return remaining(220) == 0
default:
return false
}
}
// runtimeRaidNormalizedGroup folds an unassigned member (group 0) onto the default
// squad. It is the rule for one thing only: the ordinary-party projection, which has
// to name a party that exists and puts an unassigned party into squad 1
// (runtimeRaidSquadForOrdinaryParty).
//
// Everywhere the group is a KEY or is PRESENTED, use what the member actually is:
// runtimeRaidSeatFor for the dungeon run key and its occupancy (a squad shares one
// seat; an unassigned member gets its own), and runtimeRaidMemberSquad for the 584
// squad row, the phase-clear registration and the reward reveal split. Folding an
// unassigned member onto squad 1 put it in squad 1's 战况表 cell, in squad 1's clear
// registration, on squad 1's reward board, and -- through the run key -- let it hold
// squad 1's gate against the squad that owns it (live report 2026-09-17 21:5x: an
// unassigned member entered a gate and the 战况表 drew it in squad 1).
func runtimeRaidNormalizedGroup(group byte) byte {
if group == 0 {
return runtimeRaidDefaultGroup
}
return group
}
// runtimeRaidMemberSquad reports the attack group a member actually belongs to, or 0
// when it belongs to none.
//
// 0 is what the client itself reads: the roster record carries the squad byte
// straight from the wire (sub_17B6EF0 stores it at +0x2D with no arithmetic) and
// every squad lookup compares it against 1..N, so an unassigned member matches no
// squad. The reference server draws the same line -- RaidManager.Create assigns no
// PartyIndex and BuildSituationGroups gives PartyIndex 0 its own singleton group
// (ServerS4A21-master, RaidManager.cs:339-344, RaidAggregate.cs:65
// `IsSolo => PartyIndex == 0`).
func runtimeRaidMemberSquad(group byte) byte {
if group < 1 || group > runtimeRaidMaxGroups {
return 0
}
return group
}
// runtimeRaidMembersInSquad lists the roster members that actually belong to one
// attack group, in ascending character order. An unassigned member is in no group,
// so it never appears in another squad's list.
func runtimeRaidMembersInSquad(state runtimeRaidState, group byte) []uint16 {
if group == 0 {
return nil
}
members := make([]uint16, 0, runtimeRaidGroupSize)
for _, candidate := range state.FrozenMembers {
if runtimeRaidMemberSquad(candidate.GroupIndex) == group {
members = append(members, candidate.CharID)
}
}
sort.Slice(members, func(i, j int) bool { return members[i] < members[j] })
return members
}
// runtimeRaidMembersInSquadLiveRows lists the members whose CURRENT (live) squad
// label is this squad.
//
// The 584 participation row and the 0x027A battle-status row are classified by the
// CLIENT from each listed member's own 0x024F roster byte -- the client reads that
// byte straight and renders 0 as 单人 (see runtimeRaidMemberSquad) -- and the
// 0x024F roster is built from the live rows (runtimeRaidMemberRefresh). Listing a
// member from the frozen roster while its live label is 0 therefore produces a row
// the client cannot classify at all.
//
// Live 2026-09-29 00:52:45 (raid 1507329, 8 members, own build):
//
// game-raid-squad-unassigned-on-session-leave char_id=2 previous_group=1
// group=0 frozen_row=kept_for_phase_credit source=game_session_unbound_or_disconnected
// game-raid-squad-sync-skipped-members-inside-dungeon members=[1 9 35]
//
// char 2 (its client had been reset off the wire 10 minutes earlier) lost its LIVE
// label to 0 while the frozen row kept squad 1, so every following 0x024F carried
// squad byte 0 for it while the 584/0x027A rows still named it inside squad 1 --
// the squad's own row then drew as 单人 with an unresolved portrait, and stayed
// that way because the members were inside a gate and no party gesture could
// re-label them. The operator's report is exactly that: "1队进图了 2队在外面看
// 看不到是哪队进图 就显示个单 队长头像也是黑的".
//
// The frozen roster keeps its own job (phase clear credit and the reward board);
// this helper is for the two rows the client classifies by roster byte.
func runtimeRaidMembersInSquadLiveRows(state runtimeRaidState, group byte) []uint16 {
if group == 0 {
return nil
}
members := make([]uint16, 0, runtimeRaidGroupSize)
for _, candidate := range state.Members {
if runtimeRaidMemberSquad(candidate.GroupIndex) == group {
members = append(members, candidate.CharID)
}
}
sort.Slice(members, func(i, j int) bool { return members[i] < members[j] })
return members
}
func runtimeRaidFrozenMember(state runtimeRaidState, characterID uint16) (runtimeRaidMemberState, bool) {
for _, member := range state.FrozenMembers {
if member.CharID == characterID {
return member, true
}
}
return runtimeRaidMemberState{}, false
}
// openRuntimeRaidPhaseMovie is called only after the current-version raid
// objective owner has proved that the active phase is complete. Map IDs alone
// do not imply phase completion.
func (s *Service) openRuntimeRaidPhaseMovie(raidKey uint32, phase byte, now time.Time) (runtimeRaidState, error) {
now = runtimeRaidNormalizeTime(now)
s.raidMu.Lock()
defer s.raidMu.Unlock()
state := s.raids[raidKey]
if state == nil {
return runtimeRaidState{}, errRuntimeRaidNotFound
}
state.ensureRuntimeRaidLifecycleMapsLocked()
if state.Lifecycle.Phase != phase {
return runtimeRaidState{}, errRuntimeRaidPhaseMismatch
}
if state.Lifecycle.Status == runtimeRaidLifecycleAwaitingMovie {
return cloneRuntimeRaidState(*state), nil
}
if state.Lifecycle.Status != runtimeRaidLifecyclePhaseActive || len(state.Lifecycle.GroupOccupancy) != 0 {
return runtimeRaidState{}, errRuntimeRaidLifecycleState
}
if len(state.Lifecycle.Objectives) != 0 && !runtimeRaidPhaseObjectivesComplete(*state, phase) {
return runtimeRaidState{}, errRuntimeRaidLifecycleState
}
state.Lifecycle.Status = runtimeRaidLifecycleAwaitingMovie
state.Lifecycle.MovieOpenedAt = now
clear(state.Lifecycle.MovieConfirmed)
state.Lifecycle.Revision++
return cloneRuntimeRaidState(*state), nil
}
// confirmRuntimeRaidPhaseMovie is kept as the narrow lifecycle helper used by
// older callers. Every phase now remains on that phase's reward cards after
// the movie; phase one advances only after its durable reward is published.
func (s *Service) confirmRuntimeRaidPhaseMovie(characterID uint16, phase byte, now time.Time) (runtimeRaidState, bool, error) {
return s.confirmRuntimeRaidSettlementMovie(characterID, phase, now)
}
func (s *Service) confirmRuntimeRaidSettlementMovie(characterID uint16, phase byte, now time.Time) (runtimeRaidState, bool, error) {
now = runtimeRaidNormalizeTime(now)
s.raidMu.Lock()
defer s.raidMu.Unlock()
state := s.runtimeRaidByMemberLocked(characterID)
if state == nil {
return runtimeRaidState{}, false, errRuntimeRaidNotFound
}
if _, ok := runtimeRaidFrozenMember(*state, characterID); !ok {
return runtimeRaidState{}, false, errRuntimeRaidNotMember
}
state.ensureRuntimeRaidLifecycleMapsLocked()
if state.Lifecycle.Status == runtimeRaidLifecycleAwaitingRewards && state.Lifecycle.Phase == phase &&
state.Lifecycle.MovieConfirmed[characterID] && s.runtimeRaidMovieRosterConfirmedLocked(state) {
return cloneRuntimeRaidState(*state), true, nil
}
// The card window is already open and THIS member's confirmation arrives late:
// either the bounded wait opened it for the raid (see raid_movie_fallback.go)
// or the member is re-sending after a reconnect. Record the confirmation and
// report the roster as confirmed so the caller runs the same movie-complete
// broadcast -- the client leaves its result movie and joins the open card
// window instead of waiting for an ACK that would otherwise never come.
if state.Lifecycle.Status == runtimeRaidLifecycleAwaitingRewards && state.Lifecycle.Phase == phase {
if !state.Lifecycle.MovieConfirmed[characterID] {
state.Lifecycle.MovieConfirmed[characterID] = true
state.Lifecycle.Revision++
}
return cloneRuntimeRaidState(*state), true, nil
}
if state.Lifecycle.Status != runtimeRaidLifecycleAwaitingMovie || state.Lifecycle.Phase != phase {
return runtimeRaidState{}, false, errRuntimeRaidLifecycleState
}
if state.Lifecycle.MovieConfirmed[characterID] {
return cloneRuntimeRaidState(*state), false, nil
}
state.Lifecycle.MovieConfirmed[characterID] = true
state.Lifecycle.Revision++
if !s.runtimeRaidMovieRosterConfirmedLocked(state) {
return cloneRuntimeRaidState(*state), false, nil
}
state.Lifecycle.MovieOpenedAt = time.Time{}
state.Lifecycle.PhaseDeadline = time.Time{}
if phase != runtimeRaidDungeonPhaseOne && phase != runtimeRaidDungeonPhaseTwo {
return runtimeRaidState{}, false, errRuntimeRaidPhaseMismatch
}
state.Lifecycle.Status = runtimeRaidLifecycleAwaitingRewards
state.Lifecycle.RewardToken = 0
state.Lifecycle.RewardOpenedAt = now
clear(state.Lifecycle.RewardSelections)
// The members the movie gate skipped stay skipped: this transition opens the
// cards for the members that are still here, and the record made above is
// what lets the rest of the settlement agree on who those are. It is cleared
// with the rest of the reward state when the next phase starts.
state.Lifecycle.RewardDelivery = nil
state.Lifecycle.RewardDeliveryCommitted = false
state.Lifecycle.RewardDeliveryCommittedAt = time.Time{}
state.Lifecycle.Revision++
return cloneRuntimeRaidState(*state), true, nil
}
func (s *Service) setRuntimeRaidPhaseDeadline(raidKey uint32, phase byte, deadline time.Time) (runtimeRaidState, error) {
if deadline.IsZero() {
return runtimeRaidState{}, errRuntimeRaidLifecycleState
}
deadline = deadline.UTC()
s.raidMu.Lock()
defer s.raidMu.Unlock()
state := s.raids[raidKey]
if state == nil {
return runtimeRaidState{}, errRuntimeRaidNotFound
}
if state.Lifecycle.Phase != phase || (state.Lifecycle.Status != runtimeRaidLifecyclePhaseActive && state.Lifecycle.Status != runtimeRaidLifecycleAwaitingMovie) {
return runtimeRaidState{}, errRuntimeRaidPhaseMismatch
}
if !state.Lifecycle.PhaseStartedAt.IsZero() && !deadline.After(state.Lifecycle.PhaseStartedAt) {
return runtimeRaidState{}, errRuntimeRaidLifecycleState
}
if state.Lifecycle.PhaseDeadline.Equal(deadline) {
return cloneRuntimeRaidState(*state), nil
}
state.Lifecycle.PhaseDeadline = deadline
state.Lifecycle.Revision++
return cloneRuntimeRaidState(*state), nil
}
func runtimeRaidPhaseElapsed(state runtimeRaidState, now time.Time) time.Duration {
now = runtimeRaidNormalizeTime(now)
if state.Lifecycle.PhaseStartedAt.IsZero() || now.Before(state.Lifecycle.PhaseStartedAt) {
return 0
}
return now.Sub(state.Lifecycle.PhaseStartedAt)
}
func runtimeRaidPhaseRemaining(state runtimeRaidState, now time.Time) time.Duration {
now = runtimeRaidNormalizeTime(now)
if state.Lifecycle.PhaseDeadline.IsZero() || !now.Before(state.Lifecycle.PhaseDeadline) {
return 0
}
return state.Lifecycle.PhaseDeadline.Sub(now)
}
// expireRuntimeRaidPhase is an explicit timer callback. Merely reading a zero
// remaining duration never mutates the raid, so packet retries and clock reads
// cannot race final cleanup.
// expireRuntimeRaidPhase fails the phase whose attack window just ran out.
//
// 86JP's timeout is not a teardown: RaidHandler.Runtime.cs RunAttackTimeoutAsync
// (:592-728) calls RaidManager.TryFailPhase, which turns the raid into state 4 with
// argument 1 (RaidManager.cs:1956-1982), closes every gate of that phase (state 2),
// sets the phase-one failure symbol, sends the fail result and then hands the raid
// to a ten-second remover. This server used to delete the raid on the spot, so the
// members never saw a result screen and a client that was still inside a gate kept a
// run the raid no longer owned.
//
// The raid therefore stays in the raid table in runtimeRaidLifecycleFailed; the
// caller publishes the failure presentation and arms the delayed removal
// (removeRuntimeRaidFailedPhase). Every other path already requires
// runtimeRaidLifecyclePhaseActive, so a failed raid accepts no further entry,
// completion or reward gesture while it waits.
func (s *Service) expireRuntimeRaidPhase(raidKey uint32, phase byte, now time.Time) (runtimeRaidState, error) {
now = runtimeRaidNormalizeTime(now)
s.raidMu.Lock()
defer s.raidMu.Unlock()
state := s.raids[raidKey]
if state == nil {
return runtimeRaidState{}, errRuntimeRaidNotFound
}
if state.StartPending || state.Lifecycle.Phase != phase ||
(state.Lifecycle.Status != runtimeRaidLifecyclePhaseActive && state.Lifecycle.Status != runtimeRaidLifecycleAwaitingMovie) ||
state.Lifecycle.PhaseDeadline.IsZero() || now.Before(state.Lifecycle.PhaseDeadline) {
return runtimeRaidState{}, errRuntimeRaidLifecycleState
}
state.ensureRuntimeRaidLifecycleMapsLocked()
// Every gate of the expired phase is closed, exactly as 86JP closes the phase's
// dungeon states before it announces the failure. The gate keeps its clear
// count and its remaining HP: the state row is what tells the client the gate
// is gone, and a later re-read must not look like a completed objective.
for mapID, objective := range state.Lifecycle.Objectives {
if objective.Phase != phase {
continue
}
objective.State = raid.AntonDungeonHide
state.Lifecycle.Objectives[mapID] = objective
}
if phase == runtimeRaidDungeonPhaseOne {
// 86JP AntonPhaseOneFailSymbolId = 104 (RaidHandler.AntonDefinitions.cs:55)
// is the one-shot marker that plays the failure presentation of the first
// phase; the second phase has no equivalent symbol.
state.setRuntimeRaidMovieSymbolLocked(antonSymbolPhaseOneFail, 1)
}
state.clearRuntimeRaidMechanismsLocked()
state.Lifecycle.Status = runtimeRaidLifecycleFailed
state.Lifecycle.FailedAt = now
state.Lifecycle.Revision++
return cloneRuntimeRaidState(*state), nil
}
// removeRuntimeRaidFailedPhase releases a failed raid after its result window.
//
// It is 86JP's RemoveFailedRaidAfterResultDelayAsync (RaidHandler.Runtime.cs:730-762):
// the terminal object is dropped, the runtime state is cleaned up, and the removal is
// announced to the exact sessions that were in it. On this server the members still
// standing inside a gate of the failed phase are also sent back to town, because a
// run whose raid no longer exists can never be finished or abandoned by its own
// client: without that, the operator is left standing in a dungeon nobody owns.
func (s *Service) removeRuntimeRaidFailedPhase(raidKey uint32, phase byte) {
if s == nil || raidKey == 0 {
return
}
s.raidMu.Lock()
state := s.raids[raidKey]
if state == nil || state.Lifecycle.Status != runtimeRaidLifecycleFailed || state.Lifecycle.Phase != phase {
s.raidMu.Unlock()
return
}
failed := cloneRuntimeRaidState(*state)
delete(s.raids, raidKey)
s.raidMu.Unlock()
if returned := s.forceReturnRuntimeRaidPhaseGroups(raidKey, phase, "raid_phase_attack_timeout"); returned > 0 {
s.logPacketEvent("game-raid-phase-groups-force-returned",
"raid_key", raidKey,
"raid_phase", phase,
"returned_sessions", returned,
"reason", "the_attack_window_expired_and_the_failed_raid_no_longer_owns_its_gates")
}
if err := s.publishExpiredRuntimeRaid(failed); err != nil {
s.logPacketEvent("game-raid-failed-raid-release-failed",
"raid_key", raidKey, "raid_phase", phase, "error", err)
}
}
// openRuntimeRaidRewards freezes an opaque reward lifecycle token after the
// second movie. The reward resolver owns every PVF pool and durable grant.
func (s *Service) openRuntimeRaidRewards(raidKey uint32, rewardToken uint64, now time.Time) (runtimeRaidState, error) {
if rewardToken == 0 {
return runtimeRaidState{}, errRuntimeRaidRewardToken
}
now = runtimeRaidNormalizeTime(now)
s.raidMu.Lock()
defer s.raidMu.Unlock()
state := s.raids[raidKey]
if state == nil {
return runtimeRaidState{}, errRuntimeRaidNotFound
}
state.ensureRuntimeRaidLifecycleMapsLocked()
if state.Lifecycle.Status != runtimeRaidLifecycleAwaitingRewards ||
(state.Lifecycle.Phase != runtimeRaidDungeonPhaseOne && state.Lifecycle.Phase != runtimeRaidDungeonPhaseTwo) {
return runtimeRaidState{}, errRuntimeRaidLifecycleState
}
if state.Lifecycle.RewardToken != 0 {
if state.Lifecycle.RewardToken != rewardToken {
return runtimeRaidState{}, errRuntimeRaidRewardToken
}
return cloneRuntimeRaidState(*state), nil
}
// An externally supplied token means this settlement is already keyed, so the
// eligible roster it was keyed on must stop moving here, exactly as it does
// when this server mints the token itself.
s.freezeRuntimeRaidRewardSkipsLocked(state)
state.Lifecycle.RewardToken = rewardToken
state.Lifecycle.RewardOpenedAt = now
state.Lifecycle.Revision++
return cloneRuntimeRaidState(*state), nil
}
func (s *Service) confirmRuntimeRaidRewardSelection(characterID uint16, rewardToken uint64, mode byte, index byte, now time.Time) (runtimeRaidState, bool, error) {
state, all, err := s.selectRuntimeRaidSettlementCard(characterID, 0, raid.RaidRewardCardRequest{Mode: mode, CardIndex: index}, now)
if err != nil {
return runtimeRaidState{}, false, err
}
if rewardToken != 0 && state.Lifecycle.RewardToken != 0 && state.Lifecycle.RewardToken != rewardToken {
return runtimeRaidState{}, false, errRuntimeRaidRewardToken
}
return state, all, nil
}
func (s *Service) selectRuntimeRaidSettlementCard(characterID uint16, phase byte, request raid.RaidRewardCardRequest, now time.Time) (runtimeRaidState, bool, error) {
now = runtimeRaidNormalizeTime(now)
// Normalise the clicked slot before anything stores or compares it: the
// client's resultType==3 branch only recognises card ordinals 0 and 2 and pops
// "因未满足条件�?无法获得奖励�? (string 60456) for anything else, so a click on
// slot 1 or 3 must be recorded as the slot it is drawn as. Normalising here
// keeps the stored selection, its idempotency comparison, the reward plan and
// the durable receipt consistent with the wire rows.
request.CardIndex = currentRaidRewardWireCardIndex(request.CardIndex)
s.raidMu.Lock()
defer s.raidMu.Unlock()
state := s.runtimeRaidByMemberLocked(characterID)
if state == nil {
return runtimeRaidState{}, false, errRuntimeRaidNotFound
}
if _, ok := runtimeRaidFrozenMember(*state, characterID); !ok {
return runtimeRaidState{}, false, errRuntimeRaidNotMember
}
state.ensureRuntimeRaidLifecycleMapsLocked()
if phase == 0 {
phase = state.Lifecycle.Phase
}
if state.Lifecycle.Status == runtimeRaidLifecycleStandby &&
phase == runtimeRaidDungeonPhaseOne && state.Lifecycle.Phase == phase &&
state.Lifecycle.RewardDeliveryCommitted && state.Lifecycle.RewardDelivery != nil {
selection, exists := state.Lifecycle.RewardSelections[characterID]
if request.Mode == 0 && exists && selection.Mode == 0 && selection.Index == request.CardIndex {
return cloneRuntimeRaidState(*state), true, nil
}
}
if state.Lifecycle.Status != runtimeRaidLifecycleAwaitingRewards || state.Lifecycle.Phase != phase {
return runtimeRaidState{}, false, errRuntimeRaidLifecycleState
}
// A member the settlement already wrote off (it dropped before confirming,
// and the eligible roster is now pinned) may come back and find the card
// screen waiting. Acknowledging the click keeps its client from looping on a
// failure without granting anything: its row is not in the committed receipt,
// and the roster it is being told about already excludes it.
if state.Lifecycle.RewardRosterFrozen && state.Lifecycle.RewardSkippedMembers[characterID] {
return cloneRuntimeRaidState(*state), s.runtimeRaidRewardRosterConfirmedLocked(state), nil
}
// A member that cleared no gate this phase is not part of the settlement either:
// the client may still put it in front of the cards (its own 地下城通关 cell reads
// 0/1 and its resultType==3 branch pops 60456 for an unrecognised ordinal), and
// acknowledging the click keeps it from looping on a failure without granting
// anything. Its row is not in the plan, the receipt or the boards -- see
// runtimeRaidRewardEligibleMembersLocked.
if !runtimeRaidMemberClearedGateLocked(*state, characterID) {
return cloneRuntimeRaidState(*state), s.runtimeRaidRewardRosterConfirmedLocked(state), nil
}
if selection, exists := state.Lifecycle.RewardSelections[characterID]; exists {
if selection.Index != request.CardIndex {
return runtimeRaidState{}, false, errRuntimeRaidRewardSelectionFrozen
}
if selection.Mode == request.Mode {
return cloneRuntimeRaidState(*state), s.runtimeRaidRewardRosterConfirmedLocked(state), nil
}
if selection.Mode != 1 || request.Mode != 0 {
return runtimeRaidState{}, false, errRuntimeRaidRewardSelectionOrder
}
selection.Mode = 0
selection.ConfirmedAt = now
state.Lifecycle.RewardSelections[characterID] = selection
state.Lifecycle.Revision++
return cloneRuntimeRaidState(*state), s.runtimeRaidRewardRosterConfirmedLocked(state), nil
}
if request.Mode != 1 {
return runtimeRaidState{}, false, errRuntimeRaidRewardSelectionOrder
}
state.Lifecycle.RewardSelections[characterID] = runtimeRaidRewardSelection{Mode: 1, Index: request.CardIndex}
state.Lifecycle.Revision++
return cloneRuntimeRaidState(*state), false, nil
}
// runtimeRaidRewardRosterConfirmed reports whether every member the settlement
// still owes a card to has confirmed one. A member the server has given up on is
// not owed anything and therefore cannot hold the roster open -- that is the
// whole point: one crashed client used to freeze the flip for everyone else.
//
// Caller holds raidMu.
func (s *Service) runtimeRaidRewardRosterConfirmedLocked(state *runtimeRaidState) bool {
eligible := s.runtimeRaidRewardEligibleMembersLocked(state)
if len(eligible) == 0 {
return false
}
for _, member := range eligible {
selection, ok := state.Lifecycle.RewardSelections[member.CharID]
if !ok || selection.Mode != 0 {
return false
}
}
return true
}
// runtimeRaidMovieRosterConfirmed is the movie half of the same rule: the phase
// advances to its cards once every member that is still here confirmed the
// result movie, and a member whose socket is gone is skipped instead of waited
// for.
//
// Caller holds raidMu.
func (s *Service) runtimeRaidMovieRosterConfirmedLocked(state *runtimeRaidState) bool {
eligible := s.runtimeRaidRewardEligibleMembersLocked(state)
if len(eligible) == 0 {
return false
}
for _, member := range eligible {
if !state.Lifecycle.MovieConfirmed[member.CharID] {
return false
}
}
return true
}
// runtimeRaidRewardEligibleMembersLocked is the frozen roster minus the members the
// settlement pays nothing to, in roster order: a member that dropped, and a member
// that cleared no gate in the active phase.
//
// The clear requirement is the reference's own rule: 86JP builds the reward flow's
// eligible set from `HasClearedDungeon` (RaidHandler.Progression.cs:477-480, and the
// same shape for the phase-two waiting list at :766), and every card operation checks
// that set -- the reveal (:132-135), the card request (:399) and the squad reward
// (:557). A member with 0/1 in the 地下城通关 column never cleared a gate, so the flip
// window owes it nothing.
//
// Caller holds raidMu.
func (s *Service) runtimeRaidRewardEligibleMembersLocked(state *runtimeRaidState) []runtimeRaidMemberState {
if state == nil || len(state.FrozenMembers) == 0 {
return nil
}
s.refreshRuntimeRaidRewardSkipsLocked(state)
eligible := make([]runtimeRaidMemberState, 0, len(state.FrozenMembers))
for _, member := range state.FrozenMembers {
if state.Lifecycle.RewardSkippedMembers[member.CharID] {
continue
}
if !runtimeRaidMemberClearedGateLocked(*state, member.CharID) {
continue
}
eligible = append(eligible, member)
}
return eligible
}
// runtimeRaidMemberClearedGateLocked reports whether the active phase's clear
// registration entitles this member to a reward.
//
// The counter is the same one the 地下城通关 column is drawn from
// (runtimeRaidMemberRecord's Field56), and the requirement is the phase's own
// [MINIMAL DUNGEON CLEAR COUNT]: the official reward condition is
// 「1阶段:无条件 / 2阶段:通关2个以上的地下城」, which is exactly the pair Luke
// declares (0 for phase one, 2 for phase two). The window therefore reads
// "1/2" and withholds the card, instead of paying a member whose column is not
// full.
//
// Anton is bit-for-bit unchanged: all of its phases declare 1, and
// `count >= 1` is the same predicate as `count > 0` on the uint32 counter. Its
// team phases cannot even declare 0 -- antonParsePhaseWithPartyMax demands a
// positive [MINIMAL DUNGEON CLEAR COUNT] (anton_pvf.go:586), and its single
// phase, whose PVF block omits the section entirely, is defaulted to 1
// (anton_pvf.go:503).
func runtimeRaidMemberClearedGateLocked(state runtimeRaidState, characterID uint16) bool {
if characterID == 0 {
return false
}
cleared := state.Lifecycle.PhaseClearCounts[characterID]
minimum, known := runtimeRaidPhaseMinimalClearRequirement(state, state.Lifecycle.Phase)
if !known {
// No phase catalog covers this phase (a hand-built state, an unavailable
// config). Keep the pre-PVF rule instead of reading a missing
// declaration as "no requirement" and handing everybody a card.
return cleared > 0
}
if minimum <= 0 {
// [MINIMAL DUNGEON CLEAR COUNT] 0 is the official 「1阶段:无条件」
// (Luke phase one): the phase pays its whole roster.
return true
}
return cleared >= uint32(minimum)
}
// runtimeRaidPhaseMinimalClearRequirement is the active phase's
// [MINIMAL DUNGEON CLEAR COUNT] together with whether a phase catalog actually
// declares one.
//
// It mirrors runtimeRaidPhaseConfig's branch structure, which
// runtimeRaidPhaseMinimalClearCount deliberately does not: a single-mode raid
// reads config.SingleMode.Phase, not config.Phases[0]. The two disagree for
// Luke -- team phase one declares 0, while the single-mode phase (whose PVF
// block omits the section) parses to the shared parser's default of 1 -- so
// deriving the requirement from runtimeRaidPhaseMinimalClearCount would make
// single-mode Luke unconditional.
//
// known is false whenever no catalog covers the phase; callers must then keep
// their own rule, because a missing 0 is not a declared 0.
func runtimeRaidPhaseMinimalClearRequirement(state runtimeRaidState, phase byte) (int, bool) {
if phase == 0 {
return 0, false
}
config := runtimeRaidBaseConfig(state)
if config == nil {
return 0, false
}
if state.TailFlag == 1 {
if phase != runtimeRaidDungeonPhaseOne || config.SingleMode == nil {
return 0, false
}
return config.SingleMode.Phase.MinimalDungeonClearCount, true
}
index := int(phase) - 1
if index < 0 || index >= len(config.Phases) {
return 0, false
}
return config.Phases[index].MinimalDungeonClearCount, true
}
// refreshRuntimeRaidRewardSkipsLocked keeps RewardSkippedMembers in step with
// live presence while the settlement is still open.
//
// A member is skipped exactly when it has dropped (a disconnect reservation for
// this raid, i.e. its socket is gone and only a reconnect restores the seat) or
// the grace window already removed it from the roster, AND it has not confirmed
// a card yet -- a member that picked its card before the crash earned its
// reward and must be paid even if it never comes back.
//
// The set only ever grows, and only until RewardRosterFrozen pins it: once the
// delivery token is minted the eligible roster is part of the receipt, so a
// later reconnect can neither add itself back nor invalidate the settlement.
//
// Caller holds raidMu.
func (s *Service) refreshRuntimeRaidRewardSkipsLocked(state *runtimeRaidState) {
if state == nil || state.Lifecycle.RewardRosterFrozen {
return
}
state.ensureRuntimeRaidLifecycleMapsLocked()
for _, member := range state.FrozenMembers {
if state.Lifecycle.RewardSkippedMembers[member.CharID] {
continue
}
if selection, picked := state.Lifecycle.RewardSelections[member.CharID]; picked && selection.Mode == 0 {
continue
}
if s.runtimeRaidMemberSkippedForRewardsLocked(*state, member.CharID) {
state.Lifecycle.RewardSkippedMembers[member.CharID] = true
}
}
}
// freezeRuntimeRaidRewardSkipsLocked pins the eligible roster. It is called at
// the exact moment the settlement stops being an open negotiation and becomes a
// durable receipt. Caller holds raidMu.
func (s *Service) freezeRuntimeRaidRewardSkipsLocked(state *runtimeRaidState) {
if state == nil || state.Lifecycle.RewardRosterFrozen {
return
}
s.refreshRuntimeRaidRewardSkipsLocked(state)
state.Lifecycle.RewardRosterFrozen = true
}
// runtimeRaidMemberSkippedForRewardsLocked reports whether this member can no
// longer take part in the raid's settlement: its socket is gone (a disconnect
// reservation for this raid is held, see raid_disconnect.go) or the grace window
// already removed it. A member with no reservation and a live seat is never
// skipped, which is what makes a reconnect inside the grace window restore it.
//
// Caller holds raidMu.
func (s *Service) runtimeRaidMemberSkippedForRewardsLocked(state runtimeRaidState, characterID uint16) bool {
if characterID == 0 || state.RaidKey == 0 {
return false
}
if s.runtimeRaidMemberDisconnectedLocked(state, characterID) {
return true
}
return runtimeRaidMemberIndex(state, characterID) < 0
}
// markRuntimeRaidRewardDeliveryCommitted records only the durable boundary;
// it does not resolve or grant any reward itself.
func (s *Service) markRuntimeRaidRewardDeliveryCommitted(raidKey uint32, rewardToken uint64, now time.Time) (runtimeRaidState, error) {
now = runtimeRaidNormalizeTime(now)
s.raidMu.Lock()
defer s.raidMu.Unlock()
state := s.raids[raidKey]
if state == nil {
return runtimeRaidState{}, errRuntimeRaidNotFound
}
if state.Lifecycle.Status != runtimeRaidLifecycleAwaitingRewards || state.Lifecycle.RewardToken != rewardToken || rewardToken == 0 {
return runtimeRaidState{}, errRuntimeRaidRewardToken
}
if !s.runtimeRaidRewardRosterConfirmedLocked(state) {
return runtimeRaidState{}, errRuntimeRaidRewardRosterPending
}
if !state.Lifecycle.RewardDeliveryCommitted {
state.Lifecycle.RewardDeliveryCommitted = true
state.Lifecycle.RewardDeliveryCommittedAt = now
state.Lifecycle.Revision++
}
return cloneRuntimeRaidState(*state), nil
}
// finalizeRuntimeRaid removes the global runtime object only after every
// frozen member confirmed a card and the reward owner committed delivery.
// finalizeRuntimeRaid is the terminal cleanup. It emits the per-raid cost summary
// AFTER releasing the raid lock: the summary reads the same lock (and the live
// session loops), so emitting it inside the locked body would self-deadlock.
func (s *Service) finalizeRuntimeRaid(raidKey uint32, rewardToken uint64, now time.Time) (runtimeRaidState, error) {
finished, err := s.finalizeRuntimeRaidLocked(raidKey, rewardToken, now)
if err == nil {
s.publishRuntimeRaidCostSummary(raidKey, "raid_finalized")
}
return finished, err
}
func (s *Service) finalizeRuntimeRaidLocked(raidKey uint32, rewardToken uint64, now time.Time) (runtimeRaidState, error) {
now = runtimeRaidNormalizeTime(now)
s.raidMu.Lock()
defer s.raidMu.Unlock()
state := s.raids[raidKey]
if state == nil {
return runtimeRaidState{}, errRuntimeRaidNotFound
}
if state.Lifecycle.Status != runtimeRaidLifecycleAwaitingRewards ||
state.Lifecycle.RewardToken != rewardToken || rewardToken == 0 ||
!state.Lifecycle.RewardDeliveryCommitted || !s.runtimeRaidRewardRosterConfirmedLocked(state) {
return runtimeRaidState{}, errRuntimeRaidRewardNotReady
}
state.Lifecycle.Status = runtimeRaidLifecycleFinished
state.Lifecycle.FinishedAt = now
state.Lifecycle.Revision++
finished := cloneRuntimeRaidState(*state)
delete(s.raids, raidKey)
return finished, nil
}
func (s *Service) runtimeRaidIsSingle(raidKey uint32) bool {
if s == nil {
return false
}
s.raidMu.Lock()
defer s.raidMu.Unlock()
state := s.raids[raidKey]
return state != nil && state.TailFlag == 1
}
// abandonRuntimeRaid is the explicit whole-raid abort path. A dungeon abandon
// event only releases that map attempt and never reaches this cleanup path.
func (s *Service) abandonRuntimeRaid(raidKey uint32, requestedBy uint16, now time.Time) (runtimeRaidState, error) {
now = runtimeRaidNormalizeTime(now)
s.raidMu.Lock()
defer s.raidMu.Unlock()
state := s.raids[raidKey]
if state == nil {
return runtimeRaidState{}, errRuntimeRaidNotFound
}
if state.StartPending {
return runtimeRaidState{}, errRuntimeRaidLifecycleState
}
if requestedBy != 0 && state.LeaderID != requestedBy {
return runtimeRaidState{}, errRuntimeRaidNotMember
}
if state.Lifecycle.Status == runtimeRaidLifecycleFinished {
return runtimeRaidState{}, errRuntimeRaidLifecycleState
}
state.Lifecycle.Status = runtimeRaidLifecycleAbandoned
state.Lifecycle.AbandonedAt = now
state.Lifecycle.Revision++
abandoned := cloneRuntimeRaidState(*state)
delete(s.raids, raidKey)
return abandoned, nil
}
func runtimeRaidCompletedMapIDs(state runtimeRaidState, phase byte) []int {
mapIDs := make([]int, 0)
if len(state.Lifecycle.Objectives) != 0 {
for mapID, objective := range state.Lifecycle.Objectives {
if objective.Phase == phase && objective.RemainingHP == 0 {
mapIDs = append(mapIDs, mapID)
}
}
sort.Ints(mapIDs)
return mapIDs
}
for runKey, progress := range state.Lifecycle.Dungeons {
if progress.Phase == phase && progress.Status == runtimeRaidDungeonProgressCompleted {
mapIDs = append(mapIDs, runKey.MapID)
}
}
sort.Ints(mapIDs)
return mapIDs
}
func cloneRuntimeRaidLifecycleState(state runtimeRaidLifecycleState) runtimeRaidLifecycleState {
state.MovieConfirmed = cloneRuntimeRaidBoolMap(state.MovieConfirmed)
objectives := state.Objectives
state.Objectives = make(map[int]runtimeRaidDungeonObjective, len(objectives))
for key, objective := range objectives {
state.Objectives[key] = objective
}
dungeons := state.Dungeons
state.Dungeons = make(map[runtimeRaidDungeonRunKey]runtimeRaidDungeonProgress, len(dungeons))
for key, progress := range dungeons {
state.Dungeons[key] = progress
}
groupOccupancy := state.GroupOccupancy
state.GroupOccupancy = make(map[runtimeRaidDungeonSeat]runtimeRaidDungeonBinding, len(groupOccupancy))
for key, binding := range groupOccupancy {
state.GroupOccupancy[key] = binding
}
runClearCredits := state.RunClearCredits
state.RunClearCredits = make(map[runtimeRaidDungeonRunKey]uint64, len(runClearCredits))
for key, token := range runClearCredits {
state.RunClearCredits[key] = token
}
dungeonStates := state.DungeonStates
state.DungeonStates = make(map[uint32]uint32, len(dungeonStates))
for key, value := range dungeonStates {
state.DungeonStates[key] = value
}
closedDungeonAttempts := state.ClosedDungeonAttempts
state.ClosedDungeonAttempts = make(map[runtimeRaidDungeonAttemptKey]runtimeRaidDungeonTerminal, len(closedDungeonAttempts))
for key, terminal := range closedDungeonAttempts {
state.ClosedDungeonAttempts[key] = terminal
}
rewardSelections := state.RewardSelections
state.RewardSelections = make(map[uint16]runtimeRaidRewardSelection, len(rewardSelections))
for key, selection := range rewardSelections {
state.RewardSelections[key] = selection
}
state.RewardSkippedMembers = cloneRuntimeRaidBoolMap(state.RewardSkippedMembers)
state.RewardDelivery = cloneRuntimeRaidRewardDeliveryCache(state.RewardDelivery)
mechanisms := state.Mechanisms
state.Mechanisms = make(map[runtimeRaidMechanismKey]runtimeRaidMechanismTimer, len(mechanisms))
for key, timer := range mechanisms {
state.Mechanisms[key] = timer
}
barrierRequests := state.BarrierRequests
state.BarrierRequests = make(map[runtimeRaidBarrierRequestKey]uint32, len(barrierRequests))
for key, count := range barrierRequests {
state.BarrierRequests[key] = count
}
// 卢克的四张表同样要深拷,否则「快照」会与活账本共享同一张 map ——
// 上游的 TestLukeGeneratorReportsDeduplicateAcrossMembersPerRoom 钉的就是这条。
state.LukeGeneratorReports = cloneRuntimeRaidLukeGeneratorReports(state.LukeGeneratorReports)
if groups := state.LukeSanctumMazeGroups; groups != nil {
state.LukeSanctumMazeGroups = make(map[int]int, len(groups))
for mapID, start := range groups {
state.LukeSanctumMazeGroups[mapID] = start
}
}
lukeSymbols := state.LukeSymbols
state.LukeSymbols = make(map[uint32]uint32, len(lukeSymbols))
for key, value := range lukeSymbols {
state.LukeSymbols[key] = value
}
lukeRouteLocks := state.LukeRouteLocks
state.LukeRouteLocks = make(map[uint16]byte, len(lukeRouteLocks))
for characterID, route := range lukeRouteLocks {
state.LukeRouteLocks[characterID] = route
}
// 超时空战场同样必须深拷。Clone() 自己处理 nil,所以安东/卢克这条路径
// (FiendWar 为 nil) 拿到的仍是 nil,行为不变。
state.FiendWar = state.FiendWar.Clone()
if counts := state.FiendWarClearCounts; counts != nil {
state.FiendWarClearCounts = make(map[uint16]int, len(counts))
for characterID, count := range counts {
state.FiendWarClearCounts[characterID] = count
}
}
if retreats := state.FiendWarRetreats; retreats != nil {
state.FiendWarRetreats = make(map[int]runtimeRaidDungeonProgress, len(retreats))
for dungeon, progress := range retreats {
state.FiendWarRetreats[dungeon] = progress
}
}
return state
}
func cloneRuntimeRaidBoolMap(source map[uint16]bool) map[uint16]bool {
out := make(map[uint16]bool, len(source))
for key, value := range source {
out[key] = value
}
return out
}