diff --git a/go/cmd/bd2server/main.go b/go/cmd/bd2server/main.go index ba7d3eb..866e4c0 100644 --- a/go/cmd/bd2server/main.go +++ b/go/cmd/bd2server/main.go @@ -781,6 +781,16 @@ func serve(args []string) (serveErr error) { return fmt.Errorf("load cash entitlements: %w", err) } cashEconomy.SetClock(time.Now, eventAPReset.ResetSeconds-9*3600) + cashMailTemplates, err := gamedata.LoadCashMailTemplates(gameData, *gameDataVersion) + if err != nil { + return fmt.Errorf("load cash mail templates: %w", err) + } + if err := mailService.AttachCashRewards(cashEconomy, cashMailTemplates); err != nil { + return err + } + if err := cashEconomy.AttachCashMail(mailService); err != nil { + return err + } cashService, err := commerce.NewService(cashCatalog, gameplayStore, cashEconomy) if err != nil { return fmt.Errorf("load cash purchase state: %w", err) diff --git a/go/internal/server/commerce/delivery.go b/go/internal/server/commerce/delivery.go new file mode 100644 index 0000000..4168194 --- /dev/null +++ b/go/internal/server/commerce/delivery.go @@ -0,0 +1,95 @@ +package commerce + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "fmt" + "time" + + "bd2server/internal/server/gamedata" +) + +type CashMailIssuer interface { + IssueCashOnce(string, uint64, []gamedata.Reward, time.Time) error +} + +func (e *EntitlementEconomy) AttachCashMail(issuer CashMailIssuer) error { + if issuer == nil { + return fmt.Errorf("commerce: cash mail issuer unavailable") + } + if _, ok := e.graph.(interface { + ResolveDelivery([]gamedata.BattleReward) (gamedata.CashDelivery, error) + }); !ok { + return fmt.Errorf("commerce: cash delivery resolver unavailable") + } + e.mail = issuer + return nil +} + +// ApplyPurchase selects rewards once, activates direct entitlements, and issues +// durable cash mail. All writes run in the enclosing account transaction. +func (e *EntitlementEconomy) ApplyPurchase(identity string, costs, rewards []gamedata.Reward) ([]byte, error) { + e.mu.Lock() + defer e.mu.Unlock() + if e.mail == nil { + return nil, fmt.Errorf("commerce: cash mail issuer unavailable") + } + definition, _ := json.Marshal(struct{ Costs, Rewards []gamedata.Reward }{costs, rewards}) + digest := sha256.Sum256(definition) + key := identity + ":delivery" + state, err := e.load() + if err != nil { + return nil, err + } + if receipt, ok := state.Receipts[key]; ok { + if !bytes.Equal(receipt.Definition, digest[:]) { + return nil, fmt.Errorf("commerce: delivery identity reused") + } + return append([]byte(nil), receipt.Bundle...), nil + } + resolver, ok := e.graph.(interface { + ResolveDelivery([]gamedata.BattleReward) (gamedata.CashDelivery, error) + }) + if !ok { + return nil, fmt.Errorf("commerce: missing cash delivery resolver") + } + input := make([]gamedata.BattleReward, len(rewards)) + for i, r := range rewards { + input[i] = gamedata.BattleReward(r) + } + plan, err := resolver.ResolveDelivery(input) + if err != nil { + return nil, err + } + direct := make([]gamedata.Reward, len(plan.Direct)) + for i, r := range plan.Direct { + direct[i] = gamedata.Reward(r) + } + var mailed []gamedata.BattleReward + for _, mail := range plan.Mail { + mailed = append(mailed, mail.Rewards...) + } + bundle, err := e.applyPrepared(identity+":direct", costs, direct, true, mailed) + if err != nil { + return nil, err + } + for i, mail := range plan.Mail { + attachments := make([]gamedata.Reward, len(mail.Rewards)) + for j, r := range mail.Rewards { + attachments[j] = gamedata.Reward(r) + } + if err := e.mail.IssueCashOnce(fmt.Sprintf("%s:%d", identity, i), mail.TemplateID, attachments, e.now()); err != nil { + return nil, err + } + } + state, err = e.load() + if err != nil { + return nil, err + } + state.Receipts[key] = entitlementReceipt{Definition: digest[:], Bundle: append([]byte(nil), bundle...)} + if err = e.save(state); err != nil { + return nil, err + } + return bundle, nil +} diff --git a/go/internal/server/commerce/delivery_test.go b/go/internal/server/commerce/delivery_test.go new file mode 100644 index 0000000..0d8852e --- /dev/null +++ b/go/internal/server/commerce/delivery_test.go @@ -0,0 +1,307 @@ +package commerce + +import ( + "bytes" + "errors" + "fmt" + "path/filepath" + "testing" + + "bd2server/internal/server/accountstate" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/mail" + "bd2server/internal/server/player" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" +) + +type deliveryGraph struct{ selections int } + +func (g *deliveryGraph) ResolveGranted(r []gamedata.BattleReward) ([]gamedata.BattleReward, error) { + return r, nil +} +func (g *deliveryGraph) ResolveDelivery(r []gamedata.BattleReward) (gamedata.CashDelivery, error) { + g.selections++ + return gamedata.CashDelivery{Mail: []gamedata.CashMailReward{{TemplateID: 40, Rewards: []gamedata.BattleReward{{Type: 4, Count: 40}, {Type: 9, ID: 200, Count: 1}}}}}, nil +} + +type deliveryBase struct { + wallet *player.Wallet + items *player.Inventory +} + +func (e deliveryBase) Apply(identity string, costs, rewards []gamedata.Reward) ([]byte, error) { + var currencies []gamedata.Reward + for _, r := range rewards { + if r.Type != 9 { + currencies = append(currencies, r) + } + } + if err := e.wallet.ExchangeOnce(identity, costs, currencies); err != nil { + return nil, err + } + var items []gamedata.BattleReward + var bundle []byte + for _, r := range rewards { + if r.Type == 9 { + items = append(items, gamedata.BattleReward(r)) + } else { + bundle = wire.AppendBytes(bundle, 1, player.ItemWire(player.Item{Type: r.Type, ID: r.ID, Count: r.Count})) + } + } + granted, err := e.items.GrantOnce(identity, items) + if err != nil { + return nil, err + } + if len(granted) == 0 { + granted = e.items.GrantedItems(identity) + } + for _, item := range granted { + bundle = wire.AppendBytes(bundle, 1, player.ItemWire(item)) + } + return bundle, nil +} +func cashList(t *testing.T, m *mail.Service, start, count uint64) ([]uint64, []byte) { + t.Helper() + request := wire.AppendVarint(nil, 1, 1) + request = wire.AppendVarint(request, 2, start) + request = wire.AppendVarint(request, 3, count) + code, response, handled, err := m.Handle("/CashMailInfo", request) + if err != nil || !handled || code != 140 { + t.Fatalf("cash list: %d %t %v", code, handled, err) + } + var ids []uint64 + _ = wire.Walk(response, func(f wire.Field) error { + if f.Number == 1 && f.Type == 2 { + id, _, _ := wire.Varint(f.Value, 1) + template, _, _ := wire.Varint(f.Value, 3) + typ, _, _ := wire.Varint(f.Value, 2) + cash, _, _ := wire.Varint(f.Value, 15) + if id == 0 || template != 40 || typ != 0 || cash != 1 { + t.Fatalf("invalid cash mail %x", f.Value) + } + ids = append(ids, id) + } + return nil + }) + return ids, response +} +func TestCashPurchaseMailSQLiteRollbackRestartBatchAndReplay(t *testing.T) { + fixture, _, _ := serviceFixture(t, 0) + path := filepath.Join(t.TempDir(), "state.db") + graph := &deliveryGraph{} + open := func() (*accountstate.Repository, *Service, *mail.Service, *player.Wallet, *player.Inventory) { + t.Helper() + repo, err := accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = repo.Close() }) + wallet, err := player.OpenWallet(repo, player.Currency{Jewelry: 4000}) + if err != nil { + t.Fatal(err) + } + items, err := player.OpenInventory(repo, &player.Starter{Version: "2.35.10"}) + if err != nil { + t.Fatal(err) + } + if err = wallet.EnsurePersisted(); err != nil { + t.Fatal(err) + } + if err = items.EnsurePersisted(); err != nil { + t.Fatal(err) + } + economy, err := NewEntitlementEconomy(repo, deliveryBase{wallet, items}, graph, items, &gamedata.CashEntitlementDesign{}) + if err != nil { + t.Fatal(err) + } + mailbox, err := mail.OpenService(repo, &mail.Starter{Version: "2.35.10", MailCount: 1}, items, wallet) + if err != nil { + t.Fatal(err) + } + if err = mailbox.AttachCashRewards(economy, map[uint64]bool{40: true}); err != nil { + t.Fatal(err) + } + if err = economy.AttachCashMail(mailbox); err != nil { + t.Fatal(err) + } + shop, err := NewService(fixture.catalog, repo, economy) + if err != nil { + t.Fatal(err) + } + return repo, shop, mailbox, wallet, items + } + rollback := func(op stateio.RequestOperation) { + t.Helper() + if err := op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) { + t.Fatal(err) + } + } + repo, shop, mailbox, wallet, items := open() + shop.AttachPurchaseHook(func(string, gamedata.CashProductDesign, uint64) error { + return fmt.Errorf("injected failure after issuing mail") + }) + op, err := repo.BeginOperation() + if err != nil { + t.Fatal(err) + } + if _, _, _, err = shop.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 0, ""), "s"); err == nil { + t.Fatal("failure ignored") + } + rollback(op) + _ = repo.Close() + repo, shop, mailbox, wallet, items = open() + if wallet.Snapshot().Jewelry != 4000 || wallet.Snapshot().Gold != 0 || len(items.All()) != 0 { + t.Fatal("rollback retained debit/grant") + } + if ids, _ := cashList(t, mailbox, 0, 20); len(ids) != 0 { + t.Fatal("rollback retained cash mail") + } + for seq := uint64(1); seq <= 2; seq++ { + op, err = repo.BeginOperation() + if err != nil { + t.Fatal(err) + } + if err = mailbox.BeforeDispatch("/CashShopBuy", nil); err != nil { + t.Fatal(err) + } + _, response, _, err := shop.HandleSession("/CashShopBuy", buyRequest(seq, 2, 1, 0, ""), "s") + if err != nil { + t.Fatal(err) + } + bundle, present, _ := wire.Bytes(response, 1) + if !present || len(bundle) != 0 { + t.Fatalf("mail-only purchase must not return direct rewards: %x", response) + } + notify, err := mailbox.AfterDispatch("/CashShopBuy", nil, nil) + flag, _, _ := wire.Varint(notify, 1) + if err != nil || flag != 1 { + t.Fatal("new mail notification missing") + } + if err = op.Commit(); err != nil { + t.Fatal(err) + } + } + if wallet.Snapshot().Jewelry != 2000 || wallet.Snapshot().Gold != 0 || len(items.All()) != 0 { + t.Fatal("unclaimed attachments granted early") + } + ids, list := cashList(t, mailbox, 0, 1) + total, _, _ := wire.Varint(list, 2) + if len(ids) != 1 || ids[0] != 2 || total != 2 { + t.Fatal("cash first page", ids, total) + } + next, _ := cashList(t, mailbox, ids[0], 1) + if len(next) != 1 || next[0] != 1 { + t.Fatal("cash cursor skipped/duplicated", next) + } + _, ordinary, _, err := mailbox.Handle("/MailInfo", wire.AppendVarint(nil, 1, 1)) + if err != nil { + t.Fatal(err) + } + if _, present, _ := wire.Bytes(ordinary, 1); present { + t.Fatal("cash mail leaked into ordinary list") + } + beforeSelections := graph.selections + op, err = repo.BeginOperation() + if err != nil { + t.Fatal(err) + } + if _, _, _, err = shop.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 0, ""), "s"); err != nil { + t.Fatal(err) + } + if err = op.Commit(); err != nil { + t.Fatal(err) + } + if graph.selections != beforeSelections || wallet.Snapshot().Jewelry != 2000 { + t.Fatal("purchase replay selected or charged again") + } + _ = repo.Close() + repo, shop, mailbox, wallet, items = open() + claim := wire.AppendVarint(nil, 1, 3) + claim = wire.AppendVarint(claim, 2, 1) + claim = wire.AppendVarint(claim, 2, 2) + op, err = repo.BeginOperation() + if err != nil { + t.Fatal(err) + } + if _, _, _, err = mailbox.Handle("/MailOpen", claim); err != nil { + t.Fatal(err) + } + rollback(op) + _ = repo.Close() + repo, shop, mailbox, wallet, items = open() + if wallet.Snapshot().Gold != 0 || len(items.All()) != 0 { + t.Fatal("claim rollback retained rewards") + } + if ids, _ := cashList(t, mailbox, 0, 20); len(ids) != 2 { + t.Fatal("claim rollback hid mails") + } + op, err = repo.BeginOperation() + if err != nil { + t.Fatal(err) + } + _, claimed, _, err := mailbox.Handle("/MailOpen", claim) + if err != nil { + t.Fatal(err) + } + if err = op.Commit(); err != nil { + t.Fatal(err) + } + countBoxes := func(items *player.Inventory) uint64 { + var n uint64 + for _, r := range items.All() { + if r.Type == 9 && r.ID == 200 { + n += r.Count + } + } + return n + } + if wallet.Snapshot().Gold != 80 || countBoxes(items) != 2 { + t.Fatal("batch claim failed", wallet.Snapshot(), items.All()) + } + _ = repo.Close() + repo, _, mailbox, wallet, items = open() + defer repo.Close() + op, err = repo.BeginOperation() + if err != nil { + t.Fatal(err) + } + _, replayed, _, err := mailbox.Handle("/MailOpen", claim) + if err != nil { + t.Fatal(err) + } + if err = op.Commit(); err != nil { + t.Fatal(err) + } + if !bytes.Equal(claimed, replayed) || wallet.Snapshot().Gold != 80 || countBoxes(items) != 2 { + t.Fatal("restart replay duplicated claim") + } + if ids, _ := cashList(t, mailbox, 0, 20); len(ids) != 0 { + t.Fatal("claimed mails still visible") + } + historyReq := wire.AppendVarint(nil, 1, 4) + historyReq = wire.AppendVarint(historyReq, 3, 20) + _, history, _, err := mailbox.Handle("/MailHistoryInfo", historyReq) + n, _, _ := wire.Varint(history, 2) + if err != nil || n != 2 { + t.Fatal("claim history missing", n, err) + } +} +func TestDirectSubscriptionDoesNotDuplicateFirstDayAlreadyInMail(t *testing.T) { + e, _, base, _, _ := entitlementFixture(t) + e.mu.Lock() + _, err := e.applyPrepared("direct", nil, []gamedata.Reward{{Type: 19, ID: 38, Count: 1}}, true, []gamedata.BattleReward{{Type: 3, Count: 1}}) + e.mu.Unlock() + if err != nil || len(base.Rewards) != 0 { + t.Fatal("mailed first day was granted directly", base.Rewards, err) + } + if _, err = e.ApplyResolved("mail", nil, []gamedata.Reward{{Type: 3, Count: 1}}); err != nil { + t.Fatal(err) + } + if len(base.Rewards) != 1 || base.Rewards[0].Count != 1 { + t.Fatal("claim did not grant exactly one first day", base.Rewards) + } + if _, err = e.ClaimSubscriptions("same day"); err != nil || len(base.Rewards) != 1 { + t.Fatal("same-day login duplicated first day", err) + } +} diff --git a/go/internal/server/commerce/entitlements.go b/go/internal/server/commerce/entitlements.go index c05e229..e0bc024 100644 --- a/go/internal/server/commerce/entitlements.go +++ b/go/internal/server/commerce/entitlements.go @@ -42,6 +42,7 @@ type EntitlementEconomy struct { design *gamedata.CashEntitlementDesign now func() time.Time resetSeconds int64 + mail CashMailIssuer } func NewEntitlementEconomy(store stateio.Store, base Economy, graph grantedResolver, items *player.Inventory, design *gamedata.CashEntitlementDesign) (*EntitlementEconomy, error) { @@ -99,7 +100,15 @@ func (e *EntitlementEconomy) Apply(identity string, costs, rewards []gamedata.Re defer e.mu.Unlock() return e.apply(identity, costs, rewards) } +func (e *EntitlementEconomy) ApplyResolved(identity string, costs, rewards []gamedata.Reward) ([]byte, error) { + e.mu.Lock() + defer e.mu.Unlock() + return e.applyPrepared(identity, costs, rewards, true, nil) +} func (e *EntitlementEconomy) apply(identity string, costs, rewards []gamedata.Reward) ([]byte, error) { + return e.applyPrepared(identity, costs, rewards, false, nil) +} +func (e *EntitlementEconomy) applyPrepared(identity string, costs, rewards []gamedata.Reward, resolved bool, mailed []gamedata.BattleReward) ([]byte, error) { if identity == "" { return nil, fmt.Errorf("commerce: missing entitlement identity") } @@ -119,7 +128,10 @@ func (e *EntitlementEconomy) apply(identity string, costs, rewards []gamedata.Re for i, r := range rewards { input[i] = gamedata.BattleReward{Type: r.Type, ID: r.ID, Count: r.Count} } - leaves, err := e.graph.ResolveGranted(input) + leaves := input + if !resolved { + leaves, err = e.graph.ResolveGranted(input) + } if err != nil { return nil, err } @@ -141,7 +153,7 @@ func (e *EntitlementEconomy) apply(identity string, costs, rewards []gamedata.Re return nil, resolveErr } available := map[[2]uint64]uint64{} - for _, leaf := range leaves { + for _, leaf := range append(append([]gamedata.BattleReward(nil), leaves...), mailed...) { k := [2]uint64{leaf.Type, leaf.ID} available[k] += leaf.Count } diff --git a/go/internal/server/commerce/installed_audit_test.go b/go/internal/server/commerce/installed_audit_test.go index 9b16604..6bd6927 100644 --- a/go/internal/server/commerce/installed_audit_test.go +++ b/go/internal/server/commerce/installed_audit_test.go @@ -6,6 +6,7 @@ import ( "bd2server/internal/server/gameconfig" "bd2server/internal/server/gamedata" "bd2server/internal/server/hunting" + "bd2server/internal/server/mail" "bd2server/internal/server/player" "bd2server/internal/server/readonly" "bd2server/internal/server/stateio" @@ -13,6 +14,7 @@ import ( "bytes" "os" "path/filepath" + "reflect" "strings" "testing" "time" @@ -117,6 +119,10 @@ func TestInstalledCashProductRewardCoverage23510(t *testing.T) { if err != nil { t.Fatal(err) } + templates, err := gamedata.LoadCashMailTemplates(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"}) @@ -156,6 +162,17 @@ func TestInstalledCashProductRewardCoverage23510(t *testing.T) { if err != nil { t.Fatal(err) } + e.SetClock(func() time.Time { return time.Date(2026, 10, 5, 5, 0, 0, 0, time.UTC) }, 0) + mailbox, err := mail.OpenService(store, &mail.Starter{Version: "2.35.10", MailCount: 1}, items, wallet) + if err != nil { + t.Fatal(err) + } + if err = mailbox.AttachCashRewards(e, templates); err != nil { + t.Fatal(err) + } + if err = e.AttachCashMail(mailbox); err != nil { + t.Fatal(err) + } return e, wallet } t.Run("event shop complete calendar and native purchase", func(t *testing.T) { @@ -321,7 +338,7 @@ func TestInstalledCashProductRewardCoverage23510(t *testing.T) { } delegated[gamedata.CashProductKey{GroupID: d.ProductGroupID, ProductID: d.ProductID, SaleGroup: d.SaleGroup}] = true } - cash, recharge, delegateCount, retiredCount := 0, 0, 0, 0 + cash, recharge, delegateCount, retiredCount, mailedProducts := 0, 0, 0, 0, 0 for _, p := range catalog.Products { if p.PriceType != 1 { continue @@ -372,8 +389,8 @@ func TestInstalledCashProductRewardCoverage23510(t *testing.T) { 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 { + bundle, err := e.ApplyPurchase("installed-audit", costs, rewards) + if err != nil { t.Errorf("cash product %+v failed real debit/grant: %v", p.Key, err) continue } @@ -387,10 +404,68 @@ func TestInstalledCashProductRewardCoverage23510(t *testing.T) { return nil }) balance := wallet.Snapshot() - replay, err := e.Apply("installed-audit", costs, rewards) + replay, err := e.ApplyPurchase("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) } + + mailbox := e.mail.(*mail.Service) + listReq := wire.AppendVarint(nil, 1, 1) + listReq = wire.AppendVarint(listReq, 3, 100) + code, list, ok, err := mailbox.Handle("/CashMailInfo", listReq) + if err != nil || !ok || code != 140 { + t.Fatalf("cash list %+v: %v", p.Key, err) + } + claim := wire.AppendVarint(nil, 1, 2) + mailCount := 0 + _ = wire.Walk(list, func(f wire.Field) error { + if f.Number == 1 && f.Type == 2 { + id, _, _ := wire.Varint(f.Value, 1) + template, _, _ := wire.Varint(f.Value, 3) + cash, _, _ := wire.Varint(f.Value, 15) + if !templates[template] || cash != 1 { + t.Errorf("invalid cash mail template/flag %+v", p.Key) + } + claim = wire.AppendVarint(claim, 2, id) + mailCount++ + } + return nil + }) + if mailCount > 0 { + mailedProducts++ + _, claimed, _, err := mailbox.Handle("/MailOpen", claim) + if err != nil { + t.Errorf("cash claim %+v: %v", p.Key, err) + continue + } + claimedBalance := wallet.Snapshot() + claimedItems := e.items.All() + _, replayed, _, err := mailbox.Handle("/MailOpen", claim) + if err != nil || !bytes.Equal(claimed, replayed) || wallet.Snapshot() != claimedBalance || !reflect.DeepEqual(e.items.All(), claimedItems) { + t.Errorf("cash claim replay %+v duplicated: %v", p.Key, err) + } + _, remaining, _, err := mailbox.Handle("/CashMailInfo", listReq) + n, _, _ := wire.Varint(remaining, 2) + if err != nil || n != 0 { + t.Errorf("claimed cash mail remains %+v", p.Key) + } + } + // Cash mailbox delivery must grant exactly what the previous immediate + // reward resolver granted, including subscriptions' first-day deduplication. + reference, referenceWallet := newEconomy() + if _, err := reference.Apply("reference", costs, rewards); err != nil { + t.Fatal(err) + } + itemTotals := func(items []player.Item) map[[3]uint64]uint64 { + totals := map[[3]uint64]uint64{} + for _, item := range items { + totals[[3]uint64{item.Type, item.ID, item.ExpiryTime}] += item.Count + } + return totals + } + if wallet.Snapshot() != referenceWallet.Snapshot() || !reflect.DeepEqual(itemTotals(e.items.All()), itemTotals(reference.items.All())) { + t.Errorf("cash product %+v mailed delivery differs from direct grant: wallet=%+v expected=%+v items=%+v expected=%+v", p.Key, wallet.Snapshot(), referenceWallet.Snapshot(), e.items.All(), reference.items.All()) + } } 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) @@ -403,5 +478,5 @@ func TestInstalledCashProductRewardCoverage23510(t *testing.T) { 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) + t.Logf("validated %d cash rows: %d recharge variants, %d preview designs, %d unavailable historical draws; checked %d products through cash mail creation, batch claim, and replay with typed ticket/skin entitlements", cash, recharge, delegateCount, retiredCount, mailedProducts) } diff --git a/go/internal/server/commerce/service.go b/go/internal/server/commerce/service.go index a41f24b..906d556 100644 --- a/go/internal/server/commerce/service.go +++ b/go/internal/server/commerce/service.go @@ -550,7 +550,15 @@ func (s *Service) buy(session string, request []byte) (int, []byte, bool, error) rewards = append(rewards, gamedata.Reward{Type: 9, ID: d.BonusRandomBoxID, Count: l.Count}) } } - b, err := s.economy.Apply(operation, costs, rewards) + var b []byte + var err error + if delivery, ok := s.economy.(interface { + ApplyPurchase(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error) + }); ok && d.PriceType == 1 { + b, err = delivery.ApplyPurchase(operation, costs, rewards) + } else { + b, err = s.economy.Apply(operation, costs, rewards) + } if err != nil { return 61, nil, true, err } diff --git a/go/internal/server/feature/bootstrap.go b/go/internal/server/feature/bootstrap.go index ca30538..b6b2993 100644 --- a/go/internal/server/feature/bootstrap.go +++ b/go/internal/server/feature/bootstrap.go @@ -7,9 +7,6 @@ import "bd2server/internal/server/wire" // entities (inventory, characters, quests, decks) are deliberately excluded. func initialResponse(path string) (int, []byte, bool) { switch path { - case "/CashMailInfo": - // MaxInvenIndex is explicitly present even with zero cash mails. - return 140, wire.AppendVarint(nil, 3, 0), true case "/AvatarInfo": return 467, wire.AppendBytes(nil, 1, nil), true case "/GuildRaidSeasonReward": diff --git a/go/internal/server/feature/bootstrap_test.go b/go/internal/server/feature/bootstrap_test.go index 8f6da52..e4936ee 100644 --- a/go/internal/server/feature/bootstrap_test.go +++ b/go/internal/server/feature/bootstrap_test.go @@ -13,7 +13,6 @@ func TestBootstrapProtocolDefaults(t *testing.T) { code int proto []byte }{ - {"/CashMailInfo", 140, []byte{0x18, 0}}, {"/AvatarInfo", 467, []byte{0x0a, 0}}, {"/GuildRaidSeasonReward", 310, []byte{0x0a, 0}}, } { diff --git a/go/internal/server/gamedata/cash_mail.go b/go/internal/server/gamedata/cash_mail.go new file mode 100644 index 0000000..a3566ae --- /dev/null +++ b/go/internal/server/gamedata/cash_mail.go @@ -0,0 +1,26 @@ +package gamedata + +import "fmt" + +// LoadCashMailTemplates shares the decrypted GameData cache and validates the +// localized template IDs used by RewardGroupTable.MailId. +func LoadCashMailTemplates(root, version string) (map[uint64]bool, error) { + db, done, err := openStatDatabase(root, version) + if err != nil { + return nil, err + } + defer done() + templates := map[uint64]bool{} + err = readCashMetadata(db, "MailInfoTable", func(raw []byte) error { + id, err := optionalScalar(raw, 1) + if err != nil { + return err + } + if id == 0 { + return fmt.Errorf("gamedata: invalid cash mail template") + } + templates[id] = true + return nil + }) + return templates, err +} diff --git a/go/internal/server/gamedata/cash_rewards.go b/go/internal/server/gamedata/cash_rewards.go index 8402d73..866a8f7 100644 --- a/go/internal/server/gamedata/cash_rewards.go +++ b/go/internal/server/gamedata/cash_rewards.go @@ -30,18 +30,49 @@ func LoadCashRewardResolver(root, version string, shared ...*RewardGraph) (*Cash // The shared immutable design maps are initialized completely at startup. return &CashRewardResolver{graph: graph, boxes: graph.boxes, direct: graph.direct, groups: graph.groups}, nil } + +// CashMailReward is a fully selected attachment set using the game's localized mail template. +type CashMailReward struct { + TemplateID uint64 + Rewards []BattleReward +} +type CashDelivery struct { + Direct []BattleReward + Mail []CashMailReward +} + func (c *CashRewardResolver) ResolveGranted(rewards []BattleReward) ([]BattleReward, error) { + plan, err := c.resolve(rewards, false) + return plan.Direct, err +} +func (c *CashRewardResolver) ResolveDelivery(rewards []BattleReward) (CashDelivery, error) { + return c.resolve(rewards, true) +} +func (c *CashRewardResolver) resolve(rewards []BattleReward, delivery bool) (CashDelivery, error) { budget := uint64(100000) visiting := map[uint64]bool{} - var out []BattleReward - var emit func(BattleReward, bool) error - emit = func(r BattleReward, force bool) error { + var out CashDelivery + appendReward := func(r BattleReward, template uint64) { + if template == 0 { + out.Direct = append(out.Direct, r) + return + } + for i := range out.Mail { + if out.Mail[i].TemplateID == template { + out.Mail[i].Rewards = append(out.Mail[i].Rewards, r) + return + } + } + out.Mail = append(out.Mail, CashMailReward{TemplateID: template, Rewards: []BattleReward{r}}) + } + var emit func(BattleReward, bool, uint64) error + emit = func(r BattleReward, force bool, template uint64) error { if budget == 0 || r.Count == 0 || r.Count > math.MaxInt32 || r.Type == 0 { return fmt.Errorf("gamedata: invalid cash reward/budget") } budget-- if r.Type != 9 || !force && c.direct[r.ID] { - out = append(out, r) + appendReward(r, template) return nil } if visiting[r.ID] || len(visiting) >= 32 { @@ -55,6 +86,13 @@ func (c *CashRewardResolver) ResolveGranted(rewards []BattleReward) ([]BattleRew if !ok { return fmt.Errorf("gamedata: unknown cash reward group %d", gid) } + if delivery && template == 0 { + var err error + template, err = optionalScalar(raw, 7) + if err != nil { + return err + } + } drop, err := optionalScalar(raw, 2) if err != nil { return err @@ -64,7 +102,9 @@ func (c *CashRewardResolver) ResolveGranted(rewards []BattleReward) ([]BattleRew if err != nil { return err } - out = append(out, selected...) + for _, leaf := range selected { + appendReward(leaf, template) + } return nil } if drop != 1 { @@ -88,15 +128,15 @@ func (c *CashRewardResolver) ResolveGranted(rewards []BattleReward) ([]BattleRew return fmt.Errorf("gamedata: cash reward overflow") } child.Count *= r.Count - if err = emit(child, false); err != nil { + if err = emit(child, false, template); err != nil { return err } } return nil } for _, r := range rewards { - if err := emit(r, true); err != nil { - return nil, err + if err := emit(r, true, 0); err != nil { + return CashDelivery{}, err } } return out, nil diff --git a/go/internal/server/gamedata/cash_rewards_test.go b/go/internal/server/gamedata/cash_rewards_test.go index 1e01de3..7cadbb7 100644 --- a/go/internal/server/gamedata/cash_rewards_test.go +++ b/go/internal/server/gamedata/cash_rewards_test.go @@ -39,3 +39,32 @@ func TestLoginPassTopLevelWrapperExpandsAndNestedManualGiftStaysOwned(t *testing t.Fatalf("login pass wrapper=%+v err=%v", got, err) } } + +func TestCashDeliverySeparatesNestedMailAndKeepsEntitlementAndManualBox(t *testing.T) { + group := func(mail uint64, rewards []BattleReward) []byte { + raw := wire.AppendVarint(nil, 2, 1) + if mail > 0 { + raw = wire.AppendVarint(raw, 7, mail) + } + for _, r := range rewards { + raw = wire.AppendVarint(raw, 6, r.Type) + raw = wire.AppendVarint(raw, 5, r.ID) + raw = wire.AppendVarint(raw, 4, r.Count) + raw = wire.AppendVarint(raw, 8, 100) + } + return raw + } + c := &CashRewardResolver{boxes: map[uint64]uint64{1: 10, 2: 20, 3: 30}, direct: map[uint64]bool{3: true}, groups: map[uint64][]byte{ + 10: group(0, []BattleReward{{Type: 19, ID: 36, Count: 1}, {Type: 9, ID: 2, Count: 1}}), + 20: group(40, []BattleReward{{Type: 2, Count: 150}, {Type: 9, ID: 3, Count: 1}}), + }} + plan, err := c.ResolveDelivery([]BattleReward{{Type: 9, ID: 1, Count: 2}}) + if err != nil || len(plan.Direct) != 1 || plan.Direct[0].Type != 19 || plan.Direct[0].Count != 2 || len(plan.Mail) != 1 || plan.Mail[0].TemplateID != 40 || len(plan.Mail[0].Rewards) != 2 || plan.Mail[0].Rewards[0].Count != 300 || plan.Mail[0].Rewards[1].ID != 3 { + t.Fatalf("delivery=%+v err=%v", plan, err) + } + // Claim expansion retains the old behavior for callers that don't purchase. + leaves, err := c.ResolveGranted([]BattleReward{{Type: 9, ID: 1, Count: 1}}) + if err != nil || len(leaves) != 3 { + t.Fatalf("ordinary resolve=%+v err=%v", leaves, err) + } +} diff --git a/go/internal/server/mail/cash.go b/go/internal/server/mail/cash.go new file mode 100644 index 0000000..9fd43bf --- /dev/null +++ b/go/internal/server/mail/cash.go @@ -0,0 +1,112 @@ +package mail + +import ( + "errors" + "fmt" + "math" + "reflect" + "sort" + "strings" + "time" + + "bd2server/internal/server/gamedata" + "bd2server/internal/server/wire" +) + +// Cash attachments are already resolved at purchase, so manual boxes must not +// be expanded again and selected random rewards must not be rolled on claim. +type CashRewardEconomy interface { + ApplyResolved(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error) +} + +func (s *Service) AttachCashRewards(e CashRewardEconomy, templates map[uint64]bool) error { + if e == nil || len(templates) == 0 { + return errors.New("mail: missing cash economy or templates") + } + s.cashEconomy = e + s.cashTemplates = make(map[uint64]bool, len(templates)) + for id, ok := range templates { + s.cashTemplates[id] = ok + } + return nil +} +func (s *Service) IssueCashOnce(identity string, template uint64, rewards []gamedata.Reward, sentAt time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.cashEconomy == nil || !s.cashTemplates[template] { + return fmt.Errorf("mail: cash template %d or economy unavailable", template) + } + if identity == "" || strings.TrimSpace(identity) != identity || len(identity) > 450 || sentAt.UnixMilli() <= 0 || sentAt.UnixMilli() >= 253402300799000 { + return errors.New("mail: invalid cash mail identity or time") + } + if err := validateCashAttachments(rewards); err != nil { + return err + } + key := "cash:" + identity + if id, ok := s.issued[key]; ok { + previous := s.dynamic[id] + if !previous.IsCash || previous.TemplateID != template || !reflect.DeepEqual(mailAttachments(previous), rewards) { + return errors.New("mail: cash identity reused with different attachments") + } + return nil + } + return s.enqueueCompensations([]compensation{{identity: key, templateID: template, isCash: true, rewards: rewards, sentAt: sentAt}}) +} +func validateCashAttachments(rewards []gamedata.Reward) error { + if len(rewards) == 0 { + return errors.New("mail: empty cash attachments") + } + for _, r := range rewards { + if r.Type < 2 || r.Type > 72 || r.Count == 0 || r.Count > math.MaxInt32 || r.ID > math.MaxInt32 { + return fmt.Errorf("mail: invalid cash attachment %+v", r) + } + } + return nil +} +func (s *Service) cashInfo(request []byte) ([]byte, error) { + start, _, err := wire.Varint(request, 2) + if err != nil || start > math.MaxInt64 { + return nil, errors.New("mail: invalid cash cursor") + } + count, present, err := wire.Varint(request, 3) + if err != nil || !present || count == 0 || count > math.MaxInt32 { + return nil, errors.New("mail: invalid cash select count") + } + if count > mailHistoryPageMax { + count = mailHistoryPageMax + } + entries := map[uint64]MailDBInfo{} + for _, entry := range s.Starter.Mails { + if entry.IsCash && !containsID(s.state.Opened, entry.MailID) { + entries[entry.MailID] = entry + } + } + for id, entry := range s.dynamic { + if entry.IsCash && !containsID(s.state.Opened, id) { + entries[id] = entry + } + } + ids := make([]uint64, 0, len(entries)) + var max uint64 + for id := range entries { + ids = append(ids, id) + if id > max { + max = id + } + } + sort.Slice(ids, func(i, j int) bool { return ids[i] > ids[j] }) + var result []byte + var selected uint64 + for _, id := range ids { + if start != 0 && id >= start { + continue + } + if selected >= count { + break + } + result = wire.AppendBytes(result, 1, entries[id].encode()) + selected++ + } + result = wire.AppendVarint(result, 2, uint64(len(entries))) + return wire.AppendVarint(result, 3, max), nil +} diff --git a/go/internal/server/mail/grant_spool.go b/go/internal/server/mail/grant_spool.go index eea3822..839004f 100644 --- a/go/internal/server/mail/grant_spool.go +++ b/go/internal/server/mail/grant_spool.go @@ -43,6 +43,8 @@ type compensation struct { identity, title, body string sentAt time.Time rewards []gamedata.Reward + templateID uint64 + isCash bool } func (g compensation) validate() error { diff --git a/go/internal/server/mail/mail.go b/go/internal/server/mail/mail.go index 8562a15..ef58133 100644 --- a/go/internal/server/mail/mail.go +++ b/go/internal/server/mail/mail.go @@ -241,6 +241,8 @@ type stateSnapshot struct { type Service struct { contentTickets *gamedata.GachaContentTicketDesign attendanceEconomy AttendanceRewardEconomy + cashEconomy CashRewardEconomy + cashTemplates map[uint64]bool beforeMailID uint64 mu sync.Mutex Starter *Starter @@ -415,7 +417,7 @@ func (s *Service) AttachSeedPath(path string) error { } func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) { - if path != "/MailInfo" && path != "/MailOpen" && path != "/MailHistoryInfo" { + if path != "/MailInfo" && path != "/MailOpen" && path != "/MailHistoryInfo" && path != "/CashMailInfo" { return 0, nil, false, nil } if s == nil || s.Starter == nil || s.inventory == nil || s.wallet == nil { @@ -440,6 +442,10 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) } return packetCode, s.info(), true, nil } + if path == "/CashMailInfo" { + response, err := s.cashInfo(request) + return 140, response, true, err + } if path == "/MailHistoryInfo" { response, err := s.historyInfo(request) return historyPacketCode, response, true, err @@ -499,7 +505,7 @@ func (s *Service) info() []byte { var result []byte remaining := uint64(0) for _, entry := range s.Starter.Mails { - if containsID(s.state.Opened, entry.MailID) { + if entry.IsCash || containsID(s.state.Opened, entry.MailID) { continue } result = wire.AppendBytes(result, 1, entry.encode()) @@ -511,7 +517,7 @@ func (s *Service) info() []byte { } sort.Slice(dynamicIDs, func(i, j int) bool { return dynamicIDs[i] < dynamicIDs[j] }) for _, id := range dynamicIDs { - if containsID(s.state.Opened, id) { + if s.dynamic[id].IsCash || containsID(s.state.Opened, id) { continue } result = wire.AppendBytes(result, 1, s.dynamic[id].encode()) @@ -557,8 +563,16 @@ func (s *Service) open(request []byte) ([]byte, error) { return nil, fmt.Errorf("mail: unknown mail %d", id) } } - // Validate the entire new attendance batch before any reward domain writes. + // Validate the entire attendance/cash batch before any reward domain writes. for _, entry := range selected { + if entry.IsCash { + if s.cashEconomy == nil { + return nil, errors.New("mail: cash reward economy unavailable") + } + if err := validateCashAttachments(mailAttachments(entry)); err != nil { + return nil, err + } + } if s.isAttendanceMail(entry.MailID) { if s.attendanceEconomy == nil { return nil, errors.New("mail: attendance reward economy unavailable") @@ -579,7 +593,13 @@ func (s *Service) open(request []byte) ([]byte, error) { newHistory := make([]MailDBInfo, 0, len(selected)) for _, entry := range selected { identity := fmt.Sprintf("mail:%d", entry.MailID) - if s.isAttendanceMail(entry.MailID) { + if entry.IsCash { + granted, err := s.cashEconomy.ApplyResolved(identity+":cash", nil, mailAttachments(entry)) + if err != nil { + return nil, err + } + bundle = append(bundle, granted...) + } else if s.isAttendanceMail(entry.MailID) { granted, err := s.attendanceEconomy.Apply(identity+":attendance", nil, mailAttachments(entry)) if err != nil { return nil, err @@ -945,6 +965,12 @@ func (s *Service) enqueueCompensations(grants []compensation) error { MailID: next.NextDynamicMailID, MailType: 2, Title: grant.title, Body: grant.body, SentAt: uint64(grant.sentAt.UTC().UnixMilli()), ExpiresAt: uint64(grant.sentAt.UTC().Add(30 * 24 * time.Hour).UnixMilli()), } + if grant.isCash { + entry.IsCash = true + entry.TemplateID = grant.templateID + entry.MailType = 0 + entry.ExpiresAt = 253402300799000 // cash mail has no claim expiry in the client + } for _, reward := range grant.rewards { entry.RewardTypes = append(entry.RewardTypes, reward.Type) entry.RewardIDs = append(entry.RewardIDs, reward.ID) diff --git a/plugins/CashShop/CommercePriceLayout.cs b/plugins/CashShop/CommercePriceLayout.cs new file mode 100644 index 0000000..0540092 --- /dev/null +++ b/plugins/CashShop/CommercePriceLayout.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using TMPro; +using UnityEngine; +using UnityEngine.UI; + +namespace Bd2CashShop; + +// Own only the price pair. The fee caption and confirmation buttons keep their +// prefab layout; pooled dialogs restore their original geometry on reuse. +internal sealed class CommercePriceLayout : MonoBehaviour +{ + private RectTransform region, icon; + private TMP_Text price; + private RectState iconState, textState; + private bool configured; + private float fontSize; + private bool autoSize, wrap; + private TextAlignmentOptions alignment; + private readonly List> drivers = new List>(); + private readonly Vector3[] corners = new Vector3[4]; + + internal static void Apply(RectTransform region, UISprite sprite, TMP_Text text) + { + if (region == null || sprite == null || text == null) return; + var layout = region.GetComponent() ?? region.gameObject.AddComponent(); + layout.Restore(); + layout.region = region; + layout.icon = (RectTransform)sprite.transform; + layout.price = text; + layout.iconState = new RectState(layout.icon); + layout.textState = new RectState(text.rectTransform); + layout.fontSize = text.fontSize; + layout.autoSize = text.enableAutoSizing; + layout.wrap = text.enableWordWrapping; + layout.alignment = text.alignment; + // Disable only drivers attached to the pair itself, never the dialog. + layout.CaptureDrivers(layout.icon); + layout.CaptureDrivers(text.rectTransform); + text.enableAutoSizing = false; + text.enableWordWrapping = false; + text.alignment = TextAlignmentOptions.MidlineRight; + layout.configured = true; + layout.enabled = true; + layout.Arrange(); + } + + internal static void Reset(RectTransform region) + { + if (region != null) region.GetComponent()?.Restore(); + } + + private void CaptureDrivers(RectTransform target) + { + foreach (var driver in target.GetComponents()) + if (driver is ContentSizeFitter || driver is AspectRatioFitter) + { + drivers.Add(new KeyValuePair(driver, driver.enabled)); + driver.enabled = false; + } + } + + private void LateUpdate() => Arrange(); + private void OnDisable() => Restore(); + + private void Arrange() + { + if (!configured || region == null || icon == null || price == null || region.rect.width <= 0) return; + float padding = Mathf.Max(8f, fontSize * 0.35f); + float left = region.rect.xMin + padding; + float right = region.rect.xMax - padding; + float middle = region.rect.center.y; + // Reserve the actual caption width in this price strip, even after a + // language, screen size or font change. Decorative labels are ignored. + foreach (var label in region.GetComponentsInChildren()) + { + if (label == price || !label.gameObject.activeInHierarchy || string.IsNullOrWhiteSpace(label.text)) continue; + label.rectTransform.GetWorldCorners(corners); + Vector3 a = region.InverseTransformPoint(corners[0]), b = region.InverseTransformPoint(corners[2]); + if (a.x < region.rect.center.x && a.y <= middle && b.y >= middle) + left = Mathf.Max(left, a.x + Mathf.Min(label.preferredWidth, b.x - a.x) + padding); + } + float available = Mathf.Max(1f, right - left); + float size = Mathf.Min(fontSize, Mathf.Max(1f, region.rect.height - padding)); + for (int attempt = 0; attempt < 3; attempt++) + { + price.fontSize = size; + float width = price.GetPreferredValues(price.text).x + size + size * 0.25f; + if (width <= available) break; + size *= available / width; + } + price.fontSize = size; + float textWidth = price.GetPreferredValues(price.text).x; + float gap = size * 0.25f; + // The icon is a square one numeric em high, with a measured gap. This + // preserves the game's sprite while preventing overlap with any digit. + Place(price.rectTransform, right - textWidth * 0.5f, middle, textWidth, size * 1.5f); + Place(icon, right - textWidth - gap - size * 0.5f, middle, size, size); + } + + private void Place(RectTransform target, float x, float y, float width, float height) + { + var parent = target.parent as RectTransform; + if (parent == null) return; + Vector3 p = parent.InverseTransformPoint(region.TransformPoint(new Vector3(x, y, 0))); + Vector3 horizontal = parent.InverseTransformVector(region.TransformVector(Vector3.right)); + Vector3 vertical = parent.InverseTransformVector(region.TransformVector(Vector3.up)); + target.anchorMin = target.anchorMax = new Vector2(0.5f, 0.5f); + target.pivot = new Vector2(0.5f, 0.5f); + target.localScale = Vector3.one; + target.sizeDelta = new Vector2(width * horizontal.magnitude, height * vertical.magnitude); + target.anchoredPosition = new Vector2(p.x - parent.rect.center.x, p.y - parent.rect.center.y); + } + + private void Restore() + { + if (!configured) return; + configured = false; + if (icon != null) iconState.Restore(icon); + if (price != null) + { + textState.Restore(price.rectTransform); + price.fontSize = fontSize; + price.enableAutoSizing = autoSize; + price.enableWordWrapping = wrap; + price.alignment = alignment; + } + foreach (var driver in drivers) if (driver.Key != null) driver.Key.enabled = driver.Value; + drivers.Clear(); + } + + private struct RectState + { + private readonly Vector2 min, max, pivot, position, size; + private readonly Vector3 scale; + internal RectState(RectTransform rect) + { + min = rect.anchorMin; max = rect.anchorMax; pivot = rect.pivot; + position = rect.anchoredPosition; size = rect.sizeDelta; scale = rect.localScale; + } + internal void Restore(RectTransform rect) + { + rect.anchorMin = min; rect.anchorMax = max; rect.pivot = pivot; + rect.anchoredPosition = position; rect.sizeDelta = size; rect.localScale = scale; + } + } +} diff --git a/plugins/CashShop/Plugin.cs b/plugins/CashShop/Plugin.cs index 8eb822f..b414454 100644 --- a/plugins/CashShop/Plugin.cs +++ b/plugins/CashShop/Plugin.cs @@ -372,6 +372,7 @@ public sealed class Plugin : BaseUnityPlugin private static bool SetCash(CurrencyButton __instance, string __0) { + CommercePriceLayout.Reset(__instance.transform as RectTransform); RestorePricePrefixes(__instance); if (__0 == null || !__0.Contains(Marker)) return true; string sku = ExtractSku(__0); @@ -391,6 +392,7 @@ public sealed class Plugin : BaseUnityPlugin return; } button.Set((EElementType)policy.ItemType, 0, policy.Amount); + button.TextPrice.text = PriceText(policy); button.SetEnough(CommonPacket.GetHaveCurrencyCount((EElementType)policy.ItemType, false) >= policy.Amount); } @@ -420,12 +422,30 @@ public sealed class Plugin : BaseUnityPlugin { CurrencyButton button = Field(__instance, "_currencyButton"); if (button == null) return; + CommercePriceLayout.Reset(button.transform as RectTransform); RestorePricePrefixes(button); priceViews.Remove(button); if (__3 != EElementType.Cash) return; string sku = __7; - priceViews[button] = () => { Render(button, Resolve(sku)); ShowUnavailablePopupPrice(__instance, button, Resolve(sku)); }; - ShowUnavailablePopupPrice(__instance, button, Resolve(sku)); + priceViews[button] = () => RefreshPopupPrice(__instance, button, Resolve(sku)); + RefreshPopupPrice(__instance, button, Resolve(sku)); + } + + private static string PriceText(CommerceProduct policy) + { + string amount = policy.Amount.ToString("N0", CultureInfo.InvariantCulture); + return policy.ItemType == (int)EElementType.Jewelry + ? amount + " " + LocalTextInfo.GetText(2560) + "" + : amount; + } + + private static void RefreshPopupPrice(CashShopBuyPopupUI popup, CurrencyButton button, CommerceProduct policy) + { + CommercePriceLayout.Reset(button.transform as RectTransform); + Render(button, policy); + if (policy != null && policy.Enabled && policy.ItemType != 0) + CommercePriceLayout.Apply(button.transform as RectTransform, Field(button, "_spritePrice"), button.TextPrice); + ShowUnavailablePopupPrice(popup, button, policy); } private static void ShowUnavailablePopupPrice(CashShopBuyPopupUI popup, CurrencyButton button, CommerceProduct policy) @@ -533,13 +553,19 @@ public sealed class Plugin : BaseUnityPlugin private static void PassPopupPrice(BuyConfirmPopupUI __instance, int __0, Define_PassBuyType __1) { + var price = Field(__instance, "_textCurrency"); + CommercePriceLayout.Reset(price?.transform.parent as RectTransform); priceViews.Remove(__instance); if (__1 != Define_PassBuyType.PbPremium2) return; var table = PassInfo.GetPassBuyTable(__0, __1); int pass = __0; Define_PassBuyType buy = __1; priceViews[__instance] = () => PassPopupPrice(__instance, pass, buy); - Render(Field(__instance, "_uiSpriteCurrency"), Field(__instance, "_textCurrency"), Resolve(table.CashProductGroupId, table.CashProductId, table.CashSalesGroup)); + var policy = Resolve(table.CashProductGroupId, table.CashProductId, table.CashSalesGroup); + var sprite = Field(__instance, "_uiSpriteCurrency"); + Render(sprite, price, policy); + if (policy != null && policy.Enabled && policy.ItemType != 0) + CommercePriceLayout.Apply(price.transform.parent as RectTransform, sprite, price); } private static void Render(UISprite sprite, TMP_Text text, CommerceProduct policy) @@ -549,7 +575,7 @@ public sealed class Plugin : BaseUnityPlugin sprite.gameObject.SetActive(policy != null && policy.Enabled && policy.ItemType != 0); if (policy != null && policy.Enabled && policy.ItemType != 0) sprite.SetSprite(CurrencyInfo.GetCurrencyDTO((EElementType)policy.ItemType).IconSpriteNameSmall); } - if (text != null) text.text = policy == null || !policy.Enabled ? Unavailable : policy.ItemType == 0 ? "Free" : policy.Amount.ToString(CultureInfo.InvariantCulture); + if (text != null) text.text = policy == null || !policy.Enabled ? Unavailable : policy.ItemType == 0 ? "Free" : PriceText(policy); } private static void GachaPrice(GachaProductButtonElement __instance, string __4) diff --git a/plugins/CashShop/README.md b/plugins/CashShop/README.md index 22b38fb..8b5f996 100644 --- a/plugins/CashShop/README.md +++ b/plugins/CashShop/README.md @@ -6,7 +6,9 @@ 客户端从当前服务器 `/client/commerce` 获取完整价格目录。配置未加载、版本不匹配或禁止购买时,购买入口会显示游戏提示框,不会调用外部支付 SDK。收费商品使用游戏原有货币图标与数量;免费充值显示 `Free`。 -补丁保留原购买回调,因此无限抽最终确认、特殊甄选券、月卡、礼包、皮肤和现金通行证继续执行各自的游戏流程。原本使用普通钻石、付费钻石、金币等游戏内货币的商品不修改价格类型。服务器独立验证商品、价格、余额、购买次数、开放时间和接力前置条件,并在一个存档事务内完成扣款与发奖;客户端发送的价格仅用于检测过期目录。 +补丁保留原购买回调,因此无限抽最终确认、特殊甄选券、月卡、礼包、皮肤和现金通行证继续执行各自的游戏流程。原本使用普通钻石、付费钻石、金币等游戏内货币的商品不修改价格类型。服务器独立验证商品、价格、余额、购买次数、开放时间和接力前置条件,并在一个存档事务内完成扣款、权益激活与现金邮件创建;客户端发送的价格仅用于检测过期目录。 + +现金商品按真实 `RewardGroupTable.MailId` 发放:带邮件模板的奖励进入现金邮箱,未标记的通行证、月卡等资格按游戏规则立即生效,嵌套奖励组也使用同一规则。邮件沿用 `MailInfoTable` 的本地化标题和正文,现金附件不过期。邮寄部分在购买时只创建附件,`CashMailInfo` 返回现金邮件,玩家通过游戏原有 `MailOpen` 单封或批量领取后才将附件写入背包和货币数据。购买与领取都有持久回执,重试或重启不会重复扣款、重复发信或重复发奖;以前已经直接发放的订单保留原回执,不会再补发同一份附件。 月卡、登录通行礼包和付费签到的领取状态在服务器保存,`AttendanceResponse` 与 `EventRewardResponse` 的本服扩展字段同步每日奖励并用回执防止重复应用。现金通行证激活必须消费购买权益。主线通关礼包按服务器主线进度领取;恶魔城奖励需要对应玩法提供真实完成记录,目前占位玩法不能领取通关奖励。