diff --git a/go/cmd/bd2server/main.go b/go/cmd/bd2server/main.go index 31cf0e9..18e6218 100644 --- a/go/cmd/bd2server/main.go +++ b/go/cmd/bd2server/main.go @@ -786,6 +786,14 @@ func serve(args []string) (serveErr error) { if err := cashService.AttachShopSeed(serverConfig); err != nil { return fmt.Errorf("attach cash product availability: %w", err) } + cashBonusDesign, err := gamedata.LoadCashBonusCatalog(gameData, *gameDataVersion) + if err != nil { + return fmt.Errorf("load cash bonus design: %w", err) + } + cashBonuses, err := commerce.NewCashBonuses(gameplayStore, cashEconomy, cashService, cashBonusDesign, cashDesign.Packages) + if err != nil { + return fmt.Errorf("load cash bonus claims: %w", err) + } cashService.AttachLegacyCounts(gachaService) cashService.AttachDelegate(func(key gamedata.CashProductKey, request []byte) ([]byte, bool, error) { known := key.GroupID == infiniteGacha.ProductGroupID && key.ProductID == infiniteGacha.ProductID && key.SaleGroup == infiniteGacha.SaleGroup @@ -1034,6 +1042,7 @@ func serve(args []string) (serveErr error) { eventTasksService.AttachGameplayProvider(worldService.GameplayAchievementProvider(achievementCounterDesign, achievementGrades)) game, err := session.NewServerWithProgress(login, progressState, cashService, + cashBonuses, clearPackages, commerce.PackInfoHandler{World: worldService, Claims: clearPackages}, commerce.AttendanceHandler{Events: eventTasksService, Economy: cashEconomy, LoginPasses: loginPasses, Store: gameplayStore}, diff --git a/go/internal/server/commerce/bonuses.go b/go/internal/server/commerce/bonuses.go new file mode 100644 index 0000000..1e6b64c --- /dev/null +++ b/go/internal/server/commerce/bonuses.go @@ -0,0 +1,216 @@ +package commerce + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "math" + "sort" + "sync" +) + +type bonusGroup struct{ ProductGroup, ContentsGroup uint64 } +type bonusPurchases interface { + LifetimePurchaseTotal([]gamedata.CashProductKey) (uint64, error) +} +type bonusState struct { + Claims map[string]bool `json:"claims"` + Receipts map[string]purchaseReceipt `json:"receipts"` +} +type CashBonuses struct { + mu sync.Mutex + store stateio.Store + economy Economy + counts bonusPurchases + groups map[bonusGroup][]gamedata.CashProductKey + rewards map[bonusGroup][]gamedata.CashBonusReward + ordered []bonusGroup + claimIDs map[string]bool +} + +func bonusClaimID(group bonusGroup, id uint64) string { + return fmt.Sprintf("%d:%d:%d", group.ProductGroup, group.ContentsGroup, id) +} + +func NewCashBonuses(store stateio.Store, economy Economy, counts bonusPurchases, design *gamedata.CashBonusCatalog, packages []gamedata.CashPackageDesign) (*CashBonuses, error) { + if store == nil || economy == nil || counts == nil || design == nil { + return nil, fmt.Errorf("commerce: missing cash bonus dependency") + } + s := &CashBonuses{store: store, economy: economy, counts: counts, groups: map[bonusGroup][]gamedata.CashProductKey{}, rewards: map[bonusGroup][]gamedata.CashBonusReward{}, claimIDs: map[string]bool{}} + seen := map[gamedata.CashProductKey]bool{} + for _, p := range packages { + // EPackages.BonusBundleGroup=8; unrelated packages can reuse a contents ID. + if p.PackageType != 8 || len(design.Groups[p.ContentsGroupID]) == 0 { + continue + } + k := gamedata.CashProductKey{GroupID: p.GroupID, ProductID: p.ID, SaleGroup: p.SaleGroup} + if seen[k] { + return nil, fmt.Errorf("commerce: duplicate cash bonus product %+v", k) + } + seen[k] = true + group := bonusGroup{p.GroupID, p.ContentsGroupID} + s.groups[group] = append(s.groups[group], k) + s.rewards[group] = design.Groups[p.ContentsGroupID] + } + for group, rows := range s.rewards { + s.ordered = append(s.ordered, group) + for _, row := range rows { + s.claimIDs[bonusClaimID(group, row.ID)] = true + } + } + sort.Slice(s.ordered, func(i, j int) bool { + if s.ordered[i].ProductGroup != s.ordered[j].ProductGroup { + return s.ordered[i].ProductGroup < s.ordered[j].ProductGroup + } + return s.ordered[i].ContentsGroup < s.ordered[j].ContentsGroup + }) + _, err := s.load() + return s, err +} + +func (s *CashBonuses) load() (bonusState, error) { + v := bonusState{Claims: map[string]bool{}, Receipts: map[string]purchaseReceipt{}} + raw, err := s.store.Load("commerce_cash_bonuses") + if err != nil || raw == nil { + return v, err + } + if err = stateio.RequireExactJSONObject(raw, "claims", "receipts"); err != nil { + return v, err + } + if err = json.Unmarshal(raw, &v); err != nil { + return v, err + } + if v.Claims == nil || v.Receipts == nil { + return v, fmt.Errorf("commerce: malformed cash bonus state") + } + for id, claimed := range v.Claims { + if !claimed || !s.claimIDs[id] { + return v, fmt.Errorf("commerce: invalid cash bonus claim %s", id) + } + } + for id, r := range v.Receipts { + digest, err := hex.DecodeString(r.Digest) + if id == "" || err != nil || len(digest) != sha256.Size || len(r.Response) == 0 { + return v, fmt.Errorf("commerce: invalid cash bonus receipt") + } + } + return v, nil +} + +func (s *CashBonuses) rewarded(v bonusState, group bonusGroup, field int) []byte { + var out []byte + for _, row := range s.rewards[group] { + if v.Claims[bonusClaimID(group, row.ID)] { + out = wire.AppendVarint(out, field, row.ID) + } + } + return out +} + +func (s *CashBonuses) Handle(path string, request []byte) (int, []byte, bool, error) { + return s.HandleSession(path, request, "") +} + +func (s *CashBonuses) HandleSession(path string, request []byte, session string) (int, []byte, bool, error) { + code := 588 + if path == "/CashBonusReward" { + code = 589 + } else if path != "/CashBonusInfo" { + return 0, nil, false, nil + } + seq, present, err := wire.Varint(request, 1) + if err != nil || !present || seq == 0 || seq > math.MaxInt32 { + return code, nil, true, fmt.Errorf("commerce: invalid cash bonus sequence") + } + if err = wire.Walk(request, func(wire.Field) error { return nil }); err != nil { + return code, nil, true, err + } + s.mu.Lock() + defer s.mu.Unlock() + v, err := s.load() + if err != nil { + return code, nil, true, err + } + if path == "/CashBonusInfo" { + var out []byte + for _, group := range s.ordered { + n, err := s.counts.LifetimePurchaseTotal(s.groups[group]) + if err != nil { + return code, nil, true, err + } + // Preserve the native empty response for groups with no purchases. + if n == 0 { + continue + } + b := wire.AppendVarint(nil, 1, group.ProductGroup) + b = wire.AppendVarint(b, 2, group.ContentsGroup) + b = wire.AppendVarint(b, 3, n) + b = append(b, s.rewarded(v, group, 4)...) + out = wire.AppendBytes(out, 1, b) + } + return code, out, true, nil + } + if session == "" { + return code, nil, true, fmt.Errorf("commerce: cash bonus session unavailable") + } + identity := fmt.Sprintf("cash-bonus:%x:%d", sha256.Sum256([]byte(session)), seq) + digest := fmt.Sprintf("%x", sha256.Sum256(request)) + if receipt, ok := v.Receipts[identity]; ok { + if receipt.Digest != digest { + return code, nil, true, fmt.Errorf("commerce: conflicting cash bonus replay") + } + return code, append([]byte(nil), receipt.Response...), true, nil + } + groupID, _, err := wire.Varint(request, 2) + if err != nil { + return code, nil, true, err + } + contents, _, err := wire.Varint(request, 3) + if err != nil { + return code, nil, true, err + } + id, _, err := wire.Varint(request, 4) + if err != nil { + return code, nil, true, err + } + group := bonusGroup{groupID, contents} + var chosen *gamedata.CashBonusReward + for _, row := range s.rewards[group] { + if row.ID == id { + copy := row + chosen = © + break + } + } + if chosen == nil { + return code, nil, true, fmt.Errorf("commerce: unknown cash bonus group=%d contents=%d bonus=%d", groupID, contents, id) + } + n, err := s.counts.LifetimePurchaseTotal(s.groups[group]) + if err != nil { + return code, nil, true, err + } + if n < chosen.RequireCount { + return code, nil, true, fmt.Errorf("commerce: cash bonus purchase threshold not reached group=%d contents=%d bonus=%d count=%d require=%d", groupID, contents, id, n, chosen.RequireCount) + } + claimID := bonusClaimID(group, id) + var bundle []byte + if !v.Claims[claimID] { + bundle, err = s.economy.Apply("cash-bonus-grant:"+claimID, nil, []gamedata.Reward{chosen.Reward}) + if err != nil { + return code, nil, true, err + } + v.Claims[claimID] = true + } + response := wire.AppendBytes(nil, 1, bundle) + response = append(response, s.rewarded(v, group, 2)...) + v.Receipts[identity] = purchaseReceipt{Digest: digest, Response: response} + raw, err := json.Marshal(v) + if err == nil { + err = s.store.Save("commerce_cash_bonuses", raw) + } + return code, response, true, err +} diff --git a/go/internal/server/commerce/bonuses_test.go b/go/internal/server/commerce/bonuses_test.go new file mode 100644 index 0000000..13638b6 --- /dev/null +++ b/go/internal/server/commerce/bonuses_test.go @@ -0,0 +1,114 @@ +package commerce + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/wire" + "bytes" + "testing" +) + +func bonusRequest(seq, group, contents, bonus uint64) []byte { + b := wire.AppendVarint(nil, 1, seq) + b = wire.AppendVarint(b, 2, group) + b = wire.AppendVarint(b, 3, contents) + return wire.AppendVarint(b, 4, bonus) +} + +func TestCashBonusUsesCommittedSeriesPurchasesAndNativeClaimProtocol(t *testing.T) { + shop, eco, store := serviceFixture(t, 1) + packages := []gamedata.CashPackageDesign{ + {GroupID: 2, ID: 1, PackageType: 8, ContentsGroupID: 7}, + {GroupID: 2, ID: 1, SaleGroup: 1, PackageType: 8, ContentsGroupID: 7}, + // Another package kind can reuse contents=7 but must not count. + {GroupID: 1, ID: 1, PackageType: 3, ContentsGroupID: 7}, + } + design := &gamedata.CashBonusCatalog{Groups: map[uint64][]gamedata.CashBonusReward{ + 7: {{ID: 1, RequireCount: 2, Reward: gamedata.Reward{Type: 8, ID: 1000, Count: 2}}, {ID: 2, RequireCount: 3, Reward: gamedata.Reward{Type: 8, ID: 1000, Count: 15}}}, + }} + s, err := NewCashBonuses(store, eco, shop, design, packages) + if err != nil { + t.Fatal(err) + } + infoRequest := wire.AppendVarint(nil, 1, 1) + if code, info, handled, err := s.Handle("/CashBonusInfo", infoRequest); err != nil || code != 588 || !handled || len(info) != 0 { + t.Fatal("new account bonus info must be empty", code, info, err) + } + claim := bonusRequest(1, 2, 7, 1) + if _, _, _, err := s.HandleSession("/CashBonusReward", claim, "s"); err == nil || eco.calls != 0 { + t.Fatal("unearned bonus granted") + } + for seq, sale := range []uint64{0, 1} { + if _, _, _, err := shop.HandleSession("/CashShopBuy", buyRequest(uint64(seq+1), 2, 1, sale, ""), "s"); err != nil { + t.Fatal(err) + } + } + // Normal recharge is not a member of the bonus series. + if _, _, _, err := shop.HandleSession("/CashShopBuy", buyRequest(3, 1, 1, 0, ""), "s"); err != nil { + t.Fatal(err) + } + _, info, _, err := s.Handle("/CashBonusInfo", infoRequest) + if err != nil { + t.Fatal(err) + } + row, present, _ := wire.Bytes(info, 1) + count, _, _ := wire.Varint(row, 3) + if !present || count != 2 { + t.Fatal("incorrect committed series purchase count", count) + } + calls := eco.calls + code, reply, handled, err := s.HandleSession("/CashBonusReward", claim, "s") + if err != nil || code != 589 || !handled || eco.calls != calls+1 || len(eco.rewards) != 1 || eco.rewards[0].Count != 2 || len(eco.costs) != 0 { + t.Fatal("earned bonus not granted through native protocol", code, err, eco.rewards) + } + if id, _, _ := wire.Varint(reply, 2); id != 1 { + t.Fatal("native rewarded ID list missing") + } + reopened, err := NewCashBonuses(store, eco, shop, design, packages) + if err != nil { + t.Fatal(err) + } + _, replay, _, err := reopened.HandleSession("/CashBonusReward", claim, "s") + if err != nil || !bytes.Equal(reply, replay) || eco.calls != calls+1 { + t.Fatal("restart replay duplicated bonus", err) + } + _, noOp, _, err := reopened.HandleSession("/CashBonusReward", bonusRequest(2, 2, 7, 1), "s") + bundle, present, _ := wire.Bytes(noOp, 1) + if err != nil || !present || len(bundle) != 0 || eco.calls != calls+1 { + t.Fatal("fresh duplicate must return empty grant with claimed IDs", err) + } + for _, request := range [][]byte{bonusRequest(1, 2, 7, 2), bonusRequest(4, 1, 7, 1), bonusRequest(5, 2, 7, 99), bonusRequest(6, 2, 7, 2)} { + if _, _, _, err := reopened.HandleSession("/CashBonusReward", request, "s"); err == nil || eco.calls != calls+1 { + t.Fatal("conflicting replay, wrong series or unmet bonus accepted") + } + } + _, info, _, err = reopened.Handle("/CashBonusInfo", infoRequest) + row, _, _ = wire.Bytes(info, 1) + if id, _, _ := wire.Varint(row, 4); err != nil || id != 1 { + t.Fatal("claimed IDs missing after restart", err) + } +} + +func TestCashBonusFailedGrantDoesNotMarkClaimed(t *testing.T) { + shop, eco, store := serviceFixture(t, 0) + if _, _, _, err := shop.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 0, ""), "s"); err != nil { + t.Fatal(err) + } + design := &gamedata.CashBonusCatalog{Groups: map[uint64][]gamedata.CashBonusReward{7: {{ID: 1, RequireCount: 1, Reward: gamedata.Reward{Type: 8, ID: 1000, Count: 2}}}}} + s, err := NewCashBonuses(store, eco, shop, design, []gamedata.CashPackageDesign{{GroupID: 2, ID: 1, PackageType: 8, ContentsGroupID: 7}}) + if err != nil { + t.Fatal(err) + } + eco.fail = true + request := bonusRequest(1, 2, 7, 1) + if _, _, _, err := s.HandleSession("/CashBonusReward", request, "s"); err == nil { + t.Fatal("failed grant accepted") + } + v, err := s.load() + if err != nil || len(v.Claims) != 0 || len(v.Receipts) != 0 { + t.Fatal("failed grant marked bonus claimed", err) + } + eco.fail = false + if _, _, _, err := s.HandleSession("/CashBonusReward", request, "s"); err != nil { + t.Fatal("failed grant could not be retried", err) + } +} diff --git a/go/internal/server/commerce/installed_audit_test.go b/go/internal/server/commerce/installed_audit_test.go new file mode 100644 index 0000000..1f2e2c7 --- /dev/null +++ b/go/internal/server/commerce/installed_audit_test.go @@ -0,0 +1,255 @@ +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) + } + 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) + } + 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) + 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 + } + 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 + } + 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) +} diff --git a/go/internal/server/commerce/service.go b/go/internal/server/commerce/service.go index cfb5a27..976ff7b 100644 --- a/go/internal/server/commerce/service.go +++ b/go/internal/server/commerce/service.go @@ -651,6 +651,26 @@ func (s *Service) purchaseCountDBInfos() ([][]byte, error) { return out, nil } func (s *Service) PurchaseCountDBInfos() [][]byte { v, _ := s.purchaseCountDBInfos(); return v } + +// LifetimePurchaseTotal derives cumulative bonuses from committed purchases, +// including previous reset periods, without keeping a second purchase counter. +func (s *Service) LifetimePurchaseTotal(keys []gamedata.CashProductKey) (uint64, error) { + s.mu.Lock() + defer s.mu.Unlock() + v, err := s.load() + if err != nil { + return 0, err + } + var total uint64 + for _, k := range keys { + n := v.Counts[skuKey(k)].Lifetime + if n > math.MaxInt32-total { + return 0, fmt.Errorf("commerce: bonus purchase count overflow") + } + total += n + } + return total, nil +} func (s *Service) HasPurchased(k gamedata.CashProductKey) bool { s.mu.Lock() defer s.mu.Unlock() diff --git a/go/internal/server/eventtasks/pass_reward_test.go b/go/internal/server/eventtasks/pass_reward_test.go new file mode 100644 index 0000000..4c4d0b4 --- /dev/null +++ b/go/internal/server/eventtasks/pass_reward_test.go @@ -0,0 +1,157 @@ +package eventtasks + +import ( + "bd2server/internal/server/gamedata" + "bd2server/internal/server/wire" + "bytes" + "testing" +) + +func passClaimRequest(seq, rewardType uint64, all bool, level uint64) []byte { + b := req(seq) + if all { + b = wire.AppendVarint(b, 2, 1) + } + b = wire.AppendVarint(b, 3, 8) + if level != 0 { + b = wire.AppendVarint(b, 4, level) + } + if rewardType != 0 { + b = wire.AppendVarint(b, 5, rewardType) + } + return b +} + +func claimPass(t *testing.T, s *Service, request []byte) []byte { + t.Helper() + code, response, handled, err := s.Handle("/PassReward", request) + if err != nil || code != 126 || !handled { + t.Fatalf("pass claim failed: code=%d handled=%t err=%v", code, handled, err) + } + if _, present, err := wire.Bytes(response, 1); err != nil || !present { + t.Fatalf("native receiver requires a non-null reward bundle: %x, %v", response, err) + } + return response +} + +func TestPassRewardNativeOneClickAfterPurchaseAndRestart(t *testing.T) { + s, e, store := setup(t) + s.design.PassLevels[8] = []gamedata.EventPassLevel{ + {ID: 1, NeedExp: 20, Basic: gamedata.Reward{Type: 3, Count: 1}, Premium: gamedata.Reward{Type: 4, Count: 10}}, + {ID: 2, NeedExp: 30, Basic: gamedata.Reward{Type: 3, Count: 2}, Premium: gamedata.Reward{Type: 4, Count: 20}}, + {ID: 3, NeedExp: 40, Basic: gamedata.Reward{Type: 3, Count: 3}, Premium: gamedata.Reward{Type: 4, Count: 30}}, + {ID: 4, Basic: gamedata.Reward{Type: 3, Count: 4}, Premium: gamedata.Reward{Type: 4, Count: 40}}, + } + s.design.PassBuys = map[uint64][]gamedata.EventPassBuy{8: {{ID: 1, Type: 1, LevelsGranted: 3, Cost: gamedata.Reward{Type: 3, Count: 1000}}}} + buy := wire.AppendVarint(req(1), 2, 8) + buy = wire.AppendVarint(buy, 3, 1) + if _, _, _, err := s.Handle("/PassBuy", buy); err != nil { + t.Fatal(err) + } + + // PassRootUI.ReceiveAllReward sends BASIC first, then PREMIUM_1 in its callback. + basic := passClaimRequest(2, 0, true, 0) + basicReply := claimPass(t, s, basic) + for _, lv := range s.design.PassLevels[8] { + p := s.pass(s.registry.List()[2]) + if !p.Claimed[key(lv.ID, 0)] || p.Claimed[key(lv.ID, 1)] { + t.Fatalf("basic phase incorrectly claimed premium at level %d", lv.ID) + } + } + if len(e.rewards) != 4 || e.rewards[0].Type != 3 || e.rewards[3].Count != 4 { + t.Fatalf("basic phase granted wrong rewards: %+v", e.rewards) + } + reopened, err := Open(store, s.design, s.registry, e) + if err != nil { + t.Fatal(err) + } + reopened.now = s.now + reopened.SetSession("test") + if replay := claimPass(t, reopened, basic); !bytes.Equal(replay, basicReply) || e.calls != 2 { + t.Fatal("restart replay repeated basic grants") + } + premium := passClaimRequest(3, 1, true, 0) + premiumReply := claimPass(t, reopened, premium) + if len(e.rewards) != 8 || e.rewards[4].Type != 4 || e.rewards[7].Count != 40 || e.calls != 3 { + t.Fatalf("premium phase granted wrong rewards: %+v", e.rewards) + } + for _, lv := range reopened.design.PassLevels[8] { + p := reopened.pass(reopened.registry.List()[2]) + if !p.Claimed[key(lv.ID, 0)] || !p.Claimed[key(lv.ID, 1)] { + t.Fatalf("claim state incomplete at level %d", lv.ID) + } + } + if replay := claimPass(t, reopened, premium); !bytes.Equal(replay, premiumReply) || e.calls != 3 { + t.Fatal("premium replay repeated grants") + } + // A new one-click request after claiming must complete without granting again. + for typ := uint64(0); typ <= 1; typ++ { + response := claimPass(t, reopened, passClaimRequest(4+typ, typ, true, 0)) + bundle, _, _ := wire.Bytes(response, 1) + if len(bundle) != 0 || e.calls != 3 || len(e.rewards) != 8 { + t.Fatal("already-claimed all request repeated rewards") + } + } +} + +func TestPassRewardBasicAlreadyClaimedBeforePremiumPurchase(t *testing.T) { + s, e, _ := setup(t) + s.design.PassLevels[8] = []gamedata.EventPassLevel{{ID: 1, Basic: gamedata.Reward{Type: 3, Count: 5}, Premium: gamedata.Reward{Type: 4, Count: 50}}} + claimPass(t, s, passClaimRequest(1, 0, true, 0)) + s.design.PassBuys = map[uint64][]gamedata.EventPassBuy{8: {{ID: 1, Type: 1, Cost: gamedata.Reward{Type: 3, Count: 1000}}}} + buy := wire.AppendVarint(req(2), 2, 8) + buy = wire.AppendVarint(buy, 3, 1) + if _, _, _, err := s.Handle("/PassBuy", buy); err != nil { + t.Fatal(err) + } + claimPass(t, s, passClaimRequest(3, 0, true, 0)) + if len(e.rewards) != 1 || e.calls != 2 { + t.Fatal("empty basic phase granted again") + } + claimPass(t, s, passClaimRequest(4, 1, true, 0)) + if len(e.rewards) != 2 || e.rewards[1].Type != 4 || e.rewards[1].Count != 50 || e.calls != 3 { + t.Fatalf("empty basic phase prevented premium claim: %+v", e.rewards) + } +} + +func TestPassRewardSingleClaimsAndLockedLevels(t *testing.T) { + s, e, _ := setup(t) + s.design.PassLevels[8] = []gamedata.EventPassLevel{ + {ID: 1, NeedExp: 20, Basic: gamedata.Reward{Type: 3, Count: 5}, Premium: gamedata.Reward{Type: 4, Count: 50}}, + {ID: 2, Basic: gamedata.Reward{Type: 3, Count: 6}, Premium: gamedata.Reward{Type: 4, Count: 60}}, + } + if _, _, _, err := s.Handle("/PassReward", passClaimRequest(1, 1, true, 0)); err == nil || e.calls != 0 { + t.Fatal("premium reward granted without purchase") + } + claimPass(t, s, passClaimRequest(2, 0, false, 1)) + if len(e.rewards) != 1 || e.rewards[0].Count != 5 { + t.Fatal("single basic reward incorrect") + } + for _, request := range [][]byte{passClaimRequest(3, 0, false, 1), passClaimRequest(4, 0, false, 2), passClaimRequest(5, 0, false, 99), passClaimRequest(6, 2, true, 0)} { + if _, _, _, err := s.Handle("/PassReward", request); err == nil || e.calls != 1 { + t.Fatal("duplicate, locked or fabricated reward accepted") + } + } + s.pass(s.registry.List()[2]).Premium = true + claimPass(t, s, passClaimRequest(7, 1, false, 1)) + if len(e.rewards) != 2 || e.rewards[1].Count != 50 || s.pass(s.registry.List()[2]).Claimed[key(2, 1)] { + t.Fatal("single premium claim changed another level") + } +} + +func TestPassRewardKeepsCurrentNewbieStepInEveryResponse(t *testing.T) { + s, _, _ := setup(t) + d := s.design.Passes[8] + d.NewbieStep = 1 + s.design.Passes[8] = d + s.state.NewbieStep = 1 + s.design.PassLevels[8] = []gamedata.EventPassLevel{{ID: 1, NeedExp: 20, Basic: gamedata.Reward{Type: 3, Count: 5}}, {ID: 2, Basic: gamedata.Reward{Type: 3, Count: 6}}} + response := claimPass(t, s, passClaimRequest(1, 0, true, 0)) + if scalar(response, 2) != 1 || s.state.NewbieStep != 1 { + t.Fatal("partial claim resets native guide step") + } + response = claimPass(t, s, passClaimRequest(2, 0, true, 0)) + if scalar(response, 2) != 1 { + t.Fatal("empty phase resets native guide step") + } +} diff --git a/go/internal/server/eventtasks/service.go b/go/internal/server/eventtasks/service.go index 7c68227..16b84fa 100644 --- a/go/internal/server/eventtasks/service.go +++ b/go/internal/server/eventtasks/service.go @@ -687,7 +687,10 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error) } all, id, rt := scalar(b, 2) != 0, scalar(b, 4), scalar(b, 5) if rt > 1 { - return nil, errors.New("eventtasks: invalid pass reward kind") + return nil, fmt.Errorf("eventtasks: invalid pass reward kind pass=%d level=%d reward_type=%d all=%t", v.ID, id, rt, all) + } + if rt == 1 && !p.Premium { + return nil, fmt.Errorf("eventtasks: premium pass required pass=%d level=%d reward_type=%d all=%t", v.ID, id, rt, all) } var rewards []gamedata.Reward var claims []string @@ -699,34 +702,36 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error) if !eligible || !all && lv.ID != id { continue } - for typ := uint64(0); typ <= 1; typ++ { - if !all && typ != rt || typ == 1 && !p.Premium { - continue - } - ck := key(lv.ID, typ) - if p.Claimed[ck] { - continue - } - r := lv.Basic - if typ == 1 { - r = lv.Premium - } - if r.Type > 0 && r.Count > 0 { - rewards = append(rewards, r) - } - claims = append(claims, ck) + // IsAll selects levels within RewardType. The native one-click flow + // sends an all-basic request followed by a separate all-premium one. + ck := key(lv.ID, rt) + if p.Claimed[ck] { + continue } + r := lv.Basic + if rt == 1 { + r = lv.Premium + } + if r.Type > 0 && r.Count > 0 { + rewards = append(rewards, r) + } + claims = append(claims, ck) } - if len(claims) == 0 { - return nil, errors.New("eventtasks: pass reward not eligible") + if len(claims) == 0 && !all { + return nil, fmt.Errorf("eventtasks: pass reward not eligible pass=%d level=%d reward_type=%d all=%t exp=%d premium=%t", v.ID, id, rt, all, p.Exp, p.Premium) } - bundle, e := s.economy.Apply(identity, nil, rewards) - if e != nil { - return nil, e + var bundle []byte + if len(claims) > 0 { + bundle, e = s.economy.Apply(identity, nil, rewards) + if e != nil { + return nil, fmt.Errorf("eventtasks: pass reward grant failed pass=%d level=%d reward_type=%d all=%t: %w", v.ID, id, rt, all, e) + } } for _, k := range claims { p.Claimed[k] = true } + // An already-claimed all-basic phase must still succeed so the client + // can proceed to all-premium. Include a non-null empty reward bundle. out := wire.AppendBytes(nil, 1, bundle) if d.NewbieStep > 0 { complete := true @@ -747,8 +752,9 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error) } if complete && s.state.NewbieStep == d.NewbieStep { s.state.NewbieStep++ - out = wire.AppendVarint(out, 2, s.state.NewbieStep) } + // The native receiver assigns this field even on an incomplete step. + out = wire.AppendVarint(out, 2, s.state.NewbieStep) } return out, nil case "/PassBuy": diff --git a/go/internal/server/feature/feature.go b/go/internal/server/feature/feature.go index 85a485f..d5a10ad 100644 --- a/go/internal/server/feature/feature.go +++ b/go/internal/server/feature/feature.go @@ -22,7 +22,6 @@ var ErrInvalidRequest = errors.New("feature: invalid protobuf request") var emptyResponses = map[string]int{ "/AvatarShopWishListInfo": 470, "/CafeteriaInfo": 351, - "/CashBonusInfo": 588, "/CharPartnerInfo": 65, "/CommunityRewardInfo": 289, "/DailyStoryInfo": 538, diff --git a/go/internal/server/gamedata/cash_bonuses.go b/go/internal/server/gamedata/cash_bonuses.go new file mode 100644 index 0000000..02aab09 --- /dev/null +++ b/go/internal/server/gamedata/cash_bonuses.go @@ -0,0 +1,46 @@ +package gamedata + +import ( + "fmt" + "sort" +) + +type CashBonusReward struct { + ID, RequireCount uint64 + Reward Reward +} + +type CashBonusCatalog struct{ Groups map[uint64][]CashBonusReward } + +func LoadCashBonusCatalog(root, version string) (*CashBonusCatalog, error) { + db, done, err := openStatDatabase(root, version) + if err != nil { + return nil, err + } + defer done() + d := &CashBonusCatalog{Groups: map[uint64][]CashBonusReward{}} + err = readCashMetadata(db, "CashBonusTable", func(raw []byte) error { + v, err := cashScalars(raw, 1, 2, 3, 6, 5, 4) + if err != nil { + return err + } + if v[0] == 0 || v[1] == 0 || v[2] == 0 || v[3] == 0 || v[5] == 0 { + return fmt.Errorf("gamedata: invalid cash bonus") + } + d.Groups[v[0]] = append(d.Groups[v[0]], CashBonusReward{v[1], v[2], Reward{Type: v[3], ID: v[4], Count: v[5]}}) + return nil + }) + if err != nil { + return nil, err + } + for group, rows := range d.Groups { + sort.Slice(rows, func(i, j int) bool { return rows[i].ID < rows[j].ID }) + for i, row := range rows { + if i > 0 && row.ID == rows[i-1].ID { + return nil, fmt.Errorf("gamedata: duplicate cash bonus group=%d id=%d", group, row.ID) + } + } + d.Groups[group] = rows + } + return d, nil +}