Files
90CNServer/server/internal/services/dnfbridge/cera_shop_test.go
T
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

2304 lines
109 KiB
Go

package dnfbridge
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"os"
"sort"
"strconv"
"strings"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/dnfenum"
"longheng.io/server/internal/modules/dnf/premium"
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"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func seedCeraShopAccountCera(t *testing.T, ctx context.Context, repositories dnfrepo.Group, accountID string, balance int64) {
t.Helper()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: accountID,
Metadata: map[string]string{"account_cera": strconv.FormatInt(balance, 10)},
}); err != nil {
t.Fatal(err)
}
}
func loadCeraShopAccountCera(t *testing.T, ctx context.Context, repositories dnfrepo.Group, accountID string) int64 {
t.Helper()
account, found, err := repositories.Account.Load(ctx, accountID)
if err != nil || !found {
t.Fatalf("load account found=%t err=%v", found, err)
}
return currentAccountCera(account)
}
func TestCurrentCeraShopWalletBodyCarriesAllThreeAccountCurrencies(t *testing.T) {
want := []byte{
1,
0xBC, 0x02, 0x00, 0x00,
0x64, 0x00, 0x00, 0x00,
0xA4, 0x86, 0x01, 0x00,
}
if got := buildCurrentCeraShopWalletBody(700, 100, 100004); !bytes.Equal(got, want) {
t.Fatalf("wallet body=%x want=%x", got, want)
}
}
func TestCurrentCeraShopPurchaseLegacyRouteCommitsPVFProductAndSendsCurrentPackets(t *testing.T) {
catalog := mustCurrentCeraShopTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
seedCeraShopAccountCera(t, ctx, repositories, "account-1", 200)
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "19",
AccountID: "account-1",
Stats: map[string]int64{"cera": 200},
}); 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)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "account-1",
gameUpperHeader: gameUpperHeaderChannel13,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
ceraShopCatalog: catalog,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{conn: connection, connID: "cera-shop-test", selectedCharacterID: 19}
// The live current client sent this exact 19-byte cart shape through the
// legacy game decoder (class1/op64): count=1, Cera payment mode, FF FF row
// fields, and the commodity little-endian at offset seven. Use a supported
// fixture commodity here to prove the legacy route reaches the real owner.
if err := service.handleGameCommand(
session,
byte(dnfenum.GameCmdCommand),
uint16(dnfenum.CmdPacketBuyCerashopItem),
currentCeraShopTestRequestBody(100050),
); err != nil {
t.Fatal(err)
}
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.Classification != dnfproto.DefaultChannelClassification || ack.Header.MsgID != uint16(dnfenum.CmdPacketBuyCerashopItem) {
t.Fatalf("purchase ACK header=%+v", ack.Header)
}
if want := buildCurrentCeraShopPurchaseSuccessBodyWithCount(100050, 3); !bytes.Equal(ack.Body, want) {
t.Fatalf("purchase ACK body=%x want=%x", ack.Body, want)
}
itemUpdate, rest := splitGameServerUpperPacket(t, rest)
if itemUpdate.Header.Classification != 0 || itemUpdate.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) {
t.Fatalf("item update header=%+v", itemUpdate.Header)
}
if len(itemUpdate.Body) != 3+currentItemListEntryWireSize || itemUpdate.Body[0] != dnfrepo.MainInventoryListType ||
binary.LittleEndian.Uint16(itemUpdate.Body[1:3]) != 1 || binary.LittleEndian.Uint16(itemUpdate.Body[3:5]) != 3 ||
binary.LittleEndian.Uint32(itemUpdate.Body[5:9]) != 37 || binary.LittleEndian.Uint32(itemUpdate.Body[9:13]) != 3 ||
binary.LittleEndian.Uint16(itemUpdate.Body[3+0x0B:3+0x0D]) != 0xFFFF ||
binary.LittleEndian.Uint32(itemUpdate.Body[3+0x6E:3+0x72]) != 0 {
t.Fatalf("item update body=%x", itemUpdate.Body)
}
balance, trailing := splitGameServerUpperPacket(t, rest)
if len(trailing) != 0 || balance.Header.Classification != 0 || balance.Header.MsgID != currentCeraShopUpdateMsgID {
t.Fatalf("balance header=%+v trailing=%d", balance.Header, len(trailing))
}
if want := buildCurrentCeraShopBalanceBody(120); !bytes.Equal(balance.Body, want) {
t.Fatalf("balance body=%x want=%x", balance.Body, want)
}
if got := loadCeraShopAccountCera(t, ctx, repositories, "account-1"); got != 120 {
t.Fatalf("persisted account cera=%d, want 120", got)
}
inventory, found, err := repositories.Inventory.Load(ctx, "19")
if err != nil || !found {
t.Fatalf("load persisted inventory found=%t err=%v", found, err)
}
stack, found := inventory.Slots["0:3"]
if !found || stack.ItemID != 37 || stack.Count != 3 || len(stack.RawEntry) != currentItemListEntryWireSize || stack.Extra["last_grant_source"] != "cera_shop" ||
!stack.ExpireAt.Equal(time.Date(2028, time.August, 15, 22, 0, 0, 0, time.UTC)) ||
stack.Extra["expire_time"] != "1849989600" || stack.Extra["expire_unix"] != "1849989600" ||
binary.LittleEndian.Uint16(stack.RawEntry[0x0B:0x0D]) != 0xFFFF || binary.LittleEndian.Uint32(stack.RawEntry[0x6E:0x72]) != 0 {
t.Fatalf("persisted item=%+v found=%t", stack, found)
}
}
func TestCurrentCeraShopPurchaseRejectsUnknownCommodityWithoutMutation(t *testing.T) {
catalog := mustCurrentCeraShopTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
seedCeraShopAccountCera(t, ctx, repositories, "account-1", 200)
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "19", AccountID: "account-1", Stats: map[string]int64{"cera": 200}}); 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)
}
connection := &bufferConn{}
service := &Service{
options: options{accountID: "account-1", gameUpperHeader: gameUpperHeaderChannel13, gameUpperBodyCodec: gameUpperBodyCodecPlain},
ceraShopCatalog: catalog,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
if err := service.handleCurrentCeraShopPurchase(&gameSession{conn: connection, selectedCharacterID: 19}, currentCeraShopTestRequestBody(999999)); err != nil {
t.Fatal(err)
}
failure, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(trailing) != 0 || failure.Header.Classification != dnfproto.DefaultChannelClassification ||
failure.Header.MsgID != uint16(dnfenum.CmdPacketBuyCerashopItem) ||
!bytes.Equal(failure.Body, buildCurrentCeraShopPurchaseFailureBody()) {
t.Fatalf("failure packet=%+v body=%x trailing=%d", failure.Header, failure.Body, len(trailing))
}
if got := loadCeraShopAccountCera(t, ctx, repositories, "account-1"); got != 200 {
t.Fatalf("cera mutated: account cera=%d, want 200", got)
}
inventory, found, err := repositories.Inventory.Load(ctx, "19")
if err != nil || !found || len(inventory.Slots) != 0 {
t.Fatalf("inventory mutated inventory=%+v found=%t err=%v", inventory, found, err)
}
}
func TestCurrentCeraShopPurchaseRoutesPVFFeedToPetConsumables(t *testing.T) {
catalog := mustCurrentCeraShopTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
seedCeraShopAccountCera(t, ctx, repositories, "account-pet-feed", 100)
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "23",
AccountID: "account-pet-feed",
Stats: map[string]int64{"cera": 100},
}); err != nil {
t.Fatal(err)
}
// Keep an already misplaced same-template row in list 0. A correct
// purchase must neither merge into it nor select another main-bag slot.
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "23",
Slots: map[string]dnfrepo.ItemStack{
"0:65": {
ItemID: 42,
Count: 6,
Extra: map[string]string{
"item_kind": string(dungeonDropItemStackable),
"stackable_type": "[feed]",
},
},
},
}); err != nil {
t.Fatal(err)
}
service := &Service{
options: options{accountID: "account-pet-feed"},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
request, err := parseCurrentCeraShopPurchaseRequest(currentCeraShopTestRequestBody(100055))
if err != nil {
t.Fatal(err)
}
for purchaseIndex := 0; purchaseIndex < 2; purchaseIndex++ {
result, err := service.commitCurrentCeraShopPurchase(ctx, &gameSession{selectedCharacterID: 23}, catalog, request)
if err != nil {
t.Fatalf("commit pet-feed purchase %d: %v", purchaseIndex+1, err)
}
if !result.PetInventoryChanged {
t.Fatalf("purchase %d did not request a pet-container refresh: %+v", purchaseIndex+1, result)
}
}
if got := loadCeraShopAccountCera(t, ctx, repositories, "account-pet-feed"); got != 60 {
t.Fatalf("account cera after pet-feed purchases=%d, want 60", got)
}
inventory, found, err := repositories.Inventory.Load(ctx, "23")
if err != nil || !found {
t.Fatalf("load pet-feed inventory found=%t err=%v", found, err)
}
if main := inventory.Slots["0:65"]; main.ItemID != 42 || main.Count != 6 {
t.Fatalf("misplaced pre-existing main row was merged or mutated: %+v", main)
}
feed, found := inventory.Slots["7:189"]
if !found || feed.ItemID != 42 || feed.Count != 20 || feed.Extra["stackable_type"] != "[feed]" ||
len(feed.RawEntry) != currentItemListEntryWireSize ||
binary.LittleEndian.Uint16(feed.RawEntry[0:2]) != 189 ||
binary.LittleEndian.Uint32(feed.RawEntry[2:6]) != 42 ||
binary.LittleEndian.Uint32(feed.RawEntry[6:10]) != 20 {
t.Fatalf("pet-consumable row=%+v found=%t raw=%x", feed, found, feed.RawEntry)
}
if len(inventory.Slots) != 2 {
t.Fatalf("unexpected extra rows after pet-feed purchases: %+v", inventory.Slots)
}
if isCurrentCeraShopPetConsumable(dungeonDropItemDefinition{Kind: dungeonDropItemStackable, StackableType: "[waste]"}) {
t.Fatal("ordinary stackable was classified as a pet consumable")
}
}
func TestCurrentCeraShopPurchaseRoutesPetArtifactAndRenameCardToPetInventory(t *testing.T) {
catalog := mustCurrentCeraShopTestCatalog(t)
catalog.products[100059] = currentCeraShopProduct{CommodityID: 100059, ItemID: 7001, Count: 1, CeraPrice: 20, Section: "creature"}
catalog.products[100061] = currentCeraShopProduct{CommodityID: 100061, ItemID: 7002, Count: 1, CeraPrice: 20, Section: "creature"}
catalog.items.itemCache[7001] = dungeonDropItemDefinition{
ItemID: 7001,
Kind: dungeonDropItemEquipment,
PVFPath: "equipment/creature/artifact_red/test_artifact.equ",
EquipmentType: "[artifact red]",
}
catalog.items.itemCache[7002] = dungeonDropItemDefinition{
ItemID: 7002,
Kind: dungeonDropItemStackable,
PVFPath: "stackable/cash/creature/rename_card.stk",
StackableType: "[creature]",
StackLimit: 1,
}
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
seedCeraShopAccountCera(t, ctx, repositories, "account-pet-routes", 100)
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "26", AccountID: "account-pet-routes", Stats: map[string]int64{"cera": 100},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "26", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
service := &Service{options: options{accountID: "account-pet-routes"}, repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true }}
session := &gameSession{selectedCharacterID: 26}
for _, commodityID := range []uint32{100059, 100061} {
request, err := parseCurrentCeraShopPurchaseRequest(currentCeraShopTestRequestBody(commodityID))
if err != nil {
t.Fatal(err)
}
result, err := service.commitCurrentCeraShopPurchase(ctx, session, catalog, request)
if err != nil {
t.Fatalf("commit pet purchase commodity=%d: %v", commodityID, err)
}
if !result.PetInventoryChanged || len(result.Updates) != 0 {
t.Fatalf("pet purchase commodity=%d result=%+v", commodityID, result)
}
}
inventory, found, err := repositories.Inventory.Load(ctx, "26")
if err != nil || !found {
t.Fatalf("load pet inventory found=%t err=%v", found, err)
}
artifact := inventory.Slots["7:140"]
if artifact.ItemID != 7001 || artifact.Count != 1 || artifact.Extra["equipment_type"] != "[artifact red]" {
t.Fatalf("pet artifact route=%+v", artifact)
}
renameCard := inventory.Slots["7:189"]
if renameCard.ItemID != 7002 || renameCard.Count != 1 || renameCard.Extra["pvf_path"] != "stackable/cash/creature/rename_card.stk" {
t.Fatalf("pet rename-card route=%+v", renameCard)
}
if _, inMainInventory := inventory.Slots["0:0"]; inMainInventory {
t.Fatal("pet equipment or rename card entered main inventory")
}
}
func TestCurrentCeraShopPurchasePersistsUsablePeriodWithoutMergingLegacyRow(t *testing.T) {
catalog := mustCurrentCeraShopTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
seedCeraShopAccountCera(t, ctx, repositories, "account-timed-item", 100)
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "24",
AccountID: "account-timed-item",
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "24",
Slots: map[string]dnfrepo.ItemStack{
"0:65": {ItemID: 43, Count: 1, Extra: map[string]string{"item_kind": "stackable"}},
},
}); err != nil {
t.Fatal(err)
}
service := &Service{
options: options{accountID: "account-timed-item"},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
request, err := parseCurrentCeraShopPurchaseRequest(currentCeraShopTestRequestBody(100056))
if err != nil {
t.Fatal(err)
}
before := time.Now().UTC()
result, err := service.commitCurrentCeraShopPurchase(ctx, &gameSession{selectedCharacterID: 24}, catalog, request)
if err != nil {
t.Fatal(err)
}
if result.CeraAfter != 80 || result.PetInventoryChanged || len(result.Updates) != 1 || result.Updates[0].ListType != dnfrepo.MainInventoryListType {
t.Fatalf("commit result=%+v", result)
}
if got := loadCeraShopAccountCera(t, ctx, repositories, "account-timed-item"); got != 80 {
t.Fatalf("account cera=%d want=80", got)
}
inventory, found, err := repositories.Inventory.Load(ctx, "24")
if err != nil || !found {
t.Fatalf("load inventory found=%t err=%v", found, err)
}
// The current inventory merge contract lets the legacy row at 0:65 absorb
// the same-item purchase and receive the active-PVF expiration.
merged := inventory.Slots["0:65"]
minExpire := time.Unix(before.Unix()+10*86400, 0).UTC()
maxExpire := time.Unix(time.Now().UTC().Unix()+10*86400, 0).UTC()
if merged.Count != 2 || merged.ItemID != 43 || merged.ExpireAt.Before(minExpire) || merged.ExpireAt.After(maxExpire) {
t.Fatalf("merged row=%+v want count=2 with expiration", merged)
}
if _, hasOldSlot := inventory.Slots["0:66"]; hasOldSlot {
t.Fatalf("expected merge into 0:65, but 0:66 also exists: %+v", inventory.Slots["0:66"])
}
}
func TestCurrentCeraShopPurchaseMaterializesVisualDurationAtCheckout(t *testing.T) {
catalog := mustCurrentCeraShopTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
seedCeraShopAccountCera(t, ctx, repositories, "account-visual", 100)
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "25", AccountID: "account-visual"}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "25", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
service := &Service{
options: options{accountID: "account-visual"},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
request, err := parseCurrentCeraShopPurchaseRequest(currentCeraShopTestRequestBody(100057))
if err != nil {
t.Fatal(err)
}
before := time.Now().UTC()
result, err := service.commitCurrentCeraShopPurchase(ctx, &gameSession{selectedCharacterID: 25}, catalog, request)
if err != nil {
t.Fatal(err)
}
if result.CeraAfter != 40 || len(result.Updates) != 1 || result.Updates[0].ListType != dnfrepo.MainInventoryListType {
t.Fatalf("result=%+v", result)
}
inventory, found, err := repositories.Inventory.Load(ctx, "25")
if err != nil || !found {
t.Fatalf("load inventory found=%t err=%v", found, err)
}
stack, found := inventory.Slots["0:9"]
minExpire := time.Unix(before.Unix()+30*86400, 0).UTC()
maxExpire := time.Unix(time.Now().UTC().Unix()+30*86400, 0).UTC()
if !found || stack.ItemID != 9002 || stack.Count != 1 || stack.ExpireAt.Before(minExpire) || stack.ExpireAt.After(maxExpire) ||
stack.Extra["cera_shop_duration_days"] != "30" || stack.Extra["expiration_source"] != "runtime_pvf_cera_shop_duration_grant" ||
stack.Extra["last_cera_shop_commodity"] != "100057" || len(stack.RawEntry) != currentItemListEntryWireSize ||
binary.LittleEndian.Uint32(stack.RawEntry[currentItemListExpireTimeOffset:currentItemListExpireTimeOffset+4]) != uint32(stack.ExpireAt.Unix()) ||
binary.LittleEndian.Uint32(stack.RawEntry[legacyWrongCurrentItemListExpireTimeOffset:legacyWrongCurrentItemListExpireTimeOffset+4]) != 0 {
t.Fatalf("visual row=%+v found=%t raw=%x", stack, found, stack.RawEntry)
}
}
func TestCurrentCeraShopPurchaseConsumesPVFAccountCargoUpgrade(t *testing.T) {
ctx := context.Background()
catalog := mustCurrentCeraShopTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "account-cargo-1",
Metadata: map[string]string{
"account_cargo_created": "true",
"account_cargo_level": "1",
"account_cargo_gold": "0",
"account_cera": "5000",
},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "29", AccountID: "account-cargo-1", Stats: map[string]int64{"cera": 5000}}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "29", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
service := &Service{
options: options{accountID: "account-cargo-1"},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
request, err := parseCurrentCeraShopPurchaseRequest(currentCeraShopTestRequestBody(100083))
if err != nil {
t.Fatal(err)
}
got, err := service.commitCurrentCeraShopPurchase(ctx, &gameSession{selectedCharacterID: 29}, catalog, request)
if err != nil {
t.Fatalf("commit account cargo purchase: %v", err)
}
if got.AccountCargoSelectionKey != 8 || got.CeraAfter != 3000 || len(got.Updates) != 0 {
t.Fatalf("commit result=%+v", got)
}
account, found, err := repositories.Account.Load(ctx, "account-cargo-1")
if err != nil || !found || account.Metadata["account_cargo_level"] != "8" {
t.Fatalf("account after purchase=%+v found=%t err=%v", account, found, err)
}
if got := loadCeraShopAccountCera(t, ctx, repositories, "account-cargo-1"); got != 3000 {
t.Fatalf("account cera after purchase=%d, want 3000", got)
}
}
// TestCurrentCeraShopPurchaseUpgradesAccountCargoPastTier64 固定 2026-08-25 现网
// 缺陷:容量 64 时客户端请求第 6 个帐号金库升级工具(64->72),阶梯只写到 64
// 时结算返回 errCurrentCeraShopProductUnavailable,客户端把失败码 4 显示成
// "物品栏空间不足"。
func TestCurrentCeraShopPurchaseUpgradesAccountCargoPastTier64(t *testing.T) {
ctx := context.Background()
catalog := mustCurrentCeraShopTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "account-cargo-64",
Metadata: map[string]string{
"account_cargo_created": "true",
"account_cargo_level": "64",
"account_cargo_gold": "0",
"account_cera": "5000",
},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "29", AccountID: "account-cargo-64", Stats: map[string]int64{"cera": 5000}}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "29", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
service := &Service{
options: options{accountID: "account-cargo-64"},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
request, err := parseCurrentCeraShopPurchaseRequest(currentCeraShopTestRequestBody(100083))
if err != nil {
t.Fatal(err)
}
got, err := service.commitCurrentCeraShopPurchase(ctx, &gameSession{selectedCharacterID: 29}, catalog, request)
if err != nil {
t.Fatalf("commit account cargo purchase at tier 64: %v", err)
}
if got.AccountCargoSelectionKey != 72 {
t.Fatalf("commit result=%+v, want AccountCargoSelectionKey=72", got)
}
account, found, err := repositories.Account.Load(ctx, "account-cargo-64")
if err != nil || !found || account.Metadata["account_cargo_level"] != "72" {
t.Fatalf("account after purchase=%+v found=%t err=%v", account, found, err)
}
}
func TestCurrentCeraShopPurchaseExpandsCharacterSlotsAtomically(t *testing.T) {
ctx := context.Background()
catalog := mustCurrentCeraShopTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Account.Save(ctx, dnfrepo.AccountRecord{
AccountID: "account-character-slots",
Metadata: map[string]string{"account_cera": "5000"},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "30", AccountID: "account-character-slots"}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "30", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
service := &Service{
options: options{accountID: "account-character-slots"},
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
request, err := parseCurrentCeraShopPurchaseRequest(currentCeraShopTestRequestBody(100084))
if err != nil {
t.Fatal(err)
}
result, err := service.commitCurrentCeraShopPurchase(ctx, &gameSession{selectedCharacterID: 30}, catalog, request)
if err != nil {
t.Fatalf("commit character-slot expansion: %v", err)
}
if result.CharacterSlotLimit != 33 || result.CeraAfter != 2100 || len(result.Updates) != 0 {
t.Fatalf("commit result=%+v", result)
}
account, found, err := repositories.Account.Load(ctx, "account-character-slots")
if err != nil || !found || dnfrepo.AccountCharacterSlotLimit(account) != 33 || account.Metadata[dnfrepo.CharacterSlotLimitMetadataKey] != "33" {
t.Fatalf("persisted account=%+v found=%t err=%v", account, found, err)
}
if account.Metadata[currentCharacterSlotExtendEffectPendingMetadataKey] != "33" {
t.Fatalf("persisted slot-effect pending marker=%q, want 33", account.Metadata[currentCharacterSlotExtendEffectPendingMetadataKey])
}
if got := currentAccountCera(account); got != 2100 {
t.Fatalf("persisted cera=%d, want 2100", got)
}
inventory, found, err := repositories.Inventory.Load(ctx, "30")
if err != nil || !found || len(inventory.Slots) != 0 {
t.Fatalf("character-slot ticket entered inventory: %+v found=%t err=%v", inventory, found, err)
}
account.Metadata[dnfrepo.CharacterSlotLimitMetadataKey] = "60"
if err := repositories.Account.Save(ctx, account); err != nil {
t.Fatal(err)
}
if _, err := service.commitCurrentCeraShopPurchase(ctx, &gameSession{selectedCharacterID: 30}, catalog, request); err == nil {
t.Fatal("60-slot account accepted another expansion")
}
accountAfter, _, _ := repositories.Account.Load(ctx, "account-character-slots")
if dnfrepo.AccountCharacterSlotLimit(accountAfter) != 60 || currentAccountCera(accountAfter) != 2100 {
t.Fatalf("failed 60-slot purchase mutated account=%+v", accountAfter)
}
}
func TestCurrentCeraShopCharacterSlotTicketConsumesOneDurableOp415Effect(t *testing.T) {
ctx := context.Background()
catalog := mustCurrentCeraShopTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
seedCeraShopAccountCera(t, ctx, repositories, "account-1", 5000)
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "30", AccountID: "account-1", Level: 90}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "30", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
conn := &bufferConn{}
service := newCeraShopPremiumTestService(repositories, catalog)
session := &gameSession{conn: conn, connID: "character-slot-ticket", selectedCharacterID: 30}
if err := service.handleCurrentCeraShopPurchase(session, currentCeraShopTestRequestBody(100084)); err != nil {
t.Fatal(err)
}
purchase, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
if purchase.Header.MsgID != uint16(dnfenum.CmdPacketBuyCerashopItem) {
t.Fatalf("purchase ACK header=%+v", purchase.Header)
}
wallet, rest := splitGameServerUpperPacket(t, rest)
if wallet.Header.MsgID != currentCeraShopUpdateMsgID || len(rest) != 0 {
t.Fatalf("wallet header=%+v trailing=%d", wallet.Header, len(rest))
}
conn.write.Reset()
if err := service.handleUpperCharacSlotExtendEffect(session, []byte{0}); err != nil {
t.Fatal(err)
}
if conn.write.Len() != 0 {
t.Fatalf("malformed op415 wrote %d bytes: %x", conn.write.Len(), conn.write.Bytes())
}
accountBefore, found, err := repositories.Account.Load(ctx, "account-1")
if err != nil || !found || accountBefore.Metadata[currentCharacterSlotExtendEffectPendingMetadataKey] != "33" {
t.Fatalf("malformed op415 consumed pending marker: account=%+v found=%t err=%v", accountBefore, found, err)
}
if err := service.handleUpperCharacSlotExtendEffect(session, nil); err != nil {
t.Fatal(err)
}
effect, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
if effect.Header.MsgID != uint16(dnfenum.UpperMsgCharacSlotExtendEffect) ||
effect.Header.Classification != dnfproto.DefaultChannelClassification ||
!bytes.Equal(effect.Body, []byte{1}) || len(rest) != 0 {
t.Fatalf("slot effect header=%+v body=%x trailing=%d", effect.Header, effect.Body, len(rest))
}
account, found, err := repositories.Account.Load(ctx, "account-1")
if err != nil || !found || dnfrepo.AccountCharacterSlotLimit(account) != 33 ||
account.Metadata[currentCharacterSlotExtendEffectPendingMetadataKey] != "0" {
t.Fatalf("consumed slot effect account=%+v found=%t err=%v", account, found, err)
}
conn.write.Reset()
if err := service.handleUpperCharacSlotExtendEffect(session, nil); err != nil {
t.Fatal(err)
}
if conn.write.Len() != 0 {
t.Fatalf("duplicate op415 replayed %d bytes: %x", conn.write.Len(), conn.write.Bytes())
}
}
func TestCurrentCeraShopPurchaseStoresAvatarInAvatarContainerAndUpdatesThatContainer(t *testing.T) {
catalog := mustCurrentCeraShopTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
seedCeraShopAccountCera(t, ctx, repositories, "account-1", 40)
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "20",
AccountID: "account-1",
Stats: map[string]int64{"cera": 40},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "20", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "account-1",
gameUpperHeader: gameUpperHeaderChannel13,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
ceraShopCatalog: catalog,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
if err := service.handleCurrentCeraShopPurchase(&gameSession{conn: connection, selectedCharacterID: 20}, currentCeraShopTestRequestBody(100070)); err != nil {
t.Fatal(err)
}
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.MsgID != uint16(dnfenum.CmdPacketBuyCerashopItem) || !bytes.Equal(ack.Body, buildCurrentCeraShopPurchaseSuccessBodyWithCount(100070, 1)) {
t.Fatalf("avatar purchase ACK=%+v body=%x", ack.Header, ack.Body)
}
update, rest := splitGameServerUpperPacket(t, rest)
if update.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) || update.Header.Classification != 0 ||
len(update.Body) != 3+currentItemListEntryWireSize+2*4 || update.Body[0] != 1 || binary.LittleEndian.Uint16(update.Body[1:3]) != 1 ||
binary.LittleEndian.Uint16(update.Body[3:5]) != 0 || binary.LittleEndian.Uint32(update.Body[5:9]) != 9001 || update.Body[3+0x0A] != 0 ||
binary.LittleEndian.Uint16(update.Body[3+0x0B:3+0x0D]) != 0xff ||
!bytes.Equal(update.Body[3+currentItemListEntryWireSize:], make([]byte, 8)) {
t.Fatalf("avatar list1 update=%+v body=%x", update.Header, update.Body)
}
balance, trailing := splitGameServerUpperPacket(t, rest)
if len(trailing) != 0 || balance.Header.MsgID != currentCeraShopUpdateMsgID || !bytes.Equal(balance.Body, buildCurrentCeraShopBalanceBody(10)) {
t.Fatalf("avatar balance=%+v body=%x trailing=%d", balance.Header, balance.Body, len(trailing))
}
inventory, found, err := repositories.Inventory.Load(ctx, "20")
if err != nil || !found {
t.Fatalf("load avatar inventory found=%t err=%v", found, err)
}
stack, found := inventory.Slots["1:0"]
if !found || stack.ItemID != 9001 || stack.Count != 1 || stack.Extra["amount_or_count"] != "0" || stack.Extra["last_cera_shop_section"] != "avatar" ||
stack.Extra["avatar_ability_no"] != "255" || stack.Extra["durability"] != "255" || stack.Extra["ext_data0"] != "" {
t.Fatalf("persisted avatar=%+v found=%t", stack, found)
}
}
func TestCurrentCeraShopPurchaseAvatarCouponConsumesVoucherAndDoesNotDeductCera(t *testing.T) {
catalog := mustCurrentCeraShopTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
seedCeraShopAccountCera(t, ctx, repositories, "account-1", 40)
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "30",
AccountID: "account-1",
Stats: map[string]int64{"cera": 40},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "30",
Slots: map[string]dnfrepo.ItemStack{
"0:67": {ItemID: 2681594, Count: 8, Bind: true},
},
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "account-1",
gameUpperHeader: gameUpperHeaderChannel13,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
ceraShopCatalog: catalog,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
if err := service.handleCurrentCeraShopPurchase(
&gameSession{conn: connection, selectedCharacterID: 30},
currentCeraShopTestRequestBodyWithPayment(100071, currentCeraShopPaymentModeAvatarCoupon, 0),
); err != nil {
t.Fatal(err)
}
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.MsgID != uint16(dnfenum.CmdPacketBuyCerashopItem) || !bytes.Equal(ack.Body, buildCurrentCeraShopPurchaseSuccessBodyWithCount(100071, 1)) {
t.Fatalf("avatar coupon purchase ACK=%+v body=%x", ack.Header, ack.Body)
}
couponUpdate, rest := splitGameServerUpperPacket(t, rest)
if couponUpdate.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) || couponUpdate.Body[0] != dnfrepo.MainInventoryListType ||
binary.LittleEndian.Uint16(couponUpdate.Body[1:3]) != 1 ||
binary.LittleEndian.Uint16(couponUpdate.Body[3:5]) != 67 ||
binary.LittleEndian.Uint32(couponUpdate.Body[5:9]) != 2681594 ||
binary.LittleEndian.Uint32(couponUpdate.Body[9:13]) != 7 {
t.Fatalf("coupon update=%+v body=%x", couponUpdate.Header, couponUpdate.Body)
}
avatarUpdate, rest := splitGameServerUpperPacket(t, rest)
if avatarUpdate.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) || avatarUpdate.Body[0] != 1 ||
binary.LittleEndian.Uint16(avatarUpdate.Body[1:3]) != 1 ||
binary.LittleEndian.Uint16(avatarUpdate.Body[3:5]) != 0 ||
binary.LittleEndian.Uint32(avatarUpdate.Body[5:9]) != 9001 {
t.Fatalf("avatar update=%+v body=%x", avatarUpdate.Header, avatarUpdate.Body)
}
balance, trailing := splitGameServerUpperPacket(t, rest)
if len(trailing) != 0 || balance.Header.MsgID != currentCeraShopUpdateMsgID || !bytes.Equal(balance.Body, buildCurrentCeraShopBalanceBody(40)) {
t.Fatalf("avatar coupon balance=%+v body=%x trailing=%d", balance.Header, balance.Body, len(trailing))
}
if got := loadCeraShopAccountCera(t, ctx, repositories, "account-1"); got != 40 {
t.Fatalf("account cera after avatar coupon=%d, want 40", got)
}
inventory, found, err := repositories.Inventory.Load(ctx, "30")
if err != nil || !found {
t.Fatalf("load avatar coupon inventory found=%t err=%v", found, err)
}
if coupon := inventory.Slots["0:67"]; coupon.ItemID != 2681594 || coupon.Count != 7 {
t.Fatalf("coupon after purchase=%+v", coupon)
}
if avatar := inventory.Slots["1:0"]; avatar.ItemID != 9001 || avatar.Count != 1 || avatar.Extra["avatar_duration_selector_index"] != "3" {
t.Fatalf("avatar after coupon purchase=%+v", avatar)
}
}
func TestCurrentCeraShopPurchaseAvatarCouponSupportsMultiSelectAtomically(t *testing.T) {
catalog := mustCurrentCeraShopTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
seedCeraShopAccountCera(t, ctx, repositories, "account-multi-coupon", 40)
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "31", AccountID: "account-multi-coupon"}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "31",
Slots: map[string]dnfrepo.ItemStack{
"0:67": {ItemID: 2681594, Count: 8, Bind: true},
},
}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{
accountID: "account-multi-coupon",
gameUpperHeader: gameUpperHeaderChannel13,
gameUpperBodyCodec: gameUpperBodyCodecPlain,
},
ceraShopCatalog: catalog,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
request := currentCeraShopTestMultiRequestBody(
currentCeraShopPaymentModeAvatarCoupon,
currentCeraShopTestCartRow{CommodityID: 100071, AttributeValue: 0},
currentCeraShopTestCartRow{CommodityID: 100071, AttributeValue: 1},
)
if err := service.handleCurrentCeraShopPurchase(&gameSession{conn: connection, selectedCharacterID: 31}, request); err != nil {
t.Fatal(err)
}
firstACK, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
secondACK, rest := splitGameServerUpperPacket(t, rest)
for index, packet := range []dnfproto.ChannelPacket{firstACK, secondACK} {
if packet.Header.MsgID != uint16(dnfenum.CmdPacketBuyCerashopItem) ||
!bytes.Equal(packet.Body, buildCurrentCeraShopPurchaseSuccessBodyWithCount(100071, 1)) {
t.Fatalf("ACK %d=%+v body=%x", index, packet.Header, packet.Body)
}
}
couponUpdate, rest := splitGameServerUpperPacket(t, rest)
if couponUpdate.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) || couponUpdate.Body[0] != dnfrepo.MainInventoryListType ||
binary.LittleEndian.Uint16(couponUpdate.Body[1:3]) != 1 ||
binary.LittleEndian.Uint16(couponUpdate.Body[3:5]) != 67 ||
binary.LittleEndian.Uint32(couponUpdate.Body[5:9]) != 2681594 ||
binary.LittleEndian.Uint32(couponUpdate.Body[9:13]) != 6 {
t.Fatalf("coupon update=%+v body=%x", couponUpdate.Header, couponUpdate.Body)
}
avatarUpdate, rest := splitGameServerUpperPacket(t, rest)
secondAvatarOffset := 3 + currentItemListEntryWireSize
for blob := 0; blob < currentAvatarItemListOptionalBlobCount; blob++ {
if secondAvatarOffset+4 > len(avatarUpdate.Body) {
t.Fatalf("avatar optional blob %d overran packet body=%x", blob, avatarUpdate.Body)
}
blobLength := int(binary.LittleEndian.Uint32(avatarUpdate.Body[secondAvatarOffset : secondAvatarOffset+4]))
secondAvatarOffset += 4 + blobLength
}
if avatarUpdate.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) || avatarUpdate.Body[0] != 1 ||
binary.LittleEndian.Uint16(avatarUpdate.Body[1:3]) != 2 ||
binary.LittleEndian.Uint16(avatarUpdate.Body[3:5]) != 0 ||
secondAvatarOffset+2 > len(avatarUpdate.Body) ||
binary.LittleEndian.Uint16(avatarUpdate.Body[secondAvatarOffset:secondAvatarOffset+2]) != 1 {
t.Fatalf("avatar update=%+v body=%x", avatarUpdate.Header, avatarUpdate.Body)
}
balance, trailing := splitGameServerUpperPacket(t, rest)
if len(trailing) != 0 || balance.Header.MsgID != currentCeraShopUpdateMsgID || !bytes.Equal(balance.Body, buildCurrentCeraShopBalanceBody(40)) {
t.Fatalf("balance=%+v body=%x trailing=%x", balance.Header, balance.Body, trailing)
}
inventory, found, err := repositories.Inventory.Load(ctx, "31")
if err != nil || !found {
t.Fatalf("load inventory found=%t err=%v", found, err)
}
if coupon := inventory.Slots["0:67"]; coupon.ItemID != 2681594 || coupon.Count != 6 {
t.Fatalf("coupon=%+v", coupon)
}
for slot, attribute := range map[string]string{"1:0": "0", "1:1": "1"} {
avatar, exists := inventory.Slots[slot]
if !exists || avatar.ItemID != 9001 || avatar.Count != 1 || avatar.Extra["avatar_ability_no"] != attribute {
t.Fatalf("avatar slot=%s found=%t stack=%+v", slot, exists, avatar)
}
}
}
func TestGrantCurrentCeraShopAvatarAutoOpensOnlyAuthoredGiftSockets(t *testing.T) {
tests := []struct {
name string
equipmentType string
flavorText string
socketPVF string
wantTypes []byte
}{
{
name: "aura default sockets",
equipmentType: "[aurora avatar]",
socketPVF: "[avatar emblem socket num]\n2\n[emblem socket default]\n`[M socket]`\n`[M socket]`\n[/emblem socket default]\n",
wantTypes: []byte{0xEF, 0xEF},
},
{
name: "aura manual socket choices",
equipmentType: "[aurora avatar]",
socketPVF: "[avatar type select]\n`[M socket]`\n`[M socket]`\n[/avatar type select]\n",
},
{
name: "ordinary avatar default declaration",
equipmentType: "[hat avatar]",
socketPVF: "[avatar emblem socket num]\n2\n[emblem socket default]\n`[M socket]`\n`[M socket]`\n[/emblem socket default]\n",
},
{
name: "2018 Spring Festival package skin choices",
equipmentType: "[skin avatar]",
flavorText: "<2018春节礼包>",
socketPVF: "[avatar emblem socket num]\n2\n[avatar type select]\n`[M socket]`\n`[M socket]`\n[/avatar type select]\n",
wantTypes: []byte{0xEF, 0xEF},
},
{
name: "ordinary skin with manual socket choices",
equipmentType: "[skin avatar]",
flavorText: "<ordinary package>",
socketPVF: "[avatar emblem socket num]\n2\n[avatar type select]\n`[M socket]`\n`[M socket]`\n[/avatar type select]\n",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
const path = "equipment/avatar/test_default_socket.equ"
source := dungeonDropCatalogTestSource{
path: "[equipment type]\n`" + test.equipmentType + "`\n[flavor text]\n`" + test.flavorText + "`\n" + test.socketPVF,
}
inventory := dnfrepo.InventoryRecord{CharacterID: "19", Slots: make(map[string]dnfrepo.ItemStack)}
slot, err := grantCurrentCeraShopAvatar(
&inventory,
source,
dungeonDropItemDefinition{ItemID: 9001, Kind: dungeonDropItemEquipment, PVFPath: path},
currentCeraShopProduct{ItemID: 9001, Count: 1, Section: "avatar"},
0,
)
if err != nil {
t.Fatal(err)
}
stack := inventory.Slots[currentCeraShopInventorySlotKey(1, slot)]
data := currentAvatarSocketData(stack.Extra)
if got := currentAvatarSocketOpenCount(data); got != len(test.wantTypes) {
t.Fatalf("open sockets=%d want=%d data=%x extra=%+v", got, len(test.wantTypes), data, stack.Extra)
}
for index, want := range test.wantTypes {
if got := currentAvatarSocketType(data, byte(index)); got != want {
t.Fatalf("socket[%d]=0x%02x want=0x%02x data=%x", index, got, want, data)
}
}
if len(test.wantTypes) == 0 {
for _, key := range []string{"avatar_socket_data", "reserved2", "jewel_socket"} {
if stack.Extra[key] != "" {
t.Fatalf("unopened avatar wrote %s=%q", key, stack.Extra[key])
}
}
return
}
for _, key := range []string{"avatar_socket_data", "reserved2", "jewel_socket"} {
if stack.Extra[key] == "" {
t.Fatalf("opened aura missing %s: %+v", key, stack.Extra)
}
}
})
}
}
func TestCurrentCeraShopPurchaseNormalizesPackageQuantityAndUsesMainContainer(t *testing.T) {
catalog := mustCurrentCeraShopTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
seedCeraShopAccountCera(t, ctx, repositories, "account-1", 60)
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "21", AccountID: "account-1", Stats: map[string]int64{"cera": 60}}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "21", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{accountID: "account-1", gameUpperHeader: gameUpperHeaderChannel13, gameUpperBodyCodec: gameUpperBodyCodecPlain},
ceraShopCatalog: catalog,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
if err := service.handleCurrentCeraShopPurchase(&gameSession{conn: connection, selectedCharacterID: 21}, currentCeraShopTestRequestBody(100060)); err != nil {
t.Fatal(err)
}
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.MsgID != uint16(dnfenum.CmdPacketBuyCerashopItem) || !bytes.Equal(ack.Body, buildCurrentCeraShopPurchaseSuccessBodyWithCount(100060, 1)) {
t.Fatalf("package purchase ACK=%+v body=%x", ack.Header, ack.Body)
}
update, rest := splitGameServerUpperPacket(t, rest)
if update.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) || update.Body[0] != dnfrepo.MainInventoryListType ||
binary.LittleEndian.Uint16(update.Body[1:3]) != 1 || binary.LittleEndian.Uint32(update.Body[5:9]) != 37 || binary.LittleEndian.Uint32(update.Body[9:13]) != 1 {
t.Fatalf("package main update=%+v body=%x", update.Header, update.Body)
}
balance, trailing := splitGameServerUpperPacket(t, rest)
if len(trailing) != 0 || balance.Header.MsgID != currentCeraShopUpdateMsgID || !bytes.Equal(balance.Body, buildCurrentCeraShopBalanceBody(10)) {
t.Fatalf("package balance=%+v body=%x trailing=%d", balance.Header, balance.Body, len(trailing))
}
inventory, found, err := repositories.Inventory.Load(ctx, "21")
if err != nil || !found {
t.Fatalf("load package inventory found=%t err=%v", found, err)
}
stack, found := inventory.Slots["0:3"]
if !found || stack.ItemID != 37 || stack.Count != 1 || stack.Extra["last_cera_shop_section"] != "package" {
t.Fatalf("persisted package=%+v found=%t", stack, found)
}
}
func TestCurrentCeraShopPurchaseConsumesPersonalCargoUpgradeAndRefreshesListTwo(t *testing.T) {
catalog := mustCurrentCeraShopTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
seedCeraShopAccountCera(t, ctx, repositories, "account-1", 120)
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "22", AccountID: "account-1", Stats: map[string]int64{"cera": 120}}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "22", Slots: map[string]dnfrepo.ItemStack{}, Warehouse: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
if err := repositories.Settings.Save(ctx, newCharacterContainerStateSettings("22", time.Now().UTC())); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{accountID: "account-1", gameUpperHeader: gameUpperHeaderChannel13, gameUpperBodyCodec: gameUpperBodyCodecPlain},
ceraShopCatalog: catalog,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{conn: connection, connID: "cera-cargo-upgrade", selectedCharacterID: 22}
if err := service.handleCurrentCeraShopPurchase(session, currentCeraShopTestRequestBody(100080)); err != nil {
t.Fatal(err)
}
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.MsgID != uint16(dnfenum.CmdPacketBuyCerashopItem) || !bytes.Equal(ack.Body, buildCurrentCeraShopPurchaseSuccessBodyWithCount(100080, 1)) {
t.Fatalf("cargo upgrade ACK=%+v body=%x", ack.Header, ack.Body)
}
// 扩容改变了仓库容量:ACK 之后必须先重建仓库底板(op391),否则面板上那些新解锁的
// 格子还画着锁(现场 2026-09-18)。
pad, rest := splitGameServerUpperPacketWithHeader(t, rest, dnfproto.GameServerUpperHeaderSize16)
if pad.Header.Classification != 0 || pad.Header.MsgID != uint16(dnfenum.CmdPacketCancelCargoPad) {
t.Fatalf("cargo upgrade must reset the warehouse pad first: header=%+v", pad.Header)
}
cargo, rest := splitGameServerUpperPacketWithHeader(t, rest, dnfproto.GameServerUpperHeaderSize16)
if cargo.Header.Classification != 0 || cargo.Header.MsgID != uint16(dnfenum.CmdPacketLeaveParty) || !bytes.Equal(cargo.Body, []byte{2, 24, 0, 0, 0, 0}) {
t.Fatalf("cargo refresh header=%+v body=%x", cargo.Header, cargo.Body)
}
balance, trailing := splitGameServerUpperPacket(t, rest)
if len(trailing) != 0 || balance.Header.MsgID != currentCeraShopUpdateMsgID || !bytes.Equal(balance.Body, buildCurrentCeraShopBalanceBody(60)) {
t.Fatalf("cargo upgrade balance=%+v body=%x trailing=%d", balance.Header, balance.Body, len(trailing))
}
state, found, err := dnfrepo.LoadCharacterContainerState(ctx, repositories.Settings, "22")
if err != nil || !found || state.PersonalCargoSlotCount != 24 {
t.Fatalf("persisted personal cargo state=%+v found=%t err=%v", state, found, err)
}
if got := loadCeraShopAccountCera(t, ctx, repositories, "account-1"); got != 60 {
t.Fatalf("persisted account cera=%d, want 60", got)
}
inventory, found, err := repositories.Inventory.Load(ctx, "22")
if err != nil || !found || len(inventory.Slots) != 0 {
t.Fatalf("personal cargo upgrade must not create a bag stack: inventory=%+v found=%t err=%v", inventory, found, err)
}
retryConnection := &bufferConn{}
if err := service.handleCurrentCeraShopPurchase(&gameSession{conn: retryConnection, selectedCharacterID: 22}, currentCeraShopTestRequestBody(100080)); err != nil {
t.Fatal(err)
}
failure, trailing := splitGameServerUpperPacket(t, retryConnection.write.Bytes())
if len(trailing) != 0 || failure.Header.MsgID != uint16(dnfenum.CmdPacketBuyCerashopItem) || !bytes.Equal(failure.Body, buildCurrentCeraShopPurchaseFailureBody()) {
t.Fatalf("repeated cargo upgrade response=%+v body=%x trailing=%d", failure.Header, failure.Body, len(trailing))
}
if got := loadCeraShopAccountCera(t, ctx, repositories, "account-1"); got != 60 {
t.Fatalf("repeated cargo upgrade debited account Cera: %d", got)
}
}
func TestCurrentCeraShopPurchaseConsumesAvatarInventoryUpgradesAndRefreshesListOne(t *testing.T) {
catalog := mustCurrentCeraShopTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
seedCeraShopAccountCera(t, ctx, repositories, "account-1", 1000)
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "29", AccountID: "account-1", Stats: map[string]int64{"cera": 1000}}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "29", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
if err := repositories.Settings.Save(ctx, newCharacterContainerStateSettings("29", time.Now().UTC())); err != nil {
t.Fatal(err)
}
service := &Service{
options: options{accountID: "account-1", gameUpperHeader: gameUpperHeaderChannel13, gameUpperBodyCodec: gameUpperBodyCodecPlain},
ceraShopCatalog: catalog,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
stages := []struct {
commodityID uint32
expansion uint16
price int64
}{
{commodityID: 102644, expansion: 7, price: 60},
{commodityID: 102645, expansion: 14, price: 70},
}
remainingCera := int64(1000)
for _, stage := range stages {
connection := &bufferConn{}
session := &gameSession{conn: connection, connID: "cera-avatar-inventory-upgrade", selectedCharacterID: 29}
if err := service.handleCurrentCeraShopPurchase(session, currentCeraShopTestRequestBody(stage.commodityID)); err != nil {
t.Fatal(err)
}
// The avatar expansion list must be published BEFORE the class1/op64
// success ACK: the client rebuilds its visible Cera-shop product list when
// it handles that ACK and re-derives "the next stage" ticket from the
// expansion value it then holds.
refresh, rest := splitGameServerUpperPacketWithHeader(t, connection.write.Bytes(), dnfproto.GameServerUpperHeaderSize16)
wantRefresh := []byte{currentAvatarInventoryListType, byte(stage.expansion), 0, 0, 0}
if refresh.Header.Classification != 0 || refresh.Header.MsgID != uint16(dnfenum.CmdPacketLeaveParty) || !bytes.Equal(refresh.Body, wantRefresh) {
t.Fatalf("avatar inventory refresh must precede the purchase ACK: header=%+v body=%x want=%x", refresh.Header, refresh.Body, wantRefresh)
}
ack, rest := splitGameServerUpperPacket(t, rest)
if ack.Header.MsgID != uint16(dnfenum.CmdPacketBuyCerashopItem) || !bytes.Equal(ack.Body, buildCurrentCeraShopPurchaseSuccessBodyWithCount(stage.commodityID, 1)) {
t.Fatalf("avatar inventory ACK=%+v body=%x", ack.Header, ack.Body)
}
remainingCera -= stage.price
balance, trailing := splitGameServerUpperPacket(t, rest)
if len(trailing) != 0 || balance.Header.MsgID != currentCeraShopUpdateMsgID || !bytes.Equal(balance.Body, buildCurrentCeraShopBalanceBody(remainingCera)) {
t.Fatalf("avatar inventory balance=%+v body=%x trailing=%d", balance.Header, balance.Body, len(trailing))
}
}
state, found, err := dnfrepo.LoadCharacterContainerState(ctx, repositories.Settings, "29")
if err != nil || !found || state.AvatarExpansion != 14 {
t.Fatalf("persisted avatar inventory state=%+v found=%t err=%v", state, found, err)
}
inventory, found, err := repositories.Inventory.Load(ctx, "29")
if err != nil || !found || len(inventory.Slots) != 0 {
t.Fatalf("avatar inventory upgrades must not create bag stacks: inventory=%+v found=%t err=%v", inventory, found, err)
}
if got := loadCeraShopAccountCera(t, ctx, repositories, "account-1"); got != remainingCera {
t.Fatalf("persisted account cera=%d want=%d", got, remainingCera)
}
for _, commodityID := range []uint32{102644, 102663} {
retryConnection := &bufferConn{}
if err := service.handleCurrentCeraShopPurchase(&gameSession{conn: retryConnection, selectedCharacterID: 29}, currentCeraShopTestRequestBody(commodityID)); err != nil {
t.Fatal(err)
}
failure, trailing := splitGameServerUpperPacket(t, retryConnection.write.Bytes())
if len(trailing) != 0 || failure.Header.MsgID != uint16(dnfenum.CmdPacketBuyCerashopItem) || !bytes.Equal(failure.Body, buildCurrentCeraShopPurchaseFailureBody()) {
t.Fatalf("avatar inventory rejected commodity=%d response=%+v body=%x trailing=%d", commodityID, failure.Header, failure.Body, len(trailing))
}
}
if got := loadCeraShopAccountCera(t, ctx, repositories, "account-1"); got != remainingCera {
t.Fatalf("rejected avatar inventory upgrade debited account Cera: %d", got)
}
}
func TestCurrentCeraShopAvatarInventoryUpgradeStageUsesPVFNameAndExplain(t *testing.T) {
catalog := mustCurrentCeraShopTestCatalog(t)
for _, itemID := range []uint32{2683901, 2683902, 490701722} {
definition, err := catalog.items.ResolveItem(itemID)
if err != nil {
t.Fatal(err)
}
stage, isUpgrade, err := catalog.avatarInventoryUpgradeStage(definition)
if err != nil || !isUpgrade {
t.Fatalf("item=%d stage=%d upgrade=%t err=%v", itemID, stage, isUpgrade, err)
}
want := uint16(1)
if itemID == 2683902 {
want = 2
} else if itemID == 490701722 {
want = 20
}
if stage != want {
t.Fatalf("item=%d stage=%d want=%d", itemID, stage, want)
}
}
for stage := uint16(1); stage <= 20; stage++ {
name := fmt.Sprintf("装扮栏扩展券 (%d阶段)", stage)
explain := fmt.Sprintf("使用后, 装扮栏的空间增加7格。 可以扩展到%d阶段。", stage)
gotName, namedTicket := currentCeraShopAvatarInventoryStageFromName(name)
gotExplain, explainedTicket := currentCeraShopAvatarInventoryStageFromExplain(explain)
if !namedTicket || !explainedTicket || gotName != stage || gotExplain != stage {
t.Fatalf("stage=%d name=(%d,%t) explain=(%d,%t)", stage, gotName, namedTicket, gotExplain, explainedTicket)
}
}
}
func TestCurrentCeraShopPrepareContainerUpgradesAllowsAvatarStageTwentyOnlyAtStageNineteen(t *testing.T) {
now := time.Date(2026, time.August, 10, 9, 0, 0, 0, time.UTC)
record := newCharacterContainerStateSettings("30", now)
record.Values["avatar_list_param16"] = "133"
purchase := currentCeraShopResolvedPurchase{
Product: currentCeraShopProduct{CommodityID: 102663},
AvatarInventoryUpgradeStage: 20,
}
after, mainExpansion, avatarExpansion, cargoSlots, changed, err := currentCeraShopPrepareContainerUpgrades(&record, true, "30", []currentCeraShopResolvedPurchase{purchase}, now)
if err != nil || !changed || mainExpansion != 0 || avatarExpansion != 140 || cargoSlots != 0 {
t.Fatalf("stage twenty result after=%+v main=%d avatar=%d cargo=%d changed=%t err=%v", after, mainExpansion, avatarExpansion, cargoSlots, changed, err)
}
if after.Values["avatar_list_param16"] != "140" || after.Values["avatar_inventory_last_upgrade_commodity"] != "102663" {
t.Fatalf("stage twenty settings=%v", after.Values)
}
if _, _, _, _, _, err := currentCeraShopPrepareContainerUpgrades(&after, true, "30", []currentCeraShopResolvedPurchase{purchase}, now); err == nil {
t.Fatal("repeated stage twenty upgrade was accepted")
}
}
func TestCurrentCeraShopPurchaseConsumesMainInventoryUpgradesAndRefreshesListZero(t *testing.T) {
catalog := mustCurrentCeraShopTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
seedCeraShopAccountCera(t, ctx, repositories, "account-1", 1000)
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "26", AccountID: "account-1", Stats: map[string]int64{"cera": 1000}}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "26", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
if err := repositories.Settings.Save(ctx, newCharacterContainerStateSettings("26", time.Now().UTC())); err != nil {
t.Fatal(err)
}
service := &Service{
options: options{accountID: "account-1", gameUpperHeader: gameUpperHeaderChannel13, gameUpperBodyCodec: gameUpperBodyCodecPlain},
ceraShopCatalog: catalog,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
remainingCera := int64(1000)
stages := []struct {
commodityID uint32
expansion uint16
price int64
}{
{commodityID: 101098, expansion: 8, price: 60},
{commodityID: 102046, expansion: 16, price: 70},
{commodityID: 102531, expansion: 24, price: 80},
}
for _, stage := range stages {
connection := &bufferConn{}
session := &gameSession{conn: connection, connID: "cera-main-inventory-upgrade", selectedCharacterID: 26}
if err := service.handleCurrentCeraShopPurchase(session, currentCeraShopTestRequestBody(stage.commodityID)); err != nil {
t.Fatal(err)
}
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.MsgID != uint16(dnfenum.CmdPacketBuyCerashopItem) || !bytes.Equal(ack.Body, buildCurrentCeraShopPurchaseSuccessBodyWithCount(stage.commodityID, 1)) {
t.Fatalf("main inventory upgrade ACK=%+v body=%x", ack.Header, ack.Body)
}
refresh, rest := splitGameServerUpperPacketWithHeader(t, rest, dnfproto.GameServerUpperHeaderSize16)
wantRefresh := []byte{0, byte(stage.expansion), 0, 0, 0}
if refresh.Header.Classification != 0 || refresh.Header.MsgID != uint16(dnfenum.CmdPacketLeaveParty) || !bytes.Equal(refresh.Body, wantRefresh) {
t.Fatalf("main inventory refresh header=%+v body=%x want=%x", refresh.Header, refresh.Body, wantRefresh)
}
remainingCera -= stage.price
balance, trailing := splitGameServerUpperPacket(t, rest)
if len(trailing) != 0 || balance.Header.MsgID != currentCeraShopUpdateMsgID || !bytes.Equal(balance.Body, buildCurrentCeraShopBalanceBody(remainingCera)) {
t.Fatalf("main inventory balance=%+v body=%x trailing=%d", balance.Header, balance.Body, len(trailing))
}
state, found, err := dnfrepo.LoadCharacterContainerState(ctx, repositories.Settings, "26")
if err != nil || !found || state.MainSlotCount != stage.expansion {
t.Fatalf("persisted main inventory state=%+v found=%t err=%v", state, found, err)
}
}
if got := loadCeraShopAccountCera(t, ctx, repositories, "account-1"); got != remainingCera {
t.Fatalf("persisted account cera=%d want=%d", got, remainingCera)
}
inventory, found, err := repositories.Inventory.Load(ctx, "26")
if err != nil || !found || len(inventory.Slots) != 0 {
t.Fatalf("main inventory upgrades must not create bag stacks: inventory=%+v found=%t err=%v", inventory, found, err)
}
}
func TestCurrentCeraShopPurchaseRejectsOutOfOrderMainInventoryUpgradeWithoutDebit(t *testing.T) {
catalog := mustCurrentCeraShopTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
seedCeraShopAccountCera(t, ctx, repositories, "account-1", 500)
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "27", AccountID: "account-1", Stats: map[string]int64{"cera": 500}}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "27", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
if err := repositories.Settings.Save(ctx, newCharacterContainerStateSettings("27", time.Now().UTC())); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{accountID: "account-1", gameUpperHeader: gameUpperHeaderChannel13, gameUpperBodyCodec: gameUpperBodyCodecPlain},
ceraShopCatalog: catalog,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
if err := service.handleCurrentCeraShopPurchase(&gameSession{conn: connection, selectedCharacterID: 27}, currentCeraShopTestRequestBody(102046)); err != nil {
t.Fatal(err)
}
failure, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(trailing) != 0 || failure.Header.MsgID != uint16(dnfenum.CmdPacketBuyCerashopItem) || !bytes.Equal(failure.Body, buildCurrentCeraShopPurchaseFailureBody()) {
t.Fatalf("out-of-order main inventory response=%+v body=%x trailing=%d", failure.Header, failure.Body, len(trailing))
}
state, found, err := dnfrepo.LoadCharacterContainerState(ctx, repositories.Settings, "27")
if err != nil || !found || state.MainSlotCount != 0 {
t.Fatalf("out-of-order main inventory changed state=%+v found=%t err=%v", state, found, err)
}
if got := loadCeraShopAccountCera(t, ctx, repositories, "account-1"); got != 500 {
t.Fatalf("out-of-order main inventory debited account Cera: %d", got)
}
}
func TestCurrentCeraShopPurchaseMainInventoryUpgradeRestoresCapacityWhenSettlementRejects(t *testing.T) {
catalog := mustCurrentCeraShopTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
seedCeraShopAccountCera(t, ctx, repositories, "account-1", 59)
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "28", AccountID: "account-1", Stats: map[string]int64{"cera": 59}}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "28", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
if err := repositories.Settings.Save(ctx, newCharacterContainerStateSettings("28", time.Now().UTC())); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{accountID: "account-1", gameUpperHeader: gameUpperHeaderChannel13, gameUpperBodyCodec: gameUpperBodyCodecPlain},
ceraShopCatalog: catalog,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
if err := service.handleCurrentCeraShopPurchase(&gameSession{conn: connection, selectedCharacterID: 28}, currentCeraShopTestRequestBody(101098)); err != nil {
t.Fatal(err)
}
failure, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
// 点券不足必须回 90CN 的 3「点券不足,请充值后再次尝试」,而不是兜底的
// 4「无法购买物品」—— 这两条用例本来就是"点券不够"的场景,所以直接把码钉住。
if want := buildCurrentCeraShopPurchaseFailureBodyWithCode(currentCeraShopPurchaseFailureCodeInsufficientCera); len(trailing) != 0 || !bytes.Equal(failure.Body, want) {
t.Fatalf("insufficient Cera main inventory response=%+v body=%x trailing=%d", failure.Header, failure.Body, len(trailing))
}
state, found, err := dnfrepo.LoadCharacterContainerState(ctx, repositories.Settings, "28")
if err != nil || !found || state.MainSlotCount != 0 {
t.Fatalf("insufficient Cera left main inventory capacity changed: state=%+v found=%t err=%v", state, found, err)
}
if got := loadCeraShopAccountCera(t, ctx, repositories, "account-1"); got != 59 {
t.Fatalf("insufficient Cera changed wallet: account cera=%d want=59", got)
}
}
func TestCurrentCeraShopMainInventoryUpgradeStageUsesExactPVFPaths(t *testing.T) {
cases := []struct {
path string
stage uint16
upgrade bool
}{
{path: "cash/inven_upgradekit1.stk", stage: 1, upgrade: true},
{path: "stackable/cash/inven_upgradekit2.stk", stage: 2, upgrade: true},
{path: "cash/chn_20140617_new_sales_item/chn_inventory_3rd_expansion_2683675.stk", stage: 3, upgrade: true},
{path: "cash/safe_upgradekit.stk", stage: 0, upgrade: false},
{path: "cash/inven_upgradekit3.stk", stage: 0, upgrade: false},
}
for _, test := range cases {
stage, upgrade := currentCeraShopMainInventoryUpgradeStage(dungeonDropItemDefinition{Kind: dungeonDropItemStackable, PVFPath: test.path})
if stage != test.stage || upgrade != test.upgrade {
t.Fatalf("path=%q stage=%d upgrade=%t want_stage=%d want_upgrade=%t", test.path, stage, upgrade, test.stage, test.upgrade)
}
}
}
func TestCurrentCeraShopPersonalCargoUpgradeTargetUsesOnlyRealPersonalCargoToolPaths(t *testing.T) {
cases := []struct {
path string
want uint16
isTool bool
}{
{path: "stackable/cash/safe_upgradekit.stk", want: 24, isTool: true},
{path: "stackable/cash/safe_upgradekit1.stk", want: 40, isTool: true},
{path: "stackable/cash/chn_account_cargo/safe_upgradekit.stk", want: 0, isTool: false},
{path: "stackable/cash/not_safe_upgradekit.stk", want: 0, isTool: false},
}
for _, test := range cases {
got, isTool := currentCeraShopPersonalCargoUpgradeTarget(dungeonDropItemDefinition{Kind: dungeonDropItemStackable, PVFPath: test.path})
if got != test.want || isTool != test.isTool {
t.Fatalf("path=%q target=%d tool=%t want_target=%d want_tool=%t", test.path, got, isTool, test.want, test.isTool)
}
}
}
func TestCurrentCeraShopPurchaseConsumesDocumentDefinedPersonalCargoUpgrade(t *testing.T) {
catalog := mustCurrentCeraShopTestCatalog(t)
definition, err := catalog.items.ResolveItem(40)
if err != nil {
t.Fatal(err)
}
if target, isUpgrade := catalog.personalCargoUpgradeTarget(definition); !isUpgrade || target != 168 {
t.Fatalf("document-defined cargo target=%d is_upgrade=%t", target, isUpgrade)
}
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
seedCeraShopAccountCera(t, ctx, repositories, "account-1", 2500)
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "24", AccountID: "account-1", Stats: map[string]int64{"cera": 2500}}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "24", Slots: map[string]dnfrepo.ItemStack{}, Warehouse: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
state := newCharacterContainerStateSettings("24", time.Now().UTC())
state.Values["personal_cargo_list_param16"] = "152"
if err := repositories.Settings.Save(ctx, state); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{accountID: "account-1", gameUpperHeader: gameUpperHeaderChannel13, gameUpperBodyCodec: gameUpperBodyCodecPlain},
ceraShopCatalog: catalog,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
if err := service.handleCurrentCeraShopPurchase(&gameSession{conn: connection, selectedCharacterID: 24}, currentCeraShopTestRequestBody(100082)); err != nil {
t.Fatal(err)
}
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.MsgID != uint16(dnfenum.CmdPacketBuyCerashopItem) || !bytes.Equal(ack.Body, buildCurrentCeraShopPurchaseSuccessBodyWithCount(100082, 1)) {
t.Fatalf("document-defined cargo ACK=%+v body=%x", ack.Header, ack.Body)
}
// 同上:扩容券同样先重建仓库底板再发列表。
pad, rest := splitGameServerUpperPacketWithHeader(t, rest, dnfproto.GameServerUpperHeaderSize16)
if pad.Header.Classification != 0 || pad.Header.MsgID != uint16(dnfenum.CmdPacketCancelCargoPad) {
t.Fatalf("document-defined cargo upgrade must reset the warehouse pad first: header=%+v", pad.Header)
}
cargo, rest := splitGameServerUpperPacketWithHeader(t, rest, dnfproto.GameServerUpperHeaderSize16)
if cargo.Header.Classification != 0 || cargo.Header.MsgID != uint16(dnfenum.CmdPacketLeaveParty) || !bytes.Equal(cargo.Body, []byte{2, 168, 0, 0, 0, 0}) {
t.Fatalf("document-defined cargo refresh header=%+v body=%x", cargo.Header, cargo.Body)
}
balance, trailing := splitGameServerUpperPacket(t, rest)
if len(trailing) != 0 || balance.Header.MsgID != currentCeraShopUpdateMsgID || !bytes.Equal(balance.Body, buildCurrentCeraShopBalanceBody(0)) {
t.Fatalf("document-defined cargo balance=%+v body=%x trailing=%d", balance.Header, balance.Body, len(trailing))
}
updatedState, found, loadErr := dnfrepo.LoadCharacterContainerState(ctx, repositories.Settings, "24")
if loadErr != nil || !found || updatedState.PersonalCargoSlotCount != 168 {
t.Fatalf("persisted document-defined cargo state=%+v found=%t err=%v", updatedState, found, loadErr)
}
inventory, found, loadErr := repositories.Inventory.Load(ctx, "24")
if loadErr != nil || !found || len(inventory.Slots) != 0 {
t.Fatalf("document-defined cargo upgrade must not create a bag stack: inventory=%+v found=%t err=%v", inventory, found, loadErr)
}
}
func TestCurrentCeraShopPurchasePersonalCargoUpgradeRestoresCapacityWhenCeraSettlementRejects(t *testing.T) {
catalog := mustCurrentCeraShopTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
seedCeraShopAccountCera(t, ctx, repositories, "account-1", 59)
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "23", AccountID: "account-1", Stats: map[string]int64{"cera": 59}}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "23", Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
if err := repositories.Settings.Save(ctx, newCharacterContainerStateSettings("23", time.Now().UTC())); err != nil {
t.Fatal(err)
}
connection := &bufferConn{}
service := &Service{
options: options{accountID: "account-1", gameUpperHeader: gameUpperHeaderChannel13, gameUpperBodyCodec: gameUpperBodyCodecPlain},
ceraShopCatalog: catalog,
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
if err := service.handleCurrentCeraShopPurchase(&gameSession{conn: connection, selectedCharacterID: 23}, currentCeraShopTestRequestBody(100080)); err != nil {
t.Fatal(err)
}
failure, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
// 同上:点券不足 = 3,不是 4。
if want := buildCurrentCeraShopPurchaseFailureBodyWithCode(currentCeraShopPurchaseFailureCodeInsufficientCera); len(trailing) != 0 || !bytes.Equal(failure.Body, want) {
t.Fatalf("insufficient Cera response=%+v body=%x trailing=%d", failure.Header, failure.Body, len(trailing))
}
state, found, err := dnfrepo.LoadCharacterContainerState(ctx, repositories.Settings, "23")
if err != nil || !found || state.PersonalCargoSlotCount != 8 {
t.Fatalf("insufficient Cera left capacity changed: state=%+v found=%t err=%v", state, found, err)
}
if got := loadCeraShopAccountCera(t, ctx, repositories, "account-1"); got != 59 {
t.Fatalf("insufficient Cera changed wallet: account cera=%d, want 59", got)
}
}
func TestParseCurrentCeraShopPurchaseRequestUsesCurrentCartOffset(t *testing.T) {
request, err := parseCurrentCeraShopPurchaseRequest(currentCeraShopTestRequestBody(100050))
if err != nil || request.PaymentMode != 0 || len(request.Items) != 1 || request.Items[0].CommodityID != 100050 || request.Items[0].AttributeValue != 0xff {
t.Fatalf("parsed request=%+v err=%v", request, err)
}
}
func TestRealPVFCeraShopCatalogResolvesAtLeastOneSupportedProduct(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify real Cera-shop product parsing")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatalf("load real Script.pvf: %v", err)
}
catalog, err := newPVFCeraShopCatalog(archive)
if err != nil {
t.Fatalf("load real Cera-shop catalog: %v", err)
}
if len(catalog.products) == 0 {
t.Fatal("real Cera-shop catalog has no supported standard products")
}
for _, product := range catalog.products {
if _, err := catalog.items.ResolveItem(product.ItemID); err == nil {
return
}
}
t.Fatal("real Cera-shop catalog has no product backed by a current inventory item definition")
}
func TestRealPVFCharacterSlotAndRenameProductsKeepNativeAutoUseContracts(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify the character-slot and rename products")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
catalog, err := newPVFCeraShopCatalog(archive)
if err != nil {
t.Fatal(err)
}
var slotProduct currentCeraShopProduct
var renameProduct currentCeraShopProduct
for _, product := range catalog.products {
definition, resolveErr := catalog.items.ResolveItem(product.ItemID)
if resolveErr != nil {
continue
}
if catalog.isCharacterSlotExpansionTicket(definition) {
slotProduct = product
}
if autoUse, recognized := catalog.characterRenameCardKind(definition); recognized && autoUse {
renameProduct = product
}
}
if slotProduct.CommodityID == 0 || slotProduct.Count != 1 || slotProduct.CeraPrice != 2900 {
t.Fatalf("character-slot product=%+v", slotProduct)
}
if renameProduct.CommodityID == 0 || renameProduct.Count != 1 || renameProduct.CeraPrice != 8800 {
t.Fatalf("rename product=%+v", renameProduct)
}
}
func TestRealPVFCeraShopCatalogResolvesObservedCurrentCommodity(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify the commodity from the current client log")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatalf("load real Script.pvf: %v", err)
}
catalog, err := newPVFCeraShopCatalog(archive)
if err != nil {
t.Fatalf("load real Cera-shop catalog: %v", err)
}
const observedCommodityID = 102930 // 12 92 01 00 at current C2S op64 body[7:11].
product, found := catalog.Product(observedCommodityID)
if !found {
t.Fatalf("current-client commodity=%d is not in a supported runtime Cera-shop section", observedCommodityID)
}
if _, err := catalog.items.ResolveItem(product.ItemID); err != nil {
t.Fatalf("current-client commodity=%d item=%d cannot be represented in current inventory: %v", observedCommodityID, product.ItemID, err)
}
t.Logf("current-client commodity=%d item=%d count=%d cera=%d section=%s", product.CommodityID, product.ItemID, product.Count, product.CeraPrice, product.Section)
}
func TestRealPVFCeraShopVisualDurationMatchesReportedPurchase(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify the reported visual duration product")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
catalog, err := newPVFCeraShopCatalog(archive)
if err != nil {
t.Fatal(err)
}
product, found := catalog.Product(102240)
if !found || product.Section != "visual" || product.ItemID != 100330500 || product.Count != 1 || product.DurationDays != 30 || product.CeraPrice != 600 {
t.Fatalf("reported visual product=%+v found=%t", product, found)
}
definition, err := catalog.items.ResolveItem(product.ItemID)
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, time.July, 23, 1, 0, 0, 0, time.UTC)
grant, err := currentCeraShopProductDefinitionForGrantAt(definition, product, now)
if err != nil {
t.Fatal(err)
}
want := now.Add(30 * 24 * time.Hour)
if !grant.ExpirationDate.Equal(want) {
t.Fatalf("grant expiration=%s want=%s", grant.ExpirationDate, want)
}
}
func TestRealPVFCeraShopCatalogResolvesObservedPackageAndAvatarCommodities(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify current package and avatar rows")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatalf("load real Script.pvf: %v", err)
}
catalog, err := newPVFCeraShopCatalog(archive)
if err != nil {
t.Fatalf("load real Cera-shop catalog: %v", err)
}
packageProduct, found := catalog.Product(104801)
if !found || packageProduct.Section != "package" || packageProduct.ItemID != 490702473 || packageProduct.Count != 1 || packageProduct.CeraPrice != 27800 {
t.Fatalf("package product=%+v found=%t", packageProduct, found)
}
if _, err := catalog.items.ResolveItem(packageProduct.ItemID); err != nil {
t.Fatalf("package item=%d cannot be represented: %v", packageProduct.ItemID, err)
}
avatarProduct, found := catalog.Product(589071)
if !found || avatarProduct.Section != "avatar" || avatarProduct.ItemID != 112500217 || avatarProduct.AvatarDurationIndex != 3 {
t.Fatalf("avatar product=%+v found=%t", avatarProduct, found)
}
avatarProduct, err = catalog.resolvePurchase(avatarProduct)
if err != nil || avatarProduct.CeraPrice == 0 {
t.Fatalf("resolved avatar=%+v err=%v", avatarProduct, err)
}
selectablePremium, found := catalog.Product(100817)
if !found || selectablePremium.Section != "selectable character premium" || selectablePremium.ItemID != 2682205 || selectablePremium.CeraPrice != 250 {
t.Fatalf("selectable premium product=%+v found=%t", selectablePremium, found)
}
contractPackage, found := catalog.Product(100625)
if !found || contractPackage.Section != "charac premium package" || contractPackage.ItemID != 2682006 || contractPackage.CeraPrice != 3880 {
t.Fatalf("contract package product=%+v found=%t", contractPackage, found)
}
t.Logf("package commodity=%d item=%d cera=%d; avatar commodity=%d item=%d duration_days=%d cera=%d", packageProduct.CommodityID, packageProduct.ItemID, packageProduct.CeraPrice, avatarProduct.CommodityID, avatarProduct.ItemID, avatarProduct.AvatarDurationDays, avatarProduct.CeraPrice)
}
func TestRealPVFCeraShopCatalogLocatesPersonalCargoUpgradeTool(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify the real personal-cargo upgrade product")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatalf("load real Script.pvf: %v", err)
}
catalog, err := newPVFCeraShopCatalog(archive)
if err != nil {
t.Fatalf("load real Cera-shop catalog: %v", err)
}
matches := make([]string, 0)
for _, product := range catalog.products {
definition, definitionErr := catalog.items.ResolveItem(product.ItemID)
if definitionErr != nil {
continue
}
target, isUpgrade := catalog.personalCargoUpgradeTarget(definition)
if !isUpgrade || target == 0 {
continue
}
if product.Count != 1 || product.CeraPrice == 0 {
t.Fatalf("personal cargo product=%+v definition=%+v", product, definition)
}
matches = append(matches, fmt.Sprintf("commodity=%d item=%d path=%s target_slots=%d cera=%d", product.CommodityID, product.ItemID, definition.PVFPath, target, product.CeraPrice))
}
if len(matches) == 0 {
t.Fatal("real Cera-shop catalog has no safe_upgradekit personal-cargo product")
}
sort.Strings(matches)
for _, match := range matches {
t.Logf("personal cargo %s", match)
}
}
func TestRealPVFCeraShopCatalogLocatesMainInventoryUpgradeStages(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify the real main-inventory upgrade products")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatalf("load real Script.pvf: %v", err)
}
catalog, err := newPVFCeraShopCatalog(archive)
if err != nil {
t.Fatalf("load real Cera-shop catalog: %v", err)
}
expected := []struct {
commodityID uint32
itemID uint32
stage uint16
}{
{commodityID: 101098, itemID: 2660296, stage: 1},
{commodityID: 102046, itemID: 2660297, stage: 2},
{commodityID: 102531, itemID: 2683675, stage: 3},
}
for _, want := range expected {
product, found := catalog.Product(want.commodityID)
if !found || product.ItemID != want.itemID || product.Count != 1 {
t.Fatalf("main inventory commodity=%d product=%+v found=%t", want.commodityID, product, found)
}
definition, resolveErr := catalog.items.ResolveItem(product.ItemID)
if resolveErr != nil {
t.Fatalf("main inventory commodity=%d item=%d: %v", want.commodityID, product.ItemID, resolveErr)
}
stage, isUpgrade := currentCeraShopMainInventoryUpgradeStage(definition)
if !isUpgrade || stage != want.stage {
t.Fatalf("main inventory commodity=%d item=%d path=%q stage=%d upgrade=%t", want.commodityID, product.ItemID, definition.PVFPath, stage, isUpgrade)
}
t.Logf("main inventory commodity=%d item=%d path=%s stage=%d cera=%d", product.CommodityID, product.ItemID, definition.PVFPath, stage, product.CeraPrice)
}
}
func TestRealPVFListsEveryPersonalCargoUpgradeToolPath(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to inspect every real personal-cargo tool path")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatalf("load real Script.pvf: %v", err)
}
items, err := newPVFDungeonDropCatalog(archive)
if err != nil {
t.Fatalf("load real item catalog: %v", err)
}
matches := make([]string, 0)
for itemID, reference := range items.itemRefs {
definition := dungeonDropItemDefinition{ItemID: itemID, Kind: reference.kind, PVFPath: cleanDungeonDropPath(reference.path)}
target, isUpgrade := currentCeraShopPersonalCargoUpgradeTarget(definition)
if !isUpgrade {
continue
}
matches = append(matches, fmt.Sprintf("item=%d path=%s target_slots=%d", itemID, definition.PVFPath, target))
}
if len(matches) == 0 {
t.Fatal("runtime PVF contains no safe_upgradekit path")
}
sort.Strings(matches)
for _, match := range matches {
t.Logf("personal cargo tool %s", match)
}
}
func TestRealPVFCeraShopLocatesAccountCargoUpgradeTools(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to inspect real account-cargo Cera products")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatalf("load real Script.pvf: %v", err)
}
catalog, err := newPVFCeraShopCatalog(archive)
if err != nil {
t.Fatalf("load real Cera catalog: %v", err)
}
found := 0
for _, product := range catalog.products {
definition, err := catalog.items.ResolveItem(product.ItemID)
if err != nil || !catalog.isAccountCargoUpgradeTool(definition) {
continue
}
found++
t.Logf("commodity=%d item=%d count=%d cera=%d path=%s", product.CommodityID, product.ItemID, product.Count, product.CeraPrice, definition.PVFPath)
}
if found == 0 {
t.Fatal("real Cera catalog contains no PVF account-cargo upgrade tool")
}
}
func TestRealPVFCeraShopResolvesObservedPost152Commodity(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to inspect the observed post-152 purchase")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatalf("load real Script.pvf: %v", err)
}
catalog, err := newPVFCeraShopCatalog(archive)
if err != nil {
t.Fatalf("load real Cera-shop catalog: %v", err)
}
const observedPost152Commodity = 103662 // current live legacy op64 body[7:11] = ee 94 01 00.
product, found := catalog.Product(observedPost152Commodity)
if !found {
t.Fatalf("post-152 commodity=%d is absent from the parsed Cera catalog", observedPost152Commodity)
}
definition, err := catalog.items.ResolveItem(product.ItemID)
if err != nil {
t.Fatalf("resolve post-152 commodity=%d item=%d: %v", observedPost152Commodity, product.ItemID, err)
}
target, isUpgrade := catalog.personalCargoUpgradeTarget(definition)
itemDocument, err := parseDungeonCardPVFDocument(archive, definition.PVFPath)
if err != nil {
t.Fatalf("parse post-152 item document %s: %v", definition.PVFPath, err)
}
name, _ := itemDocument.Text("name")
explain, _ := itemDocument.Text("explain")
t.Logf("post-152 commodity=%d product=%+v item_path=%s name=%q explain=%q personal_cargo_target=%d is_personal_cargo=%t", observedPost152Commodity, product, definition.PVFPath, name, explain, target, isUpgrade)
}
func TestRealPVFCeraShopDocumentLocatesObservedCommodityRows(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to inspect observed Cera-shop product rows")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatalf("load real Script.pvf: %v", err)
}
document, err := parseDungeonCardPVFDocument(archive, "etc/cerashop.etc")
if err != nil {
t.Fatalf("parse real Cera-shop document: %v", err)
}
for _, commodity := range []int64{101062, 101063, 103662} {
found := false
for _, section := range document.Sections {
tokens, ok := document.Section(section.Name)
if !ok {
continue
}
for index, token := range tokens {
if token.Int != commodity {
continue
}
end := index + 12
if end > len(tokens) {
end = len(tokens)
}
window := make([]string, 0, end-index)
for _, near := range tokens[index:end] {
window = append(window, near.Raw)
}
t.Logf("commodity=%d section=%s tokens=%s", commodity, section.Name, strings.Join(window, " "))
found = true
}
}
if !found {
t.Logf("commodity=%d is absent from etc/cerashop.etc", commodity)
}
}
}
func TestRealPVFCeraShopDocumentReportsProductSectionShapes(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to inspect Cera-shop category section shapes")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatalf("load real Script.pvf: %v", err)
}
document, err := parseDungeonCardPVFDocument(archive, "etc/cerashop.etc")
if err != nil {
t.Fatalf("parse real Cera-shop document: %v", err)
}
for _, section := range document.Sections {
tokens, found := document.Section(section.Name)
if !found {
continue
}
values := currentCeraShopPVFValues(tokens)
if len(values) == 0 {
continue
}
if len(values)%6 == 0 || len(values)%8 == 0 || len(values)%9 == 0 || len(values)%11 == 0 {
t.Logf("section=%q values=%d", section.Name, len(values))
}
if section.Name == "selectable character premium" || section.Name == "charac premium package" {
limit := 24
if len(values) < limit {
limit = len(values)
}
first := make([]string, 0, limit)
for _, value := range values[:limit] {
first = append(first, value.Raw)
}
t.Logf("section=%q first=%s", section.Name, strings.Join(first, " "))
}
}
}
func mustCurrentCeraShopPremiumCatalog() *currentPremiumCatalog {
return &currentPremiumCatalog{
contractsByItem: map[int64]currentPremiumContractInfo{
16: {ItemID: 16, PremiumType: premium.TypePrivateStore, DurationSeconds: 30 * 86400},
44: {ItemID: 44, PremiumType: premium.TypeOverSkill, DurationSeconds: 3 * 86400},
45: {ItemID: 45, PremiumType: premium.TypeCrystal, DurationSeconds: 7 * 86400},
193: {ItemID: 193, PremiumType: currentAuctionPremiumType, DurationSeconds: 30 * 86400},
},
crystalCubeIDs: [6]int64{3033, 3034, 3035, 3036, 3037, 3262},
devilSlots: map[uint32]currentPremiumDevilSlotInfo{
100817: {CommodityID: 100817, ItemID: 2682205, Slot: premium.DevilSlotAutoRepair, Days: 7, CeraPrice: 250},
100818: {CommodityID: 100818, ItemID: 2682205, Slot: premium.DevilSlotAutoRepair, Days: 30, CeraPrice: 500},
},
}
}
func newCeraShopPremiumTestService(repositories dnfrepo.Group, catalog *pvfCeraShopCatalog) *Service {
return &Service{
options: options{accountID: "account-1", gameUpperHeader: gameUpperHeaderChannel13, gameUpperBodyCodec: gameUpperBodyCodecPlain},
ceraShopCatalog: catalog,
premiumCatalog: mustCurrentCeraShopPremiumCatalog(),
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
}
func seedCeraShopPremiumCharacter(t *testing.T, ctx context.Context, repositories dnfrepo.Group, characterID string, cera int64) {
t.Helper()
seedCeraShopAccountCera(t, ctx, repositories, "account-1", cera)
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: characterID, AccountID: "account-1", Stats: map[string]int64{"cera": cera}}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: characterID, Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
t.Fatal(err)
}
}
func readPremiumActivatedPacket(t *testing.T, rest []byte, wantTypeByte byte, wantRemaining int64) []byte {
t.Helper()
noti, trailing := splitGameServerUpperPacket(t, rest)
if noti.Header.Classification != 0 || noti.Header.MsgID != currentPremiumActivatedMsgID {
t.Fatalf("premium NOTI header=%+v, want class0/0x42", noti.Header)
}
if len(noti.Body) != 11 || noti.Body[0] != 2 || noti.Body[1] != 0 || noti.Body[2] != wantTypeByte {
t.Fatalf("premium NOTI body=%x, want prefix 02 00 %02X", noti.Body, wantTypeByte)
}
remaining := int64(binary.LittleEndian.Uint64(noti.Body[3:11]))
if remaining < wantRemaining-30 || remaining > wantRemaining {
t.Fatalf("premium NOTI remaining=%d, want ~%d", remaining, wantRemaining)
}
return trailing
}
func readPremiumServiceStatePacket(t *testing.T, rest []byte) []byte {
t.Helper()
state, trailing := splitGameServerUpperPacket(t, rest)
if state.Header.Classification != dnfproto.DefaultChannelClassification ||
state.Header.MsgID != uint16(dnfenum.CmdPacketPremiumService) {
t.Fatalf("premium service header=%+v, want class1/op903", state.Header)
}
// serviceType 4 is the silent variant: these flows are not the login/entry
// scene tail, so they must not carry the variant that opens the contract
// panel (see currentPremiumServiceTypeForSession).
if len(state.Body) != 77 || !bytes.Equal(state.Body[:3], []byte{1, byte(premium.ServiceTypeSilent), 0}) {
t.Fatalf("premium service body=%x, want silent serviceType4/74-byte state", state.Body)
}
return trailing
}
func TestCurrentCeraShopPurchaseDevilSlotActivatesAccountPremium(t *testing.T) {
catalog := mustCurrentCeraShopTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
seedCeraShopPremiumCharacter(t, ctx, repositories, "19", 1000)
connection := &bufferConn{}
service := newCeraShopPremiumTestService(repositories, catalog)
session := &gameSession{conn: connection, connID: "cera-shop-devil", selectedCharacterID: 19}
if err := service.handleCurrentCeraShopPurchase(session, currentCeraShopTestRequestBody(100817)); err != nil {
t.Fatal(err)
}
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if ack.Header.MsgID != uint16(dnfenum.CmdPacketBuyCerashopItem) || !bytes.Equal(ack.Body, buildCurrentCeraShopPurchaseSuccessBodyWithCount(100817, 6)) {
t.Fatalf("purchase ACK=%+v body=%x", ack.Header, ack.Body)
}
rest = readPremiumServiceStatePacket(t, rest)
balance, trailing := splitGameServerUpperPacket(t, rest)
if len(trailing) != 0 || balance.Header.MsgID != currentCeraShopUpdateMsgID || !bytes.Equal(balance.Body, buildCurrentCeraShopBalanceBody(750)) {
t.Fatalf("balance=%+v body=%x trailing=%d", balance.Header, balance.Body, len(trailing))
}
account, ok, err := repositories.Account.Load(ctx, "account-1")
if err != nil || !ok {
t.Fatalf("account load ok=%t err=%v", ok, err)
}
expire := premium.ExpireAt(account, premium.DevilSlotType(premium.DevilSlotAutoRepair))
if want := time.Now().Unix() + 7*86400; expire < want-30 || expire > want {
t.Fatalf("devil slot expire=%d, want ~%d", expire, want)
}
if got := loadCeraShopAccountCera(t, ctx, repositories, "account-1"); got != 750 {
t.Fatalf("cera=%d, want 750", got)
}
inventory, found, err := repositories.Inventory.Load(ctx, "19")
if err != nil || !found || len(inventory.Slots) != 0 {
t.Fatalf("devil slot purchase must not deliver items: %+v", inventory.Slots)
}
}
func TestCurrentCeraShopPurchaseContractItemActivatesWithoutDelivery(t *testing.T) {
catalog := mustCurrentCeraShopTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
seedCeraShopPremiumCharacter(t, ctx, repositories, "19", 2000)
connection := &bufferConn{}
service := newCeraShopPremiumTestService(repositories, catalog)
session := &gameSession{conn: connection, connID: "cera-shop-contract", selectedCharacterID: 19}
if err := service.handleCurrentCeraShopPurchase(session, currentCeraShopTestRequestBody(100090)); err != nil {
t.Fatal(err)
}
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if !bytes.Equal(ack.Body, buildCurrentCeraShopPurchaseSuccessBodyWithCount(100090, 1)) {
t.Fatalf("purchase ACK body=%x", ack.Body)
}
rest = readPremiumActivatedPacket(t, rest, byte(premium.TypeOverSkill), 3*86400)
balance, trailing := splitGameServerUpperPacket(t, rest)
if len(trailing) != 0 || !bytes.Equal(balance.Body, buildCurrentCeraShopBalanceBody(920)) {
t.Fatalf("balance body=%x trailing=%d", balance.Body, len(trailing))
}
account, _, _ := repositories.Account.Load(ctx, "account-1")
if !premium.Active(account, premium.TypeOverSkill, time.Now()) {
t.Fatalf("over-skill premium not active: %+v", account.Metadata)
}
inventory, _, _ := repositories.Inventory.Load(ctx, "19")
if len(inventory.Slots) != 0 {
t.Fatalf("contract item must be consumed on purchase: %+v", inventory.Slots)
}
}
func TestCurrentCeraShopAuctionCouponActivatesPrivateStoreContract(t *testing.T) {
catalog := mustCurrentCeraShopTestCatalog(t)
catalog.products[100092] = currentCeraShopProduct{
CommodityID: 100092,
ItemID: 193,
Count: 1,
CeraPrice: 780,
Section: "premium",
}
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
seedCeraShopPremiumCharacter(t, ctx, repositories, "19", 2000)
connection := &bufferConn{}
service := newCeraShopPremiumTestService(repositories, catalog)
session := &gameSession{
conn: connection,
connID: "cera-shop-auction-contract",
accountID: "account-1",
selectedCharacterID: 19,
}
if err := service.handleCurrentCeraShopPurchase(session, currentCeraShopTestRequestBody(100092)); err != nil {
t.Fatal(err)
}
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if !bytes.Equal(ack.Body, buildCurrentCeraShopPurchaseSuccessBodyWithCount(100092, 1)) {
t.Fatalf("purchase ACK body=%x", ack.Body)
}
rest = readPremiumActivatedPacket(t, rest, byte(currentAuctionPremiumType), 30*86400)
rest = readPremiumActivatedPacket(t, rest, byte(premium.TypePrivateStore), 30*86400)
actorRefresh, rest := splitGameServerUpperPacket(t, rest)
if actorRefresh.Header.Classification != 0 ||
actorRefresh.Header.MsgID != uint16(dnfenum.CmdPacketSetUDPIPPort) ||
len(actorRefresh.Body) == 0 || actorRefresh.Body[0] != 0 {
t.Fatalf("private-store actor refresh header=%+v body=%x", actorRefresh.Header, actorRefresh.Body)
}
if permission, ok := currentSceneObjectTailPrivateStorePermissionForLog(actorRefresh.Body); !ok || permission != 1 {
t.Fatalf("private-store actor refresh permission=%d ok=%t", permission, ok)
}
balance, trailing := splitGameServerUpperPacket(t, rest)
if len(trailing) != 0 || !bytes.Equal(balance.Body, buildCurrentCeraShopBalanceBody(1220)) {
t.Fatalf("balance body=%x trailing=%d", balance.Body, len(trailing))
}
account, found, err := repositories.Account.Load(ctx, "account-1")
if err != nil || !found || !premium.Active(account, currentAuctionPremiumType, time.Now()) ||
!premium.Active(account, premium.TypePrivateStore, time.Now()) {
t.Fatalf("auction premium found=%t err=%v metadata=%+v", found, err, account.Metadata)
}
allowed, err := service.currentPrivateStoreEntitled(session)
if err != nil || !allowed {
t.Fatalf("private-store entitlement allowed=%t err=%v", allowed, err)
}
inventory, found, err := repositories.Inventory.Load(ctx, "19")
if err != nil || !found || len(inventory.Slots) != 0 {
t.Fatalf("auction contract must auto-consume: found=%t err=%v slots=%+v", found, err, inventory.Slots)
}
}
func TestCurrentCeraShopPurchaseCrystalContractRefreshesSelectionState(t *testing.T) {
catalog := mustCurrentCeraShopTestCatalog(t)
catalog.products[100091] = currentCeraShopProduct{
CommodityID: 100091,
ItemID: 45,
Count: 1,
CeraPrice: 600,
Section: "premium",
}
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
seedCeraShopPremiumCharacter(t, ctx, repositories, "19", 2000)
connection := &bufferConn{}
service := newCeraShopPremiumTestService(repositories, catalog)
session := &gameSession{conn: connection, connID: "cera-shop-crystal-contract", selectedCharacterID: 19}
if err := service.handleCurrentCeraShopPurchase(session, currentCeraShopTestRequestBody(100091)); err != nil {
t.Fatal(err)
}
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if !bytes.Equal(ack.Body, buildCurrentCeraShopPurchaseSuccessBodyWithCount(100091, 1)) {
t.Fatalf("purchase ACK body=%x", ack.Body)
}
rest = readPremiumActivatedPacket(t, rest, byte(premium.TypeCrystal), 7*86400)
state, rest := splitGameServerUpperPacket(t, rest)
if state.Header.Classification != 0 ||
state.Header.MsgID != currentCrystalContractStateMsgID ||
!bytes.Equal(state.Body, []byte{0, 0xff}) {
t.Fatalf("crystal state header=%+v body=%x", state.Header, state.Body)
}
balance, trailing := splitGameServerUpperPacket(t, rest)
if len(trailing) != 0 || !bytes.Equal(balance.Body, buildCurrentCeraShopBalanceBody(1400)) {
t.Fatalf("balance body=%x trailing=%d", balance.Body, len(trailing))
}
account, _, _ := repositories.Account.Load(ctx, "account-1")
if !premium.Active(account, premium.TypeCrystal, time.Now()) {
t.Fatalf("crystal premium not active: %+v", account.Metadata)
}
inventory, _, _ := repositories.Inventory.Load(ctx, "19")
if len(inventory.Slots) != 0 {
t.Fatalf("crystal contract must be consumed on purchase: %+v", inventory.Slots)
}
}
func TestCurrentCeraShopPurchaseDevilPackageActivatesAllSlots(t *testing.T) {
catalog := mustCurrentCeraShopTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
seedCeraShopPremiumCharacter(t, ctx, repositories, "19", 5000)
connection := &bufferConn{}
service := newCeraShopPremiumTestService(repositories, catalog)
session := &gameSession{conn: connection, connID: "cera-shop-package", selectedCharacterID: 19}
if err := service.handleCurrentCeraShopPurchase(session, currentCeraShopTestRequestBody(100625)); err != nil {
t.Fatal(err)
}
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if !bytes.Equal(ack.Body, buildCurrentCeraShopPurchaseSuccessBodyWithCount(100625, 1)) {
t.Fatalf("purchase ACK body=%x", ack.Body)
}
rest = readPremiumServiceStatePacket(t, rest)
balance, trailing := splitGameServerUpperPacket(t, rest)
if len(trailing) != 0 || !bytes.Equal(balance.Body, buildCurrentCeraShopBalanceBody(1120)) {
t.Fatalf("balance body=%x trailing=%d", balance.Body, len(trailing))
}
account, _, _ := repositories.Account.Load(ctx, "account-1")
now := time.Now()
for slot := int64(0); slot < premium.DevilSlotCount; slot++ {
if !premium.Active(account, premium.DevilSlotType(slot), now) {
t.Fatalf("devil slot %d not active after package purchase: %+v", slot, account.Metadata)
}
}
}
func TestCurrentCeraShopPurchaseThirtyDayDevilSlotActivates(t *testing.T) {
catalog := mustCurrentCeraShopTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
seedCeraShopPremiumCharacter(t, ctx, repositories, "19", 1000)
connection := &bufferConn{}
service := newCeraShopPremiumTestService(repositories, catalog)
session := &gameSession{conn: connection, connID: "cera-shop-devil-unknown", selectedCharacterID: 19}
if err := service.handleCurrentCeraShopPurchase(session, currentCeraShopTestRequestBody(100818)); err != nil {
t.Fatal(err)
}
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if !bytes.Equal(ack.Body, buildCurrentCeraShopPurchaseSuccessBodyWithCount(100818, 6)) {
t.Fatalf("purchase ACK body=%x", ack.Body)
}
rest = readPremiumServiceStatePacket(t, rest)
balance, trailing := splitGameServerUpperPacket(t, rest)
if len(trailing) != 0 || !bytes.Equal(balance.Body, buildCurrentCeraShopBalanceBody(500)) {
t.Fatalf("balance body=%x trailing=%d", balance.Body, len(trailing))
}
account, _, _ := repositories.Account.Load(ctx, "account-1")
expire := premium.ExpireAt(account, premium.DevilSlotType(premium.DevilSlotAutoRepair))
if want := time.Now().Unix() + 30*86400; expire < want-30 || expire > want {
t.Fatalf("thirty-day devil slot expire=%d, want ~%d", expire, want)
}
}
func TestCurrentCeraShopPurchaseCommitsNameTagWithWalletInOneCheckout(t *testing.T) {
catalog := mustCurrentCeraShopTestCatalog(t)
repositories := dnfrepomemory.NewMemoryGroup()
ctx := context.Background()
seedCeraShopAccountCera(t, ctx, repositories, "account-1", 200)
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "31",
AccountID: "account-1",
Stats: make(map[string]int64),
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "31",
Slots: make(map[string]dnfrepo.ItemStack),
}); err != nil {
t.Fatal(err)
}
service := &Service{
options: options{accountID: "account-1"},
repositoryProvider: func() (dnfrepo.Group, bool) {
return repositories, true
},
}
session := &gameSession{selectedCharacterID: 31}
result, err := service.commitCurrentCeraShopPurchase(
ctx,
session,
catalog,
currentCeraShopPurchaseRequest{
PaymentMode: currentCeraShopPaymentModeCera,
Items: []currentCeraShopPurchaseRequestItem{{CommodityID: 100058}},
},
)
if err != nil {
t.Fatal(err)
}
if result.CeraAfter != 140 || result.NameTagActivation == nil ||
result.NameTagActivation.ItemID != 9003 || result.NameTagActivation.Action != "new" {
t.Fatalf("result=%+v activation=%+v", result, result.NameTagActivation)
}
account, _, _ := repositories.Account.Load(ctx, "account-1")
character, _, _ := repositories.Character.Load(ctx, "31")
equipment, found, err := repositories.Equipment.Load(ctx, "31")
if err != nil || !found {
t.Fatalf("load equipment found=%t err=%v", found, err)
}
nameTag := equipment.Entries[strconv.Itoa(int(currentNameTagEquipmentSlot))]
if currentAccountCera(account) != 140 ||
character.Stats["name_tag_item_id"] != 9003 ||
character.Stats["name_tag_expire_time"] <= time.Now().UTC().Unix() ||
nameTag.ItemID != 9003 || len(nameTag.RawEntry) != currentItemListEntryWireSize {
t.Fatalf("account=%+v character=%+v equipment=%+v", account, character, equipment)
}
inventory, _, _ := repositories.Inventory.Load(ctx, "31")
if len(inventory.Slots) != 0 {
t.Fatalf("consumed name tag entered inventory: %+v", inventory.Slots)
}
}
func mustCurrentCeraShopTestCatalog(t *testing.T) *pvfCeraShopCatalog {
t.Helper()
catalog, err := newPVFCeraShopCatalog(currentCeraShopTestSource())
if err != nil {
t.Fatal(err)
}
assertCurrentCeraShopTestCatalogProducts(t, catalog)
return catalog
}
// currentCeraShopTestSource 是商城测试的共用 PVF 源:限购相关的用例会在这份内容上
// 追加 `[buy restrict item]` 段再建目录,避免动到既有用例的期望。
func currentCeraShopTestSource() bridgePVFSource {
return bridgePVFSource{
"etc/cerashop.etc": "[item]\n100050 37 3 0 0 80 `test potion` 0 0\n100056 43 1 0 0 20 `test timed item` 0 0\n100080 38 1 0 0 60 `test personal cargo upgrade` 0 0\n100081 39 1 0 0 60 `test personal cargo upgrade two` 0 0\n100082 40 1 0 0 2500 `test personal cargo upgrade 168` 0 0\n100083 41 1 0 0 2000 `test account cargo upgrade` 0 0\n100084 46 1 0 0 2900 `test character slot expansion` 0 0\n100902 2682290 1 0 0 8800 `角色更名卡` 0 0\n101098 2660296 1 0 0 60 `test main inventory upgrade one` 0 0\n102046 2660297 1 0 0 70 `test main inventory upgrade two` 0 0\n102531 2683675 1 0 0 80 `test main inventory upgrade three` 0 0\n102644 2683901 1 0 0 60 `test avatar inventory upgrade one` 0 0\n102645 2683902 1 0 0 70 `test avatar inventory upgrade two` 0 0\n102663 490701722 1 0 0 100 `test avatar inventory upgrade twenty` 0 0\n[/item]\n" +
"[premium]\n100090 44 1 0 0 1080 `test expert contract` 0 0\n[/premium]\n[creature]\n100055 42 10 0 0 20 `test pet feed` 0 0\n[/creature]\n[coin]\n[/coin]\n[material]\n[/material]\n[recoveryitem]\n[/recoveryitem]\n" +
"[package]\n100060 37 0 0 50 `test package` 0 0 0 0 0\n[/package]\n" +
"[visual]\n100057 9002 -1 30 0 60 `test visual 30 days` 0\n100058 9003 -1 30 0 60 `test name tag 30 days` 0\n[/visual]\n" +
"[avatar]\n100070 9001 1 0 0 30\n100071 9001 3 0 0 30\n[/avatar]\n" +
"[selectable character premium]\n-1 2681927 -1 7 0 2000 `魔王之契约` 0 -1 100817 2682205 6 7 0 250 `自动修理` 0 -1 100818 2682205 6 30 0 500 `自动修理` 0 -1\n[/selectable character premium]\n" +
"[charac premium package]\n100625 2682006 3880 `60天` 0 0 1 60 100614\n[/charac premium package]\n",
"monster/monster.lst": "",
"stackable/stackable.lst": "37 `test_potion.stk`\n38 `cash/safe_upgradekit.stk`\n39 `cash/safe_upgradekit1.stk`\n40 `10098001/10098631.stk`\n41 `cash/chn_account_cargo/account_cargo_upgrade.stk`\n42 `cash/creature/test_feed.stk`\n43 `cash/test_timed.stk`\n46 `cash/character_slotkit.stk`\n2660698 `cash/charcter_rename_card.stk`\n2660296 `cash/inven_upgradekit1.stk`\n2660297 `cash/inven_upgradekit2.stk`\n2682290 `cash/chn_character_rename.stk`\n2683675 `cash/chn_20140617_new_sales_item/chn_inventory_3rd_expansion_2683675.stk`\n2683901 `cash/chn_20140819_avatar_inven/avatar_inven_2683901.stk`\n2683902 `cash/chn_20140819_avatar_inven/avatar_inven_2683902.stk`\n490701722 `cash/chn_490701722.stk`\n",
"equipment/equipment.lst": "9001 `avatar/test_avatar.equ`\n9002 `visual/test_nameplate.equ`\n9003 `visual/test_name_tag.equ`\n",
"stackable/test_potion.stk": "[stackable type]\n`[waste]`\n[stack limit]\n1000\n[expiration date]\n`2028-08-16 06:00:00`\n",
"stackable/cash/safe_upgradekit.stk": "[stackable type]\n`[etc]`\n[stack limit]\n1\n",
"stackable/cash/safe_upgradekit1.stk": "[stackable type]\n`[etc]`\n[stack limit]\n1\n",
"stackable/cash/chn_account_cargo/account_cargo_upgrade.stk": "[stackable type]\n`[etc]`\n[stack limit]\n1\n",
"stackable/cash/character_slotkit.stk": "[name]\n`角色栏扩展券`\n[explain]\n`可以增加一个角色栏, 角色栏总上限为60个。`\n[stackable type]\n`[etc]`\n[stack limit]\n1\n",
"stackable/cash/charcter_rename_card.stk": "[name]\n`角色更名卡`\n[stackable type]\n`[waste]`\n[action type]\n`[character name change]`\n[stack limit]\n1\n",
// Mirrors the runtime PVF exactly: this file is 529 bytes and declares no
// [action type] at all. An earlier fixture gave it one, which is why the
// unit tests stayed green while TestRealPVF... kept failing and the live
// 8800-Cera purchase delivered a card instead of arming the rename.
"stackable/cash/chn_character_rename.stk": "[name]\n`角色更名卡`\n[stackable type]\n`[etc]`\n[stack limit]\n1\n",
"stackable/cash/creature/test_feed.stk": "[stackable type]\n`[feed]`\n[stack limit]\n1000\n",
"stackable/cash/test_timed.stk": "[stackable type]\n`[etc]`\n[stack limit]\n100\n[usable period]\n10\n",
"stackable/cash/inven_upgradekit1.stk": "[stackable type]\n`[etc]`\n[stack limit]\n1\n",
"stackable/cash/inven_upgradekit2.stk": "[stackable type]\n`[etc]`\n[stack limit]\n1\n",
"stackable/cash/chn_20140617_new_sales_item/chn_inventory_3rd_expansion_2683675.stk": "[stackable type]\n`[etc]`\n[stack limit]\n1\n",
"stackable/cash/chn_20140819_avatar_inven/avatar_inven_2683901.stk": "[name]\n`装扮栏扩展券 (1阶段)`\n[explain]\n`使用后, 装扮栏的空间增加7格。 可以扩展到1阶段。`\n[stackable type]\n`[etc]`\n[stack limit]\n1\n",
"stackable/cash/chn_20140819_avatar_inven/avatar_inven_2683902.stk": "[name]\n`装扮栏扩展券 (2阶段)`\n[explain]\n`使用后, 装扮栏的空间增加7格。 可以扩展到2阶段。`\n[stackable type]\n`[etc]`\n[stack limit]\n1\n",
"stackable/cash/chn_490701722.stk": "[name]\n`装扮栏扩展券 (20阶段)`\n[explain]\n`使用后, 装扮栏的空间增加7格。 可以扩展到20阶段。`\n[stackable type]\n`[etc]`\n[stack limit]\n1\n",
"stackable/10098001/10098631.stk": "[name]\n`金库升级工具`\n[explain]\n`可以使金库的空间增加到168格, 最大上限为200格。`\n[stackable type]\n`[etc]`\n[stack limit]\n1\n",
"equipment/avatar/test_avatar.equ": "[equipment type]\n`[hat avatar]`\n[grade]\n3\n[avatar type select]\n7 0 0 30 0 0 0 30 0 0 30 0 0 0 0 0 0 30 0 0 0\n",
"equipment/visual/test_nameplate.equ": "[equipment type]\n`[title name]`\n[attach type]\n`[trade]`\n",
"equipment/visual/test_name_tag.equ": "[equipment type]\n`[name tag]`\n[visual duration]\n30\n[attach type]\n`[trade]`\n",
"stackable/cash/creature/rename_card.stk": "[name]\n`宠物更名卡`\n[stackable type]\n`[creature]`\n[stack limit]\n1\n",
}
}
// mustCurrentCeraShopProductCatalogSanity 校验共用夹具里的几个关键商品行,任何
// 改动夹具的用例都要先过这一关(原来这些断言写在 mustCurrentCeraShopTestCatalog 里)。
func assertCurrentCeraShopTestCatalogProducts(t *testing.T, catalog *pvfCeraShopCatalog) {
t.Helper()
if product, found := catalog.Product(100050); !found || product.ItemID != 37 || product.Count != 3 || product.CeraPrice != 80 {
t.Fatalf("catalog product=%+v found=%t", product, found)
}
if product, found := catalog.Product(100060); !found || product.Section != "package" || product.ItemID != 37 || product.Count != 1 || product.CeraPrice != 50 {
t.Fatalf("package catalog product=%+v found=%t", product, found)
}
if product, found := catalog.Product(100070); !found || product.Section != "avatar" || product.ItemID != 9001 || product.CeraPrice != 30 {
t.Fatalf("avatar catalog product=%+v found=%t", product, found)
}
if product, found := catalog.Product(100057); !found || product.Section != "visual" || product.ItemID != 9002 || product.Count != 1 || product.DurationDays != 30 || product.CeraPrice != 60 {
t.Fatalf("visual catalog product=%+v found=%t", product, found)
}
if product, found := catalog.Product(100058); !found || product.ItemID != 9003 || product.CeraPrice != 60 {
t.Fatalf("name-tag catalog product=%+v found=%t", product, found)
}
}
func currentCeraShopTestRequestBody(commodityID uint32) []byte {
return currentCeraShopTestRequestBodyWithPayment(commodityID, currentCeraShopPaymentModeCera, 0xff)
}
func currentCeraShopTestRequestBodyWithPayment(commodityID uint32, paymentMode byte, attributeValue byte) []byte {
return currentCeraShopTestMultiRequestBody(paymentMode, currentCeraShopTestCartRow{CommodityID: commodityID, AttributeValue: attributeValue})
}
type currentCeraShopTestCartRow struct {
CommodityID uint32
AttributeValue byte
}
func currentCeraShopTestMultiRequestBody(paymentMode byte, rows ...currentCeraShopTestCartRow) []byte {
body := make([]byte, currentCeraShopRequestHeaderSize+len(rows)*currentCeraShopRequestItemStride)
body[2] = byte(len(rows))
for index, row := range rows {
offset := currentCeraShopRequestHeaderSize + index*currentCeraShopRequestItemStride
if index == 0 {
body[offset] = paymentMode
}
body[offset+1] = row.AttributeValue
binary.LittleEndian.PutUint32(body[offset+3:offset+7], row.CommodityID)
}
return body
}