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,274 @@
package dnfbridge
import (
"context"
"encoding/binary"
"testing"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfquest "longheng.io/server/internal/modules/dnf/quest"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// 任务通关奖励(PVF [reward int data])是**真实数据里唯一还在漏的晶块来源**:
// 实测真实 Script.pvf 的 9487 个 .qst 里,命中六个晶块/六个灵魂的
// [enemy reward item] / [monster reward item] 行是 0 行,而 [reward int data]
// 命中晶块 90 行、[reward selection int data] 4 行(例如 3037 x100 / 3037 x1000)。
//
// 这条路由 services/dnfbridge/quest_finish.go 的 currentQuestFinishItemAllocator 发放,
// 它过去只调 addCurrentDungeonPickupToInventory —— 纯角色背包,完全不认账号仓,
// 于是 20 个无色又落进背包,与账号仓那 43890 个形成"同一条 list-0 报文两行同 ID",
// 客户端把背包那行显示成仓库的数量(2026-09-21 事故的原始症状)。
//
// 本轮的接法:账号记录随 FinishItemGrantRequest 进入发放器(它本来就在结算事务里,
// finish_owner.go 早已 Load/Clone/Save 过),发放器先走统一入口落固定格。
func prepareCurrentFinishQuestCubeTest(t *testing.T) (dnfrepo.Group, *dnfquest.Catalog, string) {
t.Helper()
ctx := context.Background()
repositories := finishBridgeSeed(t)
accountID := defaultAccountPrefix + "1"
// 现场形态:账号晶体仓里已经堆着 43890 个无色。
if err := repositories.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
AccountID: accountID,
Slots: map[string]dnfrepo.ItemStack{"0:358": {ItemID: 3037, Count: 43890}},
}); err != nil {
t.Fatal(err)
}
return repositories, finishBridgeCubeRewardCatalog(t), accountID
}
// finishBridgeCubeRewardCatalog 是既有 finishBridgeCatalog 的镜像,只把 3145 的奖励
// 从 `10403 x2` 换成 `3037 x20` —— 与玩家实际遇到的那次完全同形(含后继任务 3146,
// 保证任务链推进逻辑与既有用例逐字一致)。
func finishBridgeCubeRewardCatalog(t *testing.T) *dnfquest.Catalog {
t.Helper()
source := finishBridgePVFSource{
dnfquest.DefaultList: "3145 `finish.qst`\n3146 `next.qst`\n",
"n_quest/finish.qst": "[grade]\n`[epic]`\n[type]\n`[clear map]`\n[difficulty]\n`N`\n[reward type]\n`[item]`\n[reward int data]\n0 1 3037 20\n[level]\n1 99\n[job]\n`[gunner]`\n[exposed by npc]\n1\n[int data]\n76126 0 0 1\n",
"n_quest/next.qst": "[grade]\n`[epic]`\n[type]\n`[quest clear]`\n[difficulty]\n`N`\n[reward type]\n`[item]`\n[reward int data]\n10404 1\n[level]\n2 99\n[job]\n`[gunner]`\n[exposed by npc]\n1\n[pre required quest]\n3145\n[int data]\n3157 3054\n",
}
index, err := dnfpvf.Build(context.Background(), source, dnfpvf.BuildOptions{Lists: []string{dnfquest.DefaultList}})
if err != nil {
t.Fatal(err)
}
catalog, err := dnfquest.Load(context.Background(), index)
if err != nil {
t.Fatal(err)
}
return catalog
}
func finishBridgeCubeItemCatalog() *pvfDungeonDropCatalog {
// StackLimit 0 与真实 PVF 一致:这 12 个道具都没有 [stack limit]。
return &pvfDungeonDropCatalog{
source: bridgePVFSource{},
itemCache: map[uint32]dungeonDropItemDefinition{
3037: {
ItemID: 3037, Kind: dungeonDropItemStackable, StackableType: "[material]",
StackLimit: 0, SlotStart: 1, SlotEnd: 120,
PVFPath: "stackable/material/cubepiece_clear.stk",
},
},
}
}
func currentFinishQuestRequest() dnfquest.FinishQuestRequest {
return dnfquest.FinishQuestRequest{
QuestID: 3145,
RewardSelectIndex: ^uint16(0),
Multiplier: 1,
Reserved: dnfquest.CurrentFinishQuestObservedTailMarker,
}
}
// currentFinishQuestUpdateRows 取出任务结算后 op14 更新报文里的全部行。
//
// 报文里第一行通常是金币行(patchCore(0,0,gold)),所以断言必须按 itemID 找目标行 ——
// 而且"同一条 list-0 报文里该 itemID 只允许出现一行"正是 2026-09-21 事故的核心不变量:
// 当时背包 154 与账号格 358 各有一行 3037,客户端把背包那行显示成了仓库数量。
func currentFinishQuestUpdateRows(t *testing.T, connection *bufferConn, marker int) [][]byte {
t.Helper()
packets := splitAllFinishBridgePackets(t, connection.write.Bytes()[marker:])
var rows [][]byte
for _, packet := range packets {
if packet.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) {
continue
}
body := packet.Body
if len(body) < 3 || body[0] != dnfrepo.MainInventoryListType {
t.Fatalf("unexpected finish item update body=%x", body)
}
count := int(binary.LittleEndian.Uint16(body[1:3]))
if len(body) != 3+count*currentItemListEntryWireSize {
t.Fatalf("finish item update body rows=%d len=%d", count, len(body))
}
for index := 0; index < count; index++ {
start := 3 + index*currentItemListEntryWireSize
rows = append(rows, body[start:start+currentItemListEntryWireSize])
}
}
if len(rows) == 0 {
t.Fatalf("no item update packet in %v", finishBridgePacketIDs(packets))
}
return rows
}
// currentFinishQuestUpdateRowForItem 返回该 itemID 在更新报文里的唯一一行。
func currentFinishQuestUpdateRowForItem(t *testing.T, connection *bufferConn, marker int, itemID uint32) []byte {
t.Helper()
var found [][]byte
for _, row := range currentFinishQuestUpdateRows(t, connection, marker) {
if binary.LittleEndian.Uint32(row[2:6]) == itemID {
found = append(found, row)
}
}
if len(found) != 1 {
t.Fatalf("update body must report item %d exactly once, got %d rows", itemID, len(found))
}
return found[0]
}
func TestCurrentFinishQuestRewardRoutesCubeIntoAccountCell(t *testing.T) {
ctx := context.Background()
repositories, catalog, accountID := prepareCurrentFinishQuestCubeTest(t)
connection := &bufferConn{}
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
questCatalog: catalog,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{conn: connection, connID: "finish-quest-cube", selectedCharacterID: 19}
allocator := currentQuestFinishItemAllocator(finishBridgeCubeItemCatalog(), 24)
marker := connection.write.Len()
if err := service.handleCurrentFinishQuestWithResources(
session, currentFinishQuestRequest(), repositories, catalog, finishBridgeProgression(t), allocator,
); err != nil {
t.Fatal(err)
}
// 1. 账号记录必须真的落库了(这是 finish_owner.go 里 save 条件的改动:
// 过去只有"交材料"才存账号记录,"发奖励"改到它却不会存)。
account, found, err := repositories.AccountInventory.Load(ctx, accountID)
if err != nil || !found {
t.Fatalf("load account inventory found=%t err=%v", found, err)
}
stack, exists := account.Slots["0:358"]
if !exists || stack.ItemID != 3037 || stack.Count != 43910 {
t.Fatalf("account crystal cell=%+v exists=%t want 3037 x43910 (43890 + the 20 cube reward)", stack, exists)
}
// 2. 背包里不能再出现无色小晶块 —— 事故的可见症状就是背包里多出一行。
inventory, found, err := repositories.Inventory.Load(ctx, "19")
if err != nil || !found {
t.Fatalf("load inventory found=%t err=%v", found, err)
}
for key, bagStack := range inventory.Slots {
if bagStack.ItemID == 3037 {
t.Fatalf("the quest reward must not sit in the backpack: %s=%+v", key, bagStack)
}
}
// 3. 客户端那一半:更新报文必须**只**报 358 格一次,报的也是并库后的 43910。
row := currentFinishQuestUpdateRowForItem(t, connection, marker, 3037)
slot := binary.LittleEndian.Uint16(row[0:2])
itemID := binary.LittleEndian.Uint32(row[2:6])
count := binary.LittleEndian.Uint32(row[6:10])
if slot != 358 || itemID != 3037 || count != 43910 {
t.Fatalf("finish reward update row = slot %d item %d count %d, want slot 358 item 3037 count 43910",
slot, itemID, count)
}
// 4. 重放:重连后同一任务再结算一次,必须走收据重放而不是判 stale。
// finish_owner.go 的收据校验过去只在角色背包里找奖励行,奖励落账号格之后
// 会直接 ErrFinishQuestSettlementStale —— 那时玩家重连就再也拿不到那张回执。
reconnect := &gameSession{conn: connection, connID: "finish-quest-cube-replay", selectedCharacterID: 19}
replayMarker := connection.write.Len()
if err := service.handleCurrentFinishQuestWithResources(
reconnect, currentFinishQuestRequest(), repositories, catalog, finishBridgeProgression(t), allocator,
); err != nil {
t.Fatalf("replay: %v", err)
}
packets := splitAllFinishBridgePackets(t, connection.write.Bytes()[replayMarker:])
if len(packets) == 0 || packets[0].Header.MsgID != uint16(dnfenum.CmdPacketFinishQuest) ||
len(packets[0].Body) < 2 || packets[0].Body[0] != 1 {
t.Fatalf("replay receipt missing: %v", finishBridgePacketIDs(packets))
}
account, _, err = repositories.AccountInventory.Load(ctx, accountID)
if err != nil {
t.Fatal(err)
}
if again := account.Slots["0:358"]; again.Count != 43910 {
t.Fatalf("replay duplicated the reward: %+v", again)
}
}
// 固定格被别人占着时(唯一会真的走不到账号仓的情况;线上这 12 个道具没有
// [stack limit],固定格不封顶),整份奖励退回背包、占用者的行一点不动 ——
// 与翻牌 P2-2 同一口径"绝不半进半留"。
//
// 为什么必须整份退回而不是拆行:任务结算的收据一行只能描述一个格子,
// 而 allocateFinishRewardItems 要求 Delta 等于整份数量;拆行会让 PostCount 对不上
// Delta,校验直接失败并把**整笔任务结算**打挂(玩家奖励拿不到、任务也完不成)。
func TestCurrentFinishQuestRewardFallsBackToBackpackWhenCellOccupied(t *testing.T) {
ctx := context.Background()
repositories, catalog, accountID := prepareCurrentFinishQuestCubeTest(t)
// 358 格里塞一个外来道具:唯一入库原语**绝不驱赶外来占用者**,只会拒绝。
if err := repositories.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{
AccountID: accountID,
Slots: map[string]dnfrepo.ItemStack{
"0:358": {ItemID: 9999, Count: 7},
"0:359": {ItemID: 3262, Count: 3},
},
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
questCatalog: catalog,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{conn: connection, connID: "finish-quest-cube-occupied", selectedCharacterID: 19}
allocator := currentQuestFinishItemAllocator(finishBridgeCubeItemCatalog(), 24)
marker := connection.write.Len()
if err := service.handleCurrentFinishQuestWithResources(
session, currentFinishQuestRequest(), repositories, catalog, finishBridgeProgression(t), allocator,
); err != nil {
t.Fatalf("occupied cell must not fail the settlement: %v", err)
}
account, _, err := repositories.AccountInventory.Load(ctx, accountID)
if err != nil {
t.Fatal(err)
}
if occupant := account.Slots["0:358"]; occupant.ItemID != 9999 || occupant.Count != 7 {
t.Fatalf("occupant was disturbed: %+v", occupant)
}
if _, exists := account.Slots["0:359"]; exists {
if crystal := account.Slots["0:359"]; crystal.ItemID != 3262 || crystal.Count != 3 {
t.Fatalf("unrelated account cell changed: %+v", crystal)
}
}
inventory, _, err := repositories.Inventory.Load(ctx, "19")
if err != nil {
t.Fatal(err)
}
dropped := int64(0)
for _, bagStack := range inventory.Slots {
if bagStack.ItemID == 3037 {
dropped += bagStack.Count
}
}
if dropped != 20 {
t.Fatalf("the whole reward must fall back to the backpack: got %d of 20 (cells=%+v)", dropped, inventory.Slots)
}
row := currentFinishQuestUpdateRowForItem(t, connection, marker, 3037)
slot := binary.LittleEndian.Uint16(row[0:2])
if dnfrepo.IsAccountSharedInventorySlot(dnfrepo.MainInventoryListType, int16(slot)) {
t.Fatalf("update row reported account slot %d although the whole reward went to the backpack: %x", slot, row[:10])
}
if count := binary.LittleEndian.Uint32(row[6:10]); count != 20 {
t.Fatalf("update row count=%d want the whole 20 in the backpack", count)
}
}