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,403 @@
package dnfbridge
import (
"context"
"encoding/binary"
"testing"
dnfcerashop "longheng.io/server/internal/modules/dnf/cerashop"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
// GM 补发路径上的代币券 / 欢乐代币券处理。
//
// 券在 PVF 里是普通 `[material]`、没有 `[action type]`,客户端无法自行兑换;
// 所以凡是发放路径都必须由服务端直接入账号钱包,否则玩家拿到的是用不掉的"材料"。
//
// (任务奖励路径的纯函数测试在 quest 包内:那两个 helper 是未导出的。)
func TestGMRepairMovesBothVoucherKindsIntoAccountWallet(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "dnf:test",
Metadata: map[string]string{
"account_cera": "700",
"account_token_cera": "100",
"account_happy_token_cera": "1",
},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "88", AccountID: "dnf:test", Name: "Receiver", Level: 90,
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "88",
Slots: map[string]dnfrepo.ItemStack{
"0:121": {ItemID: 2681917, Count: 99999}, // 欢乐代币券
"0:123": {ItemID: 2681762, Count: 42}, // 代币券
"0:122": {ItemID: 3227, Count: 5}, // 无关道具,必须留下
},
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{accountID: "dnf:test", gameUpperHeader: gameUpperHeaderServer16, gameUpperBodyCodec: gameUpperBodyCodecPlain},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
gameSessions: map[uint16]*gameSession{88: {
conn: connection, connID: "gm-voucher-repair", selectedCharacterID: 88,
}},
}
result, err := service.GMRepairHappyTokenCeraVoucher(ctx, GMWalletRequest{CharacterID: "88"})
if err != nil {
t.Fatal(err)
}
// Granted 仍然只表示欢乐代币券(既有调用方与用例都按这个口径读)。
if result.Granted != 99999 {
t.Fatalf("Granted 必须仍然只表示欢乐代币券: %+v", result)
}
if result.TokenCeraGranted != 42 {
t.Fatalf("TokenCeraGranted=%d want=42", result.TokenCeraGranted)
}
// 代币券 100+42、欢乐代币券 1+99999,点券不受影响。
if result.Cera != 700 || result.TokenCera != 142 || result.HappyTokenCera != 100000 {
t.Fatalf("repair result = %+v", result)
}
inventory, found, err := repositories.Inventory.Load(ctx, "88")
if err != nil || !found {
t.Fatalf("inventory found=%t err=%v", found, err)
}
if _, exists := inventory.Slots["0:121"]; exists {
t.Fatalf("欢乐代币券行没有被搬走: %+v", inventory.Slots)
}
if _, exists := inventory.Slots["0:123"]; exists {
t.Fatalf("代币券行没有被搬走: %+v", inventory.Slots)
}
if stack := inventory.Slots["0:122"]; stack.ItemID != 3227 || stack.Count != 5 {
t.Fatalf("无关道具被动了: %+v", stack)
}
}
// 支付瀑布的端到端证明:**点券为 0、只有代币券**也能在商城买到东西。
//
// 这正是现场抱怨的另一半 —— 代币券以前只是一组能看不能用的数字:
// 扣费只认点券,客户端又没有"用代币券支付"的选项,所以代币券永远花不出去。
func TestCurrentCeraShopPurchaseSpendsHappyThenTokenCeraBeforeCera(t *testing.T) {
ctx := context.Background()
catalog := mustCurrentCeraShopTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
// 商品 100050 单价 80:欢乐代币券 50 先花掉,剩下 30 从代币券扣,点券一分不动。
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "account-1",
Metadata: map[string]string{
"account_cera": "0",
"account_token_cera": "100",
"account_happy_token_cera": "50",
},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19", AccountID: "account-1", Level: 90, Stats: map[string]int64{"cera": 0},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19", Slots: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatal(err)
}
service := &Service{
options: options{accountID: "account-1"},
ceraShopCatalog: catalog,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{selectedCharacterID: 19}
result, err := service.commitCurrentCeraShopPurchase(ctx, session, catalog, currentCeraShopPurchaseRequest{
PaymentMode: currentCeraShopPaymentModeCera,
Items: []currentCeraShopPurchaseRequestItem{{CommodityID: 100050}},
})
if err != nil {
t.Fatalf("只有代币券时也必须能买: %v", err)
}
if result.PaidHappyTokenCera != 50 || result.PaidTokenCera != 30 || result.PaidCera != 0 {
t.Fatalf("扣费拆分 happy=%d token=%d cera=%d want 50/30/0",
result.PaidHappyTokenCera, result.PaidTokenCera, result.PaidCera)
}
if result.CeraAfter != 0 || result.TokenCera != 70 || result.HappyTokenCera != 0 {
t.Fatalf("结账后钱包 cera=%d token=%d happy=%d want 0/70/0",
result.CeraAfter, result.TokenCera, result.HappyTokenCera)
}
// 钱包真的落库了(不是只改了内存)。
account, _, err := repositories.Account.Load(ctx, "account-1")
if err != nil {
t.Fatal(err)
}
if wallet := dnfcerashop.WalletBalance(account); wallet.Cera != 0 || wallet.TokenCera != 70 || wallet.HappyTokenCera != 0 {
t.Fatalf("落库钱包=%+v want 0/70/0", wallet)
}
// 商品照常进背包。
inventory, _, err := repositories.Inventory.Load(ctx, "19")
if err != nil {
t.Fatal(err)
}
granted := int64(0)
for _, stack := range inventory.Slots {
if stack.ItemID == 37 {
granted += int64(stack.Count)
}
}
if granted != 3 {
t.Fatalf("商品应发 3 个道具 37,实际=%d", granted)
}
}
// 三种钱包加起来都不够时,整笔失败、任何钱包都不动。
func TestCurrentCeraShopPurchaseRefusesWhenWholeWalletIsShort(t *testing.T) {
ctx := context.Background()
catalog := mustCurrentCeraShopTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "account-1",
Metadata: map[string]string{
"account_cera": "10",
"account_token_cera": "20",
"account_happy_token_cera": "30",
},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19", AccountID: "account-1", Level: 90, Stats: map[string]int64{"cera": 10},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "19", Slots: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatal(err)
}
service := &Service{
options: options{accountID: "account-1"},
ceraShopCatalog: catalog,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{selectedCharacterID: 19}
// 100050 单价 80 > 10+20+30 = 60。
if _, err := service.commitCurrentCeraShopPurchase(ctx, session, catalog, currentCeraShopPurchaseRequest{
PaymentMode: currentCeraShopPaymentModeCera,
Items: []currentCeraShopPurchaseRequestItem{{CommodityID: 100050}},
}); err == nil {
t.Fatal("三个钱包加起来也不够时必须失败")
}
account, _, err := repositories.Account.Load(ctx, "account-1")
if err != nil {
t.Fatal(err)
}
if wallet := dnfcerashop.WalletBalance(account); wallet.Cera != 10 || wallet.TokenCera != 20 || wallet.HappyTokenCera != 30 {
t.Fatalf("失败后钱包不该被改动: %+v", wallet)
}
}
// 溢出时整笔拒绝,两种券都不搬、库存行也不删(全有或全无)。
func TestGMRepairRefusesWhenTokenCeraWouldOverflow(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "dnf:test",
Metadata: map[string]string{"account_token_cera": "2147483647"},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "88", AccountID: "dnf:test", Name: "Receiver", Level: 90,
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "88",
Slots: map[string]dnfrepo.ItemStack{
"0:123": {ItemID: 2681762, Count: 1},
"0:121": {ItemID: 2681917, Count: 10},
},
}); err != nil {
t.Fatal(err)
}
service := &Service{
options: options{accountID: "dnf:test"},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
gameSessions: map[uint16]*gameSession{},
}
if _, err := service.GMRepairHappyTokenCeraVoucher(ctx, GMWalletRequest{CharacterID: "88"}); err == nil {
t.Fatal("代币券溢出时必须整笔拒绝")
}
inventory, _, err := repositories.Inventory.Load(ctx, "88")
if err != nil {
t.Fatal(err)
}
if stack := inventory.Slots["0:123"]; stack.ItemID != 2681762 || stack.Count != 1 {
t.Fatalf("拒绝后代币券行应原样保留: %+v", stack)
}
if stack := inventory.Slots["0:121"]; stack.ItemID != 2681917 || stack.Count != 10 {
t.Fatalf("拒绝后欢乐代币券行应原样保留(不能只搬一种): %+v", stack)
}
account, _, err := repositories.Account.Load(ctx, "dnf:test")
if err != nil {
t.Fatal(err)
}
if wallet := dnfcerashop.WalletBalance(account); wallet.HappyTokenCera != 0 || wallet.TokenCera != 2147483647 {
t.Fatalf("拒绝后钱包不该被改动: %+v", wallet)
}
}
// currentVoucherWalletPacketBody 从连接缓冲里找出 class0/op53 钱包包并返回 13 字节包体。
func currentVoucherWalletPacketBody(t *testing.T, connection *bufferConn) []byte {
t.Helper()
stream := connection.write.Bytes()
for len(stream) > 0 {
var packet dnfproto.ChannelPacket
packet, stream = splitGameServerUpperPacket(t, stream)
if packet.Header.Classification != 0 || packet.Header.MsgID != currentCeraShopWalletMsgID {
continue
}
body := packet.Body
if len(body) > 3 && body[0] == 0 && body[1] == 0 && body[2] == 0 {
body = body[3:]
}
return body
}
return nil
}
// GM 改**代币券 / 欢乐代币券**之后必须把新钱包推给在线客户端。
//
// 早先这里判的是 `request.Currency == "cera"`,于是这两种新面额会掉到金币分支:
// 钱包已落库、客户端三个数字不变、接口返回 refreshed=false。后果是"GM 把代币券设成
// 10 万、商城还是 0",排查方向会被带偏到支付瀑布上 —— 而这恰恰是本批扩展 GM 读写
// 的目的(造"点券为 0、只有代币券"的验收数据)。
func TestGMUpdateWalletPushesTokenCeraAndHappyTokenCeraToOnlineClient(t *testing.T) {
ctx := context.Background()
for _, testCase := range []struct {
currency string
amount int64
// 期望 op53 包体里的代币券 / 欢乐代币券位置
wantTokenCera int32
wantHappyTokenCera int32
}{
{"token_cera", 1000, 1000, 0},
{"happy_token_cera", 2000, 0, 2000},
} {
t.Run(testCase.currency, func(t *testing.T) {
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "dnf:test",
Metadata: map[string]string{"account_cera": "700"},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "88", AccountID: "dnf:test", Name: "Receiver", Level: 90,
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "dnf:test",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
gameSessions: map[uint16]*gameSession{88: {
conn: connection, connID: "gm-wallet-" + testCase.currency, selectedCharacterID: 88,
}},
}
result, err := service.GMUpdateWallet(ctx, GMWalletRequest{
CharacterID: "88", Currency: testCase.currency, Mode: "set", Amount: testCase.amount,
})
if err != nil {
t.Fatal(err)
}
if !result.Refreshed {
t.Fatalf("在线时 %s 必须刷新客户端(refresh_error=%q)", testCase.currency, result.RefreshError)
}
body := currentVoucherWalletPacketBody(t, connection)
if body == nil {
t.Fatalf("%s 改动后没有推送 class0/op53 钱包包", testCase.currency)
}
if len(body) != 13 {
t.Fatalf("钱包包体长度=%d want=13", len(body))
}
if got := int32(binary.LittleEndian.Uint32(body[5:9])); got != testCase.wantTokenCera {
t.Fatalf("%s: 推送的代币券=%d want=%d", testCase.currency, got, testCase.wantTokenCera)
}
if got := int32(binary.LittleEndian.Uint32(body[9:13])); got != testCase.wantHappyTokenCera {
t.Fatalf("%s: 推送的欢乐代币券=%d want=%d", testCase.currency, got, testCase.wantHappyTokenCera)
}
})
}
}
// GM 券补发:背包里**只有代币券**时也必须刷新在线客户端。
//
// `Granted` 只统计欢乐代币券(既有语义),所以只补代币券时它是 0。早先的刷新门只判
// `Granted == 0` → 整段跳过:行已删、钱包已入账,客户端两样都看不到,还会去点那行
// 已经不存在的幽灵券。
func TestGMRepairRefreshesWhenOnlyTokenCeraVoucherPresent(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "dnf:test", Metadata: map[string]string{"account_token_cera": "10"},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "88", AccountID: "dnf:test", Name: "Receiver", Level: 90,
}); err != nil {
t.Fatal(err)
}
// 只有代币券行,没有欢乐代币券行。
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "88",
Slots: map[string]dnfrepo.ItemStack{"0:123": {ItemID: 2681762, Count: 5}},
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "dnf:test",
gameUpperHeader: gameUpperHeaderServer16,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
gameSessions: map[uint16]*gameSession{88: {
conn: connection, connID: "gm-token-only-repair", selectedCharacterID: 88,
}},
}
result, err := service.GMRepairHappyTokenCeraVoucher(ctx, GMWalletRequest{CharacterID: "88"})
if err != nil {
t.Fatal(err)
}
if result.Granted != 0 || result.TokenCeraGranted != 5 {
t.Fatalf("Granted=%d TokenCeraGranted=%d want 0/5(Granted 仍只表示欢乐代币券)",
result.Granted, result.TokenCeraGranted)
}
if !result.Refreshed {
t.Fatalf("只补代币券时也必须刷新客户端(refresh_error=%q)", result.RefreshError)
}
body := currentVoucherWalletPacketBody(t, connection)
if body == nil {
t.Fatal("只补代币券时没有推送 class0/op53 钱包包")
}
if got := int32(binary.LittleEndian.Uint32(body[5:9])); got != 15 {
t.Fatalf("推送的代币券=%d want=15(10+5)", got)
}
}