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: "", 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 ¤tPremiumCatalog{ 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 }