Restore event and mini hubs, current shop and hunt calendars, and regular seasons; preserve all published windows. Implement story/quiz routes, native shop purchases, avatar sets, currencies, rank-change items and permanent buffs with rollback and reward coverage tests. Refresh client buff stats and update Cash Shop to 0.1.1.
408 lines
13 KiB
Go
408 lines
13 KiB
Go
package commerce
|
|
|
|
import (
|
|
"bd2server/internal/server/calendar"
|
|
"bd2server/internal/server/events"
|
|
"bd2server/internal/server/gameconfig"
|
|
"bd2server/internal/server/gamedata"
|
|
"bd2server/internal/server/hunting"
|
|
"bd2server/internal/server/player"
|
|
"bd2server/internal/server/readonly"
|
|
"bd2server/internal/server/stateio"
|
|
"bd2server/internal/server/wire"
|
|
"bytes"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestInstalledCashProductRewardCoverage23510(t *testing.T) {
|
|
root := os.Getenv("BD2_REAL_GAMEDATA")
|
|
if root == "" {
|
|
t.Skip("BD2_REAL_GAMEDATA not configured")
|
|
}
|
|
catalog, err := gamedata.LoadCashCatalog(root, "20260923193640")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resolver, err := gamedata.LoadCashRewardResolver(root, "20260923193640")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
entitlements, err := gamedata.LoadCashEntitlementDesign(root, "20260923193640")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Infinite draw products grant the user's confirmed preview, rather than
|
|
// the placeholder cash box. Validate their dedicated design and route.
|
|
db, release, err := gamedata.OpenDatabase(root, "20260923193640", "common")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rowsDB, err := db.Query("SELECT id,ProtoBuf FROM GachaGroupTable")
|
|
if err != nil {
|
|
release()
|
|
t.Fatal(err)
|
|
}
|
|
type drawGroup struct{ id, gacha uint64 }
|
|
var groups []drawGroup
|
|
for rowsDB.Next() {
|
|
var id uint64
|
|
var raw []byte
|
|
if err := rowsDB.Scan(&id, &raw); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
typ, _, _ := wire.Varint(raw, 17)
|
|
sub, _, _ := wire.Varint(raw, 16)
|
|
if typ == 1 && sub == 5 {
|
|
gacha, _, _ := wire.Varint(raw, 33)
|
|
groups = append(groups, drawGroup{id, gacha})
|
|
}
|
|
}
|
|
if err := rowsDB.Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rowsDB.Close()
|
|
release()
|
|
policy, err := NewCatalog("2.35.10", catalog, gameconfig.Default().Purchases)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
shop, err := NewService(policy, stateio.NewMemory(), &purchaseEconomy{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
seed, err := readonly.Load(filepath.Join("..", "..", "..", "seed", "v2_35_10", "readonly.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
calendars, err := calendar.LoadDirectory(filepath.Join("..", "..", "..", "..", "schedules"), "2.35.10", "20260923193640")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
seed, err = calendars.ApplyReadonly(seed)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
shop.SetClock(func() time.Time { return time.Date(2026, 10, 5, 5, 0, 0, 0, time.UTC) }, 0)
|
|
if err := shop.AttachShopSeed(seed); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := shop.AttachEventShopSchedules(catalog, calendars.Events); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
graph, err := gamedata.LoadRewardGraph(root, "20260923193640")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
costumes, err := gamedata.LoadRewardCostumeCatalog(root, "20260923193640")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
equipment, err := gamedata.LoadRewardEquipmentCatalog(root, "20260923193640")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
skins, err := gamedata.LoadPrestigeSkins(root, "20260923193640")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ownedDesign, err := gamedata.LoadOwnedEventItemDesign(root, "20260923193640")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
avatarRewards, err := gamedata.LoadAvatarRewardDesign(root, "20260923193640")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
newEconomy := func() (*EntitlementEconomy, *player.Wallet) {
|
|
store := stateio.NewMemory()
|
|
items, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wallet, err := player.OpenWallet(store, player.Currency{Jewelry: 100000000, FreeJewelry: 100000000, Gold: 100000000})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
collection, err := player.OpenCollectionStore(store, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ownedEquipment, err := player.OpenEquipmentInventory(store)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, persist := range []func() error{items.EnsurePersisted, wallet.EnsurePersisted, collection.EnsurePersisted, ownedEquipment.EnsurePersisted} {
|
|
if err := persist(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
base, err := events.NewEconomy(store, items, wallet, collection, ownedEquipment, costumes, equipment, graph, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
base.AttachPrestigeSkins(skins)
|
|
base.AttachOwnedItemDesign(ownedDesign)
|
|
base.AttachAvatarRewards(avatarRewards)
|
|
ap, err := hunting.Open(store, root, "20260923193640", items, wallet, func() (int, error) { return 21, nil }, 0, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
base.AttachHuntingAP(ap)
|
|
e, err := NewEntitlementEconomy(store, base, resolver, items, entitlements)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return e, wallet
|
|
}
|
|
t.Run("event shop complete calendar and native purchase", func(t *testing.T) {
|
|
e, wallet := newEconomy()
|
|
shop, err := NewService(policy, e.store, e)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = shop.AttachShopSeed(seed); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rows := append([]events.Schedule(nil), calendars.Events...)
|
|
var current events.Schedule
|
|
var lastEnd int64
|
|
for _, row := range rows {
|
|
if row.Type == 15 && row.ID == 71 {
|
|
current = row
|
|
if row.End > lastEnd {
|
|
lastEnd = row.End
|
|
}
|
|
}
|
|
}
|
|
if current.UID == 0 {
|
|
t.Fatal("real event shop 71 calendar missing")
|
|
}
|
|
future := events.Schedule{UID: 99999999, Type: 15, ID: 71, Start: lastEnd + 86400000, End: lastEnd + 2*86400000}
|
|
rows = append(rows, future)
|
|
if err = shop.AttachEventShopSchedules(catalog, rows); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
shop.SetClock(func() time.Time { return time.UnixMilli(current.Start + 1) }, 0)
|
|
_, info, handled, err := shop.HandleSession("/CashShopInfo", wire.AppendVarint(nil, 1, 1), "audit")
|
|
if err != nil || !handled {
|
|
t.Fatal(err)
|
|
}
|
|
actual := map[uint64]map[gamedata.CashProductKey]bool{}
|
|
_ = wire.Walk(info, func(f wire.Field) error {
|
|
if f.Number != 1 || f.Type != 2 {
|
|
return nil
|
|
}
|
|
uid, _, _ := wire.Varint(f.Value, 8)
|
|
if uid == 0 {
|
|
return nil
|
|
}
|
|
group, _, _ := wire.Varint(f.Value, 1)
|
|
id, _, _ := wire.Varint(f.Value, 2)
|
|
sale, _, _ := wire.Varint(f.Value, 3)
|
|
if actual[uid] == nil {
|
|
actual[uid] = map[gamedata.CashProductKey]bool{}
|
|
}
|
|
actual[uid][gamedata.CashProductKey{GroupID: group, ProductID: id, SaleGroup: sale}] = true
|
|
return nil
|
|
})
|
|
seenOld := false
|
|
for _, row := range rows {
|
|
if row.Type != 15 {
|
|
continue
|
|
}
|
|
group := uint64(0)
|
|
for _, design := range catalog.EventShops {
|
|
if design.ID == row.ID {
|
|
group = design.ProductGroupID
|
|
}
|
|
}
|
|
if row.ID == 70 {
|
|
seenOld = true
|
|
}
|
|
if row.ID == 71 && group != 900071 {
|
|
t.Fatal("event shop 71 product group mismatch")
|
|
}
|
|
expected := 0
|
|
for _, product := range catalog.Products {
|
|
if product.Key.GroupID == group {
|
|
expected++
|
|
if !actual[row.UID][product.Key] {
|
|
t.Fatalf("shop %d UID %d missing product %+v", row.ID, row.UID, product.Key)
|
|
}
|
|
}
|
|
}
|
|
if len(actual[row.UID]) != expected {
|
|
t.Fatalf("shop %d UID %d count=%d expected=%d", row.ID, row.UID, len(actual[row.UID]), expected)
|
|
}
|
|
}
|
|
if !seenOld || len(actual[future.UID]) == 0 {
|
|
t.Fatal("old or future event shop filtered")
|
|
}
|
|
fund := []gamedata.Reward{{Type: 8, ID: 2074, Count: 100}}
|
|
if _, err = e.Apply("audit funds", nil, fund); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
beforeWallet := wallet.Snapshot()
|
|
countItem := func(id uint64) uint64 {
|
|
var n uint64
|
|
for _, item := range e.items.All() {
|
|
if item.Type == 8 && item.ID == id {
|
|
n += item.Count
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
beforeCoin, beforeReward := countItem(2074), countItem(710)
|
|
request := wire.AppendVarint(nil, 1, 2)
|
|
request = wire.AppendVarint(request, 3, 900071)
|
|
for _, id := range []uint64{1, 5} {
|
|
line := wire.AppendVarint(nil, 1, id)
|
|
line = wire.AppendVarint(line, 3, 1)
|
|
request = wire.AppendBytes(request, 4, line)
|
|
}
|
|
code, response, handled, err := shop.HandleSession("/CashShopBuy", request, "audit")
|
|
if err != nil || !handled || code != 61 {
|
|
t.Fatalf("native batch code=%d handled=%t err=%v", code, handled, err)
|
|
}
|
|
if wallet.Snapshot().FreeJewelry != beforeWallet.FreeJewelry-100 || countItem(2074) != beforeCoin-12 || countItem(710) != beforeReward+1 {
|
|
t.Fatal("native batch debit or real reward missing", wallet.Snapshot(), e.items.All())
|
|
}
|
|
for _, id := range []uint64{1, 5} {
|
|
found := false
|
|
for _, raw := range shop.PurchaseCountDBInfos() {
|
|
g, _, _ := wire.Varint(raw, 1)
|
|
p, _, _ := wire.Varint(raw, 2)
|
|
n, _, _ := wire.Varint(raw, 4)
|
|
if g == 900071 && p == id && n == 1 {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("native purchase count missing", id)
|
|
}
|
|
}
|
|
balance := wallet.Snapshot()
|
|
coins := countItem(2074)
|
|
rewardCount := countItem(710)
|
|
_, replay, _, err := shop.HandleSession("/CashShopBuy", request, "audit")
|
|
if err != nil || !bytes.Equal(response, replay) || wallet.Snapshot() != balance || countItem(2074) != coins || countItem(710) != rewardCount {
|
|
t.Fatal("native purchase replay duplicated", err)
|
|
}
|
|
})
|
|
delegated := map[gamedata.CashProductKey]bool{}
|
|
retired := map[gamedata.CashProductKey]bool{}
|
|
for _, group := range groups {
|
|
d, err := gamedata.LoadInfiniteGachaForSchedules(root, "20260923193640", []uint64{group.id})
|
|
if err != nil && strings.Contains(err.Error(), "infinite cash product missing") {
|
|
// Early infinite draws retain placeholder gold/count=0 boxes in this
|
|
// version. They are historical timed products, not grantable purchases.
|
|
found := false
|
|
for _, p := range catalog.Products {
|
|
if p.Key.ProductID != group.gacha {
|
|
continue
|
|
}
|
|
found = true
|
|
if p.TimeLimitType == 0 || shop.IsAvailable(p.Key) {
|
|
t.Fatalf("retired infinite product %+v is purchasable without a valid preview design", p.Key)
|
|
}
|
|
retired[p.Key] = true
|
|
}
|
|
if !found {
|
|
t.Fatalf("infinite group=%d has no matching retired product", group.id)
|
|
}
|
|
continue
|
|
}
|
|
if err != nil || d == nil || d.Count == 0 {
|
|
t.Fatalf("infinite draw group=%d cannot grant confirmed preview: %v", group.id, err)
|
|
}
|
|
delegated[gamedata.CashProductKey{GroupID: d.ProductGroupID, ProductID: d.ProductID, SaleGroup: d.SaleGroup}] = true
|
|
}
|
|
cash, recharge, delegateCount, retiredCount := 0, 0, 0, 0
|
|
for _, p := range catalog.Products {
|
|
if p.PriceType != 1 {
|
|
continue
|
|
}
|
|
cash++
|
|
if p.Recharge {
|
|
recharge++
|
|
}
|
|
if delegated[p.Key] {
|
|
delegateCount++
|
|
continue
|
|
}
|
|
if retired[p.Key] {
|
|
retiredCount++
|
|
continue
|
|
}
|
|
if p.RandomBoxID == 0 {
|
|
t.Errorf("cash product %+v has no reward box", p.Key)
|
|
continue
|
|
}
|
|
for _, box := range []uint64{p.RandomBoxID, p.BonusRandomBoxID} {
|
|
if box == 0 {
|
|
continue
|
|
}
|
|
leaves, err := resolver.ResolveGranted([]gamedata.BattleReward{{Type: 9, ID: box, Count: 1}})
|
|
if err != nil || len(leaves) == 0 {
|
|
t.Errorf("cash product %+v box=%d cannot grant rewards: %v", p.Key, box, err)
|
|
continue
|
|
}
|
|
for _, r := range leaves {
|
|
if r.Type == 19 && entitlements.TicketTypes[r.ID] == 0 || r.Type == 62 && !entitlements.AvatarSets[r.ID] {
|
|
t.Errorf("cash product %+v has unknown entitlement %+v", p.Key, r)
|
|
}
|
|
}
|
|
}
|
|
// Exercise the same grant and debit modules used by CashShopBuy against
|
|
// isolated in-memory accounts; no real save or authenticated API is touched.
|
|
q, err := policy.Quote(p.Key, 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var costs []gamedata.Reward
|
|
if q.Cost > 0 {
|
|
costs = []gamedata.Reward{{Type: q.ItemType, Count: q.Cost}}
|
|
}
|
|
rewards := []gamedata.Reward{{Type: 9, ID: p.RandomBoxID, Count: 1}}
|
|
if p.BonusRandomBoxID != 0 {
|
|
rewards = append(rewards, gamedata.Reward{Type: 9, ID: p.BonusRandomBoxID, Count: 1})
|
|
}
|
|
e, wallet := newEconomy()
|
|
bundle, err := e.Apply("installed-audit", costs, rewards)
|
|
if err != nil || len(bundle) == 0 {
|
|
t.Errorf("cash product %+v failed real debit/grant: %v", p.Key, err)
|
|
continue
|
|
}
|
|
_ = wire.Walk(bundle, func(f wire.Field) error {
|
|
if f.Number == 1 && f.Type == 2 {
|
|
typ, _, _ := wire.Varint(f.Value, 3)
|
|
if typ == 62 {
|
|
t.Errorf("cash product %+v returned synthetic avatar set", p.Key)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
balance := wallet.Snapshot()
|
|
replay, err := e.Apply("installed-audit", costs, rewards)
|
|
if err != nil || !bytes.Equal(replay, bundle) || wallet.Snapshot() != balance {
|
|
t.Errorf("cash product %+v replay repeated debit/grant: %v", p.Key, err)
|
|
}
|
|
}
|
|
if cash != 614 || recharge != 14 || delegateCount != 3 || retiredCount != 5 {
|
|
t.Fatalf("cash coverage changed: cash=%d recharge=%d delegated=%d retired=%d", cash, recharge, delegateCount, retiredCount)
|
|
}
|
|
bonus, err := gamedata.LoadCashBonusCatalog(root, "20260923193640")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rows := bonus.Groups[1]
|
|
if len(rows) != 3 || rows[0].RequireCount != 2 || rows[0].Reward.Count != 2 || rows[1].RequireCount != 6 || rows[1].Reward.Count != 15 || rows[2].RequireCount != 8 || rows[2].Reward.Count != 30 {
|
|
t.Fatalf("cash bonus thresholds or rewards changed: %+v", rows)
|
|
}
|
|
t.Logf("validated %d cash rows: %d recharge variants, %d preview designs, %d unavailable historical draws; checked other reward boxes and typed ticket/skin entitlements", cash, recharge, delegateCount, retiredCount)
|
|
}
|