服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
929 lines
40 KiB
Go
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
|
|
}
|