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