fix(all): deliver cash rewards through mail and align paid diamond prices
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}},
|
||||
} {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
|
||||
客户端从当前服务器 `/client/commerce` 获取完整价格目录。配置未加载、版本不匹配或禁止购买时,购买入口会显示游戏提示框,不会调用外部支付 SDK。收费商品使用游戏原有货币图标与数量;免费充值显示 `Free`。
|
||||
|
||||
补丁保留原购买回调,因此无限抽最终确认、特殊甄选券、月卡、礼包、皮肤和现金通行证继续执行各自的游戏流程。原本使用普通钻石、付费钻石、金币等游戏内货币的商品不修改价格类型。服务器独立验证商品、价格、余额、购买次数、开放时间和接力前置条件,并在一个存档事务内完成扣款与发奖;客户端发送的价格仅用于检测过期目录。
|
||||
补丁保留原购买回调,因此无限抽最终确认、特殊甄选券、月卡、礼包、皮肤和现金通行证继续执行各自的游戏流程。原本使用普通钻石、付费钻石、金币等游戏内货币的商品不修改价格类型。服务器独立验证商品、价格、余额、购买次数、开放时间和接力前置条件,并在一个存档事务内完成扣款、权益激活与现金邮件创建;客户端发送的价格仅用于检测过期目录。
|
||||
|
||||
现金商品按真实 `RewardGroupTable.MailId` 发放:带邮件模板的奖励进入现金邮箱,未标记的通行证、月卡等资格按游戏规则立即生效,嵌套奖励组也使用同一规则。邮件沿用 `MailInfoTable` 的本地化标题和正文,现金附件不过期。邮寄部分在购买时只创建附件,`CashMailInfo` 返回现金邮件,玩家通过游戏原有 `MailOpen` 单封或批量领取后才将附件写入背包和货币数据。购买与领取都有持久回执,重试或重启不会重复扣款、重复发信或重复发奖;以前已经直接发放的订单保留原回执,不会再补发同一份附件。
|
||||
|
||||
月卡、登录通行礼包和付费签到的领取状态在服务器保存,`AttendanceResponse` 与 `EventRewardResponse` 的本服扩展字段同步每日奖励并用回执防止重复应用。现金通行证激活必须消费购买权益。主线通关礼包按服务器主线进度领取;恶魔城奖励需要对应玩法提供真实完成记录,目前占位玩法不能领取通关奖励。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user