Files
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

761 lines
33 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
}
}