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