fix(server): complete native shop purchases, quantities and diamond payments

This commit is contained in:
2026-10-05 17:21:27 +08:00
parent ce638a6649
commit 0e6a8ef97b
6 changed files with 525 additions and 13 deletions
+9
View File
@@ -823,6 +823,15 @@ func serve(args []string) (serveErr error) {
return fmt.Errorf("load cash bonus claims: %w", err)
}
cashService.AttachLegacyCounts(gachaService)
cashSpecialProducts := []gamedata.CashProductKey{{GroupID: infiniteGacha.ProductGroupID, ProductID: infiniteGacha.ProductID, SaleGroup: infiniteGacha.SaleGroup}}
for _, group := range regularGacha.Groups() {
if group.CashProductGroupID != 0 && group.CashProductID != 0 {
cashSpecialProducts = append(cashSpecialProducts, gamedata.CashProductKey{GroupID: group.CashProductGroupID, ProductID: group.CashProductID, SaleGroup: group.CashSalesGroup})
}
}
if err := cashService.AttachSpecialProducts(cashSpecialProducts); err != nil {
return fmt.Errorf("attach special cash products: %w", err)
}
cashService.AttachDelegate(func(key gamedata.CashProductKey, request []byte) ([]byte, bool, error) {
known := key.GroupID == infiniteGacha.ProductGroupID && key.ProductID == infiniteGacha.ProductID && key.SaleGroup == infiniteGacha.SaleGroup
for _, group := range regularGacha.Groups() {
@@ -0,0 +1,220 @@
package commerce
import (
"bd2server/internal/server/gameconfig"
"bd2server/internal/server/readonly"
"bd2server/internal/server/wire"
"bytes"
"fmt"
"os"
"testing"
"time"
"bd2server/internal/server/events"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/hunting"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
)
// Covers static native goods independently of their current publication
// windows. The account is an isolated memory store; no player saves are read.
func TestInstalledAllNativeProductRewardAndPriceDomains(t *testing.T) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
t.Skip("BD2_REAL_GAMEDATA not configured")
}
const version = "20260923193640"
catalog, err := gamedata.LoadCashCatalog(root, version)
if err != nil {
t.Fatal(err)
}
graph, err := gamedata.LoadRewardGraph(root, version)
if err != nil {
t.Fatal(err)
}
costumes, err := gamedata.LoadRewardCostumeCatalog(root, version)
if err != nil {
t.Fatal(err)
}
options, err := gamedata.LoadRewardEquipmentCatalog(root, version)
if err != nil {
t.Fatal(err)
}
owned, err := gamedata.LoadOwnedEventItemDesign(root, version)
if err != nil {
t.Fatal(err)
}
avatars, err := gamedata.LoadAvatarRewardDesign(root, version)
if err != nil {
t.Fatal(err)
}
skins, err := gamedata.LoadPrestigeSkins(root, version)
if err != nil {
t.Fatal(err)
}
newAccount := func() (*events.Economy, *player.Inventory, *player.Wallet, stateio.Store, *hunting.Service) {
store := stateio.NewMemory()
items, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
wallet, err := player.OpenWallet(store, player.Currency{})
if err != nil {
t.Fatal(err)
}
collection, err := player.OpenCollectionStore(store, nil)
if err != nil {
t.Fatal(err)
}
equipment, err := player.OpenEquipmentInventory(store)
if err != nil {
t.Fatal(err)
}
economy, err := events.NewEconomy(store, items, wallet, collection, equipment, costumes, options, graph, nil)
if err != nil {
t.Fatal(err)
}
economy.AttachOwnedItemDesign(owned)
economy.AttachAvatarRewards(avatars)
economy.AttachPrestigeSkins(skins)
ap, err := hunting.Open(store, root, version, items, wallet, func() (int, error) { return 21, nil }, 0, 0)
if err != nil {
t.Fatal(err)
}
economy.AttachHuntingAP(ap)
return economy, items, wallet, store, ap
}
economy, _, _, _, _ := newAccount()
families := map[uint64]int{}
free := 0
native := 0
// Each product reward is settled once through the actual shared economy.
// Prices are funded in that same typed domain and spent once separately,
// avoiding historical publication or predecessor constraints in this audit.
for _, product := range catalog.Products {
if product.PriceType == 1 {
continue
}
native++
if native%50 == 0 {
economy, _, _, _, _ = newAccount()
}
families[product.PriceType]++
identity := fmt.Sprintf("native-audit:%d:%d:%d", product.Key.GroupID, product.Key.ProductID, product.Key.SaleGroup)
var cost []gamedata.Reward
if product.PriceType == 0 {
free++
if product.PriceID != 0 || product.PriceCount != 0 {
t.Errorf("free product has nonzero price %+v", product.Key)
}
} else {
cost = []gamedata.Reward{{Type: product.PriceType, ID: product.PriceID, Count: product.PriceCount}}
if _, err = economy.Apply(identity+":fund", nil, cost); err != nil {
t.Errorf("native price domain %+v failed: %v", product.Key, err)
continue
}
}
rewards := []gamedata.Reward{{Type: 9, ID: product.RandomBoxID, Count: 1}}
if product.BonusRandomBoxID != 0 {
rewards = append(rewards, gamedata.Reward{Type: 9, ID: product.BonusRandomBoxID, Count: 1})
}
if _, err = economy.Apply(identity, cost, rewards); err != nil {
t.Errorf("native product %+v real reward/debit failed: %v", product.Key, err)
}
}
if native != 2274 || free != 100 || len(families) != 14 {
t.Fatalf("native design coverage changed: goods=%d free=%d families=%v", native, free, families)
}
t.Run("native dispatcher quantity free and total diamonds", func(t *testing.T) {
base, items, wallet, store, ap := newAccount()
entitlementDesign, err := gamedata.LoadCashEntitlementDesign(root, version)
if err != nil {
t.Fatal(err)
}
resolver, err := gamedata.LoadCashRewardResolver(root, version, graph)
if err != nil {
t.Fatal(err)
}
entitlement, err := NewEntitlementEconomy(store, base, resolver, items, entitlementDesign)
if err != nil {
t.Fatal(err)
}
policy, err := NewCatalog("2.35.10", catalog, gameconfig.Default().Purchases)
if err != nil {
t.Fatal(err)
}
service, err := NewService(policy, store, entitlement)
if err != nil {
t.Fatal(err)
}
field := func(n int, v uint64) readonly.Field { return readonly.Field{Number: n, Type: 0, Varint: v} }
seed := &readonly.Seed{Responses: map[string]readonly.Response{"/CashShopInfo": {Fields: []readonly.Field{{Number: 1, Type: 2, Fields: []readonly.Field{field(1, 1700001), field(2, 1), field(3, 0), field(4, 1), field(5, uint64(time.Now().Add(time.Hour).UnixMilli()))}}}}}}
if err = service.AttachShopSeed(seed); err != nil {
t.Fatal(err)
}
if _, err = base.Apply("fund diamonds", nil, []gamedata.Reward{{Type: 3, Count: 1000}, {Type: 2, Count: 50000}}); err != nil {
t.Fatal(err)
}
makeBuy := func(seq, group, id, count uint64) []byte {
r := wire.AppendVarint(nil, 1, seq)
r = wire.AppendVarint(r, 3, group)
line := wire.AppendVarint(nil, 1, id)
line = wire.AppendVarint(line, 3, count)
return wire.AppendBytes(r, 4, line)
}
request := makeBuy(1, 400001, 2, 300)
_, bonusBefore, err := ap.HuntingAP()
if err != nil {
t.Fatal(err)
}
code, response, handled, err := service.HandleSession("/CashShopBuy", request, "native-audit")
if err != nil || !handled || code != 61 {
t.Fatalf("native quantity300 code%d handled%t err%v", code, handled, err)
}
balance := wallet.Snapshot()
_, bonus, err := ap.HuntingAP()
if err != nil || balance.FreeJewelry != 0 || balance.Jewelry != 0 || bonus != bonusBefore+18000 {
t.Fatal("total diamond split or AP grant wrong", balance, bonus, err)
}
_, replay, _, err := service.HandleSession("/CashShopBuy", request, "native-audit")
_, againBonus, _ := ap.HuntingAP()
if err != nil || !bytes.Equal(response, replay) || againBonus != bonus || wallet.Snapshot() != balance {
t.Fatal("bulk native replay duplicated", err)
}
found := false
for _, raw := range service.PurchaseCountDBInfos() {
g, _, _ := wire.Varint(raw, 1)
id, _, _ := wire.Varint(raw, 2)
count, _, _ := wire.Varint(raw, 4)
if g == 400001 && id == 2 && count == 300 {
found = true
}
}
if !found {
t.Fatal("quantity300 count missing")
}
free := makeBuy(2, 1700001, 1, 1)
if code, _, handled, err = service.HandleSession("/CashShopBuy", free, "native-audit"); err != nil || !handled || code != 61 {
t.Fatal("free native goods failed", err)
}
if wallet.Snapshot().Jewelry != balance.Jewelry || wallet.Snapshot().FreeJewelry != balance.FreeJewelry {
t.Fatal("free goods charged currency")
}
if _, _, _, err = service.HandleSession("/CashShopBuy", makeBuy(3, 1700001, 1, 1), "native-audit"); err == nil {
t.Fatal("free limit bypassed")
}
if _, err = base.Apply("fund resource", nil, []gamedata.Reward{{Type: 8, ID: 1002, Count: 3000}}); err != nil {
t.Fatal(err)
}
if code, _, handled, err = service.HandleSession("/CashShopBuy", makeBuy(4, 700001, 2, 300), "native-audit"); err != nil || !handled || code != 61 {
t.Fatal("native resource quantity300 failed", err)
}
for _, item := range items.All() {
if item.Type == 8 && item.ID == 1002 && item.Count != 0 {
t.Fatal("resource price not consumed", item)
}
}
})
t.Logf("validated %d native goods across %d typed price domains, including %d free goods", native, len(families), free)
}
@@ -0,0 +1,47 @@
package commerce
import (
"fmt"
"math"
"bd2server/internal/server/gamedata"
)
// Native prices belong to GameData; cash quotes remain server policy.
func nativePrice(d gamedata.CashProductDesign, count uint64) (uint64, error) {
if d.PriceType == 0 {
if d.PriceID != 0 || d.PriceCount != 0 {
return 0, fmt.Errorf("commerce: invalid free product price")
}
return 0, nil
}
if count == 0 || count > math.MaxInt32 || d.PriceCount > math.MaxInt32/count {
return 0, fmt.Errorf("commerce: native price overflow")
}
return d.PriceCount * count, nil
}
type nativeCostResolver interface {
NativePurchaseCosts(gamedata.Reward) ([]gamedata.Reward, error)
}
// AttachSpecialProducts reserves exact keys whose rewards depend on a prior
// gameplay preview. Ordinary tickets and native goods stay in the economy.
func (s *Service) AttachSpecialProducts(keys []gamedata.CashProductKey) error {
products := map[gamedata.CashProductKey]bool{}
for _, key := range keys {
if _, ok := s.catalog.Design(key); !ok {
return fmt.Errorf("commerce: unknown special product %+v", key)
}
products[key] = true
}
s.specialProducts = products
return nil
}
func (e *EntitlementEconomy) NativePurchaseCosts(cost gamedata.Reward) ([]gamedata.Reward, error) {
if resolver, ok := e.base.(nativeCostResolver); ok {
return resolver.NativePurchaseCosts(cost)
}
return []gamedata.Reward{cost}, nil
}
@@ -0,0 +1,191 @@
package commerce
import (
"bytes"
"errors"
"fmt"
"path/filepath"
"testing"
"bd2server/internal/server/accountstate"
"bd2server/internal/server/events"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
func nativeBuyRequest(seq, group uint64, lines ...[3]uint64) []byte {
b := wire.AppendVarint(nil, 1, seq)
b = wire.AppendVarint(b, 3, group)
for _, line := range lines {
row := wire.AppendVarint(nil, 1, line[0])
row = wire.AppendVarint(row, 2, line[1])
row = wire.AppendVarint(row, 3, line[2])
b = wire.AppendBytes(b, 4, row)
}
// Native clients repeat these display fields on subsequent purchases.
return wire.AppendBytes(b, 7, wire.AppendString(nil, 1, "2"))
}
func TestNativeGoodsSingleQuantityFreeClaimsAndDisplayBilling(t *testing.T) {
s, eco, _ := serviceFixture(t, 0)
key := gamedata.CashProductKey{GroupID: 44, ProductID: 2}
d := gamedata.CashProductDesign{Key: key, PriceType: 22, PriceCount: 10, RandomBoxID: 500, PurchaseLimitType: 3, PurchaseLimitCount: 301}
s.catalog.designs[key] = d
req := nativeBuyRequest(1, 44, [3]uint64{2, 0, 300})
code, out, handled, err := s.HandleSession("/CashShopBuy", req, "s")
if err != nil || !handled || code != 61 || eco.calls != 1 {
t.Fatal(code, handled, err)
}
if eco.costs[0] != (gamedata.Reward{Type: 22, Count: 3000}) || eco.rewards[0].Count != 300 {
t.Fatal(eco)
}
_, replay, _, err := s.HandleSession("/CashShopBuy", req, "s")
if err != nil || !bytes.Equal(out, replay) || eco.calls != 1 {
t.Fatal("duplicate native request", err)
}
if _, _, _, err = s.HandleSession("/CashShopBuy", nativeBuyRequest(2, 44, [3]uint64{2, 0, 1}), "s"); err != nil {
t.Fatal("display billing treated as receipt", err)
}
if _, _, _, err = s.HandleSession("/CashShopBuy", nativeBuyRequest(3, 44, [3]uint64{2, 0, 1}), "s"); err == nil || eco.calls != 2 {
t.Fatal("monthly limit ignored")
}
freeKey := gamedata.CashProductKey{GroupID: 45, ProductID: 1}
s.catalog.designs[freeKey] = gamedata.CashProductDesign{Key: freeKey, RandomBoxID: 501, PurchaseLimitType: 4, PurchaseLimitCount: 1}
if _, _, _, err = s.HandleSession("/CashShopBuy", nativeBuyRequest(4, 45, [3]uint64{1, 0, 1}), "s"); err != nil || len(eco.costs) != 0 {
t.Fatal("free claim failed", err)
}
if _, _, _, err = s.HandleSession("/CashShopBuy", nativeBuyRequest(5, 45, [3]uint64{1, 0, 1}), "s"); err == nil {
t.Fatal("free claimed twice")
}
}
func TestNativeMultipleProductsAndSpecialPreviewsAreValidatedBeforeGrant(t *testing.T) {
s, eco, _ := serviceFixture(t, 0)
key := gamedata.CashProductKey{GroupID: 44, ProductID: 1}
other := gamedata.CashProductKey{GroupID: 44, ProductID: 2}
s.catalog.designs[key] = gamedata.CashProductDesign{Key: key, PriceType: 4, PriceCount: 1, RandomBoxID: 1}
s.catalog.designs[other] = gamedata.CashProductDesign{Key: other, PriceType: 4, PriceCount: 1, RandomBoxID: 2, BulkOrderAvailability: 1}
if _, _, _, err := s.HandleSession("/CashShopBuy", nativeBuyRequest(1, 44, [3]uint64{1, 0, 1}, [3]uint64{2, 0, 1}), "s"); err == nil || eco.calls != 0 {
t.Fatal("ineligible batch executed")
}
if err := s.AttachSpecialProducts([]gamedata.CashProductKey{key}); err != nil {
t.Fatal(err)
}
delegates := 0
s.AttachDelegate(func(k gamedata.CashProductKey, _ []byte) ([]byte, bool, error) {
delegates++
if k != key {
return nil, false, fmt.Errorf("unexpected delegate key")
}
return []byte{8, 1}, true, nil
})
if _, _, _, err := s.HandleSession("/CashShopBuy", nativeBuyRequest(2, 44, [3]uint64{1, 0, 2}), "s"); err == nil || delegates != 0 || eco.calls != 0 {
t.Fatal("invalid preview mutated state")
}
if _, _, _, err := s.HandleSession("/CashShopBuy", nativeBuyRequest(3, 44, [3]uint64{1, 0, 1}), "s"); err != nil || delegates != 1 || len(eco.rewards) != 0 {
t.Fatal("preview did not use delegate", err)
}
if _, _, _, err := s.HandleSession("/CashShopBuy", nativeBuyRequest(4, 44, [3]uint64{2, 0, 2}), "s"); err != nil || delegates != 1 {
t.Fatal("ordinary native delegated", err)
}
}
type nativeTestGraph struct{}
func (nativeTestGraph) Resolve(rewards []gamedata.BattleReward) ([]gamedata.BattleReward, error) {
var out []gamedata.BattleReward
for _, r := range rewards {
out = append(out, gamedata.BattleReward{Type: 8, ID: 710, Count: r.Count})
}
return out, nil
}
func TestNativeTotalDiamondPurchaseSQLiteRollbackAndRestartReplay(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
repo, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
wallet, err := player.OpenWallet(repo, player.Currency{FreeJewelry: 5, Jewelry: 10})
if err != nil {
t.Fatal(err)
}
if err = wallet.EnsurePersisted(); err != nil {
t.Fatal(err)
}
fixture, _, _ := serviceFixture(t, 0)
key := gamedata.CashProductKey{GroupID: 44, ProductID: 2}
fixture.catalog.designs[key] = gamedata.CashProductDesign{Key: key, PriceType: 3, PriceCount: 7, RandomBoxID: 1}
open := func() (*Service, *player.Inventory) {
t.Helper()
items, err := player.OpenInventory(repo, &player.Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
collection, err := player.OpenCollectionStore(repo, nil)
if err != nil {
t.Fatal(err)
}
equipment, err := player.OpenEquipmentInventory(repo)
if err != nil {
t.Fatal(err)
}
economy, err := events.NewEconomy(repo, items, wallet, collection, equipment, &gamedata.RegularGachaCatalog{}, &gamedata.EquipmentGachaCatalog{}, nativeTestGraph{}, nil)
if err != nil {
t.Fatal(err)
}
s, err := NewService(fixture.catalog, repo, economy)
if err != nil {
t.Fatal(err)
}
return s, items
}
s, _ := open()
s.AttachPurchaseHook(func(string, gamedata.CashProductDesign, uint64) error { return fmt.Errorf("grant failed") })
req := nativeBuyRequest(1, 44, [3]uint64{2, 0, 1})
op, err := repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
if _, _, _, err := s.HandleSession("/CashShopBuy", req, "s"); err == nil {
t.Fatal("hook failure swallowed")
}
if err = op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) {
t.Fatal(err)
}
repo.Close()
repo, err = accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
defer repo.Close()
wallet, err = player.OpenWallet(repo, player.Currency{})
if err != nil {
t.Fatal(err)
}
s, items := open()
if wallet.Snapshot().FreeJewelry != 5 || wallet.Snapshot().Jewelry != 10 || len(items.All()) != 0 || s.HasPurchased(key) {
t.Fatal("rollback retained partial purchase")
}
op, err = repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
_, out, _, err := s.HandleSession("/CashShopBuy", req, "s")
if err != nil {
t.Fatal(err)
}
if err = op.Commit(); err != nil {
t.Fatal(err)
}
if wallet.Snapshot().FreeJewelry != 0 || wallet.Snapshot().Jewelry != 8 || len(items.All()) != 1 {
t.Fatal("total diamonds not spent free-first")
}
s, items = open()
_, replay, _, err := s.HandleSession("/CashShopBuy", req, "s")
if err != nil || !bytes.Equal(out, replay) || wallet.Snapshot().Jewelry != 8 || len(items.All()) != 1 {
t.Fatal("restart replay repeated debit/grant", err)
}
}
+19 -13
View File
@@ -48,6 +48,7 @@ type Service struct {
now func() time.Time
resetSeconds int64
delegate PurchaseDelegate
specialProducts map[gamedata.CashProductKey]bool
hook PurchaseHook
legacy CountProvider
shopProducts [][]byte
@@ -406,16 +407,14 @@ func (s *Service) buy(session string, request []byte) (int, []byte, bool, error)
}
}
}
if native && !cash {
for _, line := range lines {
if !s.eventShopGroups[line.Key.GroupID] {
return 0, nil, false, nil
}
}
}
if native && cash {
return 61, nil, true, fmt.Errorf("commerce: mixed cash and native purchase")
}
if !cash {
// Native BillingInfo contains display identifiers (often a product ID),
// not a unique payment receipt. Request identity provides replay safety.
billing = nil
}
h := sha256.Sum256(request)
digest := hex.EncodeToString(h[:])
sessionHash := sha256.Sum256([]byte(session))
@@ -451,6 +450,9 @@ func (s *Service) buy(session string, request []byte) (int, []byte, bool, error)
if !s.available(d) {
return 61, nil, true, fmt.Errorf("commerce: product is not currently available")
}
if s.specialProducts[l.Key] && (len(lines) != 1 || l.Count != 1) {
return 61, nil, true, fmt.Errorf("commerce: special purchase requires one product")
}
var q Product
if d.PriceType == 1 {
var err error
@@ -459,10 +461,7 @@ func (s *Service) buy(session string, request []byte) (int, []byte, bool, error)
return 61, nil, true, err
}
} else {
if d.PriceType == 0 || d.PriceCount == 0 {
return 61, nil, true, fmt.Errorf("commerce: invalid native price")
}
cost, err := boundedProduct(d.PriceCount, l.Count)
cost, err := nativePrice(d, l.Count)
if err != nil {
return 61, nil, true, err
}
@@ -471,7 +470,7 @@ func (s *Service) buy(session string, request []byte) (int, []byte, bool, error)
if !q.Enabled {
return 61, nil, true, fmt.Errorf("commerce: product disabled")
}
if l.Count > 1 && d.BulkOrderAvailability == 0 {
if len(lines) > 1 && d.BulkOrderAvailability != 1 {
return 61, nil, true, fmt.Errorf("commerce: bulk purchase disabled")
}
n := s.count(v, d)
@@ -512,6 +511,13 @@ func (s *Service) buy(session string, request []byte) (int, []byte, bool, error)
if q.Cost > 0 {
if d.PriceType != 1 {
costs = []gamedata.Reward{{Type: d.PriceType, ID: d.PriceID, Count: q.Cost}}
if resolver, ok := s.economy.(nativeCostResolver); ok {
var err error
costs, err = resolver.NativePurchaseCosts(costs[0])
if err != nil {
return 61, nil, true, err
}
}
} else {
var typ uint64
switch q.Currency {
@@ -531,7 +537,7 @@ func (s *Service) buy(session string, request []byte) (int, []byte, bool, error)
handled := false
// Delegate must only select known special products. Parent account transaction
// guarantees its grant and the subsequent debit commit together.
if s.delegate != nil && d.PriceType == 1 {
if s.delegate != nil && (d.PriceType == 1 || s.specialProducts[l.Key]) {
special, handled, e = s.delegate(l.Key, request)
if e != nil {
return 61, nil, true, e
+39
View File
@@ -0,0 +1,39 @@
package events
import (
"fmt"
"math"
"bd2server/internal/server/gamedata"
)
// Native shops' FreeJewelry prices use total diamonds: free first, then paid.
// Cash conversion policy and explicitly paid-only prices keep their own rules.
func (e *Economy) NativePurchaseCosts(cost gamedata.Reward) ([]gamedata.Reward, error) {
if cost.Count == 0 || cost.Count > math.MaxInt32 {
return nil, fmt.Errorf("events: invalid native purchase cost")
}
if cost.Type != 3 {
return []gamedata.Reward{cost}, nil
}
if cost.ID != 0 {
return nil, fmt.Errorf("events: diamond cost has an item ID")
}
balance := e.wallet.Snapshot()
free := cost.Count
if free > balance.FreeJewelry {
free = balance.FreeJewelry
}
paid := cost.Count - free
if paid > balance.Jewelry {
return nil, fmt.Errorf("events: insufficient total diamonds")
}
var costs []gamedata.Reward
if free > 0 {
costs = append(costs, gamedata.Reward{Type: 3, Count: free})
}
if paid > 0 {
costs = append(costs, gamedata.Reward{Type: 2, Count: paid})
}
return costs, nil
}