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,440 @@
package dnfbridge
import (
"context"
"encoding/binary"
"fmt"
"sort"
"strconv"
"strings"
"testing"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
platformpvf "longheng.io/server/internal/platform/pvf"
)
// 塔罗牌夹具:512 是 `[stackable type]` = `[monster tarot card]` 的消耗品,
// `[lottery use cost]` 100000 金币,`[int data]` 是 (item, weight, count) 三元组。
//
// 中奖项刻意选 600/610(夹具里的 `[material]` 堆叠品):这条用例只关心 op27 通道与
// 金币扣款,堆叠品能把这两个断言做得最干净。**混合表(装备行)由另外两条用例覆盖**:
//
// - tarot_lottery_mixed_table_test.go:内存夹具里第一行就是装备,钉住"抽到装备也发得出去";
// - TestRealTarotLotteryCardsGrantEveryRow:真档四张卡 × 每一行的端到端验收。
func currentTarotLotteryTestSource() dungeonDropCatalogTestSource {
source := currentBoosterTestSource()
source["stackable/stackable.lst"] += "512 `cash/tarot_card.stk`\n"
source["stackable/cash/tarot_card.stk"] = "[stackable type]\n`[monster tarot card]` 19\n" +
"[stack limit]\n100\n" +
"[lottery use cost]\n100000\n" +
"[int data]\n600 10000 3 610 10000 1\n[/int data]\n"
return source
}
func mustCurrentTarotLotteryTestCatalog(t *testing.T) *pvfDungeonDropCatalog {
t.Helper()
catalog, err := newPVFDungeonDropCatalog(currentTarotLotteryTestSource())
if err != nil {
t.Fatal(err)
}
return catalog
}
// tarotLotteryTestFixture 起一个只有一个角色、背包里只有一张塔罗牌的服务。
// 槽位 70 落在 `[monster tarot card]` 自己的 PVF 页(dungeonDropStackableSlotRange
// 的 default 分支 65..120),中奖项 600 是 `[material]`,落在 121..176,两者不打架。
const (
tarotLotteryTestCardItemID = uint32(512)
tarotLotteryTestCardSlot = int16(70)
tarotLotteryTestGold = int64(200000)
tarotLotteryTestGoldCost = int64(100000)
tarotLotteryTestRewardStack = uint32(3)
)
func newTarotLotteryTestFixture(
t *testing.T,
cardCount int64,
gold int64,
) (*Service, *gameSession, *bufferConn, context.Context, dnfrepo.Group) {
t.Helper()
ctx := context.Background()
catalog := mustCurrentTarotLotteryTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19", AccountID: "account-1", Level: 90,
Stats: map[string]int64{"gold": gold},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{
"0:70": {ItemID: int64(tarotLotteryTestCardItemID), Count: cardCount},
},
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := currentBoosterTestService(catalog, repositories)
// 抽奖的随机源:0 = 落在这个权重表的第一个三元组上(600)。
service.dungeonChoice = func(int) (int, error) { return 0, nil }
session := &gameSession{conn: connection, connID: "tarot-lottery", selectedCharacterID: 19}
return service, session, connection, ctx, repositories
}
func openTarotLotteryCard(t *testing.T, service *Service, session *gameSession, slot int16) {
t.Helper()
if err := service.handleGameCommand(
session,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketUseLotteryItem),
[]byte{0, 0, byte(slot), byte(slot >> 8)},
); err != nil {
t.Fatal(err)
}
}
// 主用例:客户端发 class-1/op27(实机 2026-09-30T11:52:47Z 就是这个形状),服务端
// 必须把它当塔罗牌抽奖而不是"非随机罐"拒绝 —— 也就是本轮的现场问题本身。
func TestCurrentTarotLotteryItemAnswersOp27AndChargesTheDeclaredGold(t *testing.T) {
service, session, connection, ctx, repositories := newTarotLotteryTestFixture(t, 2, tarotLotteryTestGold)
openTarotLotteryCard(t, service, session, tarotLotteryTestCardSlot)
result, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if result.Header.MsgID != uint16(dnfenum.CmdPacketUseLotteryItem) {
t.Fatalf("第一个包不是 op27: header=%+v body=%x", result.Header, result.Body)
}
if len(result.Body) != currentLotteryNativeResultBodySize || result.Body[0] != 1 {
t.Fatalf("op27 结果包形状不对: len=%d want=%d body=%x", len(result.Body), currentLotteryNativeResultBodySize, result.Body)
}
if slot := int16(uint16(result.Body[1]) | uint16(result.Body[2])<<8); slot != tarotLotteryTestCardSlot {
t.Fatalf("op27 结果包源槽=%d want %d", slot, tarotLotteryTestCardSlot)
}
// 花钱就必须把权威余额推给客户端;形状与罐子开出金币时那一个钱包包一致
// (lottery_item.go:115-124)。
wallet, trailing := splitGameServerUpperPacket(t, rest)
if wallet.Header.Classification != 0 ||
wallet.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) {
t.Fatalf("第二个包不是钱包同步: header=%+v body=%x", wallet.Header, wallet.Body)
}
wantGold := tarotLotteryTestGold - tarotLotteryTestGoldCost
if wantBody := buildCurrentGoldStateBody(wantGold); string(wallet.Body) != string(wantBody) {
t.Fatalf("钱包包=%x want=%x", wallet.Body, wantBody)
}
// op27 之后**不得**出现道具表包:那会替换客户端动画仍握着的道具对象
// (lottery_item.go:107-114 的既有教训)。钱包包是唯一允许的后续包。
if len(trailing) != 0 {
t.Fatalf("op27 之后多了包: %x", trailing)
}
character, found, err := repositories.Character.Load(ctx, "19")
if err != nil || !found {
t.Fatalf("load character: %v found=%t", err, found)
}
if got := character.Stats["gold"]; got != wantGold {
t.Fatalf("金币=%d want %d([lottery use cost] 必须真的扣掉)", got, wantGold)
}
inventory, found, err := repositories.Inventory.Load(ctx, "19")
if err != nil || !found {
t.Fatalf("load inventory: %v found=%t", err, found)
}
if stack := inventory.Slots["0:70"]; stack.ItemID != int64(tarotLotteryTestCardItemID) || stack.Count != 1 {
t.Fatalf("源塔罗牌槽=%+v want item=%d count=1", stack, tarotLotteryTestCardItemID)
}
// 中奖项 600 必须真的进背包(3 个),且落点用 Owner 写回的真实槽位。
granted := int64(0)
for key, stack := range inventory.Slots {
if key == "0:70" || stack.ItemID != 600 {
continue
}
granted += stack.Count
}
if granted != int64(tarotLotteryTestRewardStack) {
t.Fatalf("中奖项 600 数量=%d want %d(背包=%+v)", granted, tarotLotteryTestRewardStack, inventory.Slots)
}
}
// 金币不足:整次使用必须在动任何东西之前中止 —— 源卡不消耗、金币不变、回 op27 失败包。
func TestCurrentTarotLotteryItemFailsClosedWhenGoldIsShort(t *testing.T) {
service, session, connection, ctx, repositories := newTarotLotteryTestFixture(t, 2, tarotLotteryTestGoldCost-1)
openTarotLotteryCard(t, service, session, tarotLotteryTestCardSlot)
rejected, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
if rejected.Header.MsgID != uint16(dnfenum.CmdPacketUseLotteryItem) || rejected.Body[0] != 0 {
t.Fatalf("应回 op27 失败包: header=%+v body=%x", rejected.Header, rejected.Body)
}
if len(trailing) != 0 {
t.Fatalf("被拒的使用不该产生后续包: %x", trailing)
}
character, _, _ := repositories.Character.Load(ctx, "19")
if got := character.Stats["gold"]; got != tarotLotteryTestGoldCost-1 {
t.Fatalf("被拒的使用改了金币: %d", got)
}
inventory, _, _ := repositories.Inventory.Load(ctx, "19")
if stack := inventory.Slots["0:70"]; stack.Count != 2 {
t.Fatalf("被拒的使用消耗了源卡: %+v", inventory.Slots)
}
}
// 分派判据只认 `[monster tarot card]`:罐子(`[upgradable legacy]`)必须原样落回既有
// op27 路径。这是"修 A 不坏 B"的那条断言 —— 一旦有人把判据放宽,这里先红。
func TestCurrentTarotLotteryDispatchLeavesEveryOtherOp27ItemAlone(t *testing.T) {
ctx := context.Background()
catalog := mustCurrentTarotLotteryTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19", AccountID: "account-1", Level: 90,
Stats: map[string]int64{"gold": 0},
}); err != nil {
t.Fatal(err)
}
jar := int64(510) // `[upgradable legacy]`:既有的 op27 抽奖罐
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{
"0:70": {ItemID: int64(tarotLotteryTestCardItemID), Count: 1},
"0:71": {ItemID: jar, Count: 1},
"0:72": {ItemID: 600, Count: 1}, // 普通材料:既不是塔罗牌也不是罐子
},
}); err != nil {
t.Fatal(err)
}
service := currentBoosterTestService(catalog, repositories)
session := &gameSession{conn: &bufferConn{}, connID: "tarot-dispatch", selectedCharacterID: 19}
for _, test := range []struct {
name string
slot int16
handled bool
}{
{"tarot card is taken over", 70, true},
{"legacy jar keeps the booster route", 71, false},
{"ordinary material keeps the booster route", 72, false},
{"empty slot keeps the booster route", 90, false},
} {
test := test
t.Run(test.name, func(t *testing.T) {
handled, err := service.tryHandleCurrentTarotLotteryItem(ctx, session, catalog, false, test.slot)
if err != nil {
t.Fatalf("slot=%d err=%v", test.slot, err)
}
if handled != test.handled {
t.Fatalf("slot=%d handled=%t want %t", test.slot, handled, test.handled)
}
})
}
}
// 真档验收:真塔罗牌的 `[stackable type]` 必须命中分派判据,并且中奖项里的
// **每一行**都必须真的发得出去(不再是"装备行干净拒绝")。
//
// 这条用例的历史:上一轮发现真档 `[int data]` 里混着 9 位数的装备 id,而 inventory 的
// grantRandomRewardItem 只接受 kind=stackable,于是"落在装备行上的抽取"发不出奖;
// 当时把它钉成"干净 fail closed"。现场 2026-10-03 服主报「塔罗牌用不了」之后追下去,
// 发现那不只是边角:`[int data]` 里装备行按权重占了大半,而且服务端**从来没真正掷过
// 骰子**(roll 恒为 0 → 永远第一行),所以这些卡 100% 落在装备行上、100% 被拒。
//
// 现在两件事都修了,于是本用例改成双向断言:
//
// 1. 每一行、每一张卡都必须回 op27 成功,并且源卡消耗 + 金币扣掉;
// 2. **装备行必须是一行真正的装备**:item_kind=equipment、数量 1、0x77 行、
// PVF 的 [durability] 落在 +0x0B、[sealing] 落在 +0x0D,且带一个合法品级种子。
//
// 任何一行掉回"成功位=0"都会被这条用例抓住。
func TestRealTarotLotteryCardsGrantEveryRow(t *testing.T) {
archive, err := platformpvf.Open(realLukeRewardPVFPath())
if err != nil {
t.Skipf("runtime Script.pvf unavailable: %v", err)
}
ctx := context.Background()
catalog, err := newPVFDungeonDropCatalog(archive)
if err != nil {
t.Fatal(err)
}
// 10159717 就是服主那只(暗之偶像阿努比斯 塔罗牌,stackable/10159001/10159717.stk)。
for _, cardID := range []uint32{10159697, 10159698, 10159700, 10159717} {
cardID := cardID
t.Run("card", func(t *testing.T) {
definition, err := catalog.ResolveItem(cardID)
if err != nil {
t.Fatalf("resolve card %d: %v", cardID, err)
}
if got := normalizeDungeonDropStackableType(definition.StackableType); got != currentTarotLotteryStackableType {
t.Fatalf("card %d stackable_type=%q want %q (path=%s)",
cardID, got, currentTarotLotteryStackableType, definition.PVFPath)
}
if len(definition.LotteryEntries) == 0 {
t.Fatalf("card %d has no [int data] lottery rows (path=%s)", cardID, definition.PVFPath)
}
grantedRows, equipmentRows := 0, 0
// 每一行的起始 roll:累计权重边界。
roll := int64(0)
for index, entry := range definition.LotteryEntries {
rowRoll := roll
roll += entry.Weight
reward, resolveErr := resolveCurrentMagicBoxRewardItem(catalog, catalog.source, entry.ItemID)
if resolveErr != nil {
t.Errorf("card %d row %d item=%d: resolve reward: %v", cardID, index, entry.ItemID, resolveErr)
continue
}
// 端到端:把 roll 钉在这一行上,走完整的 op27 使用路径。
service, session, connection, repos := newRealTarotLotteryFixture(t, ctx, catalog, cardID, rowRoll)
openTarotLotteryCard(t, service, session, tarotLotteryTestCardSlot)
result, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
if result.Header.MsgID != uint16(dnfenum.CmdPacketUseLotteryItem) || len(result.Body) == 0 {
t.Fatalf("card %d row %d item=%d: 第一个回包不是 op27 header=%+v body=%x",
cardID, index, entry.ItemID, result.Header, result.Body)
}
character, _, _ := repos.Character.Load(ctx, "19")
inventory, _, _ := repos.Inventory.Load(ctx, "19")
source := inventory.Slots["0:70"]
goldAfter := character.Stats["gold"]
if result.Body[0] != 1 {
t.Errorf("card %d row %d item=%d kind=%q: 被拒了 body=%x(每一行都必须发得出去)",
cardID, index, entry.ItemID, reward.Kind, result.Body)
continue
}
grantedRows++
if len(result.Body) != currentLotteryNativeResultBodySize {
t.Errorf("card %d row %d item=%d: op27 结果包长=%d want=%d body=%x",
cardID, index, entry.ItemID, len(result.Body), currentLotteryNativeResultBodySize, result.Body)
}
// 花钱就必须推权威余额;钱包包是 op27 之后**唯一**允许的后续包
// (不能有 op14 道具行/op13 整表:那会替换客户端动画握着的对象)。
wallet, rest := splitGameServerUpperPacket(t, trailing)
if wallet.Header.Classification != 0 ||
wallet.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) {
t.Errorf("card %d row %d item=%d: op27 之后的包不是钱包同步 header=%+v body=%x",
cardID, index, entry.ItemID, wallet.Header, wallet.Body)
}
if len(rest) != 0 {
t.Errorf("card %d row %d item=%d: op27+钱包之后还多了包 %x",
cardID, index, entry.ItemID, rest)
}
if goldAfter != 0 {
t.Errorf("card %d row %d item=%d: 成功后金币=%d want 0", cardID, index, entry.ItemID, goldAfter)
}
if source.ItemID != 0 || source.Count != 0 {
t.Errorf("card %d row %d item=%d: 成功后源卡还在 %+v", cardID, index, entry.ItemID, source)
}
row := result.Body[3 : 3+currentItemListEntryWireSize]
if got := binary.LittleEndian.Uint32(row[2:6]); got != uint32(entry.ItemID) {
t.Errorf("card %d row %d: op27 行里的道具 id=%d want %d", cardID, index, got, entry.ItemID)
}
if strings.EqualFold(reward.Kind, "equipment") {
equipmentRows++
grantedKey, granted, ok := findRealTarotLotteryGrantedEquipment(inventory, int64(entry.ItemID))
if !ok {
// 源槽 70 是卡本身,奖励装备落在 9..64,不可能被卡挡住。
t.Errorf("card %d row %d item=%d: 背包里找不到发出去的装备(slots=%v)",
cardID, index, entry.ItemID, realTarotLotterySlotSummary(inventory))
continue
}
if granted.Count != 1 {
t.Errorf("card %d row %d item=%d: 装备数量=%d want 1(装备一格一件,不叠加)",
cardID, index, entry.ItemID, granted.Count)
}
if got := granted.Extra["item_kind"]; got != "equipment" {
t.Errorf("card %d row %d item=%d: item_kind=%q want equipment", cardID, index, entry.ItemID, got)
}
if len(granted.RawEntry) != currentItemListEntryWireSize {
t.Errorf("card %d row %d item=%d: RawEntry 长=%d want %d",
cardID, index, entry.ItemID, len(granted.RawEntry), currentItemListEntryWireSize)
}
if reward.Seal && granted.Extra["seal_flag"] != "1" {
t.Errorf("card %d row %d item=%d: [sealing] 没写进 seal_flag(extra=%v)",
cardID, index, entry.ItemID, granted.Extra)
}
if durability := int64(binary.LittleEndian.Uint16(row[0x0B:0x0D])); durability != int64(reward.Durability) {
t.Errorf("card %d row %d item=%d: op27 行耐久=%d want %d(PVF [durability])",
cardID, index, entry.ItemID, durability, reward.Durability)
}
if seed, parseErr := strconv.ParseUint(granted.Extra["quality_seed"], 10, 32); parseErr != nil || seed == 0 {
t.Errorf("card %d row %d item=%d: 品级种子=%q 不合法(客户端会画成最下级)",
cardID, index, entry.ItemID, granted.Extra["quality_seed"])
}
t.Logf("card=%d row=%d item=%d weight=%d kind=equipment durability=%d seal=%t -> GRANTED slot=%s",
cardID, index, entry.ItemID, entry.Weight, reward.Durability, reward.Seal, grantedKey)
} else {
t.Logf("card=%d row=%d item=%d weight=%d count=%d kind=%q -> GRANTED",
cardID, index, entry.ItemID, entry.Weight, entry.Count, reward.Kind)
}
}
t.Logf("card=%d rows=%d granted=%d equipment=%d",
cardID, len(definition.LotteryEntries), grantedRows, equipmentRows)
if grantedRows != len(definition.LotteryEntries) {
t.Errorf("card %d: 只发出去 %d/%d 行", cardID, grantedRows, len(definition.LotteryEntries))
}
if equipmentRows == 0 {
t.Errorf("card %d: 一行装备都没抽到 —— 混合表用例失去意义", cardID)
}
})
}
}
// findRealTarotLotteryGrantedEquipment 在角色背包里找那件发出去的装备,返回它的行键。
func findRealTarotLotteryGrantedEquipment(inventory dnfrepo.InventoryRecord, itemID int64) (string, dnfrepo.ItemStack, bool) {
keys := make([]string, 0, len(inventory.Slots))
for key, stack := range inventory.Slots {
if stack.ItemID == itemID && stack.Count > 0 {
keys = append(keys, key)
}
}
if len(keys) == 0 {
return "", dnfrepo.ItemStack{}, false
}
sort.Strings(keys)
return keys[0], inventory.Slots[keys[0]], true
}
func realTarotLotterySlotSummary(inventory dnfrepo.InventoryRecord) []string {
keys := make([]string, 0, len(inventory.Slots))
for key, stack := range inventory.Slots {
keys = append(keys, fmt.Sprintf("%s=%d x%d", key, stack.ItemID, stack.Count))
}
sort.Strings(keys)
return keys
}
// newRealTarotLotteryFixture 与内存夹具同形,但用真档目录与真塔罗牌 id,并把随机源
// 钉在指定 roll 上。注意真档卡的 `[lottery use cost]` 是 100000,所以起始金币给 100000,
// 成功之后余额必须是 0。
func newRealTarotLotteryFixture(
t *testing.T,
ctx context.Context,
catalog *pvfDungeonDropCatalog,
cardID uint32,
roll int64,
) (*Service, *gameSession, *bufferConn, dnfrepo.Group) {
t.Helper()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19", AccountID: "account-1", Level: 90,
Stats: map[string]int64{"gold": 100000},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19",
Slots: map[string]dnfrepo.ItemStack{
"0:70": {ItemID: int64(cardID), Count: 1},
},
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := currentBoosterTestService(catalog, repositories)
service.dungeonChoice = func(int) (int, error) { return int(roll), nil }
session := &gameSession{conn: connection, connID: "tarot-real", selectedCharacterID: 19}
return service, session, connection, repositories
}