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

275 lines
12 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 (
"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)
}
}