fix(server): complete native shop purchases, quantities and diamond payments
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user