DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

@@ -0,0 +1,432 @@
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)
}
}