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,729 @@
package dnfbridge
import (
"errors"
"strconv"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/raid"
)
// Keep the later current-client dungeon selector and NPC-purchase call sites
// connected to the PVF-backed raid owner introduced by the extracted source.
var errAntonRaidEntryBlocked = errors.New("anton raid entry requires a started, paid raid roster and an open current-phase objective")
// errRuntimeRaidLukeRouteLocked 自成一个错误,这样"路线被锁"不会被报成通用的
// 编成/目标失败。原生客户端不会在另一条路线的门上画锁,被绑定的成员会一直点它;
// 用共用错误的话日志会写成"需要已开团、已付费的攻坚编成",既是假的也没法定位。
//
// 移植自上游 raid_current_compat.go(bfb2098 / ede0bf5)。
var errRuntimeRaidLukeRouteLocked = errors.New("luke phase two member is bound to the other route")
// currentRaidLukeRouteLockedNoticeText 是客户端自己对这次拒绝的措辞,逐字取自
// 客户端字符串表里卢克攻坚那一段(docs/client-dstr-zh.txt, 67018;67013..67021
// 就是原生 루크 레이드 那一组)。"其他队伍已经入场或者归属于其他地下城" 正好就是
// 路线锁的含义,所以玩家看到的是游戏自己的句子,不是我们编的。
//
// 这条按服务端文本走:提示信封里带的是文本而不是 dstr id,措辞钉在上面那一条
// 才能保持原生。
const currentRaidLukeRouteLockedNoticeText = "其他队伍已经入场或者归属于其他地下城, 目前无法入场。"
func sessionClientPID(session *gameSession) uint32 {
if session == nil {
return 0
}
return session.clientPID
}
func isAntonRaidEntryDungeonID(dungeonID int64) bool {
_, ok := runtimeRaidDungeonPhaseForMap(int(dungeonID))
return ok
}
// The current EXE renders the single-Anton raid with its native raid selector
// slots, but submits the legacy Anton slot IDs (210-224) through the ordinary
// SelectDungeon command. Those values are UI identities only in a started
// single raid: the dungeon runtime, PVF topology and settlement must continue
// to own the dedicated 5314-5322 maps.
func runtimeRaidSingleSelectorMapID(singleRaidType byte, raidMapID int) (int, bool) {
switch raidMapID {
case 5314:
return 210, true // 黑雾之源
case 5315:
return 212, singleRaidType == 3 // 震颤的大地
case 5316:
return 216, singleRaidType == 6 // 舰炮防御战
case 5317:
return 213, singleRaidType == 0 // 擎天之柱
case 5318:
return 218, singleRaidType == 3 // 能量阻截战
case 5321:
return 218, singleRaidType == 6 // 能量阻截战
case 5322:
return 218, singleRaidType == 0 // 能量阻截战
case 5319:
return 219, true // 黑色火山
case 5320:
return 220, true // 安徒恩的心脏
default:
return 0, false
}
}
func runtimeRaidSinglePVFMapID(singleRaidType byte, selectorMapID int) (int, bool) {
for raidMapID := 5314; raidMapID <= 5322; raidMapID++ {
mapped, ok := runtimeRaidSingleSelectorMapID(singleRaidType, raidMapID)
if ok && mapped == selectorMapID {
return raidMapID, true
}
}
return 0, false
}
func runtimeRaidSingleSelectorMapIDForState(state runtimeRaidState, raidMapID int) (int, bool) {
if state.RaidMode == runtimeRaidOwnerLuke {
switch state.SingleRaidType {
case runtimeRaidLukeLightRoute:
return raidMapID, raidMapID >= 5323 && raidMapID <= 5328
case runtimeRaidLukeDarkRoute:
return raidMapID, raidMapID >= 5329 && raidMapID <= 5334
default:
return 0, false
}
}
return runtimeRaidSingleSelectorMapID(state.SingleRaidType, raidMapID)
}
func runtimeRaidSinglePVFMapIDForState(state runtimeRaidState, selectorMapID int) (int, bool) {
if state.RaidMode == runtimeRaidOwnerLuke {
return runtimeRaidSingleSelectorMapIDForState(state, selectorMapID)
}
return runtimeRaidSinglePVFMapID(state.SingleRaidType, selectorMapID)
}
func (s *Service) resolveAntonRaidDungeonSelection(session *gameSession, requestedDungeonID int64) (int64, bool) {
if s == nil || session == nil || runtimeRaidModeForSession(session) == 0 {
return requestedDungeonID, false
}
state, ok := s.runtimeRaidForMember(selectedCharacterID(session))
if !ok || state.TailFlag != 1 {
return requestedDungeonID, false
}
if state.RaidMode == runtimeRaidOwnerLuke {
// Luke's current selector and PVF use the same native 5323..5334
// identities, so no translation is required at this boundary.
return requestedDungeonID, false
}
if requestedDungeonID >= 5314 && requestedDungeonID <= 5322 {
return requestedDungeonID, false
}
raidMapID, ok := runtimeRaidSinglePVFMapID(state.SingleRaidType, int(requestedDungeonID))
if !ok {
return requestedDungeonID, false
}
return int64(raidMapID), true
}
func (s *Service) projectSingleAntonRaidClientDungeonID(session *gameSession, raidMapID int64) (int64, bool) {
if s == nil || session == nil || runtimeRaidModeForSession(session) == 0 {
return raidMapID, false
}
state, ok := s.runtimeRaidForMember(selectedCharacterID(session))
if !ok || state.TailFlag != 1 {
return raidMapID, false
}
selectorMapID, ok := runtimeRaidSingleSelectorMapIDForState(state, int(raidMapID))
if !ok {
return raidMapID, false
}
return int64(selectorMapID), true
}
func (s *Service) validateAntonRaidDungeonEntry(session *gameSession, dungeonID int64) error {
return s.validateAntonRaidDungeonEntryForEntry(session, dungeonID, false)
}
// validateAntonRaidDungeonEntryForEntry 是门禁的完整形式;partyFollower 表示
// "这一次进图是**服务端带着队友走**的"(prepareRuntimePartyDungeonEntry 在准备队友时
// 传的就是非 nil 的 plan),而不是这名成员自己按下的 op16。
//
// 它只对超时空有影响(见下面 FiendWar 分支里那段);安东/卢克逐字不变。
func (s *Service) validateAntonRaidDungeonEntryForEntry(session *gameSession, dungeonID int64, partyFollower bool) error {
if session == nil || runtimeRaidModeForSession(session) == 0 || !isAntonRaidEntryDungeonID(dungeonID) {
return nil
}
phase, _ := runtimeRaidDungeonPhaseForMap(int(dungeonID))
state, ok := s.runtimeRaidForMember(selectedCharacterID(session))
if !ok || !state.Started || !state.EntryCostPaid || len(state.FrozenMembers) == 0 ||
state.Lifecycle.Status != runtimeRaidLifecyclePhaseActive || state.Lifecycle.Phase != phase {
return errAntonRaidEntryBlocked
}
if objective, exists := state.Lifecycle.Objectives[int(dungeonID)]; !exists || objective.Phase != phase || objective.RemainingHP <= 0 || objective.State != raid.AntonDungeonOpen {
return errAntonRaidEntryBlocked
}
if !runtimeRaidAntonEntryAllowedByBarrier(state, int(dungeonID)) {
return errAntonRaidEntryBlocked
}
if _, member := runtimeRaidFrozenMember(state, selectedCharacterID(session)); !member {
return errAntonRaidEntryBlocked
}
// 超时空额外要求:这个区域此刻确实是本成员所在的小队占着的,而且本人不在
// 死亡禁锢的恢复期内。战场只认"小队占区域",所以这条同时实现了 D2 的决定 ——
// 未编队成员 (Seat.Group == 0) 的 MemberArea 落在等待区,永远对不上任何一个
// 区域下标,于是被挡在门外,而不是靠 battle.Enter 事后报错兜住。
//
// 2026-10-01 真机(raid 12058625)赔过一次:这里原来只认**本人**的
// MemberArea,于是"同小队、同一格一起进图"永远进不去第二个人,队伍建起来
// 又被拆掉。链条是:
//
// FiendWarBattle.MemberArea 优先取 MemberAreas[本人],只有本人没有这一项
// 时才回落到 GroupAreas[小队];而 town_move.go 只为**移动的那个人**调
// moveRuntimeFiendWarGroup,那个函数顺手把其他队员的 MemberAreas 钉在他们
// 当时站的格子上(raid_fiendwar_battle.go 的 pin 循环)。
//
// 于是驱动者按下进图 → 小队记进目标区域、驱动者本人也记进目标区域,
// **同伴却还钉在"按下进图时站的那一格"**:MemberArea(同伴) = 原格 ≠ 目标区域
// → 这条把同伴拒掉 → 同伴被移出临时队伍 → 掉到 1 人自动解散 → 驱动者一个人
// 进图。日志原样:
//
// game-party-dungeon-entry-blocked char_id=1 dungeon_id=4955
// reason=party_entry_preflight_failed_before_any_member_commit
// error=prepare party member 22 for dungeon 4955: anton raid entry requires
// a started, paid raid roster and an open current-phase objective
// game-party-disbanded-by-single-member-left char_id=22 party_id=4 removed_id=1
//
// "本小队已经走进这一格"(GroupAreas[小队] == 目标区域)本身就是"这一格是本小队
// 在打"的完整证据 —— 驱动者按下的那一次进图做的正是这件事,同伴按设计就留在
// 原来的格子里等(见 fiendWarEncounterPlan:发起者进 Area,其余人留 OriginArea)。
// 所以本人和本小队任一命中即放行。
//
// 这条放松**只对真正编了队的人生效**:runtimeRaidNormalizedGroup(0) 会把未编队
// 成员折到 runtimeRaidDefaultGroup(1 队),拿它去查 GroupAreas 等于把"1 队在打的
// 区域"送给一个没编制的人 —— 那正是 2026-09-17 真机那次事故的形状,也是 D2 明令
// 要挡掉的东西。所以小队那一侧用 runtimeRaidMemberSquad(0 就是 0),不折。
if state.Lifecycle.FiendWar != nil {
member, _ := runtimeRaidFrozenMember(state, selectedCharacterID(session))
area, found := state.Lifecycle.FiendWar.AreaForDungeon(int(dungeonID))
if !found {
return errAntonRaidEntryBlocked
}
// 复活禁锢与"谁驱动这一场"无关,对谁都必须成立。
if s.gameplayNow().Before(state.Lifecycle.FiendWar.RecoveryUntil[member.CharID]) {
return errAntonRaidEntryBlocked
}
// 上面那条"本人或本小队占着这个区域"只对**自己按 op16 进来**的人成立,
// 对**被服务端带进来的队友**是错的 —— 而且它与规格 D 直接矛盾:
//
// 进图单位 = 区域里实时站着的所有人(≤4),**与小队编制无关**。
//
// 2026-10-01 22:48 真机(raid 12058625,分片 030)就是它挡的:操作员在攻坚队
// 管理器把 char 22 编到了**2 队**(game-raid-manager-regrouped target_group=2,
// game-raid-squad-parties-projected squads=1:2,2:3),两人同格 4,char 1 走进
// 有怪的 7 触发进图:
//
// MemberArea(22) = 4 (他被钉在按下进图时站的格子,按设计就该留在那儿)
// GroupAreas[2] = 4 (他**自己**的小队也在原格 —— 走的不是 char 1 的 1 队)
// 目标区域 = 7
// → errAntonRaidEntryBlocked
// → "prepare party member 22 for dungeon 4955: anton raid entry requires a
// started, paid raid roster and an open current-phase objective"
// → 整队被拆(single-member-left)→ 触发者一个人进去(solo-fallback entered=true)
//
// [B] 那次放松(2026-09-19)只补上了"同伴与**驱动者同队**"这半边,所以两个人
// 不同队时两条仍然全不成立 —— 而"不同队也要一起进"正是这条规格的目的。
//
// 队友这一侧的门禁由别处的、更强的证据负责,这里不再重复问一遍:
// * 他是本团的冻结成员、团已开始并付过入场费、阶段有效、这一格的目标是开着的
// —— 上面几条通用判定;
// * 这一场进图是**驱动者**驱动的那一支临时队伍(遭遇战按区域名单建的),
// 而驱动者自己走的是同一道门禁、并且已经按"人在这一格"放行;
// * 他与驱动者**同城镇、同区域、同实例**
// —— prepareRuntimePartyDungeonEntry 的 currentPartyEntryCoPresence,
// 也就是规格里的"这一格里实时站着的人"。
//
// 所以这里只对 partyFollower 放行**区域**这一条;未编队成员、非本团成员、
// 复活禁锢、目标未开、团未开始等等一条都不放松(见用例里的两个反例)。
if !partyFollower {
memberArea := state.Lifecycle.FiendWar.MemberArea(
member.CharID, runtimeRaidNormalizedGroup(member.GroupIndex))
squadArea := 0
if squad := runtimeRaidMemberSquad(member.GroupIndex); squad != 0 {
squadArea = state.Lifecycle.FiendWar.GroupAreas[squad]
}
if memberArea != area.Index && squadArea != area.Index {
return errAntonRaidEntryBlocked
}
}
}
// 卢克二阶段是两条互斥路线:已经绑到一条的成员不能穿到另一条。
// 安徒恩(owner != Luke)短路,路径逐字不变。
if runtimeRaidTeamGraphOwner(state) == runtimeRaidOwnerLuke &&
!runtimeRaidLukeRouteEntryAllowedLocked(&state, selectedCharacterID(session), int(dungeonID)) {
s.logGameEvent(session, "game-raid-luke-route-entry-refused",
"raid_key", state.RaidKey,
"char_id", selectedCharacterID(session),
"raid_map_id", dungeonID,
"gate_route", runtimeRaidLukeGateRoute(int(dungeonID)),
"member_route", state.Lifecycle.LukeRouteLocks[selectedCharacterID(session)])
return errRuntimeRaidLukeRouteLocked
}
// Luke's [WEEKLY ENTER COUNT] cap. Off by default: runtimeRaidLukeWeeklyRefusal
// short-circuits on the flag before it touches the repositories, so the
// default path is byte-for-byte the old one.
if runtimeRaidTeamGraphOwner(state) == runtimeRaidOwnerLuke {
if used, limit, refuse := s.runtimeRaidLukeWeeklyRefusal(selectedCharacterID(session)); refuse {
s.logGameEvent(session, "game-raid-luke-weekly-entry-refused",
"raid_key", state.RaidKey,
"char_id", selectedCharacterID(session),
"raid_map_id", dungeonID,
"week_used", used,
"week_limit", limit,
"reason", "luke_weekly_enter_count_reached")
return errAntonRaidEntryBlocked
}
}
return nil
}
// A selector refresh replays the persistent raid object, allocation, map and
// timer state. op587 state=2 is the one-shot phase-start announcement and must
// not be replayed when the already-active selector is reopened. No
// participation or dungeon-change event is forged.
func (s *Service) sendRuntimeRaidSelectorSnapshot(session *gameSession, source string) error {
if s == nil || session == nil || runtimeRaidModeForSession(session) == 0 {
return nil
}
state, ok := s.runtimeRaidForMember(selectedCharacterID(session))
if !ok || !state.Started || state.Lifecycle.Status != runtimeRaidLifecyclePhaseActive {
return nil
}
// 超时空的"重开选择器"就是把整张战况表按当前战场状态重放一遍 —— 它没有
// 0x0247 对象也没有 0x0249 表,那两样是安徒恩/卢克的格式。
if state.RaidMode == runtimeRaidOwnerFiendWar {
return s.sendRuntimeFiendWarBoard(session, state, false, time.Now().UTC())
}
objectBody, err := raid.BuildRaidObjectMode0Body(runtimeRaidObjectMode0(state))
if err != nil {
return err
}
if err := s.sendGameUpperRawClassCodec(session, raid.RaidObjectMsgID, objectBody, 0, true); err != nil {
return err
}
// Mode 0 recreates/selects the raid object, but the current EXE owns the
// attack-party allocation panel in mode 3. Reopening the selector with
// only mode 0 clears that panel even though the raid object remains valid.
refreshBody, err := raid.BuildMemberRefreshMode3Body(runtimeRaidMemberRefresh(state))
if err != nil {
return err
}
if err := s.sendGameUpperRawClassCodec(session, raid.RaidMemberRefreshMsgID, refreshBody, 0, true); err != nil {
return err
}
symbolBody, dungeonBody, _, err := runtimeRaidPhaseSnapshotBodies(state)
if err != nil {
return err
}
if err := s.sendGameUpperRawClassCodec(session, raid.RaidSymbolMsgID, symbolBody, 0, true); err != nil {
return err
}
if err := s.sendGameUpperRawClassCodec(session, raid.RaidDungeonStateMsgID, dungeonBody, 0, true); err != nil {
return err
}
now := time.Now().UTC()
remaining := runtimeRaidDurationSeconds(runtimeRaidPhaseSnapshotRemaining(state, now))
duration := state.Lifecycle.PhaseDeadline.Sub(state.Lifecycle.PhaseStartedAt)
if duration < 0 {
duration = 0
}
if err := s.sendGameUpperRawClassCodec(session, raid.RaidTimerMsgID, raid.BuildRaidTimerBody(raid.RaidTimer{
TimerType: 0,
Value0: runtimeRaidTopTimerKey,
Value1: uint32(state.Lifecycle.PhaseDeadline.Unix()),
Value2: runtimeRaidDurationSeconds(duration),
}), 0, true); err != nil {
return err
}
if err := s.sendGameUpperRawClassCodec(session, raid.RaidRemainTimeMsgID, raid.BuildRaidRemainTimeBody(raid.RaidRemainTime{
TimerType: 0,
Seconds: remaining,
}), 0, true); err != nil {
return err
}
s.logGameEvent(session, "game-raid-selector-snapshot-sent",
"source", source,
"raid_key", state.RaidKey,
"phase", state.Lifecycle.Phase,
"remaining_seconds", remaining,
"reason", "persistent_raid_selector_state_replayed_without_one_shot_state2_or_fake_dungeon_entry")
return nil
}
func (s *Service) sendRuntimeRaidDungeonParticipationOnFinishLoading(session *gameSession) error {
if err := s.notifyRuntimeRaidDungeonLoaded(session); err != nil {
return err
}
// The gate is now real and loaded, which is the boundary Luke's weekly count
// is observed at (raid_luke_weekly_observation.go). Observation only: it
// never refuses, and every failure path inside it is silent, so it cannot
// change whether a member enters.
s.observeRuntimeRaidLukeWeeklyEntry(session, "raid_dungeon_loaded_after_finish_loading")
// 超时空:场景真正加载完是 PVF [ON ENTER MAP] 的触发点 —— 雷达与电池的
// 过场动画从这里开始。它自己按 FiendWar 频道与战场状态守门,
// 安东/卢克调用它直接返回 nil。
if err := s.notifyRuntimeFiendWarMapLoaded(session); err != nil {
return err
}
return s.replayRuntimeRaidStateForRebuiltScene(session, "raid_dungeon_loaded_after_finish_loading")
}
// replayRuntimeRaidStateForRebuiltScene re-sends the authoritative raid tables a
// client needs after ITS OWN scene was rebuilt: the 0x024F membership roster (the
// squad byte per member) and the 0x025B participation rows (which squad stands in
// which gate).
//
// Both are pushed only when they CHANGE, and a client that was mid-transition at
// that moment drops them -- live 2026-09-29: `game-raid-peer-actor-deferred …
// reason=scene_between_scenes local_owner=0` is the same gap on the actor path.
// The 战况表 then has a row whose members it can no longer classify (the operator's
// "看不到是哪队进图 就显示个单 队长头像也是黑的"), and nothing ever repairs it,
// because the members are inside a gate and no party gesture can re-label them.
//
// Replaying on finish-loading is the cheapest self-heal: that is exactly when the
// client has just proved its scene exists again, and it makes a missed push
// recoverable instead of permanent.
//
// The participation half of that replay is now gated per client (see
// replayRuntimeRaidParticipationRows): it re-states only when the client has not
// already been told this same (raid, phase, gate-row-set), because op=1 pops 32048
// and finish-loading fires once per ROOM inside a gate. The roster half below keeps
// running unconditionally -- 0x024F prints nothing, and its squad byte is half of
// what makes a board row resolvable at all.
func (s *Service) replayRuntimeRaidStateForRebuiltScene(session *gameSession, source string) error {
if s == nil || session == nil || session.selectedCharacterID == 0 {
return nil
}
state, ok := s.runtimeRaidForMember(session.selectedCharacterID)
if !ok || !state.Started || len(state.Members) == 0 {
return nil
}
if err := s.broadcastRuntimeRaidRefresh(state); err != nil {
s.logGameEvent(session, "game-raid-scene-replay-refresh-failed",
"source", source,
"raid_key", state.RaidKey,
"error", err)
return nil
}
if err := s.replayRuntimeRaidParticipationRows(state, source); err != nil {
s.logGameEvent(session, "game-raid-scene-replay-participation-failed",
"source", source,
"raid_key", state.RaidKey,
"error", err)
return nil
}
s.logGameEvent(session, "game-raid-scene-replay-sent",
"source", source,
"raid_key", state.RaidKey,
"raid_phase", state.Lifecycle.Phase,
"member_count", len(state.Members),
"reason", "scene_was_rebuilt_so_the_roster_is_replayed_and_the_gate_rows_are_replayed_when_they_changed")
return nil
}
// currentRaidParticipationReplayRow is one (gate, squad) row the rebuilt-scene
// replay re-states: the same "who stands in which gate" pair the entry published.
type currentRaidParticipationReplayRow struct {
gate uint32
members []uint16
}
// currentRaidParticipationReplayLedger identifies the exact participation row set
// one client was last re-stated with, so the replay can tell "this client's board
// already says this" from "this client's board has to be told". It is comparable,
// which is all the idempotency check needs.
//
// phase is part of the identity on purpose: a phase change (587 / 0x024B) makes the
// client drop and re-draw its row table, so a ledger that only looked at the row
// bytes could call the re-statement redundant right after it. raidKey is there for
// the same reason on the raid boundary -- the next raid must be replayed even if
// its rows happen to encode identically.
type currentRaidParticipationReplayLedger struct {
raidKey uint32
phase byte
rows string
}
// runtimeRaidParticipationReplayRows collects the (gate, squad) rows the
// rebuilt-scene replay would state right now, read from the live run bindings
// only. An empty result means no squad of this raid is standing in a gate.
func (s *Service) runtimeRaidParticipationReplayRows(state runtimeRaidState) []currentRaidParticipationReplayRow {
if s == nil {
return nil
}
rows := make([]currentRaidParticipationReplayRow, 0, runtimeRaidMaxGroups)
for _, group := range runtimeRaidPartyGroups(state) {
if len(group) == 0 {
continue
}
members := runtimeRaidMembersInSquadLiveRows(state, runtimeRaidMemberSquad(group[0].GroupIndex))
if len(members) == 0 {
continue
}
placement, placed := s.runtimeRaidSquadGatePlacement(group)
if !placed {
continue
}
rows = append(rows, currentRaidParticipationReplayRow{
gate: uint32(placement),
members: members,
})
}
return rows
}
// currentRaidParticipationReplayRowsKey renders the row set as one canonical,
// comparable string. Squads arrive in squad order (runtimeRaidPartyGroups) and
// members in member order (runtimeRaidMembersInSquadLiveRows), so the same board
// state always renders the same key. It is also what the log prints, which is why
// it stays readable instead of a hash.
func currentRaidParticipationReplayRowsKey(rows []currentRaidParticipationReplayRow) string {
if len(rows) == 0 {
return ""
}
var builder strings.Builder
for _, row := range rows {
builder.WriteString(strconv.FormatUint(uint64(row.gate), 10))
builder.WriteByte(':')
for index, member := range row.members {
if index > 0 {
builder.WriteByte(',')
}
builder.WriteString(strconv.FormatUint(uint64(member), 10))
}
builder.WriteByte(';')
}
return builder.String()
}
// currentRaidParticipationReplayMatches reports whether this client's 战况表 was
// already brought to exactly this row set. Nothing else writes the ledger: the
// entry publish does NOT, because a push that went out while this client was
// between scenes is precisely the one it can have dropped -- only the replay runs
// after the client proved its scene exists again, so only the replay may claim the
// client has the rows.
func (session *gameSession) currentRaidParticipationReplayMatches(ledger currentRaidParticipationReplayLedger) bool {
if session == nil {
return false
}
session.raidParticipationReplayMu.Lock()
defer session.raidParticipationReplayMu.Unlock()
return session.raidParticipationReplay == ledger
}
func (session *gameSession) rememberCurrentRaidParticipationReplay(ledger currentRaidParticipationReplayLedger) {
if session == nil {
return
}
session.raidParticipationReplayMu.Lock()
defer session.raidParticipationReplayMu.Unlock()
session.raidParticipationReplay = ledger
}
// replayRuntimeRaidParticipationRows re-states, for every squad that is standing
// in a gate right now, the same op=0/op=2 row pair the entry published -- SILENTLY.
// It never invents a row: it reads the live run bindings.
//
// It is PER RECIPIENT IDEMPOTENT, and that is the whole point of the ledger. The
// client's op=0 branch (sub_17C0FB0) deletes that dungeon's squad row silently, and
// op=2 (sub_17C0F20) inserts it -- also without text. This replay runs on every
// finish-loading, and inside a gate the client finishes loading once per ROOM, so
// 服主 saw the announcement once per room (2026-09-29, 卢克/安徒恩). The row set is
// the right identity to dedupe on: changing rooms inside one gate does not change it
// (nothing to re-state), while entering or leaving a gate does -- and that boundary
// is exactly where the client can drop the row, because its scene is being rebuilt
// when the entry publish goes out.
//
// The op pair is 0,2 and NOT 0,1: op=1 is the one branch that also pops 32048
// 「其中1支战队已进入[%s]地下城。」, and this replay is a re-statement, not a second
// announcement -- the entry publish already made the announcement. See the op table
// at the send site for the disassembly and the packet-log measurement.
//
// A client whose ledger is stale keeps the heal: a reconnecting or newly joined
// session has no ledger at all, and any change of gate re-arms every member's.
func (s *Service) replayRuntimeRaidParticipationRows(state runtimeRaidState, source string) error {
recipients := append([]runtimeRaidMemberState(nil), state.FrozenMembers...)
if len(recipients) == 0 {
return nil
}
runtimeRaidSortMembers(recipients)
rows := s.runtimeRaidParticipationReplayRows(state)
if len(rows) == 0 {
// Nobody of this raid is standing in a gate, so the board these clients hold
// carries no row at all. Forget the ledger instead of keeping the last one:
// the op=0/op=3/op=4 that emptied those rows are what removed them from the
// client, and the squad that walks into a gate later has to be replayed even
// if it walks into the same gate it left.
forgotten := 0
for _, member := range recipients {
session, online := s.onlineGameSession(member.CharID)
if !online || session == nil {
continue
}
session.rememberCurrentRaidParticipationReplay(currentRaidParticipationReplayLedger{})
forgotten++
}
s.logPacketEvent("game-raid-participation-replay-skipped",
"raid_key", state.RaidKey,
"raid_phase", int(state.Lifecycle.Phase),
"gate_rows", 0,
"recipients", forgotten,
"source", source,
"reason", "no_squad_of_this_raid_is_standing_in_a_gate")
return nil
}
ledger := currentRaidParticipationReplayLedger{
raidKey: state.RaidKey,
phase: state.Lifecycle.Phase,
rows: currentRaidParticipationReplayRowsKey(rows),
}
packets := 0
recipientsSent, recipientsSkipped := 0, 0
for _, member := range recipients {
session, online := s.onlineGameSession(member.CharID)
if !online || session == nil {
continue
}
if session.currentRaidParticipationReplayMatches(ledger) {
recipientsSkipped++
continue
}
for _, row := range rows {
// 0 then 2, NOT 0 then 1.
//
// The op table of the 584 handler sub_1D438D0 (jump table 0x1D43E80, dumped
// instruction by instruction in tools\_sit_584_tail.txt) is:
//
// op=0 -> 0x01D43BEA: call sub_17C0FB0(manager, dungeonID), jmp 0x1D43D00 -- no text
// op=1 -> 0x01D43B7A: mov esi,0x7D30; call sub_17C0F20; jmp 0x1D43C25 -- prints 32048
// op=2 -> 0x01D43B44: call sub_17C0F20; jmp 0x1D43D00 -- NO text
//
// The op=2 branch is the op=1 branch minus `esi` and minus the print tail: same
// argument setup (the member vector built by 0x47DA30, the dungeonID, the raid
// manager) and the same sub_17C0F20 roster insert. So op=2 is "re-state the row
// silently", which is exactly what a replay is -- and it is what the reference
// server sends: 86JP RaidHandler.Situation.cs:443-462 refeshes with {0,2}.
//
// Using op=1 here made every gate entry announce itself a SECOND time. The entry
// publish (raid_dungeon_lifecycle.go, runtimeRaidDungeonLoaded: ops 0,1) already
// popped 32048 「其中1支战队已进入[%s]地下城。」 for the members that received it,
// and this replay deliberately does not trust that publish -- the ledger is only
// written here (see the file header) -- so the first finish-loading inside a gate
// always replayed. Measured on the decrypted packet log, raid 1835009,
// 2026-09-30 23:54:31 (entering 5060 智慧之祭坛), conn game-000218:
//
// pkt 3995 op=0 / 3996 op=1 <- entry publish
// pkt 4000 op=0 / 4001 op=1 <- this replay => 32048 twice
//
// and that is the operator's report 「有时候进图 这个提示会刷两条」 -- "sometimes",
// because a client whose scene is still being rebuilt can drop the first pair.
// op=2 keeps the self-heal (the roster row still lands) and drops the duplicate.
for _, op := range []uint32{0, 2} {
body, err := raid.BuildRaidParticipationBody([]raid.RaidParticipationRow{{
DungeonID: row.gate,
Op: op,
MemberKeys: runtimeRaidUint32MemberKeys(row.members),
}})
if err != nil {
return err
}
if err := s.sendGameUpperRawClassCodec(
session,
raid.RaidParticipationMsgID,
body,
0,
true,
); err != nil {
return err
}
packets++
}
}
// The ledger is committed only after the whole row set went out: a send that
// failed halfway must be retried by the next finish-loading, not remembered
// as delivered.
session.rememberCurrentRaidParticipationReplay(ledger)
recipientsSent++
}
if recipientsSent > 0 {
s.logPacketEvent("game-raid-participation-replay-sent",
"raid_key", state.RaidKey,
"raid_phase", int(state.Lifecycle.Phase),
"rows", packets,
"gate_rows", len(rows),
"recipients", recipientsSent,
"skipped_recipients", recipientsSkipped,
"source", source,
"reason", "rebuilt_scene_needs_the_current_gate_rows_again")
return nil
}
if recipientsSkipped > 0 {
s.logPacketEvent("game-raid-participation-replay-skipped",
"raid_key", state.RaidKey,
"raid_phase", int(state.Lifecycle.Phase),
"gate_rows", len(rows),
"gate_rows_key", ledger.rows,
"recipients", recipientsSkipped,
"source", source,
"reason", "the_scene_rebuild_is_inside_the_gate_these_rows_already_describe")
}
return nil
}
// runtimeRaidSquadGatePlacement reports the gate one squad currently stands in, or
// 0 when none of its members holds a live raid run.
func (s *Service) runtimeRaidSquadGatePlacement(group []runtimeRaidMemberState) (int, bool) {
if s == nil || len(group) == 0 {
return 0, false
}
for _, member := range group {
session, online := s.onlineGameSession(member.CharID)
if !online || session == nil {
continue
}
session.dungeon.mu.Lock()
runtime := session.dungeon.runtime
inside := runtime != nil && dungeonRuntimeOwnsCharacter(runtime, member.CharID) &&
runtime.raidDungeon.RaidKey != 0
mapID := 0
if inside {
mapID = runtime.raidDungeon.MapID
}
session.dungeon.mu.Unlock()
if inside && mapID != 0 {
return mapID, true
}
}
return 0, false
}
func (s *Service) refreshRuntimeRaidEntryCostAfterPurchase(session *gameSession, itemID uint32) error {
if s == nil || session == nil {
return nil
}
state, ok := s.runtimeRaidForMember(selectedCharacterID(session))
if !ok || state.Started {
return nil
}
entryItemID, _ := runtimeRaidEntryCostForState(state)
if itemID != entryItemID {
return nil
}
if err := s.sendRuntimeRaidEntryCostSnapshot(session, state); err != nil {
return err
}
s.logGameEvent(session, "game-raid-entry-cost-refreshed-after-purchase",
"raid_key", state.RaidKey,
"item_id", itemID,
"member_count", len(state.Members))
return nil
}