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

730 lines
31 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 (
"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
}