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,448 @@
package cargo
import (
"context"
"errors"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestOwnerPlanAccountReadsCargoMetadata(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "acc-1",
Metadata: map[string]string{
"account_cargo_gold": "1200",
"account_cargo_level": "3",
"account_cargo_created": "1",
},
}); err != nil {
t.Fatalf("save account: %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
got, err := owner.PlanAccount(ctx, "upgrade_account_cargo", AccountCommand{AccountID: " acc-1 "})
if err != nil {
t.Fatalf("PlanAccount() error = %v", err)
}
if got.AccountID != "acc-1" || got.CargoGold != 1200 || got.CargoLevel != 3 || !got.CargoCreated {
t.Fatalf("result = %+v", got)
}
}
func TestOwnerPlanMoneyDepositChecksCharacterGold(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "acc-2",
Metadata: map[string]string{"account_cargo_gold": "50"},
}); err != nil {
t.Fatalf("save account: %v", err)
}
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "77",
AccountID: "acc-2",
Stats: map[string]int64{"gold": 500},
}); err != nil {
t.Fatalf("save character: %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
got, err := owner.PlanMoney(ctx, MoneyCommand{
AccountID: "acc-2",
SelectedCharacterID: 77,
Direction: MoneyDeposit,
Amount: 300,
})
if err != nil {
t.Fatalf("PlanMoney() error = %v", err)
}
if got.CharacterGold != 500 || got.CargoGold != 50 || got.Amount != 300 || got.Direction != MoneyDeposit {
t.Fatalf("result = %+v", got)
}
}
func TestOwnerPlanMoneyWithdrawChecksCargoGold(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "acc-3",
Metadata: map[string]string{"account_cargo_gold": "900"},
}); err != nil {
t.Fatalf("save account: %v", err)
}
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "78",
AccountID: "acc-3",
Stats: map[string]int64{"gold": 20},
}); err != nil {
t.Fatalf("save character: %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
got, err := owner.PlanMoney(ctx, MoneyCommand{
AccountID: "acc-3",
SelectedCharacterID: 78,
Direction: MoneyWithdraw,
Amount: 700,
})
if err != nil {
t.Fatalf("PlanMoney() error = %v", err)
}
if got.CharacterGold != 20 || got.CargoGold != 900 || got.Amount != 700 || got.Direction != MoneyWithdraw {
t.Fatalf("result = %+v", got)
}
}
func TestOwnerPlanMoneyRejectsInsufficientGold(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "acc-4",
Metadata: map[string]string{"account_cargo_gold": "5"},
}); err != nil {
t.Fatalf("save account: %v", err)
}
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "79",
AccountID: "acc-4",
Stats: map[string]int64{"gold": 10},
}); err != nil {
t.Fatalf("save character: %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
_, err = owner.PlanMoney(ctx, MoneyCommand{
AccountID: "acc-4",
SelectedCharacterID: 79,
Direction: MoneyWithdraw,
Amount: 6,
})
if !errors.Is(err, ErrGoldInsufficient) {
t.Fatalf("PlanMoney() error = %v, want ErrGoldInsufficient", err)
}
}
func TestOwnerApplyCreateCargoChargesGold(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: "acc-5"}); err != nil {
t.Fatalf("save account: %v", err)
}
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "80",
AccountID: "acc-5",
Stats: map[string]int64{"gold": 500000},
}); err != nil {
t.Fatalf("save character: %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
got, err := owner.ApplyAccount(ctx, "create_account_cargo", AccountCommand{
AccountID: "acc-5",
SelectedCharacterID: 80,
})
if err != nil {
t.Fatalf("ApplyAccount() error = %v", err)
}
if !got.Changed || got.CharacterGold != 400000 || got.CargoLevel != 1 || !got.CargoCreated {
t.Fatalf("result = %+v", got)
}
account, ok, err := repos.Account.Load(ctx, "acc-5")
if err != nil || !ok {
t.Fatalf("load account ok=%t err=%v", ok, err)
}
if account.Metadata["account_cargo_level"] != "1" || account.Metadata["account_cargo_created"] != "true" {
t.Fatalf("account metadata = %+v", account.Metadata)
}
character, ok, err := repos.Character.Load(ctx, "80")
if err != nil || !ok {
t.Fatalf("load character ok=%t err=%v", ok, err)
}
if got := character.Stats["gold"]; got != 400000 {
t.Fatalf("character gold = %d, want 400000", got)
}
}
func TestOwnerApplyAccountUpgradeDebitsAccountSharedCera(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "acc-cera",
Metadata: map[string]string{
"account_cargo_created": "true",
"account_cargo_level": "8",
"account_cargo_gold": "0",
"account_cera": "3000",
},
}); err != nil {
t.Fatalf("save account: %v", err)
}
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "87", AccountID: "acc-cera", Stats: map[string]int64{"gold": 1}}); err != nil {
t.Fatalf("save character: %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
got, err := owner.ApplyAccount(ctx, "upgrade_account_cargo", AccountCommand{AccountID: "acc-cera", SelectedCharacterID: 87})
if err != nil {
t.Fatalf("ApplyAccount() error = %v", err)
}
if !got.Changed || got.Cost.Kind != CostCera || got.Cost.Amount != 2000 || got.CharacterCera != 1000 || got.CargoLevel != 16 {
t.Fatalf("result = %+v", got)
}
account, ok, err := repos.Account.Load(ctx, "acc-cera")
if err != nil || !ok {
t.Fatalf("load account ok=%t err=%v", ok, err)
}
if account.Metadata["account_cera"] != "1000" || account.Metadata["account_cargo_level"] != "16" {
t.Fatalf("account metadata = %+v", account.Metadata)
}
}
func TestOwnerApplyAccountUpgradeRejectsInsufficientAccountCera(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "acc-cera-poor",
Metadata: map[string]string{
"account_cargo_created": "true",
"account_cargo_level": "8",
"account_cargo_gold": "0",
"account_cera": "100",
},
}); err != nil {
t.Fatalf("save account: %v", err)
}
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "88", AccountID: "acc-cera-poor", Stats: map[string]int64{"gold": 1}}); err != nil {
t.Fatalf("save character: %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
if _, err := owner.ApplyAccount(ctx, "upgrade_account_cargo", AccountCommand{AccountID: "acc-cera-poor", SelectedCharacterID: 88}); !errors.Is(err, ErrGoldInsufficient) {
t.Fatalf("ApplyAccount() error = %v, want ErrGoldInsufficient", err)
}
account, _, _ := repos.Account.Load(ctx, "acc-cera-poor")
if account.Metadata["account_cera"] != "100" || account.Metadata["account_cargo_level"] != "8" {
t.Fatalf("insufficient-cera attempt mutated account: %+v", account.Metadata)
}
}
func TestOwnerApplyMoneyDepositPersistsBothSides(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "acc-6",
Metadata: map[string]string{
"account_cargo_gold": "50",
"account_cargo_level": "1",
"account_cargo_created": "true",
},
}); err != nil {
t.Fatalf("save account: %v", err)
}
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "81",
AccountID: "acc-6",
Stats: map[string]int64{"gold": 500},
}); err != nil {
t.Fatalf("save character: %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
got, err := owner.ApplyMoney(ctx, MoneyCommand{
AccountID: "acc-6",
SelectedCharacterID: 81,
Direction: MoneyDeposit,
Amount: 300,
})
if err != nil {
t.Fatalf("ApplyMoney() error = %v", err)
}
if !got.Changed || got.CharacterGold != 200 || got.CargoGold != 350 {
t.Fatalf("result = %+v", got)
}
account, ok, err := repos.Account.Load(ctx, "acc-6")
if err != nil || !ok {
t.Fatalf("load account ok=%t err=%v", ok, err)
}
if got := account.Metadata["account_cargo_gold"]; got != "350" {
t.Fatalf("cargo gold = %q, want 350", got)
}
character, ok, err := repos.Character.Load(ctx, "81")
if err != nil || !ok {
t.Fatalf("load character ok=%t err=%v", ok, err)
}
if got := character.Stats["gold"]; got != 200 {
t.Fatalf("character gold = %d, want 200", got)
}
}
func TestOwnerApplyAccountUpgradeConsumesVoidMagicStoneForMiddleTiers(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "acc-void",
Metadata: map[string]string{
"account_cargo_created": "true",
"account_cargo_level": "16",
"account_cargo_gold": "0",
},
}); err != nil {
t.Fatal(err)
}
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "86", AccountID: "acc-void", Stats: map[string]int64{"gold": 1, "cera": 1}}); err != nil {
t.Fatal(err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "86", Slots: map[string]dnfrepo.ItemStack{
"0:10": {ItemID: accountCargoVoidMagicStoneItemID, Count: 200},
"0:11": {ItemID: accountCargoVoidMagicStoneItemID, Count: 100},
}}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
got, err := owner.ApplyAccount(ctx, "upgrade_account_cargo", AccountCommand{AccountID: "acc-void", SelectedCharacterID: 86})
if err != nil {
t.Fatalf("upgrade account cargo: %v", err)
}
if !got.Changed || got.CargoLevel != 24 || got.Cost.Kind != CostMaterial || got.Cost.Amount != 250 {
t.Fatalf("upgrade result=%+v", got)
}
items, found, err := repos.Inventory.Load(ctx, "86")
if err != nil || !found {
t.Fatalf("load inventory found=%t err=%v", found, err)
}
if _, exists := items.Slots["0:10"]; exists {
t.Fatalf("first material stack not consumed: %+v", items.Slots)
}
if got := items.Slots["0:11"].Count; got != 50 {
t.Fatalf("remaining void magic stones=%d want=50", got)
}
}
func TestOwnerApplyMoneyWithdrawPersistsBothSides(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "acc-7",
Metadata: map[string]string{
"account_cargo_gold": "900",
"account_cargo_level": "1",
"account_cargo_created": "true",
},
}); err != nil {
t.Fatalf("save account: %v", err)
}
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "82",
AccountID: "acc-7",
Stats: map[string]int64{"gold": 20},
}); err != nil {
t.Fatalf("save character: %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
got, err := owner.ApplyMoney(ctx, MoneyCommand{
AccountID: "acc-7",
SelectedCharacterID: 82,
Direction: MoneyWithdraw,
Amount: 700,
})
if err != nil {
t.Fatalf("ApplyMoney() error = %v", err)
}
if !got.Changed || got.CharacterGold != 720 || got.CargoGold != 200 {
t.Fatalf("result = %+v", got)
}
}
// TestNextAccountCargoTierCoversEveryCeraShopTool 锁住完整阶梯。2026-08-25 现网
// 抓包证明客户端按顺序消耗 12 个点券工具,容量 64 时请求 tool6(64->72);阶梯
// 只写到 64 时那次购买被拒,客户端显示"物品栏空间不足"。
func TestNextAccountCargoTierCoversEveryCeraShopTool(t *testing.T) {
want := []int64{1, 8, 16, 24, 32, 40, 48, 56, 64, 72, 80, 88, 96, 104, 112, 120}
tier := want[0]
for _, expected := range want[1:] {
next, ok := NextAccountCargoTier(tier)
if !ok || next != expected {
t.Fatalf("NextAccountCargoTier(%d) = %d, %t; want %d, true", tier, next, ok, expected)
}
tier = next
}
if next, ok := NextAccountCargoTier(tier); ok {
t.Fatalf("NextAccountCargoTier(%d) = %d, true; want max level", tier, next)
}
if next, ok := NextAccountCargoTier(5); ok {
t.Fatalf("NextAccountCargoTier(5) = %d, true; want an unknown tier to fail closed", next)
}
}
// TestOwnerApplyAccountUpgradeRequiresToolAboveMaterialTiers 保证新接上的高档位
// 不会被金库窗口按钮(op306)白送——那些档只有商城工具能付费。
func TestOwnerApplyAccountUpgradeRequiresToolAboveMaterialTiers(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "acc-tool",
Metadata: map[string]string{
"account_cargo_created": "true",
"account_cargo_level": "64",
"account_cargo_gold": "0",
"account_cera": "9000000",
},
}); err != nil {
t.Fatalf("save account: %v", err)
}
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "89", AccountID: "acc-tool", Stats: map[string]int64{"gold": 900000000}}); err != nil {
t.Fatalf("save character: %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner() error = %v", err)
}
if _, err := owner.ApplyAccount(ctx, "upgrade_account_cargo", AccountCommand{AccountID: "acc-tool", SelectedCharacterID: 89}); !errors.Is(err, ErrCargoUpgradeToolRequired) {
t.Fatalf("ApplyAccount() error = %v, want ErrCargoUpgradeToolRequired", err)
}
account, ok, err := repos.Account.Load(ctx, "acc-tool")
if err != nil || !ok {
t.Fatalf("load account ok=%t err=%v", ok, err)
}
if account.Metadata["account_cargo_level"] != "64" || account.Metadata["account_cera"] != "9000000" {
t.Fatalf("blocked op306 upgrade mutated account: %+v", account.Metadata)
}
}