Files
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

404 lines
15 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"
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)
}
}