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

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

778 lines
34 KiB
Go

package dnfbridge
import (
"fmt"
"sort"
"time"
"longheng.io/server/internal/modules/dnf/raid"
)
// Luke's team raid is two phases of gate graph, transcribed from the [TRIGGER]
// / [BEHAVIOR] pairs of etc/raid/luke/luke.etc. Every symbol id below is from
// etc/raid/luke/luke.etc.symbol, which is a different namespace from Anton's:
// the same number means a different thing in each file, so nothing here may be
// shared with raid_anton_phase_*.go.
//
// Phase one, 进入源生宫殿: three sanctums, three clears each. Only 诞生 (5054)
// starts open; entering it opens the other two. Whichever of 灭亡 (5055) /
// 扩散 (5056) clears first decides [2PHASE DUNGEON TYPE]. All three clear ->
// [1PHASE SUCCESS].
//
// Phase two, 进入卓越圣殿: the two entrances open together, then the light chain
// 5057 -> 5059 -> 5064 and the dark chain 5058 -> 5060 -> 5065 run in parallel,
// gated by the [ALTAR ENTERANCE CLEAR] / [ALTAR CLEAR] / [TEMPLE CLEAR] counters.
// Both temples down opens 机械王座:明 (5061); clearing it opens 机械王座:暗
// (5062), and clearing that ends the phase. The energy control room (5063) and
// the energy save room (5066) are the two shared rooms that run on timers.
const (
lukeBirthSanctumMapID = 5054
lukeExtinctionSanctumMapID = 5055
lukeSpreadSanctumMapID = 5056
lukeLightEntranceMapID = 5311
lukeDarkEntranceMapID = 5313
lukeLightEnergyMapID = 5057
lukeDarkEnergyMapID = 5058
lukeLightAltarMapID = 5059
lukeDarkAltarMapID = 5060
lukeEnergyControlMapID = 5063
lukeLightTempleMapID = 5064
lukeDarkTempleMapID = 5065
lukeLightThroneMapID = 5061
lukeDarkThroneMapID = 5062
lukeEnergySaveMapID = 5066
// Boss rooms inside the two thrones (map ids, not dungeon ids).
lukeLightThroneBossRoomMapID int64 = 55845
lukeDarkThroneBossRoomMapID int64 = 55846
lukeSymbolKillAllCharacter uint32 = 102
lukeSymbolKickOutCharacter uint32 = 103
lukeSymbolPhaseOneFail uint32 = 104
lukeSymbolPhaseOneSuccess uint32 = 105
lukeSymbolPhaseTwoInit uint32 = 106
lukeSymbolPhaseOneInit uint32 = 107
lukeSymbolPhaseTwoDungeonType uint32 = 108
lukeSymbolBirthSanctumObject uint32 = 117
lukeSymbolExtinctionSanctumObject uint32 = 118
lukeSymbolSpreadSanctumObject uint32 = 119
lukeSymbolTempleClear uint32 = 122
lukeSymbolAltarClear uint32 = 124
lukeSymbolEnergyControlOpen uint32 = 133
lukeSymbolEnergyControlEffect uint32 = 138
lukeSymbolEnergySaveEffect uint32 = 139
lukeSymbolAltarEntranceClear uint32 = 140
// [2PHASE DUNGEON TYPE] records which branch sanctum fell first. The PVF
// sets it but never reads it back in phase two, so it is carried as raid
// state the client can read rather than used as a gate here.
lukePhaseTwoDungeonTypeExtinction uint32 = 1
lukePhaseTwoDungeonTypeSpread uint32 = 2
// [ENERGY SAVE EFFECT] values: 1 while the room is open and its clock runs,
// 2 once the clock ran out and the roster was wiped, 0 otherwise.
lukeEnergySaveEffectActive uint32 = 1
lukeEnergySaveEffectFailed uint32 = 2
lukePhaseTwoOpeningWindow = 10 * time.Second
lukeEnergySaveWindow = 150 * time.Second
lukeEnergySaveRecovery = 120 * time.Second
lukeAltarEntranceRequired = 2
lukeAltarRequired = 2
lukeTempleRequired = 2
)
// lukeEnergyControlRecovery is how long the energy control room stays closed
// after each clear. etc/raid/luke/luke.etc branches on [ENERGY CONTROL OPEN]
// being 0, 1 or 2 and arms 45, 90 or 120 seconds. The PVF only ever reads that
// symbol; its value is the [1PHASE OBJ DESTROY] tier of the generators the
// roster destroyed in phase one (0-5 / 6-10 / 11-15), so a raid that wrecked
// more generators gets a longer break between debuff cycles. It is not a cycle
// counter -- an earlier version counted clears into it.
var lukeEnergyControlRecovery = [...]time.Duration{45 * time.Second, 90 * time.Second, 120 * time.Second}
// lukeLightChain and lukeDarkChain are the two route chains of phase two in
// clear order. Each link opens the next.
var (
lukeLightChain = [...]int{lukeLightEnergyMapID, lukeLightAltarMapID, lukeLightTempleMapID}
lukeDarkChain = [...]int{lukeDarkEnergyMapID, lukeDarkAltarMapID, lukeDarkTempleMapID}
)
// runtimeRaidLukeTeamDungeonPhases is which phase owns each Luke team gate.
//
// runtimeRaidDungeonPhaseForMap consults it, and that predicate is what marks a
// SelectDungeon request as a raid entry at all. Without these ids a Luke gate
// looked like an ordinary dungeon to every caller downstream: the ordinary
// level/fatigue policy ran instead of the raid roster check, and the
// town-return origin was resolved from the gate's ordinary worldmap town
// instead of the raid waiting room, so entering 5054 from the Jelva waiting
// area (32/4) was refused against its ordinary town (38/1).
var runtimeRaidLukeTeamDungeonPhases = map[int]byte{
lukeBirthSanctumMapID: runtimeRaidDungeonPhaseOne,
lukeExtinctionSanctumMapID: runtimeRaidDungeonPhaseOne,
lukeSpreadSanctumMapID: runtimeRaidDungeonPhaseOne,
lukeLightEntranceMapID: runtimeRaidDungeonPhaseTwo,
lukeDarkEntranceMapID: runtimeRaidDungeonPhaseTwo,
lukeLightEnergyMapID: runtimeRaidDungeonPhaseTwo,
lukeDarkEnergyMapID: runtimeRaidDungeonPhaseTwo,
lukeLightAltarMapID: runtimeRaidDungeonPhaseTwo,
lukeDarkAltarMapID: runtimeRaidDungeonPhaseTwo,
lukeEnergyControlMapID: runtimeRaidDungeonPhaseTwo,
lukeLightTempleMapID: runtimeRaidDungeonPhaseTwo,
lukeDarkTempleMapID: runtimeRaidDungeonPhaseTwo,
lukeLightThroneMapID: runtimeRaidDungeonPhaseTwo,
lukeDarkThroneMapID: runtimeRaidDungeonPhaseTwo,
lukeEnergySaveMapID: runtimeRaidDungeonPhaseTwo,
}
// Route tags of the phase-two gates, mirroring their `[LOCKOUT TYPE]` first
// value in etc/raid/luke/luke.etc. The exact values are pinned against the
// shipped PVF by raid.TestRealScriptPVFLukePhaseMechanismTables.
const (
lukeRouteNone byte = 0
lukeRouteLight byte = 4
lukeRouteDark byte = 5
)
var runtimeRaidLukeGateRoutes = map[int]byte{
lukeLightEntranceMapID: lukeRouteLight,
lukeLightEnergyMapID: lukeRouteLight,
lukeLightAltarMapID: lukeRouteLight,
lukeLightTempleMapID: lukeRouteLight,
lukeLightThroneMapID: lukeRouteLight,
lukeDarkEntranceMapID: lukeRouteDark,
lukeDarkEnergyMapID: lukeRouteDark,
lukeDarkAltarMapID: lukeRouteDark,
lukeDarkTempleMapID: lukeRouteDark,
lukeDarkThroneMapID: lukeRouteDark,
// The energy control and energy save rooms are shared: both sides run them,
// and running one must not commit anybody to a route.
lukeEnergyControlMapID: lukeRouteNone,
lukeEnergySaveMapID: lukeRouteNone,
}
func runtimeRaidLukeGateRoute(mapID int) byte {
return runtimeRaidLukeGateRoutes[mapID]
}
// runtimeRaidLukeRouteEntryAllowedLocked reports whether this member may enter
// this phase-two gate.
//
// Live 2026-09-23: every member could walk into both routes, because nothing
// read `[LOCKOUT TYPE]` at all. The first route gate a member enters binds it to
// that route for the rest of the phase; the shared rooms bind nothing and block
// nobody.
//
// The lock is per CHARACTER, not per squad. Three independent sources say so,
// and the granularity is the whole point of the rule -- a member bound to one
// route is locked out of the other, not merely forbidden to switch squads:
//
// - the official Korean update notes for the 3.16 revision describe the
// mechanic as 「입장 시 해당 던전에 귀속되어 다른 던전에 입장할 수 없게
// 됩니다」 and name the subject as 「1파티의 구성원들」 -- the squad's
// *members*, not the squad and not the raid;
// - the community wiki entry for the same system states it is applied
// 「파티 단위 아닌, 파티원 개개별로」 (per member, not per squad), and that
// swapping squad compositions afterwards does not clear it -- a symptom only
// a per-character record can produce;
// - the client's own notification packet for this state is named
// `RAID_MEMBER_LOCKOUT_INFO` (opcode 1262), i.e. keyed on a member.
//
// Recorded in 卢克_官方流程核对_20260929.md §2.4 (LK-2/LK-4). Do not tighten this
// to the squad.
func runtimeRaidLukeRouteEntryAllowedLocked(state *runtimeRaidState, characterID uint16, mapID int) bool {
if state == nil || state.TailFlag == 1 || characterID == 0 {
return true
}
route := runtimeRaidLukeGateRoute(mapID)
if route == lukeRouteNone {
return true
}
locked, exists := state.Lifecycle.LukeRouteLocks[characterID]
return !exists || locked == route
}
// bindRuntimeRaidLukeRouteLocked commits a member to the route of the gate it
// just entered. Caller holds raidMu.
func bindRuntimeRaidLukeRouteLocked(state *runtimeRaidState, characterID uint16, mapID int) (byte, bool) {
if state == nil || characterID == 0 {
return lukeRouteNone, false
}
route := runtimeRaidLukeGateRoute(mapID)
if route == lukeRouteNone {
return lukeRouteNone, false
}
state.ensureRuntimeRaidLifecycleMapsLocked()
if _, exists := state.Lifecycle.LukeRouteLocks[characterID]; exists {
return route, false
}
state.Lifecycle.LukeRouteLocks[characterID] = route
state.Lifecycle.Revision++
return route, true
}
// The one-shot markers. etc/raid/luke/luke.etc pulses these inside a [BEHAVIOR]
// and the script acts on the edge -- [1PHASE FAIL] is even set straight back to
// 0 in the same behavior. They are NOT durable phase state, so they live in
// MovieSymbols (delta-pulsed, deliberately absent from the periodic snapshot)
// exactly like Anton's cutscene markers.
//
// Putting them in LukeSymbols instead meant every periodic snapshot re-asserted
// [KILL ALL CHARACTER]=1 for the rest of the phase: entering the throne room
// killed the player again on the next snapshot, and a revive was undone by the
// one after it, which read as "复活币一个都用不了".
func setRuntimeRaidLukePulseLocked(state *runtimeRaidState, key uint32, value uint32) {
if state == nil {
return
}
state.ensureRuntimeRaidLifecycleMapsLocked()
state.setRuntimeRaidMovieSymbolLocked(key, value)
}
func runtimeRaidLukeSymbolLocked(state *runtimeRaidState, key uint32) uint32 {
if state == nil || state.Lifecycle.LukeSymbols == nil {
return 0
}
return state.Lifecycle.LukeSymbols[key]
}
// setRuntimeRaidLukeSymbolLocked writes one symbol and reports whether it moved.
// Caller holds raidMu.
func setRuntimeRaidLukeSymbolLocked(state *runtimeRaidState, key uint32, value uint32) bool {
if state == nil {
return false
}
state.ensureRuntimeRaidLifecycleMapsLocked()
if previous, ok := state.Lifecycle.LukeSymbols[key]; ok && previous == value {
return false
}
state.Lifecycle.LukeSymbols[key] = value
state.Lifecycle.Revision++
return true
}
func addRuntimeRaidLukeSymbolLocked(state *runtimeRaidState, key uint32, delta uint32) uint32 {
next := runtimeRaidLukeSymbolLocked(state, key) + delta
setRuntimeRaidLukeSymbolLocked(state, key, next)
return next
}
// setRuntimeRaidLukeDungeonStateLocked is the [SET DUNGEON STATE] behavior. A
// gate that has already been spent stays spent: the PVF drives the same gate to
// `hide` right after its clear, and re-opening it later would hand the roster a
// second run at an objective it already finished.
func setRuntimeRaidLukeDungeonStateLocked(state *runtimeRaidState, mapID int, want raid.AntonDungeonState) bool {
if state == nil {
return false
}
// Do NOT gate this on state.Lifecycle.Phase. activateRuntimeRaidPhase builds
// the new phase's objectives and runs this phase's init BEFORE it assigns
// Lifecycle.Phase, so during phase-two activation the objectives already read
// phase 2 while Lifecycle.Phase is still 1. Comparing the two made every
// phase-two opening a no-op: the two altar entrances stayed at their PVF
// `hide` and the whole route was unreachable, while the symbols -- which have
// no such guard -- went out correctly and made it look like init had run.
//
// The comparison was redundant anyway: initializeRuntimeRaidPhaseObjectives
// rebuilds Objectives from scratch for one phase, so every entry in the map
// belongs to the phase being activated.
objective, ok := state.Lifecycle.Objectives[mapID]
if !ok {
return false
}
if want == raid.AntonDungeonOpen && objective.RemainingHP <= 0 {
return false
}
if objective.State == want {
return false
}
objective.State = want
state.Lifecycle.Objectives[mapID] = objective
state.Lifecycle.Revision++
return true
}
// initRuntimeRaidLukePhaseOneLocked is the [1PHASE INIT] trigger: the three
// sanctum object counters start at zero. Caller holds raidMu.
func initRuntimeRaidLukePhaseOneLocked(state *runtimeRaidState) {
if state == nil {
return
}
state.ensureRuntimeRaidLifecycleMapsLocked()
// A fresh phase one draws every sanctum's maze group anew.
state.Lifecycle.LukeSanctumMazeGroups = nil
setRuntimeRaidLukeSymbolLocked(state, lukeSymbolPhaseOneInit, 1)
setRuntimeRaidLukeSymbolLocked(state, lukeSymbolBirthSanctumObject, 0)
setRuntimeRaidLukeSymbolLocked(state, lukeSymbolExtinctionSanctumObject, 0)
setRuntimeRaidLukeSymbolLocked(state, lukeSymbolSpreadSanctumObject, 0)
}
// initRuntimeRaidLukePhaseTwoLocked is the [2PHASE INIT] trigger: both altar
// entrances open and the ten-second opening timer is armed on 5054. That timer
// is never checked by any trigger and is only reset when the phase ends, so it
// is a display countdown the client draws; nothing fires when it elapses.
// Caller holds raidMu.
func initRuntimeRaidLukePhaseTwoLocked(state *runtimeRaidState, now time.Time) []runtimeRaidMechanismTimer {
if state == nil {
return nil
}
state.ensureRuntimeRaidLifecycleMapsLocked()
// A fresh phase two re-opens both routes to everybody.
state.Lifecycle.LukeRouteLocks = make(map[uint16]byte)
setRuntimeRaidLukeSymbolLocked(state, lukeSymbolPhaseTwoInit, 1)
setRuntimeRaidLukeSymbolLocked(state, lukeSymbolAltarEntranceClear, 0)
setRuntimeRaidLukeSymbolLocked(state, lukeSymbolAltarClear, 0)
setRuntimeRaidLukeSymbolLocked(state, lukeSymbolTempleClear, 0)
// [ENERGY CONTROL OPEN] is the phase-one generator tier, fixed for the
// whole of phase two (raid_luke_generator.go).
setRuntimeRaidLukeSymbolLocked(state, lukeSymbolEnergyControlOpen, runtimeRaidLukeEnergyControlTierLocked(state))
setRuntimeRaidLukeSymbolLocked(state, lukeSymbolEnergyControlEffect, 0)
setRuntimeRaidLukeSymbolLocked(state, lukeSymbolEnergySaveEffect, 0)
setRuntimeRaidLukeDungeonStateLocked(state, lukeLightEntranceMapID, raid.AntonDungeonOpen)
setRuntimeRaidLukeDungeonStateLocked(state, lukeDarkEntranceMapID, raid.AntonDungeonOpen)
return []runtimeRaidMechanismTimer{
state.armRuntimeRaidMechanismLocked(runtimeRaidMechanismActive, lukeBirthSanctumMapID, now, lukePhaseTwoOpeningWindow, 0),
}
}
// runtimeRaidLukeEnterDungeonLocked is the [ON ENTER DUNGEON] side of the graph,
// driven by a squad binding itself into a gate: [ON ENTER DUNGEON] 5054 with
// [2PHASE DUNGEON TYPE] still 0 opens the other two sanctums. Entering a throne
// gate does nothing by itself -- the energy save room opens on the throne's
// boss room (runtimeRaidLukeEnterMapLocked). Caller holds raidMu.
func runtimeRaidLukeEnterDungeonLocked(state *runtimeRaidState, mapID int) bool {
if state == nil || state.Lifecycle.Phase != runtimeRaidDungeonPhaseOne || mapID != lukeBirthSanctumMapID {
return false
}
if runtimeRaidLukeSymbolLocked(state, lukeSymbolPhaseTwoDungeonType) != 0 {
return false
}
opened := setRuntimeRaidLukeDungeonStateLocked(state, lukeExtinctionSanctumMapID, raid.AntonDungeonOpen)
return setRuntimeRaidLukeDungeonStateLocked(state, lukeSpreadSanctumMapID, raid.AntonDungeonOpen) || opened
}
// runtimeRaidLukeIsThroneBossRoom reports the two maps luke.etc opens the energy
// save room on: [ON ENTER MAP] 55845 / 55846 with [CHECK DUNGEON STATE] 5066
// `hide`. They are the second (boss) room of Luke_Light_1.dgn / Luke_Dark_1.dgn;
// each throne has only its start room (55849 / 55850) in front of it.
func runtimeRaidLukeIsThroneBossRoom(roomMapID int64) bool {
return roomMapID == lukeLightThroneBossRoomMapID || roomMapID == lukeDarkThroneBossRoomMapID
}
// runtimeRaidLukeEnterMapLocked is the [ON ENTER MAP] side: the first squad to
// reach a throne's boss room opens the energy save room, and that open edge
// ([ON CHANGE DUNGEON STATE] 5066 `open`) sets [ENERGY SAVE EFFECT] 1 and arms
// the 150-second wipe clock. It reports whether the room opened. Later arrivals,
// and a room that is closed for its recovery or already open, change nothing.
// Caller holds raidMu.
func runtimeRaidLukeEnterMapLocked(
state *runtimeRaidState,
dungeonMapID int,
roomMapID int64,
now time.Time,
) (bool, []runtimeRaidMechanismTimer) {
if state == nil || state.TailFlag == 1 || !state.Started ||
runtimeRaidTeamGraphOwner(*state) != runtimeRaidOwnerLuke ||
state.Lifecycle.Status != runtimeRaidLifecyclePhaseActive ||
state.Lifecycle.Phase != runtimeRaidDungeonPhaseTwo ||
!runtimeRaidLukeIsThroneMapID(dungeonMapID) || !runtimeRaidLukeIsThroneBossRoom(roomMapID) {
return false, nil
}
objective, ok := state.Lifecycle.Objectives[lukeEnergySaveMapID]
if !ok || objective.State != raid.AntonDungeonHide {
return false, nil
}
if !setRuntimeRaidLukeDungeonStateLocked(state, lukeEnergySaveMapID, raid.AntonDungeonOpen) {
return false, nil
}
return true, armRuntimeRaidLukeEnergySaveWindowLocked(state, now)
}
// runtimeRaidLukeGiveUpDungeonLocked is the [ON GIVEUP DUNGEON] side.
// Abandoning a sanctum resets that sanctum's object counter; abandoning a
// throne closes the energy save room again. Caller holds raidMu.
func runtimeRaidLukeGiveUpDungeonLocked(state *runtimeRaidState, mapID int) bool {
if state == nil {
return false
}
switch mapID {
case lukeBirthSanctumMapID:
return setRuntimeRaidLukeSymbolLocked(state, lukeSymbolBirthSanctumObject, 0)
case lukeExtinctionSanctumMapID:
return setRuntimeRaidLukeSymbolLocked(state, lukeSymbolExtinctionSanctumObject, 0)
case lukeSpreadSanctumMapID:
return setRuntimeRaidLukeSymbolLocked(state, lukeSymbolSpreadSanctumObject, 0)
case lukeLightThroneMapID, lukeDarkThroneMapID:
// [KICK OUT CHARACTER] belongs to the abandoning member's own exit, which
// the ordinary abandon path already performs; only the room state is this
// graph's business.
//
// [ON CHANGE DUNGEON STATE] 5066 `hide` is [RESET TIMER] 1 5066 and
// [RESET TIMER] 2 5066, so hiding the room has to take both of its timers
// down with it. Leaving the wipe clock armed made it fire on a throne
// nobody was holding any more.
state.cancelRuntimeRaidMechanismLocked(runtimeRaidMechanismActive, lukeEnergySaveMapID)
state.cancelRuntimeRaidMechanismLocked(runtimeRaidMechanismRecovery, lukeEnergySaveMapID)
changed := setRuntimeRaidLukeDungeonStateLocked(state, lukeEnergySaveMapID, raid.AntonDungeonHide)
return setRuntimeRaidLukeSymbolLocked(state, lukeSymbolEnergySaveEffect, 0) || changed
default:
return false
}
}
// applyRuntimeRaidLukePhaseOneClearLocked reacts to one committed phase-one
// clear. Caller holds raidMu.
func applyRuntimeRaidLukePhaseOneClearLocked(state *runtimeRaidState, mapID int) {
if state == nil {
return
}
objective, ok := state.Lifecycle.Objectives[mapID]
if !ok || objective.RemainingHP != 0 {
return
}
// The first branch sanctum to fall decides the phase-two route marker.
if runtimeRaidLukeSymbolLocked(state, lukeSymbolPhaseTwoDungeonType) == 0 {
switch mapID {
case lukeExtinctionSanctumMapID:
setRuntimeRaidLukeSymbolLocked(state, lukeSymbolPhaseTwoDungeonType, lukePhaseTwoDungeonTypeExtinction)
case lukeSpreadSanctumMapID:
setRuntimeRaidLukeSymbolLocked(state, lukeSymbolPhaseTwoDungeonType, lukePhaseTwoDungeonTypeSpread)
}
}
if !runtimeRaidLukeSanctumsClearedLocked(state) {
return
}
setRuntimeRaidLukeSymbolLocked(state, lukeSymbolPhaseOneSuccess, 1)
for _, sanctum := range []int{lukeBirthSanctumMapID, lukeExtinctionSanctumMapID, lukeSpreadSanctumMapID} {
setRuntimeRaidLukeDungeonStateLocked(state, sanctum, raid.AntonDungeonHide)
}
}
func runtimeRaidLukeSanctumsClearedLocked(state *runtimeRaidState) bool {
for _, sanctum := range []int{lukeBirthSanctumMapID, lukeExtinctionSanctumMapID, lukeSpreadSanctumMapID} {
objective, ok := state.Lifecycle.Objectives[sanctum]
if !ok || objective.Phase != runtimeRaidDungeonPhaseOne || objective.RemainingHP != 0 {
return false
}
}
return true
}
// applyRuntimeRaidLukePhaseTwoClearLocked reacts to one committed phase-two
// clear and returns the timers it armed. Caller holds raidMu.
func applyRuntimeRaidLukePhaseTwoClearLocked(state *runtimeRaidState, mapID int, now time.Time) []runtimeRaidMechanismTimer {
if state == nil {
return nil
}
objective, ok := state.Lifecycle.Objectives[mapID]
if !ok {
return nil
}
// The energy control room is a cycle, not a one-shot gate: it is cleared
// over and over and its own clear count never runs out.
if mapID == lukeEnergyControlMapID {
return runtimeRaidLukeCloseEnergyControlLocked(state, now)
}
if mapID == lukeEnergySaveMapID {
return runtimeRaidLukeCloseEnergySaveLocked(state, now)
}
if objective.RemainingHP != 0 {
return nil
}
switch mapID {
case lukeLightEntranceMapID, lukeDarkEntranceMapID:
setRuntimeRaidLukeDungeonStateLocked(state, mapID, raid.AntonDungeonHide)
if addRuntimeRaidLukeSymbolLocked(state, lukeSymbolAltarEntranceClear, 1) < lukeAltarEntranceRequired {
return nil
}
setRuntimeRaidLukeDungeonStateLocked(state, lukeLightEnergyMapID, raid.AntonDungeonOpen)
setRuntimeRaidLukeDungeonStateLocked(state, lukeDarkEnergyMapID, raid.AntonDungeonOpen)
setRuntimeRaidLukeDungeonStateLocked(state, lukeEnergyControlMapID, raid.AntonDungeonOpen)
setRuntimeRaidLukeSymbolLocked(state, lukeSymbolEnergyControlEffect, 1)
return nil
case lukeLightEnergyMapID:
setRuntimeRaidLukeDungeonStateLocked(state, mapID, raid.AntonDungeonHide)
setRuntimeRaidLukeDungeonStateLocked(state, lukeLightAltarMapID, raid.AntonDungeonOpen)
return nil
case lukeDarkEnergyMapID:
setRuntimeRaidLukeDungeonStateLocked(state, mapID, raid.AntonDungeonHide)
setRuntimeRaidLukeDungeonStateLocked(state, lukeDarkAltarMapID, raid.AntonDungeonOpen)
return nil
case lukeLightAltarMapID, lukeDarkAltarMapID:
setRuntimeRaidLukeDungeonStateLocked(state, mapID, raid.AntonDungeonHide)
if addRuntimeRaidLukeSymbolLocked(state, lukeSymbolAltarClear, 1) < lukeAltarRequired {
return nil
}
// Both altars down: the temples open and the energy control room shuts
// for good, so its debuff and its opening timer both stop.
setRuntimeRaidLukeDungeonStateLocked(state, lukeLightTempleMapID, raid.AntonDungeonOpen)
setRuntimeRaidLukeDungeonStateLocked(state, lukeDarkTempleMapID, raid.AntonDungeonOpen)
setRuntimeRaidLukeDungeonStateLocked(state, lukeEnergyControlMapID, raid.AntonDungeonHide)
setRuntimeRaidLukeSymbolLocked(state, lukeSymbolEnergyControlEffect, 0)
state.cancelRuntimeRaidMechanismLocked(runtimeRaidMechanismActive, lukeEnergyControlMapID)
state.cancelRuntimeRaidMechanismLocked(runtimeRaidMechanismRecovery, lukeEnergyControlMapID)
return nil
case lukeLightTempleMapID, lukeDarkTempleMapID:
setRuntimeRaidLukeDungeonStateLocked(state, mapID, raid.AntonDungeonHide)
if addRuntimeRaidLukeSymbolLocked(state, lukeSymbolTempleClear, 1) < lukeTempleRequired {
return nil
}
setRuntimeRaidLukeDungeonStateLocked(state, lukeLightThroneMapID, raid.AntonDungeonOpen)
return nil
case lukeLightThroneMapID:
// 机械王座:明 falls, 机械王座:暗 opens. The roster is wiped between the two
// stages ([KILL ALL CHARACTER]), which is the native forced regroup.
setRuntimeRaidLukeDungeonStateLocked(state, lukeLightThroneMapID, raid.AntonDungeonHide)
runtimeRaidLukeCloseEnergySaveRoomLocked(state)
setRuntimeRaidLukeDungeonStateLocked(state, lukeDarkThroneMapID, raid.AntonDungeonOpen)
setRuntimeRaidLukePulseLocked(state, lukeSymbolKillAllCharacter, 1)
return nil
case lukeDarkThroneMapID:
// 机械王座:暗 falls and the phase is over; runtimeRaidPhaseObjectivesComplete
// reads the same fact.
setRuntimeRaidLukeDungeonStateLocked(state, lukeDarkThroneMapID, raid.AntonDungeonHide)
runtimeRaidLukeCloseEnergySaveRoomLocked(state)
setRuntimeRaidLukePulseLocked(state, lukeSymbolKillAllCharacter, 1)
state.cancelRuntimeRaidMechanismLocked(runtimeRaidMechanismActive, lukeBirthSanctumMapID)
return nil
default:
return nil
}
}
// runtimeRaidLukeCloseEnergyControlLocked shuts the energy control room for its
// recovery window. luke.etc picks the window from [ENERGY CONTROL OPEN] (133)
// -- `5063 clear` with 133 = 0 / 1 / 2 arms 45 / 90 / 120 s -- and never writes
// 133 itself: it is the phase-one generator tier set once at phase-two init,
// so every cycle of one raid waits the same time.
func runtimeRaidLukeCloseEnergyControlLocked(state *runtimeRaidState, now time.Time) []runtimeRaidMechanismTimer {
tier := runtimeRaidLukeSymbolLocked(state, lukeSymbolEnergyControlOpen)
if tier >= uint32(len(lukeEnergyControlRecovery)) {
tier = uint32(len(lukeEnergyControlRecovery)) - 1
}
recovery := lukeEnergyControlRecovery[tier]
// The room is a repeatable objective: restore its clear budget so the next
// cycle can be run again.
if objective, ok := state.Lifecycle.Objectives[lukeEnergyControlMapID]; ok {
objective.RemainingHP = objective.MaxHP
objective.State = raid.AntonDungeonClosed
state.Lifecycle.Objectives[lukeEnergyControlMapID] = objective
state.Lifecycle.Revision++
}
setRuntimeRaidLukeSymbolLocked(state, lukeSymbolEnergyControlEffect, 0)
return []runtimeRaidMechanismTimer{
state.armRuntimeRaidMechanismLocked(runtimeRaidMechanismRecovery, lukeEnergyControlMapID, now, recovery, 0),
}
}
// runtimeRaidLukeCloseEnergySaveLocked handles a cleared energy save room: the
// wipe clock is disarmed, the debuff drops and the room reopens after two
// minutes.
func runtimeRaidLukeCloseEnergySaveLocked(state *runtimeRaidState, now time.Time) []runtimeRaidMechanismTimer {
state.cancelRuntimeRaidMechanismLocked(runtimeRaidMechanismActive, lukeEnergySaveMapID)
if objective, ok := state.Lifecycle.Objectives[lukeEnergySaveMapID]; ok {
objective.RemainingHP = objective.MaxHP
objective.State = raid.AntonDungeonClosed
state.Lifecycle.Objectives[lukeEnergySaveMapID] = objective
state.Lifecycle.Revision++
}
setRuntimeRaidLukeSymbolLocked(state, lukeSymbolEnergySaveEffect, 0)
return []runtimeRaidMechanismTimer{
state.armRuntimeRaidMechanismLocked(runtimeRaidMechanismRecovery, lukeEnergySaveMapID, now, lukeEnergySaveRecovery, 0),
}
}
// runtimeRaidLukeCloseEnergySaveRoomLocked hides the room and drops both of its
// timers, which is what the PVF does whenever a throne stage ends.
func runtimeRaidLukeCloseEnergySaveRoomLocked(state *runtimeRaidState) {
setRuntimeRaidLukeDungeonStateLocked(state, lukeEnergySaveMapID, raid.AntonDungeonHide)
setRuntimeRaidLukeSymbolLocked(state, lukeSymbolEnergySaveEffect, 0)
state.cancelRuntimeRaidMechanismLocked(runtimeRaidMechanismActive, lukeEnergySaveMapID)
state.cancelRuntimeRaidMechanismLocked(runtimeRaidMechanismRecovery, lukeEnergySaveMapID)
}
// armRuntimeRaidLukeEnergySaveWindowLocked starts the 150-second wipe clock the
// moment the room opens.
//
// The PVF arms it from one edge only: [ON CHANGE DUNGEON STATE] 5066 with
// [CHECK DUNGEON STATE] 5066 `open`. That edge cannot repeat while the room is
// already open, so an armed clock is never restarted -- squads walking in and
// out of 能源储藏室 must not buy the throne squad another 150 seconds. The guard
// enforces that here instead of trusting every caller to have proven the
// transition. Caller holds raidMu.
func armRuntimeRaidLukeEnergySaveWindowLocked(state *runtimeRaidState, now time.Time) []runtimeRaidMechanismTimer {
key := runtimeRaidMechanismKey{Kind: runtimeRaidMechanismActive, MapID: lukeEnergySaveMapID}
if _, armed := state.Lifecycle.Mechanisms[key]; armed {
return nil
}
setRuntimeRaidLukeSymbolLocked(state, lukeSymbolEnergySaveEffect, lukeEnergySaveEffectActive)
return []runtimeRaidMechanismTimer{
state.armRuntimeRaidMechanismLocked(runtimeRaidMechanismActive, lukeEnergySaveMapID, now, lukeEnergySaveWindow, 0),
}
}
// observeRuntimeRaidLukeEnterMapLocked feeds a committed room transition to
// runtimeRaidLukeEnterMapLocked. It is called for every room move, so it bails
// out before touching raidMu unless the run is a raid gate. Caller holds
// session.dungeon.mu (dungeon.mu -> raidMu is the allowed order); the snapshot is
// published from the gameplay queue because it locks every gate owner's dungeon
// mutex, this session's included.
func (s *Service) observeRuntimeRaidLukeEnterMapLocked(session *gameSession, runtime *runtimeDungeonState, roomMapID int64) {
if s == nil || session == nil || runtime == nil || !runtimeRaidLukeIsThroneBossRoom(roomMapID) {
return
}
binding := runtime.raidDungeon
if binding.RaidKey == 0 || binding.LifecycleToken == 0 || binding.LifecycleToken != runtime.lifecycleToken ||
!runtimeRaidLukeIsThroneMapID(binding.MapID) {
return
}
now := s.gameplayNow()
s.raidMu.Lock()
state := s.runtimeRaidByMemberLocked(session.selectedCharacterID)
var (
opened bool
armed []runtimeRaidMechanismTimer
phase byte
)
if state != nil && state.RaidKey == binding.RaidKey {
opened, armed = runtimeRaidLukeEnterMapLocked(state, binding.MapID, roomMapID, now)
phase = state.Lifecycle.Phase
}
s.raidMu.Unlock()
if !opened {
return
}
s.logGameEvent(session, "game-raid-luke-energy-save-opened",
"char_id", session.selectedCharacterID,
"raid_key", binding.RaidKey,
"throne", binding.MapID,
"room_map_id", roomMapID,
"wipe_clock_armed", len(armed) != 0)
if err := s.scheduleRuntimeRaidMechanismTimers(binding.RaidKey, armed); err != nil {
s.logPacketEvent("game-raid-luke-energy-save-timer-failed",
"raid_key", binding.RaidKey, "map_id", binding.MapID, "error", err)
}
s.scheduleRuntimeRaidLukeProgressPublish(binding.RaidKey, phase, fmt.Sprintf("energy-save-open:%d", binding.MapID))
}
func runtimeRaidLukeIsThroneMapID(mapID int) bool {
return mapID == lukeLightThroneMapID || mapID == lukeDarkThroneMapID
}
// scheduleRuntimeRaidLukeEntryProgressPublish publishes the phase snapshot for an
// entry edge once the caller's locks are gone.
//
// It must not run inline: its caller holds the entering session's dungeon mutex,
// and publishRuntimeRaidObjectiveProgress locks the dungeon mutex of every gate
// owner -- including that same session on an entry edge. Handing it to the
// gameplay queue moves it onto that queue's goroutine, which owns none of those
// locks. A zero delay keeps it on the next tick rather than a timer.
func (s *Service) scheduleRuntimeRaidLukeEntryProgressPublish(
event runtimeRaidDungeonLifecycleEvent,
kind runtimeRaidDungeonLifecycleKind,
) {
if s == nil {
return
}
queue := s.ensureGameplayTimeQueue()
if queue == nil {
return
}
name := fmt.Sprintf("raid-luke-entry-progress:%d:%d:%d:%s",
event.RaidKey, event.CharacterID, event.MapID, kind.String())
if err := queue.ScheduleAfter(name, 0, func(time.Time) {
if err := s.publishRuntimeRaidObjectiveProgress(event.RaidKey, event.Phase); err != nil {
s.logPacketEvent("game-raid-luke-entry-progress-publish-failed",
"raid_key", event.RaidKey, "raid_map_id", event.MapID,
"event", kind.String(), "error", err)
}
}); err != nil {
s.logPacketEvent("game-raid-luke-entry-progress-schedule-failed",
"raid_key", event.RaidKey, "raid_map_id", event.MapID,
"event", kind.String(), "error", err)
}
}
// fireRuntimeRaidLukeMechanismLocked runs one elapsed Luke timer. Caller holds
// raidMu and has already removed the timer from the table.
func fireRuntimeRaidLukeMechanismLocked(state *runtimeRaidState, armed runtimeRaidMechanismTimer, due time.Time) ([]runtimeRaidMechanismTimer, string) {
if state == nil {
return nil, ""
}
switch {
case armed.Kind == runtimeRaidMechanismActive && armed.MapID == lukeBirthSanctumMapID:
// The phase-two opening countdown is display only; nothing fires.
return nil, ""
case armed.Kind == runtimeRaidMechanismRecovery && armed.MapID == lukeEnergyControlMapID:
objective, ok := state.Lifecycle.Objectives[lukeEnergyControlMapID]
if !ok || objective.State != raid.AntonDungeonClosed {
return nil, ""
}
setRuntimeRaidLukeDungeonStateLocked(state, lukeEnergyControlMapID, raid.AntonDungeonOpen)
setRuntimeRaidLukeSymbolLocked(state, lukeSymbolEnergyControlEffect, 1)
return nil, "energy_control_reopened"
case armed.Kind == runtimeRaidMechanismActive && armed.MapID == lukeEnergySaveMapID:
// The clock ran out: the roster is wiped and the room closes.
setRuntimeRaidLukePulseLocked(state, lukeSymbolKillAllCharacter, 1)
setRuntimeRaidLukeSymbolLocked(state, lukeSymbolEnergySaveEffect, lukeEnergySaveEffectFailed)
setRuntimeRaidLukeDungeonStateLocked(state, lukeEnergySaveMapID, raid.AntonDungeonHide)
state.cancelRuntimeRaidMechanismLocked(runtimeRaidMechanismRecovery, lukeEnergySaveMapID)
return nil, "energy_save_expired"
case armed.Kind == runtimeRaidMechanismRecovery && armed.MapID == lukeEnergySaveMapID:
if !setRuntimeRaidLukeDungeonStateLocked(state, lukeEnergySaveMapID, raid.AntonDungeonOpen) {
return nil, ""
}
return armRuntimeRaidLukeEnergySaveWindowLocked(state, due), "energy_save_reopened"
default:
return nil, ""
}
}
// runtimeRaidLukePhaseObjectivesComplete is Luke's [CLEAR PHASE] condition:
// phase one ends when all three sanctums read clear, phase two when 机械王座:暗
// falls.
func runtimeRaidLukePhaseObjectivesComplete(state runtimeRaidState, phase byte) bool {
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:
return remaining(lukeBirthSanctumMapID) == 0 &&
remaining(lukeExtinctionSanctumMapID) == 0 &&
remaining(lukeSpreadSanctumMapID) == 0
case runtimeRaidDungeonPhaseTwo:
return remaining(lukeDarkThroneMapID) == 0
default:
return false
}
}
// runtimeRaidLukeSymbolRows are the Luke graph's symbol rows for the periodic
// phase snapshot. They are durable raid state, not one-shot cutscene pulses, so
// a reconnecting member has to receive them again.
func runtimeRaidLukeSymbolRows(state runtimeRaidState) []raid.RaidSymbolRow {
if state.TailFlag == 1 || runtimeRaidTeamGraphOwner(state) != runtimeRaidOwnerLuke ||
len(state.Lifecycle.LukeSymbols) == 0 {
return nil
}
keys := make([]uint32, 0, len(state.Lifecycle.LukeSymbols))
for key := range state.Lifecycle.LukeSymbols {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
rows := make([]raid.RaidSymbolRow, 0, len(keys))
for _, key := range keys {
rows = append(rows, raid.RaidSymbolRow{Key: key, Value: state.Lifecycle.LukeSymbols[key]})
}
return rows
}