DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

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

No files matched your search

@@ -0,0 +1,760 @@
package dnfbridge
import (
"fmt"
"sort"
"strconv"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/raid"
)
const (
runtimeRaidDungeonPhaseOne byte = 1
runtimeRaidDungeonPhaseTwo byte = 2
)
type runtimeRaidDungeonLifecycleKind byte
const (
runtimeRaidDungeonSelected runtimeRaidDungeonLifecycleKind = iota + 1
runtimeRaidDungeonLoaded
runtimeRaidDungeonCompleted
runtimeRaidDungeonAbandoned
)
type runtimeRaidDungeonBinding struct {
RaidKey uint32
Phase byte
MapID int
LifecycleToken uint64
selectedNotified bool
loadedNotified bool
completedNotified bool
abandonedNotified bool
}
type runtimeRaidDungeonLifecycleEvent struct {
Kind runtimeRaidDungeonLifecycleKind
RaidKey uint32
Phase byte
MapID int
LifecycleToken uint64
CharacterID uint16
}
// runtimeRaidDungeonLifecycleSink is the narrow hand-off to the raid state
// machine. The dungeon owner deliberately does not know any raid packet body,
// reward, timer, or final-cleanup rule.
type runtimeRaidDungeonLifecycleSink interface {
applyRuntimeRaidDungeonLifecycleEvent(runtimeRaidDungeonLifecycleEvent) error
}
type runtimeRaidDungeonBeginner interface {
beginRuntimeRaidDungeon(characterID uint16, mapID int, lifecycleToken uint64) (runtimeRaidDungeonBinding, error)
}
func runtimeRaidUint32MemberKeys(members []uint16) []uint32 {
keys := make([]uint32, 0, len(members))
for _, member := range members {
keys = append(keys, uint32(member))
}
return keys
}
func runtimeRaidDungeonPhaseForMap(mapID int) (byte, bool) {
// 卢克队伍攻坚的门不是一个连续区间:一阶段是 5054..5056,二阶段是 5057..5066
// 外加离得很远的两个祭坛入口 5311/5313。见 runtimeRaidLukeTeamDungeonPhases。
// 非卢克 id 落到下面原来的分支,行为不变。
if phase, ok := runtimeRaidLukeTeamDungeonPhases[mapID]; ok {
return phase, true
}
switch {
case mapID >= 5314 && mapID <= 5322:
return runtimeRaidDungeonPhaseOne, true
case mapID >= 5323 && mapID <= 5334:
return runtimeRaidDungeonPhaseOne, true
case mapID >= 210 && mapID <= 216:
return runtimeRaidDungeonPhaseOne, true
case mapID >= 218 && mapID <= 224:
return runtimeRaidDungeonPhaseTwo, true
// 超时空攻坚战:etc/raid/fiendwar/fiendwar.etc 的 [DUNGEON INDEX] 覆盖
// 4953..4978;PVF 的 dungeon/dungeon.lst 里 4952 与 4979 都不存在,所以这是一段
// 封闭区间。超时空只有一个阶段([PHASE MAX] 1),因此全部落在一阶段。
// 与既有区间(210..224、5314..5334)无交集,不改变任何旧 id 的判定。
case mapID >= 4953 && mapID <= 4978:
return runtimeRaidDungeonPhaseOne, true
default:
return 0, false
}
}
// freezeRuntimeRaidDungeonSelection binds a started, paid raid to exactly one
// committed dungeon run. A normal dungeon, an unstarted raid, or a non-member
// keeps the zero binding and therefore follows the existing dungeon path.
func (s *Service) freezeRuntimeRaidDungeonSelection(session *gameSession, runtime *runtimeDungeonState) error {
if s == nil || session == nil || runtime == nil || runtime.lifecycleToken == 0 {
return nil
}
beginner, ok := any(s).(runtimeRaidDungeonBeginner)
if !ok {
return nil
}
session.dungeon.mu.Lock()
defer session.dungeon.mu.Unlock()
if session.dungeon.runtime != runtime ||
!dungeonRuntimeOwnsCharacter(runtime, session.selectedCharacterID) {
return nil
}
// 超时空:一个小队共用一个区域尝试。如果本成员所在的小队已经有人开了这个区域
// 的局,就直接采用那份绑定而不是再开一份 —— 各开一份会让战场的"区域归属"与
// 双方的副本实例对不上。安东/卢克没有"小队共享区域"这回事,
// runtimeFiendWarPartyBinding 对它们恒返回 false,走的还是原来那一行。
binding, shared := s.runtimeFiendWarPartyBinding(session, runtime)
var err error
if !shared {
binding, err = beginner.beginRuntimeRaidDungeon(
session.selectedCharacterID,
int(runtime.Request.DungeonID),
runtime.lifecycleToken,
)
}
if err != nil {
return err
}
if binding.RaidKey == 0 {
return nil
}
if runtime.raidDungeon.RaidKey != 0 {
if runtime.raidDungeon.RaidKey != binding.RaidKey ||
runtime.raidDungeon.Phase != binding.Phase ||
runtime.raidDungeon.MapID != binding.MapID ||
runtime.raidDungeon.LifecycleToken != binding.LifecycleToken {
return fmt.Errorf("raid dungeon binding changed during active run")
}
return s.notifyRuntimeRaidDungeonLifecycleLocked(session, runtime, runtimeRaidDungeonSelected)
}
if binding.MapID != int(runtime.Request.DungeonID) {
return fmt.Errorf("raid dungeon begin returned mismatched binding")
}
runtime.raidDungeon = binding
if binding.LifecycleToken != runtime.lifecycleToken {
// The squad is already inside this gate and this session joined that run:
// the binding belongs to the member who opened it. Its lifecycle events are
// deliberately not published -- notifyRuntimeRaidDungeonLifecycleLocked
// requires the tokens to match -- so the run is reported once, by its owner,
// and a second member cannot double-count the squad's clear.
return nil
}
if err := s.notifyRuntimeRaidDungeonLifecycleLocked(session, runtime, runtimeRaidDungeonSelected); err != nil {
runtime.raidDungeon = runtimeRaidDungeonBinding{}
return err
}
return nil
}
func (s *Service) notifyRuntimeRaidDungeonLoaded(session *gameSession) error {
if session == nil {
return nil
}
session.dungeon.mu.Lock()
defer session.dungeon.mu.Unlock()
return s.notifyRuntimeRaidDungeonLifecycleLocked(
session,
session.dungeon.runtime,
runtimeRaidDungeonLoaded,
)
}
func (s *Service) notifyRuntimeRaidDungeonCompletedLocked(
session *gameSession,
runtime *runtimeDungeonState,
) error {
return s.notifyRuntimeRaidDungeonLifecycleLocked(session, runtime, runtimeRaidDungeonCompleted)
}
// runtimeRaidCompletedDungeonOwnsNativeSettlement reports the exact hand-off
// where the completed Raid objective has already opened the native result
// movie/card lifecycle. Ordinary op31 must not be sent after this point: the
// current EXE answers state=3 with C2S 659/660 and that protocol owns the Raid
// result, Anton card pool, durable reward, and final Raid cleanup.
//
// A completed phase is read from the objectives, not only from the lifecycle
// status: the phase-completion tail is published off the completing member's own
// call stack (scheduleRuntimeRaidPhaseCompletion, so that its forced returns never
// run under that member's session lock), which means the movie it opens is not yet
// visible at the moment this is asked. Reading the objectives answers the same
// question without depending on that timing -- and it also covers the case the old
// status-only test got wrong: a tail whose movie could not open because a gate was
// still occupied used to fall through to the ordinary op31, i.e. a card screen a
// raid gate never sends.
func (s *Service) runtimeRaidCompletedDungeonOwnsNativeSettlement(
characterID uint16,
binding runtimeRaidDungeonBinding,
) bool {
if s == nil || characterID == 0 || binding.RaidKey == 0 || !binding.completedNotified {
return false
}
state, ok := s.runtimeRaidForMember(characterID)
if !ok ||
state.RaidKey != binding.RaidKey ||
// 超时空与单人攻坚一样自己拥有一笔副本的收尾:它没有安徒恩/卢克那种
// 由阶段推进器统一发布的结算。TailFlag 是单人标志,所以这里要单独
// 放行 FiendWar owner,其余 owner 的判定逐字不变。
(state.TailFlag != 1 && state.RaidMode != runtimeRaidOwnerFiendWar) ||
!state.Started ||
!state.EntryCostPaid ||
state.Lifecycle.Phase != binding.Phase {
return false
}
if state.Lifecycle.Status == runtimeRaidLifecycleAwaitingMovie {
return true
}
return state.Lifecycle.Status == runtimeRaidLifecyclePhaseActive &&
runtimeRaidPhaseObjectivesComplete(state, binding.Phase)
}
func (s *Service) notifyRuntimeRaidDungeonAbandonedLocked(
session *gameSession,
runtime *runtimeDungeonState,
) error {
return s.notifyRuntimeRaidDungeonLifecycleLocked(session, runtime, runtimeRaidDungeonAbandoned)
}
// runtimeRaidGateRowMembers reports the member list one gate's 0x0248 participation
// row must name for the member standing in that gate. Both publishers of such a row
// -- the entry (publishRuntimeRaidDungeonParticipation) and the transfer away
// (publishRuntimeRaidDungeonTransferAwayLocked) -- must agree on it, so it lives here.
//
// WHY the mover's squad byte comes from the LIVE row and not the frozen one: the
// client classifies the row by reading EACH LISTED MEMBER's own 0x024F roster byte
// (0 renders as 单人 -- see runtimeRaidMemberSquad) and matches it against the squad
// the row is drawn for. Filtering live members by a FROZEN squad byte therefore breaks
// the moment the two disagree, and they legitimately can: a member taken out of its
// squad while the raid runs keeps its frozen squad for the phase-clear credit
// (clearRuntimeRaidSquadLabel, `frozen_row=kept_for_phase_credit`) while its live
// 0x024F label becomes 0.
//
// Live 2026-09-30 23:54:54 (raid 1835009, char 22 unassigned, own build, from the
// decrypted packet log): the entry published `mc=0 keys=[]` -- a row naming NOBODY --
// because the mover's frozen squad byte was still 2 while no live member carried
// squad 2 any more. The operator's report is exactly that symptom: 「然后单人(没队伍
// 编制 显示的标志不对 应该显示单)」. A row with an empty member list gives the client
// nothing to classify, so neither the 战况表 cell nor the row's own marker can be drawn
// as 单人.
//
// The guarantee is therefore: the row is never empty. A real squad lists that squad's
// live members; an unassigned (or frozen/live drifted) member lists itself, which is
// exactly the singleton row the client renders as 单人.
func runtimeRaidGateRowMembers(state runtimeRaidState, characterID uint16, frozenGroup byte) []uint16 {
group := runtimeRaidMemberSquad(frozenGroup)
if index := runtimeRaidMemberIndex(state, characterID); index >= 0 {
// The live row is what the 0x024F roster this client holds was built from.
group = runtimeRaidMemberSquad(state.Members[index].GroupIndex)
}
members := runtimeRaidMembersInSquadLiveRows(state, group)
if group == 0 || len(members) == 0 {
return []uint16{characterID}
}
return members
}
// publishRuntimeRaidDungeonTransferAwayLocked drops the squad's row for the gate a
// party has just walked out of on the way into another gate of the same raid.
//
// The warp from 能量阻截战 (218) into an open hatchery deliberately does NOT report
// the 218 attempt as abandoned (see commitDungeonRuntime): the attempt stays valid
// so the squad can re-enter 218 and the shield it has already taken off stays off.
// But the attempt and the client's row are two different things, and skipping the
// abandoned event skipped both. Nothing else removes that row either, so the 战况表
// kept drawing the squad inside 能量阻截战 for the rest of the phase while the
// standby room stayed empty. Re-confirmed by the operator on 2026-09-20 after this
// call was briefly reverted: "战况表不刷新 从孵化出来 还显示在能量里".
//
// Live 2026-09-20, raid 1507329: the only 0x0248 the transfer ever produced were
// {224,op=0}/{224,op=1} at 02:13:33.995Z (and {224,op=0}/{224,op=4} when 224 was
// cleared at 02:14:30.299Z) -- no packet ever named 218, so its row could only go
// when the raid itself ended.
//
// op=0 is exactly this operation for the current EXE: sub_1D438D0's jump table
// (0x1D43E80) maps op=0 to sub_17C0FB0, which removes the listed members from that
// dungeon's row and prints nothing, where op=3 is the SAME call plus the 32050
// 「其中1支战队闯关[%s]地下城失败。」 line -- which would be a lie here, because
// nothing failed. op=1/op=2 go to a different function (sub_17C0F20) that inserts
// instead. 86JP separates them the same way: Runtime.cs:369-403 sends the row
// update on every participation change, and only
// BroadcastRaidParticipationAfterMemberRemovalLockedAsync
// (Situation.cs:563-601) adds the failure line.
//
// Runs while session.dungeon.mu is held, like the abandoned notification it stands
// in for. Only the row travels: no lifecycle flag, no occupancy and no terminal
// record is touched, so the 218 attempt is exactly as valid as it was.
func (s *Service) publishRuntimeRaidDungeonTransferAwayLocked(
session *gameSession,
runtime *runtimeDungeonState,
) error {
if s == nil || session == nil || runtime == nil {
return nil
}
binding := runtime.raidDungeon
if binding.RaidKey == 0 || binding.MapID == 0 {
return nil
}
state, ok := s.runtimeRaidForMember(session.selectedCharacterID)
if !ok || state.RaidKey != binding.RaidKey || state.Lifecycle.Phase != binding.Phase {
return nil
}
member, ok := runtimeRaidFrozenMember(state, session.selectedCharacterID)
if !ok {
return nil
}
// The row names the squad, exactly as the row the entry published did: a real
// squad lists that squad and only that squad, and an unassigned member's row
// lists nobody but itself (see runtimeRaidGateRowMembers).
members := runtimeRaidGateRowMembers(state, session.selectedCharacterID, member.GroupIndex)
body, err := raid.BuildRaidParticipationBody([]raid.RaidParticipationRow{{
DungeonID: uint32(binding.MapID),
Op: 0,
MemberKeys: runtimeRaidUint32MemberKeys(members),
}})
if err != nil {
return err
}
return s.broadcastRuntimeRaidWireSequence(state, runtimeRaidWireNotification{
msgID: raid.RaidParticipationMsgID,
body: body,
})
}
// notifyRuntimeRaidDungeonLifecycleLocked runs while session.dungeon.mu is
// held. A flag is committed only after the optional state-machine sink accepts
// the event, so a failed callback remains retryable and duplicate client
// packets cannot advance the same transition twice.
func (s *Service) notifyRuntimeRaidDungeonLifecycleLocked(
session *gameSession,
runtime *runtimeDungeonState,
kind runtimeRaidDungeonLifecycleKind,
) error {
if s == nil || session == nil || runtime == nil || session.dungeon.runtime != runtime {
return nil
}
binding := &runtime.raidDungeon
if binding.RaidKey == 0 || binding.LifecycleToken == 0 ||
!dungeonRuntimeOwnsCharacter(runtime, session.selectedCharacterID) {
return nil
}
if binding.LifecycleToken != runtime.lifecycleToken {
// The squad is already inside this gate and this session joined that run:
// the binding belongs to the member who opened it, so the squad's run
// *bookkeeping* stays single -- one record, one occupancy, one 战况表 row --
// and the run itself is reported once, by its owner.
//
// The joined session still needs the local clear fact. Live incident
// 2026-09-16 11:14Z: the leader's own client authoritatively cleared gate
// 210, but because this branch returned silently its binding never carried
// completedNotified, so the exit barrier classified the cleared raid gate as
// an ordinary dungeon and blocked 返回城镇 forever with
// op72_cannot_cross_unfinished_card_or_reward_phase (layout_sent=false,
// selection_sent=false, reward_committed=false) on a card screen a raid gate
// never sends. Record the local completion; the global state machine and the
// participation broadcast stay owned by the opener.
if kind != runtimeRaidDungeonCompleted || binding.abandonedNotified {
// Diagnostic only, no behaviour change. This is the FIRST place a
// completed event can be swallowed, and it is specific to a JOINED
// member. Live 2026-09-20 (party volcano, 219): the run owner reached
// game-dungeon-boss-die-check-op115-send and then nothing at all
// followed -- no completed, no settlement entry -- while a SOLO run
// 81 ms later produced both. Without this line the operator cannot
// tell which of the three silent returns ate it.
s.logGameEvent(session, "game-raid-dungeon-lifecycle-skipped-joined",
"char_id", session.selectedCharacterID,
"raid_key", binding.RaidKey,
"raid_phase", binding.Phase,
"raid_map_id", binding.MapID,
"kind", kind.String(),
"binding_lifecycle_token", binding.LifecycleToken,
"local_lifecycle_token", runtime.lifecycleToken,
"abandoned_notified", binding.abandonedNotified,
"completed_notified", binding.completedNotified,
"reason", "joined_member_only_records_completed_and_not_abandoned")
return nil
}
// This session never published the joined binding's "selected" either, so
// record both local facts: the exit barrier reads completedNotified and a
// later duplicate op39/op117 must not try to report the run again.
binding.selectedNotified = true
runtimeRaidDungeonMarkLifecycleNotified(binding, kind)
s.logGameEvent(session, "game-raid-dungeon-lifecycle-joined-clear",
"char_id", session.selectedCharacterID,
"raid_key", binding.RaidKey,
"raid_phase", binding.Phase,
"raid_map_id", binding.MapID,
"binding_lifecycle_token", binding.LifecycleToken,
"local_lifecycle_token", runtime.lifecycleToken,
"dungeon_id", runtime.Dungeon.ID,
"global_event_published", false,
"reason", "joined_squadmate_records_only_its_own_clear")
// The gate itself is not the squad's bookkeeping: this member fought and
// cleared its own instance of it, so the clear spends one point of the
// gate's HP. See applyRuntimeRaidJoinedSquadClear.
s.applyRuntimeRaidJoinedSquadClear(session, *binding, runtime.lifecycleToken)
return nil
}
if runtimeRaidDungeonLifecycleAlreadyNotified(binding, kind) {
// Diagnostic only. Second candidate: the same kind was already reported on
// this binding, which is what a reused run would look like on a gate the
// squad clears repeatedly (219 is the only Anton gate with that shape).
s.logGameEvent(session, "game-raid-dungeon-lifecycle-skipped-already-notified",
"char_id", session.selectedCharacterID,
"raid_key", binding.RaidKey,
"raid_phase", binding.Phase,
"raid_map_id", binding.MapID,
"kind", kind.String(),
"selected_notified", binding.selectedNotified,
"completed_notified", binding.completedNotified,
"abandoned_notified", binding.abandonedNotified,
"reason", "same_kind_already_notified_on_this_binding")
return nil
}
if kind != runtimeRaidDungeonSelected && !binding.selectedNotified {
if err := s.notifyRuntimeRaidDungeonLifecycleLocked(session, runtime, runtimeRaidDungeonSelected); err != nil {
return err
}
}
if (kind == runtimeRaidDungeonCompleted && binding.abandonedNotified) ||
(kind == runtimeRaidDungeonAbandoned && binding.completedNotified) {
// Diagnostic only. Third candidate, and the one whose shape matches the
// live failure most closely: the run owner reports the clear, the binding
// already holds the OPPOSITE terminal fact, and the completed event is
// dropped without a trace -- which is exactly "boss-die-check sent, then
// nothing", with no settlement entry after it and the leader's client
// stuck on completed_settlement_entry_owner_not_ready.
s.logGameEvent(session, "game-raid-dungeon-lifecycle-skipped-abandoned-completed-conflict",
"char_id", session.selectedCharacterID,
"raid_key", binding.RaidKey,
"raid_phase", binding.Phase,
"raid_map_id", binding.MapID,
"kind", kind.String(),
"completed_notified", binding.completedNotified,
"abandoned_notified", binding.abandonedNotified,
"selected_notified", binding.selectedNotified,
"binding_lifecycle_token", binding.LifecycleToken,
"local_lifecycle_token", runtime.lifecycleToken,
"reason", "the_binding_already_holds_the_opposite_terminal_fact")
return nil
}
event := runtimeRaidDungeonLifecycleEvent{
Kind: kind,
RaidKey: binding.RaidKey,
Phase: binding.Phase,
MapID: binding.MapID,
LifecycleToken: binding.LifecycleToken,
CharacterID: session.selectedCharacterID,
}
// 超时空:一个区域是小队共用的,所以"放弃区域"只有在小队里最后一个人也离开
// 之后才算数。队友还在里面打的时候,这里只记本地标志然后返回 —— 战场不该把
// 还有人站着的区域放掉,否则队友会被连同区域一起弹出去。
if kind == runtimeRaidDungeonAbandoned && isCurrentFiendWarRaidChannel(session) && runtime.partyDungeonOwner != nil {
survivorInside := false
for _, survivor := range runtime.partyDungeonOwner.roster() {
if survivor.userID != session.selectedCharacterID {
survivorInside = true
break
}
}
if survivorInside {
runtimeRaidDungeonMarkLifecycleNotified(binding, kind)
return nil
}
}
if sink, ok := any(s).(runtimeRaidDungeonLifecycleSink); ok {
if err := sink.applyRuntimeRaidDungeonLifecycleEvent(event); err != nil {
return err
}
}
if err := s.publishRuntimeRaidDungeonParticipation(event); err != nil {
return err
}
// Entering, abandoning or clearing a raid gate is a scene change for ONE
// member: the member that just moved lost the records for everyone who stayed
// behind, and the members that stayed never had a record for the one inside --
// nor for the rest of the board row that member is drawn in.
//
// Scope history, because both cuts were driven by live reports:
// - it used to sweep every ordered pair (N*(N-1)) even though only the moved
// member's own scene changed, which on a 12-member Anton raid meant 132
// installs per boundary where 22 are stale, and 88% of the `loaded`
// boundary's were byte-identical repeats (measured 2026-09-27, 203.0.113.11);
// - it was cut to the moved member's own pairs (2*(N-1)) and that is what the
// operator then reported: "1队进图了 2队在外面看 看不到是哪队进图 就显示个单
// 队长头像也是黑的" -- the row is resolved through the record of the member
// the row STARTS with, who is typically NOT the one that entered.
// So the scope is now the moved member's board row (its squad, <= 4 members),
// which is what runtimeRaidBoardRowInstallScope returns and what
// TestRuntimeRaidDungeonBoundaryInstallsTheMovedMembersBoardRow pins.
//
// The pairs this hook does not cover stay covered by the hooks that actually
// invalidate them: ordinary co-presence, the channel-wide pass for another
// area of the same channel, raid_mate_actor_after_area_enter, admission, phase
// activation (syncRuntimeRaidMateActorsAll, still a full sweep), and the board
// window's own refresh (ensureRuntimeRaidBoardRowActors).
//
// `selected` is the one boundary where the moving client may still be between
// scenes (its channel-owned scene already gone, the next one not built yet),
// so that boundary defers the install for such a receiver; `loaded` repeats
// the same install once the scene exists. See
// syncRuntimeRaidMateActorsMovedAtSceneBoundary.
if state, ok := s.runtimeRaidForMember(event.CharacterID); ok {
fanoutStarted := time.Now()
s.syncRuntimeRaidMateActorsMovedAtSceneBoundary(
state,
event.CharacterID,
"raid_dungeon_"+kind.String()+"_raid_mate_actors",
kind == runtimeRaidDungeonSelected)
// 团本记录:一次门的场景边界 = 一次演员投影扇出。wall_ms 是这条边界真正的
// 代价(历史上这里是 N*(N-1) 全量扫,现在是 2*(N-1)),participants 是
// 这次扇出覆盖的名单,排查"某个人看不到队友/卡在加载"时最先要看的就是它。
s.recordRaidStateEventLight(session, "gate_"+kind.String(), &state, map[string]any{
"raid_map_id": event.MapID,
"lifecycle_token": event.LifecycleToken,
"fanout_source": "raid_dungeon_" + kind.String() + "_raid_mate_actors",
"fanout_mode": "moved_member_board_row",
"fanout_wall_ms": raidMillis(time.Since(fanoutStarted)),
"defer_while_between_scenes": kind == runtimeRaidDungeonSelected,
"participants": raidMemberIDsFromState(state.FrozenMembers),
})
}
if kind == runtimeRaidDungeonCompleted {
// Anton phase-one mechanisms react to the committed clear first so the
// progress snapshot below already shows the gates and timers it opened.
if err := s.afterRuntimeRaidObjectiveAdvance(event.RaidKey, event.Phase, event.MapID); err != nil {
s.logGameEvent(session, "game-raid-anton-mechanism-arm-failed",
"raid_key", event.RaidKey, "raid_map_id", event.MapID, "error", err)
}
if err := s.publishRuntimeRaidObjectiveProgress(event.RaidKey, event.Phase); err != nil {
return err
}
// The clear registration is part of the roster record, so every member
// needs a fresh member list before its 攻击队 window can show the gate as
// cleared. Without this push the 地下城通关 column stays at 0 for the rest
// of the phase, and the phase settlement then flips no cards.
if state, ok := s.runtimeRaidForMember(event.CharacterID); ok {
if err := s.broadcastRuntimeRaidPhaseClearRegistration(state); err != nil {
return err
}
s.logGameEvent(session, "game-raid-phase-clear-registration",
"raid_key", event.RaidKey,
"raid_phase", event.Phase,
"raid_map_id", event.MapID,
"char_id", event.CharacterID,
"member_clear_counts", formatRuntimeRaidClearCounts(state),
"minimum_clear_count", runtimeRaidPhaseMinimalClearCount(state, event.Phase),
"roster_refreshed", true)
}
}
runtimeRaidDungeonMarkLifecycleNotified(binding, kind)
s.logGameEvent(session, "game-raid-dungeon-lifecycle",
"raid_key", event.RaidKey,
"raid_phase", event.Phase,
"raid_map_id", event.MapID,
"dungeon_lifecycle_token", event.LifecycleToken,
"char_id", event.CharacterID,
"event", kind.String(),
"global_raid_retained", true)
return nil
}
// formatRuntimeRaidClearCounts renders the per-member phase clear registration
// of one raid for the packet log.
func formatRuntimeRaidClearCounts(state runtimeRaidState) string {
if len(state.Lifecycle.PhaseClearCounts) == 0 {
return ""
}
keys := make([]int, 0, len(state.Lifecycle.PhaseClearCounts))
for charID := range state.Lifecycle.PhaseClearCounts {
keys = append(keys, int(charID))
}
sort.Ints(keys)
var builder strings.Builder
for index, key := range keys {
if index > 0 {
builder.WriteByte(',')
}
builder.WriteString(strconv.Itoa(key))
builder.WriteByte(':')
builder.WriteString(strconv.FormatUint(uint64(state.Lifecycle.PhaseClearCounts[uint16(key)]), 10))
}
return builder.String()
}
func (s *Service) publishRuntimeRaidDungeonParticipation(event runtimeRaidDungeonLifecycleEvent) error {
state, ok := s.runtimeRaidForMember(event.CharacterID)
if !ok || state.RaidKey != event.RaidKey || state.Lifecycle.Phase != event.Phase {
return nil
}
// 超时空没有 584 参与行:它的战况表是 1452..1460 那一族,每一条副本生命周期
// 边界的对外表示就是"按当前战场状态重发一遍差异行"。所以这里整条 584 路径
// 直接绕开,改成发面板。
if state.RaidMode == runtimeRaidOwnerFiendWar {
return s.broadcastRuntimeFiendWarBoard(state, false, time.Now().UTC())
}
member, ok := runtimeRaidFrozenMember(state, event.CharacterID)
if !ok {
return nil
}
// 584 carries one row per (dungeon, squad): the client draws the row inside the
// dungeon node with that squad's member portraits, and the squad it belongs to
// comes from each member's own roster byte (op 0x024F, which the client reads
// straight -- an unassigned member's 0 renders as 单人).
//
// The row's CONTENT is therefore what says who is in it: a real squad's row lists
// that squad and only that squad, and an unassigned member's row lists nobody but
// itself. Folding the member onto squad 1 (runtimeRaidNormalizedGroup) is what put
// squad 1's whole roster into the row an unassigned member opened, which is how it
// was drawn as squad 1 in the 战况表 (live report 2026-09-17 21:5x). The row itself
// stays: the member IS inside the dungeon, and the node has to show him.
members := runtimeRaidGateRowMembers(state, event.CharacterID, member.GroupIndex)
wireMapID := event.MapID
if state.TailFlag == 1 {
if selectorMapID, mapped := runtimeRaidSingleSelectorMapIDForState(state, event.MapID); mapped {
wireMapID = selectorMapID
}
}
ops := make([]uint32, 0, 2)
switch event.Kind {
case runtimeRaidDungeonSelected:
// Selection only reserves the raid objective and group occupancy on the
// server. The current EXE's op584 handler (sub_1D438D0) treats the
// participation operations as visible dungeon lifecycle changes; sending
// op=0 from the op16 commit boundary announces entry before FINISH_LOADING,
// then op=1 announces the same entry again after the scene is real. Keep
// the selected lifecycle flag for ordering/idempotency, but do not publish
// participation until the client proves the dungeon scene loaded.
return nil
case runtimeRaidDungeonLoaded:
// Zero first, then one: 86JP RaidHandler.Situation.cs:410-414 removes the
// stale record immediately before every entry ("Remove a stale record
// immediately before every entry"). The current client's op584 handler
// (sub_1D438D0, jump table 0x1D43E80) maps op=0 to sub_17C0FB0, which
// silently drops that dungeon's squad row, and op=1 to sub_17C0F20, which
// inserts a row and pops 32048. Sending only op=1 leaves the previous row in
// place, so re-entering the same gate stacks a second twin row -- the board
// then draws dungeon_party1_%02d and dungeon_party2_%02d for one squad.
ops = append(ops, 0)
ops = append(ops, 1)
case runtimeRaidDungeonAbandoned:
ops = append(ops, 3)
case runtimeRaidDungeonCompleted:
// Every committed clear announces op=4, not just the clear that finishes a
// gate's whole quota. The current EXE's op584 handler (sub_1D438D0) maps
// op=4 to the chat line 32049 "其中1支战队已通关[%s]地下城。" and prints
// nothing for op=0, so the old `visible` whitelist (which also omitted 213,
// 215 and 220 -- the MaxHP=2/5 gates whose first clear leaves RemainingHP>0)
// silently swallowed the first 擎天之柱 clear: live 2026-09-16T14:52:09Z and
// 14:52:36Z, two-member raid 1507329, the squadmate's newest notice stayed
// the older 黑雾之源 line and the 擎天之柱 line only appeared 10s later on the
// second clear. 86JP RaidHandler.Progression.cs:122 sends op=4
// unconditionally for every clear, before any per-dungeon branch.
// op=0 goes with the clear notice, but strictly BEFORE op=4. The client's
// op=0 target (sub_17C0FB0) erases that dungeon's squad row, which is what
// the 黑雾之源 cutscene needs to run; leaving it after op=4 erased the row
// the board had just been told about, so the cell went blank on every
// clear. Dropping op=0 entirely (tried 2026-09-17T07:4xZ) also stopped the
// squadmate's cutscene, which is worse. 86JP sends only op=4
// (RaidHandler.Progression.cs:122), so this ordering is the closest match
// that keeps the observed cutscene.
ops = append(ops, 0)
ops = append(ops, 4)
default:
return nil
}
for _, op := range ops {
body, err := raid.BuildRaidParticipationBody([]raid.RaidParticipationRow{{
DungeonID: uint32(wireMapID),
Op: op,
MemberKeys: runtimeRaidUint32MemberKeys(members),
}})
if err != nil {
return err
}
if err := s.broadcastRuntimeRaidWireSequence(state, runtimeRaidWireNotification{
msgID: raid.RaidParticipationMsgID,
body: body,
}); err != nil {
return err
}
}
// 参战行变一次,行数据跟着变一次。86JP 的时序不变量就是这条:"每一次参战行变更之后
// 都紧跟一条怪物状态"(Situation.cs:550/605,Progression.cs:830/1064/1122),而
// tools\_rev_安徒恩_修复方案.md F2 记的正相反:我们的 634 只在 buff/用币/客户端刷新时
// 才发。代价实测(2026-09-29,raid 1507329):12:25:36 一队进 218 之后,门行数据
// 要等到 12:31:01(另一队进图)才更新——5 分钟里那一行只有旧值/空值。
//
// 排进队列而不是就地广播:本函数在持有着进图会话 dungeon.mu 的情况下运行,而构行
// 数据要读每个成员的会话锁(collectRuntimeRaidGatePlacements),就地扇出就是
// 2026-09-20 08:32:02Z 那次死锁的形状(见
// scheduleRuntimeRaidMonsterStateBroadcast 的注释)。
s.scheduleRuntimeRaidMonsterStateBroadcast(
state.RaidKey, "raid_participation_"+event.Kind.String())
return nil
}
func runtimeRaidDungeonLifecycleAlreadyNotified(
binding *runtimeRaidDungeonBinding,
kind runtimeRaidDungeonLifecycleKind,
) bool {
if binding == nil {
return true
}
switch kind {
case runtimeRaidDungeonSelected:
return binding.selectedNotified
case runtimeRaidDungeonLoaded:
return binding.loadedNotified
case runtimeRaidDungeonCompleted:
return binding.completedNotified
case runtimeRaidDungeonAbandoned:
return binding.abandonedNotified
default:
return true
}
}
func runtimeRaidDungeonMarkLifecycleNotified(
binding *runtimeRaidDungeonBinding,
kind runtimeRaidDungeonLifecycleKind,
) {
if binding == nil {
return
}
switch kind {
case runtimeRaidDungeonSelected:
binding.selectedNotified = true
case runtimeRaidDungeonLoaded:
binding.loadedNotified = true
case runtimeRaidDungeonCompleted:
binding.completedNotified = true
case runtimeRaidDungeonAbandoned:
binding.abandonedNotified = true
}
}
func (kind runtimeRaidDungeonLifecycleKind) String() string {
switch kind {
case runtimeRaidDungeonSelected:
return "selected"
case runtimeRaidDungeonLoaded:
return "loaded"
case runtimeRaidDungeonCompleted:
return "completed"
case runtimeRaidDungeonAbandoned:
return "abandoned"
default:
return "unknown"
}
}