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,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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user