服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
404 lines
15 KiB
Go
404 lines
15 KiB
Go
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)
|
||
}
|
||
}
|