Files
90CNServer/server/internal/services/dnfbridge/raid_fiendwar_timer_channel_test.go
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

433 lines
19 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 (
"encoding/binary"
"os"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/raid"
platformpvf "longheng.io/server/internal/platform/pvf"
)
// 超时空的计时显示必须随"人在不在副本里"切换,而客户端把它分成**两个互不相通的
// 存储**(证据:tools/攻坚队两个倒计时_根因与改法_20260916.md:12-25、:80-97):
//
// A 0x0254(596) → map<pair<timerType, value0>, {start,end}>,value0 = **地牢 ID**
// (就是 0x0249/585 那一行里的 DungeonID)。客户端的门渲染器按门状态查它:
// 状态 0 → 查 {timerType 1, dungeonID};状态 1|3 → 查 {timerType 2, dungeonID};
// 状态 2 什么都不画。
// B 0x0255(597) → map<timerType, endMs>,**只看 timerType**,是攻坚剩余时间。
//
// 另有一条按**区域**给战况表格子画的钟:op1458 的 kind 12 行,
// `{u8 area, u8 kind=12, u32 deadline, u32 limit}`(客户端 0x9B19E0 把
// `start = A - B` 落到 AreaRecord+0x4C/0x50/0x54,见
// tools/_tmp_logs/re_badge/FINDINGS2.md:40-59)。
//
// 线上实测(packet_log-20261002-018,raid 12058625,本地 15:33–15:40):
//
// 597 每个 tick 都发,body `00 7d0d0000` = timerType 0 / 3453 秒 —— 攻坚倒计时**在发**;
// 1458 每个 tick 都发 901 字节 = 90 行(30 区域 × {0,2,12})—— 但**只有被占的那一格**
// kind 12 带真 deadline,其余 29 格全是 `{area,12,0,0}`;
// 596 **一条都没发**(全场 msg_id=596 只有 26 条,全在别的路径)——
// 通用快照每条路径都是 0x0254 紧跟 0x0255 成对发,超时空的三条"战况表替换"
// 分支都直接 return 了,把 0x0254 那一半整个漏掉。
//
// 于是:进副本后客户端查存储 A 查不到这一格 → **该副本的倒计时根本不画**;而空闲格
// 每秒被 30 条 `{area,12,0,0}` 断言"这一格的钟是 0"。
//
// 本用例按**客户端视角**判定,三者都要成立:
//
// 进副本前 —— 没有任何区域的 kind 12 钟,也没有任何门倒计时包;597 带攻坚剩余时间;
// 进副本后 —— 被占那一格 kind 12 带真 deadline,且恰好一条 0x0254 指向它的地牢 ID;
// 通关之后 —— kind 12 补一条 `{area,12,0,0}` 把钟撤掉,之后不再发;597 继续在。
func TestFiendWarBoardSwitchesBetweenTheRaidClockAndTheAreaCountdown(t *testing.T) {
path := os.Getenv("DNF_FIENDWAR_REAL_PVF")
if path == "" {
t.Skip("DNF_FIENDWAR_REAL_PVF not set")
}
archive, err := platformpvf.Open(path)
if err != nil {
t.Fatalf("open real Script.pvf: %v", err)
}
config, err := raid.LoadFiendWarConfig(archive)
if err != nil {
t.Fatalf("load Fiend War config: %v", err)
}
battle, err := raid.NewFiendWarBattle(config.Battle)
if err != nil {
t.Fatalf("battle: %v", err)
}
// 区域 7 / 副本 4955 是 [DUNGEON LIMIT TIME] 210 的普通区域(PVF 实证)。
area, ok := battle.AreaForDungeon(fiendWarTimerTestDungeon)
if !ok || area.Index != fiendWarTimerTestArea {
t.Fatalf("夹具前提不成立:副本 %d 落在区域 %d (found=%t), want 区域 %d",
fiendWarTimerTestDungeon, area.Index, ok, fiendWarTimerTestArea)
}
if area.LimitSeconds != fixtureFreeAreaLimitSeconds {
t.Fatalf("区域 %d 的 [DUNGEON LIMIT TIME] = %d, want %d",
area.Index, area.LimitSeconds, fixtureFreeAreaLimitSeconds)
}
// 真机上"这一格能不能进"看它有没有怪:先放一只,再把 1 队走进去。
battle.Areas[area.Index] = raid.FiendWarAreaState{Enemy: raid.FiendWarUnit{ID: 1, Type: 1}}
battle.GroupAreas[1] = area.Index
const (
raidKey = uint32(0x00c80451)
raidPhaseLength = 3600 * time.Second
)
now := time.Now().UTC().Truncate(time.Second)
state := runtimeRaidState{
RaidKey: raidKey,
RaidMode: runtimeRaidOwnerFiendWar,
Started: true,
EntryCostPaid: true,
Members: []runtimeRaidMemberState{{CharID: 1, GroupIndex: 1, SlotOrder: 0}},
FrozenMembers: []runtimeRaidMemberState{{CharID: 1, GroupIndex: 1, SlotOrder: 0}},
Lifecycle: runtimeRaidLifecycleState{
Status: runtimeRaidLifecyclePhaseActive,
Phase: runtimeRaidDungeonPhaseOne,
PhaseStartedAt: now,
PhaseDeadline: now.Add(raidPhaseLength),
Objectives: map[int]runtimeRaidDungeonObjective{},
FiendWar: battle,
},
}
for id := range battle.Config.Areas {
if dungeon := battle.Config.Areas[id].Dungeon; dungeon > 0 {
state.Lifecycle.Objectives[dungeon] = runtimeRaidDungeonObjective{
Phase: runtimeRaidDungeonPhaseOne, MapID: dungeon,
State: raid.AntonDungeonHide, MaxHP: 1,
}
}
}
syncRuntimeFiendWarObjectives(&state)
// --- 进副本前 -------------------------------------------------------------
before, err := runtimeFiendWarBoardPackets(state, runtimeRaidState{}, fiendWarTimerTestViewer, now)
if err != nil {
t.Fatalf("board packets (before entering): %v", err)
}
if rows := fixtureKind12Rows(t, before); len(rows) != 0 {
t.Fatalf("还没进图就有 %d 条 kind 12 钟(%+v):空闲格不得断言「这一格的副本倒计时是 0」,"+
"否则客户端按 0 画,0x0255 的攻坚倒计时切不回来", len(rows), rows)
}
if got := fixtureRaidTimerBodies(t, before); len(got) != 0 {
t.Fatalf("还没进图就发了 %d 条门倒计时 0x0254:%+v", len(got), got)
}
if remaining, ok := fixtureRaidRemainSeconds(t, before); !ok || remaining != uint32(raidPhaseLength/time.Second) {
t.Fatalf("进副本前的 0x0255 = %d 秒 (found=%t), want %d:攻坚倒计时必须一直在",
remaining, ok, raidPhaseLength/time.Second)
}
// 进图前的快照 = "客户端上一帧看到的东西"。它必须在 Enter **之前**取,否则
// deadline 的变化被自己吃掉,0x0254 与 kind 12 都不会发。
previous := cloneRuntimeRaidState(state)
// --- 进副本 ---------------------------------------------------------------
if err := battle.Enter(1, fiendWarTimerTestDungeon, now); err != nil {
t.Fatalf("enter dungeon %d: %v", fiendWarTimerTestDungeon, err)
}
syncRuntimeFiendWarObjectives(&state)
// 真机进图时逐人会写一笔账本(game-fiendwar-member-area-marked);Enter 自己不写,
// 所以这里按生产形状补上。
battle.MemberAreas[fiendWarTimerTestViewer] = area.Index
deadline := battle.Areas[area.Index].Deadline
if deadline.IsZero() {
t.Fatal("Enter 之后这一格没有 deadline:后面的断言全无意义")
}
inside, err := runtimeFiendWarBoardPackets(state, previous, fiendWarTimerTestViewer, now)
if err != nil {
t.Fatalf("board packets (inside): %v", err)
}
rows := fixtureKind12Rows(t, inside)
if len(rows) != 1 || rows[0].area != fiendWarTimerTestArea {
t.Fatalf("进图后的 kind 12 行 = %+v, want 恰好一条、区域 %d", rows, fiendWarTimerTestArea)
}
if rows[0].deadline != uint32(deadline.Unix()) || rows[0].duration != fixtureFreeAreaLimitSeconds {
t.Fatalf("进图后区域 %d 的 kind 12 = {deadline %d, duration %d}, want {%d, %d}",
fiendWarTimerTestArea, rows[0].deadline, rows[0].duration,
uint32(deadline.Unix()), fixtureFreeAreaLimitSeconds)
}
timers := fixtureRaidTimerBodies(t, inside)
if len(timers) != 1 {
t.Fatalf("进图后的门倒计时 0x0254 = %+v, want 恰好一条(通用路径每一条都是 0x0254 紧跟 0x0255)",
timers)
}
if timers[0].timerType != 1 || timers[0].value0 != fiendWarTimerTestDungeon ||
timers[0].value1 != uint32(deadline.Unix()) || timers[0].value2 != fixtureFreeAreaLimitSeconds {
t.Fatalf("门倒计时 = %+v, want {type 1, value0 %d(地牢ID), value1 %d, value2 %d}",
timers[0], fiendWarTimerTestDungeon, uint32(deadline.Unix()), fixtureFreeAreaLimitSeconds)
}
// 进图后 HUD 上那个「剩余时间」必须是**这一格**的倒计时,不是整个攻坚的。
// 真机 2026-10-02 17:35 的截图就是这条没生效:进图后仍然写着 00:59:39。
if remaining, ok := fixtureRaidRemainSeconds(t, inside); !ok || remaining != fixtureFreeAreaLimitSeconds {
t.Fatalf("进图后 0x0255 = %d 秒 (found=%t), want %d(这一格的 [DUNGEON LIMIT TIME])—— "+
"HUD 的「剩余时间」读的是 0x0255 的存储 B,存储 A(0x0254) 画的是战况表上的按钮,"+
"玩家在副本里时那张表是关着的,所以补 0x0254 照不到这个显示",
remaining, ok, fixtureFreeAreaLimitSeconds)
}
// 同一时刻、**没进图**的那一位必须还是攻坚倒计时:切换是按收件人算的。
otherParty, err := runtimeFiendWarBoardPackets(state, previous, fiendWarTimerTestOtherParty, now)
if err != nil {
t.Fatalf("board packets (other party): %v", err)
}
if remaining, ok := fixtureRaidRemainSeconds(t, otherParty); !ok || remaining != uint32(raidPhaseLength/time.Second) {
t.Fatalf("没进图的队友 0x0255 = %d 秒 (found=%t), want %d:切换必须是按收件人的,不能全队一起改",
remaining, ok, raidPhaseLength/time.Second)
}
// 账本区域滞后时("走不动"那轮的病根)也必须能切:拿掉 MemberAreas 之后要靠
// 小队的区域兜住,否则这种局面下 HUD 又回到攻坚倒计时。
delete(battle.MemberAreas, fiendWarTimerTestViewer)
fallback, err := runtimeFiendWarBoardPackets(state, previous, fiendWarTimerTestViewer, now)
if err != nil {
t.Fatalf("board packets (ledger cleared): %v", err)
}
if remaining, ok := fixtureRaidRemainSeconds(t, fallback); !ok || remaining != fixtureFreeAreaLimitSeconds {
t.Fatalf("账本区域缺失时 0x0255 = %d 秒 (found=%t), want %d:还得靠小队的区域兜住",
remaining, ok, fixtureFreeAreaLimitSeconds)
}
battle.MemberAreas[fiendWarTimerTestViewer] = area.Index
// --- 下一个 tick(状态没变) ----------------------------------------------
previousTicks := cloneRuntimeRaidState(state)
ticks, err := runtimeFiendWarBoardPackets(state, previousTicks, fiendWarTimerTestViewer, now.Add(time.Second))
if err != nil {
t.Fatalf("board packets (tick): %v", err)
}
if rows := fixtureKind12Rows(t, ticks); len(rows) != 1 {
t.Fatalf("心跳里区域 %d 的 kind 12 行 = %+v, want 仍然一条(钟要每个 tick 重申)",
fiendWarTimerTestArea, rows)
}
if got := fixtureRaidTimerBodies(t, ticks); len(got) != 0 {
t.Fatalf("心跳里重复发了 %d 条 0x0254:%+v —— 若本频道的分派走「相对」分支会把倒计时钉在满值",
len(got), got)
}
// 心跳里副本钟要跟着走:deadline 没变、now 前进 1 秒 → 少 1 秒。
if remaining, ok := fixtureRaidRemainSeconds(t, ticks); !ok || remaining != fixtureFreeAreaLimitSeconds-1 {
t.Fatalf("心跳里 0x0255 = %d 秒 (found=%t), want %d(副本钟要一秒一秒地走)",
remaining, ok, fixtureFreeAreaLimitSeconds-1)
}
// --- 通关 -----------------------------------------------------------------
previousCleared := cloneRuntimeRaidState(state)
if err := battle.Clear(fiendWarTimerTestDungeon, now.Add(time.Minute)); err != nil {
t.Fatalf("clear dungeon %d: %v", fiendWarTimerTestDungeon, err)
}
syncRuntimeFiendWarObjectives(&state)
clearedAt := now.Add(time.Minute)
cleared, err := runtimeFiendWarBoardPackets(state, previousCleared, fiendWarTimerTestViewer, clearedAt)
if err != nil {
t.Fatalf("board packets (cleared): %v", err)
}
rows = fixtureKind12Rows(t, cleared)
if len(rows) != 1 || rows[0].area != fiendWarTimerTestArea ||
rows[0].deadline != 0 || rows[0].duration != 0 {
t.Fatalf("通关后的 kind 12 行 = %+v, want 恰好一条区域 %d 的 {0,0} 撤钟行", rows, fiendWarTimerTestArea)
}
if got := fixtureRaidTimerBodies(t, cleared); len(got) != 0 {
t.Fatalf("通关后还在发门倒计时 0x0254:%+v", got)
}
wantCleared := uint32((raidPhaseLength - time.Minute) / time.Second)
if remaining, ok := fixtureRaidRemainSeconds(t, cleared); !ok || remaining != wantCleared {
t.Fatalf("通关后 0x0255 = %d 秒 (found=%t), want %d —— 必须切回(还在走的)攻坚倒计时",
remaining, ok, wantCleared)
}
// --- 再下一个 tick:撤钟行不重复 -------------------------------------------
previousAfterCancel := cloneRuntimeRaidState(state)
after, err := runtimeFiendWarBoardPackets(state, previousAfterCancel, fiendWarTimerTestViewer, clearedAt.Add(time.Second))
if err != nil {
t.Fatalf("board packets (after cancel): %v", err)
}
if rows := fixtureKind12Rows(t, after); len(rows) != 0 {
t.Fatalf("撤钟行之后还在发 kind 12:%+v —— 客户端会每秒被重新断言一次", rows)
}
if remaining, ok := fixtureRaidRemainSeconds(t, after); !ok || remaining != wantCleared-1 {
t.Fatalf("撤钟后的 0x0255 = %d 秒 (found=%t), want %d", remaining, ok, wantCleared-1)
}
}
// fixtureFreeAreaLimitSeconds 是 [DUNGEON LIMIT TIME] 里普通区域的时限(PVF 实证 210)。
const fixtureFreeAreaLimitSeconds = 210
const (
fiendWarTimerTestArea = 7
fiendWarTimerTestDungeon = 4955
// fiendWarTimerTestViewer 是 HUD 上那个「剩余时间」的收件人。0 = 不看任何人。
fiendWarTimerTestViewer = 1
fiendWarTimerTestOtherParty = 2
)
// fixtureKind12Row 是一条解码后的 op1458 kind 12 行。
type fixtureKind12Row struct {
area int
deadline uint32
duration uint32
}
// fixtureKind12Rows 按 op1458 的线上格式(u8 count + rows{u8 area,u8 kind,u32,u32},
// 每 255 行分片)挑出 kind 12 的行。空行(0,0)也返回 —— 它正是"撤钟"信号。
func fixtureKind12Rows(t *testing.T, packets []runtimeRaidWireNotification) []fixtureKind12Row {
t.Helper()
var out []fixtureKind12Row
for _, packet := range packets {
if packet.msgID != 1458 || len(packet.body) == 0 {
continue
}
body := packet.body
count := int(body[0])
if len(body) != 1+count*10 {
t.Fatalf("op1458 body 长度 = %d, count = %d(每行 10 字节)", len(body), count)
}
for row := 0; row < count; row++ {
raw := body[1+row*10 : 1+(row+1)*10]
if raw[1] != 12 {
continue
}
out = append(out, fixtureKind12Row{
area: int(raw[0]),
deadline: binary.LittleEndian.Uint32(raw[2:6]),
duration: binary.LittleEndian.Uint32(raw[6:10]),
})
}
}
return out
}
// fixtureRaidTimer 是一条解码后的 0x0254 门倒计时。
type fixtureRaidTimer struct {
timerType byte
value0 uint32
value1 uint32
value2 uint32
}
func fixtureRaidTimerBodies(t *testing.T, packets []runtimeRaidWireNotification) []fixtureRaidTimer {
t.Helper()
var out []fixtureRaidTimer
for _, packet := range packets {
if packet.msgID != raid.RaidTimerMsgID {
continue
}
if len(packet.body) != 13 {
t.Fatalf("0x0254 body 长度 = %d, want 13(u8 + 3×u32)", len(packet.body))
}
out = append(out, fixtureRaidTimer{
timerType: packet.body[0],
value0: binary.LittleEndian.Uint32(packet.body[1:5]),
value1: binary.LittleEndian.Uint32(packet.body[5:9]),
value2: binary.LittleEndian.Uint32(packet.body[9:13]),
})
}
return out
}
// fixtureRaidRemainSeconds 读最后一条 0x0255 的秒数。
func fixtureRaidRemainSeconds(t *testing.T, packets []runtimeRaidWireNotification) (uint32, bool) {
t.Helper()
seconds := uint32(0)
found := false
for _, packet := range packets {
if packet.msgID != raid.RaidRemainTimeMsgID {
continue
}
if len(packet.body) != 5 {
t.Fatalf("0x0255 body 长度 = %d, want 5(u8 + u32)", len(packet.body))
}
seconds, found = binary.LittleEndian.Uint32(packet.body[1:5]), true
}
return seconds, found
}
// 门状态比 deadline 晚一帧到位时,倒计时也必须补发。
//
// 真机 2026-10-02 16:28–16:36(raid 12058625)实测:每一次进图,4 个成员的 0x0254
// 都比进图晚 1.4–3.1 秒才发(08:29:56.61 进图 → 08:29:59.72 才发;08:35:55.49 → 08:35:57.07),
// 中间那段时间 HUD 上还是攻坚倒计时(操作员截图 00:58:28)。
//
// 原因就是这条竞态:`battle.Enter` 在 loaded 边界里把 deadline 设好,而 585/0x0254
// 共用的 Objectives 要等 syncRuntimeFiendWarObjectives 才变成 open。只比 deadline 的
// 实现会在那一帧按"状态 2 → 不画"跳过,之后 deadline 再也不变,这条倒计时就永远
// 发不出去了。修法两条:loaded 边界补一次 sync,比较条件改成 (deadline, 门状态) 这一对。
func TestFiendWarAreaCountdownIsSentWhenTheGateStateArrivesAfterTheDeadline(t *testing.T) {
path := os.Getenv("DNF_FIENDWAR_REAL_PVF")
if path == "" {
t.Skip("DNF_FIENDWAR_REAL_PVF not set")
}
archive, err := platformpvf.Open(path)
if err != nil {
t.Fatalf("open real Script.pvf: %v", err)
}
config, err := raid.LoadFiendWarConfig(archive)
if err != nil {
t.Fatalf("load Fiend War config: %v", err)
}
battle, err := raid.NewFiendWarBattle(config.Battle)
if err != nil {
t.Fatalf("battle: %v", err)
}
area, ok := battle.AreaForDungeon(fiendWarTimerTestDungeon)
if !ok {
t.Fatalf("区域里没有副本 %d", fiendWarTimerTestDungeon)
}
now := time.Now().UTC().Truncate(time.Second)
deadline := now.Add(time.Duration(area.LimitSeconds) * time.Second)
// Enter 已经跑完:deadline 与占用就位,Objectives 还没同步。
battle.Areas[area.Index] = raid.FiendWarAreaState{Group: 1, Deadline: deadline}
const raidKey = uint32(0x00c80453)
state := runtimeRaidState{
RaidKey: raidKey,
RaidMode: runtimeRaidOwnerFiendWar,
Members: []runtimeRaidMemberState{{CharID: 1, GroupIndex: 1}},
FrozenMembers: []runtimeRaidMemberState{{CharID: 1, GroupIndex: 1}},
Lifecycle: runtimeRaidLifecycleState{
Status: runtimeRaidLifecyclePhaseActive,
Phase: runtimeRaidDungeonPhaseOne,
PhaseStartedAt: now,
PhaseDeadline: now.Add(time.Hour),
Objectives: map[int]runtimeRaidDungeonObjective{
fiendWarTimerTestDungeon: {
Phase: runtimeRaidDungeonPhaseOne, MapID: fiendWarTimerTestDungeon,
State: raid.AntonDungeonHide, MaxHP: 1,
},
},
FiendWar: battle,
},
}
// 第一帧:状态还是 hide(2),客户端按规则不画,服务器也不该发。
first, err := runtimeFiendWarBoardPackets(state, runtimeRaidState{}, 0, now)
if err != nil {
t.Fatalf("board packets (state still hidden): %v", err)
}
if rows := fixtureKind12Rows(t, first); len(rows) != 1 {
t.Fatalf("第一帧的 kind 12 行 = %+v, want 那条带真 deadline 的行", rows)
}
if got := fixtureRaidTimerBodies(t, first); len(got) != 0 {
t.Fatalf("门状态还是 hide 就发了 0x0254:%+v(客户端本来就不画)", got)
}
// 第二帧:Objectives 同步成 open,**deadline 一个字节都没动**。
previous := cloneRuntimeRaidState(state)
state.Lifecycle.Objectives[fiendWarTimerTestDungeon] = runtimeRaidDungeonObjective{
Phase: runtimeRaidDungeonPhaseOne, MapID: fiendWarTimerTestDungeon,
State: raid.AntonDungeonOpen, MaxHP: 1, RemainingHP: 1,
}
second, err := runtimeFiendWarBoardPackets(state, previous, 0, now.Add(time.Second))
if err != nil {
t.Fatalf("board packets (gate state arrived): %v", err)
}
timers := fixtureRaidTimerBodies(t, second)
if len(timers) != 1 {
t.Fatalf("门状态到位、deadline 未变时 0x0254 = %+v, want 恰好一条 —— "+
"只比 deadline 的实现会在这一帧静默跳过,那条副本倒计时就永远发不出去", timers)
}
if timers[0].timerType != 1 || timers[0].value0 != fiendWarTimerTestDungeon {
t.Fatalf("门状态到位的 0x0254 = %+v, want {type 1, value0 %d}", timers[0], fiendWarTimerTestDungeon)
}
}