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

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

929 lines
40 KiB
Go

package dnfbridge
import (
"math/rand"
"sort"
"time"
"longheng.io/server/internal/modules/dnf/dungeoncmd"
"longheng.io/server/internal/modules/dnf/raid"
)
// Anton phase two is a siege on the black volcano's shield. Energy
// interception (218) and the hatcheries (221-224) report shield damage from
// inside their runs through the native op662 symbol request; the shield
// (symbol 110) starts full and the volcano (219) admits nobody until it reads
// zero. A broken shield recharges every second, faster while a hatchery is
// infected, and a fully recharged shield closes the volcano again. Clearing the
// volcano closes everything but the heart (220), which needs five clears.
//
// Required clears and initial gate states come from etc/raid/anton.etc. The
// shield size, transmission values, charge rates and hatchery timings follow
// the read-only 86JP C# server (RaidHandler.AntonDefinitions.cs,
// RaidHandler.cs TryAuthorizeAntonBarrierRequest, Progression.cs
// ChangeBlackVolcanoBarrierAsync / hatchery timers, AntonRaidRewardProvider.cs
// fallbacks) and are a hypothesis until the current EXE confirms them.
const (
antonEnergyInterceptionMapID = 218
antonBlackVolcanoMapID = 219
antonHeartMapID = 220
antonFirstHatcheryMapID = 221
antonLastHatcheryMapID = 224
antonBarrierMaximum uint32 = 10000
antonEnergyLargeTransmission uint32 = 3000
antonEnergySmallTransmission uint32 = 100
antonEnergySmallTransmissionCount uint32 = 5
antonHatcheryReduction uint32 = 1000
antonInfectedHatcheryExtraReduction uint32 = 500
antonBarrierChargePerSecond uint32 = 17
antonBarrierInfectedChargePerSecond uint32 = 34
antonHatcheryOpenCount = 3
antonHatcheryOpenDelay = 180 * time.Second
antonHatcheryRecovery = 240 * time.Second
antonBarrierRecoveryTick = time.Second
// Wire symbol ids read by the native Anton phase-two script.
antonSymbolBarrier uint32 = 110
antonSymbolVolcanoOpen uint32 = 111
antonSymbolPhaseTwoInit uint32 = 106
antonSymbolInfectionFlag uint32 = 7
antonSymbolInfectionMapID uint32 = 8
// One-shot cutscene markers (etc/raid/anton.etc.symbol). The client owns the
// video files; the server only pulses the marker on the 0x0247 channel.
antonSymbolMovieHatcheryOpen uint32 = 128
antonSymbolBarrierBreak uint32 = 126
antonSymbolBarrierRecover uint32 = 127
// antonSymbolPhaseOneFail is 86JP's AntonPhaseOneFailSymbolId = 104
// (RaidHandler.AntonDefinitions.cs:55): the attack window of the first phase
// ran out, which plays the failure presentation. It is the one symbol the
// timeout sends and it has no phase-two counterpart.
antonSymbolPhaseOneFail uint32 = 104
// Hatchery presentation symbols (etc/raid/anton.etc.symbol): 3..6 are
// [221..224 HATCHERY FAIL] and 9..12 are [221..224 HATCHERY NAMED CREATE],
// one per entry of the client's [HATCHERY INFO] list (4 hatcheries,
// 221 222 223 224). The client's own etc/raid/anton.etc script drives them;
// the server only supplies the first window of the two timers that script
// waits on.
antonHatcheryFailSymbolBase uint32 = 3
antonHatcheryNamedSymbolBase uint32 = 9
// The two windows 86JP's StartHatcheryEffectTimersAsync arms per hatchery:
// timer type 1 with 120s then 45s, timer type 3 with 120s then 50s.
antonHatcheryEffectWindow = 120 * time.Second
antonHatcheryFailRepeat = 45 * time.Second
antonHatcheryNamedRepeat = 50 * time.Second
// op662 operation codes: 0 set, 1 add, 2 subtract.
runtimeRaidSymbolOperationSet byte = 0
runtimeRaidSymbolOperationAdd byte = 1
runtimeRaidSymbolOperationSubtract byte = 2
runtimeRaidMechanismHatcheryOpen byte = 3
runtimeRaidMechanismBarrierRecovery byte = 4
)
type runtimeRaidBarrierRequestKey struct {
MapID int
LifecycleToken uint64
Operand uint32
}
func isAntonHatcheryMapID(mapID int) bool {
return mapID >= antonFirstHatcheryMapID && mapID <= antonLastHatcheryMapID
}
// antonHatcherySymbolPlan maps a hatchery dungeon id onto the two presentation
// symbols the client's own etc/raid/anton.etc drives for it.
func antonHatcherySymbolPlan(mapID int) (fail uint32, named uint32, ok bool) {
if !isAntonHatcheryMapID(mapID) {
return 0, 0, false
}
index := uint32(mapID - antonFirstHatcheryMapID)
return antonHatcheryFailSymbolBase + index, antonHatcheryNamedSymbolBase + index, true
}
// armRuntimeRaidHatcheryEffectTimersLocked arms the two client-side hatchery
// effect windows for one hatchery: wire timer types 1 and 3, each 120 seconds,
// keyed by the hatchery dungeon id.
//
// The client never starts these itself. etc/raid/anton.etc gates
//
// [CHECK DUNGEON STATE] 221 `open` + [CHECK TIMER END] 1 221
// -> [SET] [221 HATCHERY FAIL] 1 + [SET TIMER] 1 221 45
// [CHECK DUNGEON STATE] 221 `open` + [CHECK TIMER END] 3 221
// -> [SET] [221 HATCHERY NAMED CREATE] 1 + [SET TIMER] 3 221 50
//
// so the script only self-perpetuates once the first window has been armed, and
// that first window is the server's. Live traffic shows the server never armed
// it: across packet_log.txt every 0x0254 send carries type 0/1/2/3/4/5, but type
// 1 only ever names 212/214/216 (phase one) plus raid keys, and type 3 only ever
// names 219 (the volcano open timer). Types 1 and 3 were never sent for
// 221..224, so [SET] [221..224 HATCHERY NAMED CREATE] never ran on any client.
// 86JP arms the same pair in StartHatcheryEffectTimersAsync
// (RaidHandler.Progression.cs:588-600) and clears it in ClearHatcheryAsync
// (:393-397).
//
// The wire types reuse runtimeRaidMechanismActive (1) and
// runtimeRaidMechanismHatcheryOpen (3) because the wire type IS the mechanism
// kind; the hatchery map id keeps them apart from the phase-one windows and from
// the volcano open timer on 219. Caller holds raidMu.
func (state *runtimeRaidState) armRuntimeRaidHatcheryEffectTimersLocked(mapID int, now time.Time) []runtimeRaidMechanismTimer {
if state == nil {
return nil
}
if _, _, ok := antonHatcherySymbolPlan(mapID); !ok {
return nil
}
return []runtimeRaidMechanismTimer{
state.armRuntimeRaidMechanismLocked(runtimeRaidMechanismActive, mapID, now, antonHatcheryEffectWindow, 0),
state.armRuntimeRaidMechanismLocked(runtimeRaidMechanismHatcheryOpen, mapID, now, antonHatcheryEffectWindow, 0),
}
}
// cancelRuntimeRaidHatcheryEffectTimersLocked drops both effect windows of one
// hatchery: a closed hatchery must not keep pulsing [HATCHERY FAIL] or
// [HATCHERY NAMED CREATE] on either side. Caller holds raidMu.
func (state *runtimeRaidState) cancelRuntimeRaidHatcheryEffectTimersLocked(mapID int) {
if state == nil {
return
}
state.cancelRuntimeRaidMechanismLocked(runtimeRaidMechanismActive, mapID)
state.cancelRuntimeRaidMechanismLocked(runtimeRaidMechanismHatcheryOpen, mapID)
}
// fireRuntimeRaidHatcheryEffectTimerLocked pulses one hatchery presentation
// symbol and re-arms its window with 86JP's repeat cadence. The pulse rides
// Lifecycle.MovieSymbols because that map is the one the mechanism callback
// diffs and broadcasts, so this edge goes out exactly once. The id keeps its
// current value in the phase snapshot too (runtimeRaidPhaseTwoSymbolRows seeds
// the pair so the client has the object at all), but that snapshot is only sent
// at boundaries -- phase activation, rejoin and a 战况表 reopen -- never on a
// timer, so it cannot race the client's own [SET] ... 1 every few seconds the
// way a periodic replay would. Caller holds raidMu.
func (state *runtimeRaidState) fireRuntimeRaidHatcheryEffectTimerLocked(mapID int, kind byte, now time.Time) ([]runtimeRaidMechanismTimer, string) {
fail, named, ok := antonHatcherySymbolPlan(mapID)
if !ok {
return nil, ""
}
objective, exists := state.Lifecycle.Objectives[mapID]
if !exists || objective.Phase != runtimeRaidDungeonPhaseTwo || objective.State != raid.AntonDungeonOpen {
// etc/raid/anton.etc guards the same window with [CHECK DUNGEON STATE]
// `<open>`; a closed hatchery has no presentation to run.
return nil, ""
}
symbol, window, reason := named, antonHatcheryNamedRepeat, "hatchery_named_created"
if kind == runtimeRaidMechanismActive {
symbol, window, reason = fail, antonHatcheryFailRepeat, "hatchery_failed"
}
state.setRuntimeRaidMovieSymbolLocked(symbol, 1)
return []runtimeRaidMechanismTimer{
state.armRuntimeRaidMechanismLocked(kind, mapID, now, window, 0),
}, reason
}
// runtimeRaidAntonEntryAllowedByBarrier is the phase-two dungeon-entry
// authority: the volcano admits nobody while the shield holds. Every other gate
// is decided by its objective state alone.
func runtimeRaidAntonEntryAllowedByBarrier(state runtimeRaidState, mapID int) bool {
if state.TailFlag == 1 || mapID != antonBlackVolcanoMapID {
return true
}
return state.Lifecycle.VolcanoOpen
}
// initRuntimeRaidPhaseTwoLocked resets the siege state when phase two is
// activated and arms the hatchery opening. Caller holds raidMu.
func initRuntimeRaidPhaseTwoLocked(state *runtimeRaidState, now time.Time) {
state.ensureRuntimeRaidLifecycleMapsLocked()
state.Lifecycle.Barrier = antonBarrierMaximum
state.Lifecycle.VolcanoOpen = false
state.Lifecycle.InfectionMapID = 0
state.Lifecycle.BarrierRequests = make(map[runtimeRaidBarrierRequestKey]uint32)
state.armRuntimeRaidMechanismLocked(runtimeRaidMechanismHatcheryOpen, antonBlackVolcanoMapID, now, antonHatcheryOpenDelay, 0)
}
// antonBarrierRequestLimit returns how many times one run may report a given
// shield reduction. Zero means the request is not a valid shield report.
func antonBarrierRequestLimit(mapID int, infectionMapID int, symbolID uint32, operand uint32, operation byte) uint32 {
if symbolID != antonSymbolBarrier || operation != runtimeRaidSymbolOperationSubtract {
return 0
}
if mapID == antonEnergyInterceptionMapID {
switch operand {
case antonEnergyLargeTransmission:
return 1
case antonEnergySmallTransmission:
return antonEnergySmallTransmissionCount
default:
return 0
}
}
if !isAntonHatcheryMapID(mapID) {
return 0
}
if operand == antonHatcheryReduction {
return 1
}
if mapID == infectionMapID && operand == antonInfectedHatcheryExtraReduction {
return 1
}
return 0
}
func applyRuntimeRaidSymbolOperation(current uint32, operand uint32, operation byte) (uint32, bool) {
switch operation {
case runtimeRaidSymbolOperationSet:
return operand, true
case runtimeRaidSymbolOperationAdd:
if operand > ^uint32(0)-current {
return ^uint32(0), true
}
return current + operand, true
case runtimeRaidSymbolOperationSubtract:
if operand >= current {
return 0, true
}
return current - operand, true
default:
return current, false
}
}
// runtimeRaidBarrierChange is the outcome of one shield mutation.
type runtimeRaidBarrierChange struct {
Previous uint32
Next uint32
Broke bool
Restored bool
}
// runtimeRaidHatcheryTransferAllowedLocked reports whether this session is
// warping out of an active Anton energy-interception run (218) into one of the
// hatcheries the raid has already opened (221..224).
//
// The client sends the ordinary C2S op15/op16 selection for that warp -- the
// corner-room wall_open warp objects carry `[int data] 0 150 <221|222|223|224>`
// and live traffic shows the request arriving every time -- but the two
// dungeon-runtime guards rejected it with "dnf dungeon session is already
// active: dungeon=218 maze=0", which left the hidden area unreachable: raid_map_id
// 221..224 never appears anywhere in the packet log. Nothing on the client side is
// missing; only the server-side in-run transfer was. The caller retires the
// previous run instead of abandoning it. Caller holds session.dungeon.mu.
func (s *Service) runtimeRaidHatcheryTransferAllowedLocked(
session *gameSession,
previous *runtimeDungeonState,
target int64,
) bool {
if s == nil || session == nil || previous == nil {
return false
}
if previous.Dungeon.ID != antonEnergyInterceptionMapID || !isAntonHatcheryMapID(int(target)) {
return false
}
characterID := session.selectedCharacterID
if characterID == 0 {
return false
}
s.raidMu.Lock()
defer s.raidMu.Unlock()
state := s.runtimeRaidByMemberLocked(characterID)
if state == nil || !state.Started || state.TailFlag == 1 {
return false
}
if state.Lifecycle.Phase != runtimeRaidDungeonPhaseTwo || state.Lifecycle.Status != runtimeRaidLifecyclePhaseActive {
return false
}
objective, ok := state.Lifecycle.Objectives[int(target)]
return ok && objective.State == raid.AntonDungeonOpen
}
// runtimeRaidHatcheryWarpAdmission owns the op15 half of the 218 -> 221..224 warp.
//
// The wall_open warp object sends the ordinary op15/op16 pair, and
// runtimeRaidSelectorAdmission owns op15 for the whole raid channel. A member
// standing inside the energy-interception gate is by definition not in the private
// waiting instance, so the portal was refused and -- because that refusal answers
// nothing -- the client never received the op15 ACK, the fatigue row or the op27
// selector page its own scene finalizer is paired with. The warp is therefore
// admitted on the session's committed run: the member must still be inside 218 and
// at least one hatchery the phase opened must still be enterable. The op16 that
// follows keeps every other gate (objective state, group occupancy, run
// lifecycle), so this only restores the answer the client waits for.
//
// Caller must not hold raidMu: the state it passes is a snapshot and this helper
// takes session.dungeon.mu, which is the outer lock everywhere else.
func (s *Service) runtimeRaidHatcheryWarpAdmission(session *gameSession, state runtimeRaidState) bool {
if s == nil || session == nil || session.selectedCharacterID == 0 {
return false
}
if state.TailFlag == 1 ||
state.Lifecycle.Phase != runtimeRaidDungeonPhaseTwo ||
state.Lifecycle.Status != runtimeRaidLifecyclePhaseActive {
return false
}
session.dungeon.mu.Lock()
runtime := session.dungeon.runtime
inEnergyInterception := runtime != nil &&
dungeonRuntimeOwnsCharacter(runtime, session.selectedCharacterID) &&
int(runtime.Dungeon.ID) == antonEnergyInterceptionMapID
session.dungeon.mu.Unlock()
if !inEnergyInterception {
return false
}
for mapID := antonFirstHatcheryMapID; mapID <= antonLastHatcheryMapID; mapID++ {
objective, ok := state.Lifecycle.Objectives[mapID]
if ok && objective.Phase == runtimeRaidDungeonPhaseTwo && objective.State == raid.AntonDungeonOpen {
return true
}
}
return false
}
// runtimeRaidHatcheryWarpApplies reports whether one select is the in-run warp out
// of the energy-interception gate into an open hatchery, i.e. whether the squad's
// single gate has to move with the member instead of blocking it.
//
// Every input comes from the raid state alone: the target map is a hatchery the
// phase still offers, the squad's occupancy is the energy-interception gate of the
// same phase, and the warping member is the one that owns that gate's run and has
// not completed it. A squadmate that never opened 218 can therefore not use this
// path to move the squad's occupancy. Caller holds raidMu.
func runtimeRaidHatcheryWarpApplies(state *runtimeRaidState, seat runtimeRaidDungeonSeat, characterID uint16, phase byte, mapID int) bool {
if state == nil || state.TailFlag == 1 ||
phase != runtimeRaidDungeonPhaseTwo ||
!isAntonHatcheryMapID(mapID) {
return false
}
objective, ok := state.Lifecycle.Objectives[mapID]
if !ok || objective.Phase != phase || objective.State != raid.AntonDungeonOpen {
return false
}
occupied, ok := state.Lifecycle.GroupOccupancy[seat]
if !ok || occupied.Phase != phase || occupied.MapID != antonEnergyInterceptionMapID {
return false
}
progress, ok := state.Lifecycle.Dungeons[runtimeRaidDungeonRunKey{
MapID: antonEnergyInterceptionMapID,
Seat: seat,
}]
if !ok || progress.Status == runtimeRaidDungeonProgressCompleted {
return false
}
// The run belongs to the whole squad, not just to whoever opened it: every
// member of a squad walks into the same gate and adopts the opener's binding,
// so the second member's warp has to move that same run. Requiring the opener
// here is what let only the FIRST member through and refused the rest with
// "runtime raid group already occupies a dungeon: group=N map=218" -- live
// operator report 2026-09-19: "孵化中心只能进去一个人 其他的人强制返回城镇了".
// A member of another squad (same seat label, different character) still cannot
// move this run.
if progress.CharacterID != characterID {
owner, found := runtimeRaidFrozenMember(*state, progress.CharacterID)
if !found || runtimeRaidSeatFor(owner) != seat {
return false
}
}
return true
}
// runtimeRaidPhaseTwoGateAfterVolcano reports whether one phase-two gate is one the
// falling volcano closes. 86JP's ClearBlackVolcanoAsync invalidates the runs of
// 218/219 and 221..224 (:1732-1734); 220, the heart, is the objective it opens.
func runtimeRaidPhaseTwoGateAfterVolcano(mapID int) bool {
return mapID == antonEnergyInterceptionMapID ||
mapID == antonBlackVolcanoMapID ||
isAntonHatcheryMapID(mapID)
}
// changeRuntimeRaidBarrierLocked applies one shield operation and flips the
// volcano authority on the two edges. It returns the timers to schedule when
// the shield just broke (recharge starts). Caller holds raidMu.
func changeRuntimeRaidBarrierLocked(state *runtimeRaidState, operand uint32, operation byte, now time.Time) (runtimeRaidBarrierChange, []runtimeRaidMechanismTimer, bool) {
if state.TailFlag == 1 || state.Lifecycle.Phase != runtimeRaidDungeonPhaseTwo || state.Lifecycle.Status != runtimeRaidLifecyclePhaseActive {
return runtimeRaidBarrierChange{}, nil, false
}
previous := state.Lifecycle.Barrier
next, ok := applyRuntimeRaidSymbolOperation(previous, operand, operation)
if !ok {
return runtimeRaidBarrierChange{}, nil, false
}
if next > antonBarrierMaximum {
next = antonBarrierMaximum
}
if next == previous {
return runtimeRaidBarrierChange{Previous: previous, Next: next}, nil, true
}
change := runtimeRaidBarrierChange{Previous: previous, Next: next}
state.Lifecycle.Barrier = next
var armed []runtimeRaidMechanismTimer
switch {
case next == 0 && previous != 0:
change.Broke = true
state.Lifecycle.VolcanoOpen = true
// The client's whole barrier reaction hangs off symbol changes
// (etc/raid/anton.etc: [ON CHANGE SYMBOL][BLACK VOLCANO BARRIER] 0 ->
// [SET] [MOVIE BARRIER BREAK] 1). 86JP ships the edge as one atomic set
// {recover:0, barrier:0, volcano:1, break:1}; broadcastRuntimeRaidBarrier
// below mirrors that, and the markers are persisted so a later snapshot
// replays the same state.
state.setRuntimeRaidMovieSymbolLocked(antonSymbolBarrierBreak, 1)
state.setRuntimeRaidMovieSymbolLocked(antonSymbolBarrierRecover, 0)
armed = append(armed, state.armRuntimeRaidMechanismLocked(runtimeRaidMechanismBarrierRecovery, antonBlackVolcanoMapID, now, antonBarrierRecoveryTick, 0))
case next == antonBarrierMaximum && previous != antonBarrierMaximum:
change.Restored = true
state.Lifecycle.VolcanoOpen = false
state.setRuntimeRaidMovieSymbolLocked(antonSymbolBarrierRecover, 1)
state.setRuntimeRaidMovieSymbolLocked(antonSymbolBarrierBreak, 0)
state.cancelRuntimeRaidMechanismLocked(runtimeRaidMechanismBarrierRecovery, antonBlackVolcanoMapID)
}
state.Lifecycle.Revision++
return change, armed, true
}
// runtimeRaidBarrierChargeLocked is one recharge tick while the volcano is
// open. Caller holds raidMu.
func runtimeRaidBarrierChargeLocked(state *runtimeRaidState, now time.Time) (runtimeRaidBarrierChange, []runtimeRaidMechanismTimer, bool) {
if !state.Lifecycle.VolcanoOpen {
return runtimeRaidBarrierChange{}, nil, false
}
rate := antonBarrierChargePerSecond
if state.Lifecycle.InfectionMapID != 0 && !state.Lifecycle.InfectionSuppressed {
rate = antonBarrierInfectedChargePerSecond
}
change, _, ok := changeRuntimeRaidBarrierLocked(state, rate, runtimeRaidSymbolOperationAdd, now)
if !ok {
return change, nil, false
}
var armed []runtimeRaidMechanismTimer
if !change.Restored {
armed = append(armed, state.armRuntimeRaidMechanismLocked(runtimeRaidMechanismBarrierRecovery, antonBlackVolcanoMapID, now, antonBarrierRecoveryTick, 0))
}
return change, armed, true
}
// runtimeRaidInfectionMapIDForWire is the one value this client resolves the
// 感染孵化场 (infected hatchery) from: the trailing dword of 0x0249.
//
// The current EXE's 0x0249 handler sub_1D39B50 reads that dword AFTER the
// (dungeonID,state) rows and calls sub_17B87B0(manager, value), which is the ONLY
// writer of manager+0x150 in the whole binary; it then re-derives symbol 8
// ([INFECTION DUNGEON INDEX]) from it and symbol 7 ([IS EXIST INFECTION DUNGEON])
// from "value != 0". The 战况表 renderers read that same field back
// (sub_1CF26F0 is `mov eax,[ecx+0x150]; ret`, consumed inside the selector/board
// init sub_F176A0 and inside the board iterators sub_17C29F0 / sub_17C2B80, which
// compare it against each dungeon id).
//
// Two consequences this helper exists for:
//
// 1. A zero tail is not "no information": the client stores 0 and CLEARS symbol 8
// that the 0x0247 table just set, because 0x0249 always follows 0x0247 in the
// snapshot sequence. Leaving the tail unset is therefore what kept the board
// from ever colouring the infected hatchery.
// 2. Suppressed means the infected hatchery was just cleared. 86JP writes symbol 7
// back to 0 on that edge (ClearHatcheryAsync, Progression.cs:1746-1764) and this
// client derives symbol 7 from the tail, so 0 is the only value that leaves the
// two projections agreeing. It is deliberately NOT the remembered map id.
//
// Shared by the symbol rows and the state table so the two can never disagree.
func runtimeRaidInfectionMapIDForWire(state runtimeRaidState) uint32 {
if state.Lifecycle.InfectionMapID == 0 || state.Lifecycle.InfectionSuppressed {
return 0
}
return uint32(state.Lifecycle.InfectionMapID)
}
// runtimeRaidPhaseTwoSymbolRows are the non-objective symbol rows the phase-two
// script reads: init flag, shield, volcano authority, infection, and the two
// presentation symbols of every hatchery.
//
// The hatchery pair ([221..224 HATCHERY FAIL] = 3..6 and
// [221..224 HATCHERY NAMED CREATE] = 9..12, ids from etc/raid/anton.etc.symbol)
// is driven by the timer windows this service arms, and the client's raid UI
// resolves both ids for every hatchery row it draws. They used to be left out of
// the table entirely, so the first time the client's own script (or a status
// board rebuild) touched one of them it walked an object that had never been
// created. Live 2026-09-17, that single walk is what both remaining crashes land
// in:
//
// entry into a hatchery 0x025BD2FF / 0x0263A2ED eip=0 (null call)
// 卡牌退出回城 rebuilding 战况表 0x01F95C63 / 0x01F95611 access_address=0xB98
//
// Both appeared only after the hatchery effect timers were introduced (before
// that, entering a hatchery was clean and only the return-to-town walk existed),
// which is consistent with "the timer is what makes the client evaluate the
// hatchery branch at all".
//
// 86JP seeds exactly these eight ids with 0 in AntonSecondPhaseInitialSymbols
// (ServerS4A21-master, RaidHandler.AntonDefinitions.cs:116-138). The value sent
// here is this service's current one for the id, so a fresh phase starts at 0
// and a later boundary snapshot repeats what the server already believes instead
// of forcing 0.
func runtimeRaidPhaseTwoSymbolRows(state runtimeRaidState) []raid.RaidSymbolRow {
if state.Lifecycle.Phase != runtimeRaidDungeonPhaseTwo || !runtimeRaidUsesAntonTeamGraph(state) {
// 下面每个键都是 etc/raid/anton.etc.symbol 里的安徒恩符号 id。卢克有它自己的
// 符号表,这些 id 在里面含义完全不同,所以卢克快照不能带它们。
//
// 上游同形(原来是 TailFlag == 1):安徒恩与单人模式的求值结果完全相同,
// 只有队伍卢克从「照发安徒恩符号」变成「不发」。
return nil
}
volcano := uint32(0)
if state.Lifecycle.VolcanoOpen {
volcano = 1
}
infectionMapID := runtimeRaidInfectionMapIDForWire(state)
infection := uint32(0)
if infectionMapID != 0 {
infection = 1
}
rows := []raid.RaidSymbolRow{
{Key: antonSymbolInfectionFlag, Value: infection},
{Key: antonSymbolInfectionMapID, Value: infectionMapID},
{Key: antonSymbolPhaseTwoInit, Value: 1},
{Key: antonSymbolBarrier, Value: state.Lifecycle.Barrier},
{Key: antonSymbolVolcanoOpen, Value: volcano},
}
for mapID := antonFirstHatcheryMapID; mapID <= antonLastHatcheryMapID; mapID++ {
fail, named, ok := antonHatcherySymbolPlan(mapID)
if !ok {
continue
}
rows = append(rows,
raid.RaidSymbolRow{Key: fail, Value: state.Lifecycle.MovieSymbols[fail]},
raid.RaidSymbolRow{Key: named, Value: state.Lifecycle.MovieSymbols[named]})
}
return rows
}
// openRuntimeRaidHatcheriesLocked opens antonHatcheryOpenCount of the four
// hatcheries, leaving one out, and infects one of the open ones. Caller holds
// raidMu; pick returns a value in [0, n).
func openRuntimeRaidHatcheriesLocked(state *runtimeRaidState, pick func(n int) int) ([]int, int) {
hatcheries := make([]int, 0, antonLastHatcheryMapID-antonFirstHatcheryMapID+1)
for mapID := antonFirstHatcheryMapID; mapID <= antonLastHatcheryMapID; mapID++ {
if objective, ok := state.Lifecycle.Objectives[mapID]; ok && objective.Phase == runtimeRaidDungeonPhaseTwo {
hatcheries = append(hatcheries, mapID)
}
}
if len(hatcheries) == 0 {
return nil, 0
}
omitted := pick(len(hatcheries))
opened := make([]int, 0, antonHatcheryOpenCount)
for index, mapID := range hatcheries {
if index == omitted || len(opened) >= antonHatcheryOpenCount {
continue
}
opened = append(opened, mapID)
}
if len(opened) == 0 {
return nil, 0
}
infection := opened[pick(len(opened))]
for _, mapID := range opened {
objective := state.Lifecycle.Objectives[mapID]
objective.State = raid.AntonDungeonOpen
objective.RemainingHP = objective.MaxHP
objective.ClearCount = 0
state.Lifecycle.Objectives[mapID] = objective
}
state.Lifecycle.InfectionMapID = infection
state.Lifecycle.InfectionSuppressed = false
// etc/raid/anton.etc fires the hatchery cutscene with the same trigger that
// opens them: [OPEN RANDOM HATCHERY] + [SET] [MOVIE HATCHERY OPEN] 1 (symbol
// 128). It rides the snapshot the caller publishes right after.
state.setRuntimeRaidMovieSymbolLocked(antonSymbolMovieHatcheryOpen, 1)
state.Lifecycle.Revision++
return opened, infection
}
// applyRuntimeRaidAntonPhaseTwoClearLocked reacts to one committed phase-two
// clear. Energy interception is repeatable, hatcheries recover after a delay,
// the volcano ends the siege. Caller holds raidMu.
func applyRuntimeRaidAntonPhaseTwoClearLocked(state *runtimeRaidState, mapID int, now time.Time) []runtimeRaidMechanismTimer {
if state == nil || state.TailFlag == 1 || state.Lifecycle.Phase != runtimeRaidDungeonPhaseTwo ||
state.Lifecycle.Status != runtimeRaidLifecyclePhaseActive {
return nil
}
if runtimeRaidPhaseObjectivesComplete(*state, runtimeRaidDungeonPhaseTwo) {
state.clearRuntimeRaidMechanismsLocked()
return nil
}
objective, ok := state.Lifecycle.Objectives[mapID]
if !ok || objective.RemainingHP != 0 {
return nil
}
switch {
case mapID == antonEnergyInterceptionMapID:
objective.State = raid.AntonDungeonOpen
objective.RemainingHP = objective.MaxHP
objective.ClearCount = 0
state.Lifecycle.Objectives[mapID] = objective
state.Lifecycle.Revision++
return nil
case mapID == antonBlackVolcanoMapID:
// advanceRuntimeRaidObjectiveLocked already hid 218/219 and the
// hatcheries and opened the heart. End the siege bookkeeping.
state.Lifecycle.VolcanoOpen = false
state.Lifecycle.InfectionMapID = 0
state.Lifecycle.InfectionSuppressed = false
state.Lifecycle.Barrier = 0
// 86JP's ClearBlackVolcanoAsync drives every hatchery's presentation pair
// back to 0 as well (:1726-1730); the entries stay in MovieSymbols, so
// without this the next snapshot re-pulses a hatchery the volcano just
// closed and the client replays its hatchery animation.
for hatchery := antonFirstHatcheryMapID; hatchery <= antonLastHatcheryMapID; hatchery++ {
if fail, named, ok := antonHatcherySymbolPlan(hatchery); ok {
state.setRuntimeRaidMovieSymbolLocked(fail, 0)
state.setRuntimeRaidMovieSymbolLocked(named, 0)
}
}
state.clearRuntimeRaidMechanismsLocked()
state.Lifecycle.Revision++
return nil
case isAntonHatcheryMapID(mapID):
// 86JP's ClearHatcheryAsync cancels timers 1 and 3 for the dungeon and
// drives both presentation symbols back to 0 before starting the
// recovery timer; that edge reaches the client through the snapshot the
// caller publishes. Clearing the infected hatchery also clears the
// infection marker itself (symbol 7 -> 0, Progression.cs:1750-1764), which
// drops the shield recharge back to its uninfected rate until that
// hatchery recovers.
state.cancelRuntimeRaidHatcheryEffectTimersLocked(mapID)
if mapID == state.Lifecycle.InfectionMapID {
state.Lifecycle.InfectionSuppressed = true
}
if fail, named, ok := antonHatcherySymbolPlan(mapID); ok {
state.setRuntimeRaidMovieSymbolLocked(fail, 0)
state.setRuntimeRaidMovieSymbolLocked(named, 0)
}
return []runtimeRaidMechanismTimer{
state.armRuntimeRaidMechanismLocked(runtimeRaidMechanismRecovery, mapID, now, antonHatcheryRecovery, 0),
}
default:
return nil
}
}
// currentRaidInRunTransferEntry reports whether one op16 moves the requesting raid
// member between its own runs of the ACTIVE phase, instead of forming a new party
// entry into the raid.
//
// It is the gate for skipping the party fan-out preflight (and with it the town
// co-presence test, which cannot hold while the party is inside the raid): the
// member must be a frozen member of a started raid, its phase must still be the
// one this map belongs to, and the session must already hold a committed run of
// that same raid whose binding names the very dungeon the member is standing in.
// A member standing in town holds no such run, so an ordinary party entry into a
// raid gate keeps every gate it has today.
func (s *Service) currentRaidInRunTransferEntry(
session *gameSession,
request dungeoncmd.SelectDungeonRequest,
) bool {
if s == nil || session == nil || session.selectedCharacterID == 0 || request.DungeonID == 0 {
return false
}
target := int(request.DungeonID)
phase, raidMap := runtimeRaidDungeonPhaseForMap(target)
if !raidMap {
return false
}
state, ok := s.runtimeRaidForMember(session.selectedCharacterID)
if !ok || !state.Started || state.TailFlag == 1 ||
state.Lifecycle.Status != runtimeRaidLifecyclePhaseActive ||
state.Lifecycle.Phase != phase {
return false
}
session.dungeon.mu.Lock()
defer session.dungeon.mu.Unlock()
runtime := session.dungeon.runtime
if runtime == nil || !dungeonRuntimeOwnsCharacter(runtime, session.selectedCharacterID) {
return false
}
return runtime.raidDungeon.RaidKey == state.RaidKey && runtime.raidDungeon.RaidKey != 0
}
// runtimeRaidCurrentRaidDungeonRun reads the session's live raid dungeon
// binding: which raid map it is inside and the shared run token of its group.
//
// The binding's token is the SQUAD's run token, never the adopting member's own
// (see the adoption branch of beginRuntimeRaidDungeon), so it must not be compared
// with the session's local token here. That comparison silently dropped every
// shield report from every member but the run's opener: live 2026-09-19
// 14:18-14:19Z, raid 1507338, 26 game-raid-barrier-request-rejected entries with
// in_run=false while both members of squad 1 stood inside 218, and the volcano's
// shield only fell on the runs the opener itself reported. What the report needs to
// prove is that this member is inside the gate its binding names; the binding's
// token stays the shared per-run transmission budget.
func runtimeRaidCurrentRaidDungeonRun(session *gameSession) (runtimeRaidDungeonBinding, bool) {
if session == nil {
return runtimeRaidDungeonBinding{}, false
}
session.dungeon.mu.Lock()
defer session.dungeon.mu.Unlock()
runtime := session.dungeon.runtime
if runtime == nil || runtime.raidDungeon.RaidKey == 0 || runtime.raidDungeon.LifecycleToken == 0 ||
!dungeonRuntimeOwnsCharacter(runtime, session.selectedCharacterID) {
return runtimeRaidDungeonBinding{}, false
}
if runtime.raidDungeon.MapID == 0 || runtime.raidDungeon.MapID != int(runtime.Dungeon.ID) {
return runtimeRaidDungeonBinding{}, false
}
return runtime.raidDungeon, true
}
// applyRuntimeRaidBarrierRequest is the team-raid owner of op662. The request
// must come from inside a live energy-interception or hatchery run of the
// active phase two and stay within that run's transmission budget.
func (s *Service) applyRuntimeRaidBarrierRequest(session *gameSession, req raid.SetSymbolRequest) (bool, error) {
characterID := selectedCharacterID(session)
if characterID == 0 {
return false, nil
}
run, inRun := runtimeRaidCurrentRaidDungeonRun(session)
if !inRun || run.Phase != runtimeRaidDungeonPhaseTwo ||
(run.MapID != antonEnergyInterceptionMapID && !isAntonHatcheryMapID(run.MapID)) {
s.logGameEvent(session, "game-raid-barrier-request-rejected",
"symbol", req.SourceValue, "operand", req.TargetValue, "operation", req.Symbol,
"in_run", inRun, "run_map_id", run.MapID, "reason", "not_inside_energy_or_hatchery_run")
return true, nil
}
now := s.gameplayNow()
s.raidMu.Lock()
state := s.runtimeRaidByMemberLocked(characterID)
if state == nil || state.RaidKey != run.RaidKey || state.TailFlag == 1 || !state.Started ||
state.Lifecycle.Phase != runtimeRaidDungeonPhaseTwo || state.Lifecycle.Status != runtimeRaidLifecyclePhaseActive {
s.raidMu.Unlock()
s.logGameEvent(session, "game-raid-barrier-request-rejected", "reason", "raid_not_in_active_phase_two")
return true, nil
}
state.ensureRuntimeRaidLifecycleMapsLocked()
limit := antonBarrierRequestLimit(run.MapID, state.Lifecycle.InfectionMapID, req.SourceValue, req.TargetValue, req.Symbol)
if limit == 0 {
s.raidMu.Unlock()
s.logGameEvent(session, "game-raid-barrier-request-rejected",
"symbol", req.SourceValue, "operand", req.TargetValue, "operation", req.Symbol,
"run_map_id", run.MapID, "reason", "not_a_valid_shield_report")
return true, nil
}
key := runtimeRaidBarrierRequestKey{MapID: run.MapID, LifecycleToken: run.LifecycleToken, Operand: req.TargetValue}
if state.Lifecycle.BarrierRequests[key] >= limit {
s.raidMu.Unlock()
s.logGameEvent(session, "game-raid-barrier-request-rejected",
"operand", req.TargetValue, "run_map_id", run.MapID, "limit", limit, "reason", "run_budget_exhausted")
return true, nil
}
state.Lifecycle.BarrierRequests[key]++
change, armed, ok := changeRuntimeRaidBarrierLocked(state, req.TargetValue, req.Symbol, now)
snapshot := cloneRuntimeRaidState(*state)
s.raidMu.Unlock()
if !ok {
return true, nil
}
if err := s.scheduleRuntimeRaidMechanismTimers(snapshot.RaidKey, armed); err != nil {
s.logPacketEvent("game-raid-barrier-recovery-timer-failed", "raid_key", snapshot.RaidKey, "error", err)
}
s.logGameEvent(session, "game-raid-barrier-changed",
"raid_key", snapshot.RaidKey,
"run_map_id", run.MapID,
"operand", req.TargetValue,
"previous", change.Previous,
"next", change.Next,
"broke", change.Broke,
"source", "op662")
return true, s.broadcastRuntimeRaidBarrier(snapshot, change)
}
// broadcastRuntimeRaidMovieSymbolRows pulses cutscene markers on the 0x0247 symbol
// channel: the current client's handler (sub_1D39AE0) feeds SetRaidSymbol, which is
// what makes it play Video/AntonCutScene and the barrier animations.
func (s *Service) broadcastRuntimeRaidMovieSymbolRows(state runtimeRaidState, rows []raid.RaidSymbolRow) error {
if len(rows) == 0 {
return nil
}
body, err := raid.BuildRaidSymbolBody(rows)
if err != nil {
return err
}
return s.broadcastRuntimeRaidWireSequence(state, runtimeRaidWireNotification{msgID: raid.RaidSymbolMsgID, body: body})
}
// broadcastRuntimeRaidBarrier publishes the shield rows to every online member.
// On the two edges the volcano authority row travels with it, plus the cutscene
// markers the PVF reaction is keyed on (126 break / 127 recover), matching 86JP's
// atomic edge set.
func (s *Service) broadcastRuntimeRaidBarrier(state runtimeRaidState, change runtimeRaidBarrierChange) error {
rows := []raid.RaidSymbolRow{{Key: antonSymbolBarrier, Value: state.Lifecycle.Barrier}}
if change.Broke || change.Restored {
volcano := uint32(0)
if state.Lifecycle.VolcanoOpen {
volcano = 1
}
rows = append(rows, raid.RaidSymbolRow{Key: antonSymbolVolcanoOpen, Value: volcano})
for _, marker := range []uint32{antonSymbolBarrierBreak, antonSymbolBarrierRecover} {
value, ok := state.Lifecycle.MovieSymbols[marker]
if !ok {
continue
}
rows = append(rows, raid.RaidSymbolRow{Key: marker, Value: value})
}
}
body, err := raid.BuildRaidSymbolBody(rows)
if err != nil {
return err
}
return s.broadcastRuntimeRaidWireSequence(state, runtimeRaidWireNotification{msgID: raid.RaidSymbolMsgID, body: body})
}
// fireRuntimeRaidPhaseTwoMechanismLocked dispatches a phase-two timer whose
// generation has already been validated. Caller holds raidMu.
func (s *Service) fireRuntimeRaidPhaseTwoMechanismLocked(state *runtimeRaidState, armed runtimeRaidMechanismTimer, due time.Time) ([]runtimeRaidMechanismTimer, string, *runtimeRaidBarrierChange) {
switch armed.Kind {
case runtimeRaidMechanismHatcheryOpen:
if isAntonHatcheryMapID(armed.MapID) {
// Wire timer type 3 on a hatchery key is the client's
// [HATCHERY NAMED CREATE] window, not the volcano open timer on 219.
armedNext, outcome := state.fireRuntimeRaidHatcheryEffectTimerLocked(armed.MapID, armed.Kind, due)
return armedNext, outcome, nil
}
opened, _ := openRuntimeRaidHatcheriesLocked(state, s.runtimeRaidRandomIndex)
if len(opened) == 0 {
return nil, "", nil
}
// 86JP starts the hatchery effect timers in the same step that opens the
// hatcheries (RunHatcheryOpenTimerAsync -> StartHatcheryEffectTimersAsync);
// they reach the client in the snapshot this callback publishes.
armedNext := make([]runtimeRaidMechanismTimer, 0, 2*len(opened))
for _, mapID := range opened {
armedNext = append(armedNext, state.armRuntimeRaidHatcheryEffectTimersLocked(mapID, due)...)
}
return armedNext, "hatcheries_opened", nil
case runtimeRaidMechanismActive:
if isAntonHatcheryMapID(armed.MapID) {
// Wire timer type 1 on a hatchery key is the client's
// [HATCHERY FAIL] window. Phase one's type-1 active windows never
// name a hatchery, and phase two has no other type-1 owner.
armedNext, outcome := state.fireRuntimeRaidHatcheryEffectTimerLocked(armed.MapID, armed.Kind, due)
return armedNext, outcome, nil
}
return nil, "", nil
case runtimeRaidMechanismRecovery:
if !isAntonHatcheryMapID(armed.MapID) {
return nil, "", nil
}
objective, ok := state.Lifecycle.Objectives[armed.MapID]
if !ok {
return nil, "", nil
}
objective.State = raid.AntonDungeonOpen
objective.RemainingHP = objective.MaxHP
objective.ClearCount = 0
state.Lifecycle.Objectives[armed.MapID] = objective
// The infected hatchery carries its infection back when it recovers
// (86JP TryRecoverHatcheryAsync writes symbol 7 back to 1, :2335-2340).
if armed.MapID == state.Lifecycle.InfectionMapID {
state.Lifecycle.InfectionSuppressed = false
}
state.Lifecycle.Revision++
// 86JP re-arms the pair after the recovery timer reopens the hatchery
// (RunHatcheryRecoveryTimerAsync -> StartHatcheryEffectTimersAsync).
return state.armRuntimeRaidHatcheryEffectTimersLocked(armed.MapID, due), "hatchery_recovered", nil
case runtimeRaidMechanismBarrierRecovery:
change, armedNext, ok := runtimeRaidBarrierChargeLocked(state, due)
if !ok {
return nil, "", nil
}
return armedNext, "barrier_charged", &change
default:
return nil, "", nil
}
}
func (s *Service) runtimeRaidRandomIndex(n int) int {
if n <= 1 {
return 0
}
if s != nil && s.raidRewardRandom != nil {
return int(s.raidRewardRandom() % uint64(n))
}
return rand.Intn(n)
}
func sortedRuntimeRaidMechanismTimers(state runtimeRaidState) []runtimeRaidMechanismTimer {
timers := make([]runtimeRaidMechanismTimer, 0, len(state.Lifecycle.Mechanisms))
for _, timer := range state.Lifecycle.Mechanisms {
timers = append(timers, timer)
}
sort.Slice(timers, func(i, j int) bool {
if timers[i].Kind != timers[j].Kind {
return timers[i].Kind < timers[j].Kind
}
return timers[i].MapID < timers[j].MapID
})
return timers
}