fix(all): deliver cash rewards through mail and align paid diamond prices

This commit is contained in:
2026-10-05 17:08:34 +08:00
parent 924a42c32e
commit ce638a6649
17 changed files with 943 additions and 30 deletions
+10
View File
@@ -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)
+95
View File
@@ -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
}
@@ -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)
}
}
+14 -2
View File
@@ -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
}
@@ -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)
}
+9 -1
View File
@@ -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
}
-3
View File
@@ -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":
@@ -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}},
} {
+26
View File
@@ -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
}
+48 -8
View File
@@ -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
@@ -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)
}
}
+112
View File
@@ -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
}
+2
View File
@@ -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 {
+31 -5
View File
@@ -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)
+147
View File
@@ -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<KeyValuePair<Behaviour, bool>> drivers = new List<KeyValuePair<Behaviour, bool>>();
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<CommercePriceLayout>() ?? region.gameObject.AddComponent<CommercePriceLayout>();
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<CommercePriceLayout>()?.Restore();
}
private void CaptureDrivers(RectTransform target)
{
foreach (var driver in target.GetComponents<Behaviour>())
if (driver is ContentSizeFitter || driver is AspectRatioFitter)
{
drivers.Add(new KeyValuePair<Behaviour, bool>(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<TMP_Text>())
{
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;
}
}
}
+30 -4
View File
@@ -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<CurrencyButton>(__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 + " <size=70%><color=#E3B01F>" + LocalTextInfo.GetText(2560) + "</color></size>"
: 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<UISprite>(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<TMP_Text>(__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<UISprite>(__instance, "_uiSpriteCurrency"), Field<TMP_Text>(__instance, "_textCurrency"), Resolve(table.CashProductGroupId, table.CashProductId, table.CashSalesGroup));
var policy = Resolve(table.CashProductGroupId, table.CashProductId, table.CashSalesGroup);
var sprite = Field<UISprite>(__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)
+3 -1
View File
@@ -6,7 +6,9 @@
客户端从当前服务器 `/client/commerce` 获取完整价格目录。配置未加载、版本不匹配或禁止购买时,购买入口会显示游戏提示框,不会调用外部支付 SDK。收费商品使用游戏原有货币图标与数量;免费充值显示 `Free`。
补丁保留原购买回调,因此无限抽最终确认、特殊甄选券、月卡、礼包、皮肤和现金通行证继续执行各自的游戏流程。原本使用普通钻石、付费钻石、金币等游戏内货币的商品不修改价格类型。服务器独立验证商品、价格、余额、购买次数、开放时间和接力前置条件,并在一个存档事务内完成扣款与发奖;客户端发送的价格仅用于检测过期目录。
补丁保留原购买回调,因此无限抽最终确认、特殊甄选券、月卡、礼包、皮肤和现金通行证继续执行各自的游戏流程。原本使用普通钻石、付费钻石、金币等游戏内货币的商品不修改价格类型。服务器独立验证商品、价格、余额、购买次数、开放时间和接力前置条件,并在一个存档事务内完成扣款、权益激活与现金邮件创建;客户端发送的价格仅用于检测过期目录。
现金商品按真实 `RewardGroupTable.MailId` 发放:带邮件模板的奖励进入现金邮箱,未标记的通行证、月卡等资格按游戏规则立即生效,嵌套奖励组也使用同一规则。邮件沿用 `MailInfoTable` 的本地化标题和正文,现金附件不过期。邮寄部分在购买时只创建附件,`CashMailInfo` 返回现金邮件,玩家通过游戏原有 `MailOpen` 单封或批量领取后才将附件写入背包和货币数据。购买与领取都有持久回执,重试或重启不会重复扣款、重复发信或重复发奖;以前已经直接发放的订单保留原回执,不会再补发同一份附件。
月卡、登录通行礼包和付费签到的领取状态在服务器保存,`AttendanceResponse` 与 `EventRewardResponse` 的本服扩展字段同步每日奖励并用回执防止重复应用。现金通行证激活必须消费购买权益。主线通关礼包按服务器主线进度领取;恶魔城奖励需要对应玩法提供真实完成记录,目前占位玩法不能领取通关奖励。