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