Files
bd2/go/internal/server/commerce/installed_audit_test.go
T
Flechazo 924a42c32e fix(all): complete activity calendars, shop purchases and reward dispatch
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.
2026-10-05 15:49:50 +08:00

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)
}