feat(all): add hunting and events, commerce and rewards, binary calendars, GameData caching and login diagnostics
Implement hunting AP and progress, event tasks, exchanges, games, stories and actions; connect battle and reward handling. Add CashShop plugin and installation, server purchases and entitlements, recharge rules and plugin version metadata. Distribute binary calendars independently; cache GameData in memory and fix single-connection query deadlocks. Improve automatic login and recovery diagnostics; document configuration and release flows.
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
package commerce
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type attendanceHandler interface {
|
||||
Handle(string, []byte) (int, []byte, bool, error)
|
||||
}
|
||||
|
||||
// AttendanceHandler preserves the original event progress response and adds
|
||||
// subscription grants in extension fields understood by the commerce plugin.
|
||||
// All operations execute inside the transport's account transaction.
|
||||
type AttendanceHandler struct {
|
||||
Events attendanceHandler
|
||||
Economy *EntitlementEconomy
|
||||
LoginPasses *LoginPasses
|
||||
Store stateio.Store
|
||||
}
|
||||
|
||||
type attendanceReceipt struct {
|
||||
Digest string `json:"digest"`
|
||||
Bundle []byte `json:"bundle"`
|
||||
}
|
||||
|
||||
func (h AttendanceHandler) Handle(path string, request []byte) (int, []byte, bool, error) {
|
||||
return h.HandleSession(path, request, "")
|
||||
}
|
||||
|
||||
func (h AttendanceHandler) HandleSession(path string, request []byte, session string) (int, []byte, bool, error) {
|
||||
if path != "/Attendance" {
|
||||
return 0, nil, false, nil
|
||||
}
|
||||
if session == "" || h.Events == nil || h.Economy == nil || h.Store == nil {
|
||||
return 0, nil, true, fmt.Errorf("commerce: attendance dependencies/session unavailable")
|
||||
}
|
||||
seq, found, err := wire.Varint(request, 1)
|
||||
if err != nil || !found || seq == 0 {
|
||||
return 0, nil, true, fmt.Errorf("commerce: missing attendance sequence")
|
||||
}
|
||||
code, response, handled, err := h.Events.Handle(path, request)
|
||||
if err != nil || !handled {
|
||||
return code, response, handled, err
|
||||
}
|
||||
key := fmt.Sprintf("commerce_attendance:%x:%d", sha256.Sum256([]byte(session)), seq)
|
||||
digest := fmt.Sprintf("%x", sha256.Sum256(request))
|
||||
var receipt attendanceReceipt
|
||||
previous, err := h.Store.Load(key)
|
||||
if err != nil {
|
||||
return code, nil, true, err
|
||||
}
|
||||
var loginBundle []byte
|
||||
if h.LoginPasses != nil {
|
||||
var infos [][]byte
|
||||
loginBundle, infos, err = h.LoginPasses.ClaimAndInfo(key)
|
||||
if err != nil {
|
||||
return code, nil, true, err
|
||||
}
|
||||
for _, info := range infos {
|
||||
response = wire.AppendBytes(response, 6, info)
|
||||
}
|
||||
}
|
||||
if previous != nil {
|
||||
if err = json.Unmarshal(previous, &receipt); err != nil || receipt.Digest != digest {
|
||||
return code, nil, true, fmt.Errorf("commerce: conflicting attendance replay")
|
||||
}
|
||||
} else {
|
||||
receipt.Digest = digest
|
||||
receipt.Bundle, err = h.Economy.ClaimSubscriptions(key)
|
||||
if err != nil {
|
||||
return code, nil, true, err
|
||||
}
|
||||
receipt.Bundle = append(receipt.Bundle, loginBundle...)
|
||||
raw, err := json.Marshal(receipt)
|
||||
if err != nil {
|
||||
return code, nil, true, err
|
||||
}
|
||||
if err = h.Store.Save(key, raw); err != nil {
|
||||
return code, nil, true, err
|
||||
}
|
||||
}
|
||||
response, err = h.Economy.MergeAttendance(response)
|
||||
if err != nil {
|
||||
return code, nil, true, err
|
||||
}
|
||||
if len(receipt.Bundle) != 0 {
|
||||
response = wire.AppendBytes(response, 1001, receipt.Bundle)
|
||||
response = wire.AppendString(response, 1002, key)
|
||||
}
|
||||
return code, response, true, nil
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package commerce
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type originalAttendance struct{}
|
||||
|
||||
func (originalAttendance) Handle(path string, _ []byte) (int, []byte, bool, error) {
|
||||
return 0, wire.AppendVarint(nil, 55, 123), path == "/Attendance", nil
|
||||
}
|
||||
|
||||
func TestAttendanceExtensionReplaysRewardAndPreservesNativeResponse(t *testing.T) {
|
||||
e, _, base, _, now := entitlementFixture(t)
|
||||
if _, err := e.Apply("subscription", nil, []gamedata.Reward{{Type: 19, ID: 38, Count: 1}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
*now = now.Add(24 * time.Hour)
|
||||
h := AttendanceHandler{Events: originalAttendance{}, Economy: e, Store: e.store}
|
||||
request := wire.AppendVarint(nil, 1, 1)
|
||||
_, response, handled, err := h.HandleSession("/Attendance", request, "session")
|
||||
if err != nil || !handled {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v, ok, _ := wire.Varint(response, 55); !ok || v != 123 {
|
||||
t.Fatal("native response lost")
|
||||
}
|
||||
bundle, ok, err := wire.Bytes(response, 1001)
|
||||
if err != nil || !ok || len(bundle) == 0 {
|
||||
t.Fatal("reward extension missing")
|
||||
}
|
||||
if receipt, ok, _ := wire.Bytes(response, 1002); !ok || len(receipt) == 0 {
|
||||
t.Fatal("receipt extension missing")
|
||||
}
|
||||
calls := base.Calls
|
||||
_, replay, _, err := h.HandleSession("/Attendance", request, "session")
|
||||
if err != nil || !bytes.Equal(response, replay) || base.Calls != calls {
|
||||
t.Fatal("retry lost reward or granted twice", err)
|
||||
}
|
||||
_, next, _, err := h.HandleSession("/Attendance", wire.AppendVarint(nil, 1, 2), "session")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok, _ := wire.Bytes(next, 1001); ok || base.Calls != calls {
|
||||
t.Fatal("same day duplicate grant")
|
||||
}
|
||||
_, _, _, err = h.HandleSession("/Attendance", append(request, wire.AppendVarint(nil, 2, 1)...), "session")
|
||||
if err == nil {
|
||||
t.Fatal("conflicting sequence accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttendanceCombinesLoginPassAndSubscriptionWithoutDuplicateClaims(t *testing.T) {
|
||||
e, items, base, _, now := entitlementFixture(t)
|
||||
if _, err := e.Apply("subscription", nil, []gamedata.Reward{{Type: 19, ID: 38, Count: 1}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
*now = now.Add(24 * time.Hour)
|
||||
design := &gamedata.LoginPassCatalog{Groups: map[uint64][]gamedata.LoginPassReward{
|
||||
10: {{ID: 1, TicketID: 77, Free: gamedata.Reward{Type: 3, Count: 11}, Premium: gamedata.Reward{Type: 3, Count: 22}}},
|
||||
}}
|
||||
passes, err := NewLoginPasses(e.store, design, e, items, func(group uint64) bool { return group == 10 })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
passes.SetClock(func() time.Time { return *now }, 0)
|
||||
h := AttendanceHandler{Events: originalAttendance{}, Economy: e, LoginPasses: passes, Store: e.store}
|
||||
calls := base.Calls
|
||||
request := wire.AppendVarint(nil, 1, 1)
|
||||
_, response, _, err := h.HandleSession("/Attendance", request, "combined-session")
|
||||
if err != nil || base.Calls != calls+2 {
|
||||
t.Fatalf("daily claims not combined: calls=%d err=%v", base.Calls-calls, err)
|
||||
}
|
||||
info, ok, err := wire.Bytes(response, 6)
|
||||
if err != nil || !ok {
|
||||
t.Fatal("login-pass metadata lost", err)
|
||||
}
|
||||
if group, _, _ := wire.Varint(info, 1); group != 10 {
|
||||
t.Fatal("wrong login-pass group")
|
||||
}
|
||||
bundle, ok, err := wire.Bytes(response, 1001)
|
||||
if err != nil || !ok || len(bundle) == 0 {
|
||||
t.Fatal("combined reward envelope missing", err)
|
||||
}
|
||||
_, replay, _, err := h.HandleSession("/Attendance", request, "combined-session")
|
||||
if err != nil || !bytes.Equal(replay, response) || base.Calls != calls+2 {
|
||||
t.Fatal("retry changed the envelope or repeated claims", err)
|
||||
}
|
||||
_, next, _, err := h.HandleSession("/Attendance", wire.AppendVarint(nil, 1, 2), "combined-session")
|
||||
if err != nil || base.Calls != calls+2 {
|
||||
t.Fatal("fresh request claimed the same day again", err)
|
||||
}
|
||||
if _, ok, _ := wire.Bytes(next, 1001); ok {
|
||||
t.Fatal("fresh request replayed old rewards")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// Package commerce exposes server-authoritative prices for SDK cash goods.
|
||||
package commerce
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
"bd2server/internal/server/gameconfig"
|
||||
"bd2server/internal/server/gamedata"
|
||||
)
|
||||
|
||||
type Product struct {
|
||||
SKU string `json:"sku"`
|
||||
GroupID uint64 `json:"group_id"`
|
||||
ProductID uint64 `json:"product_id"`
|
||||
SaleGroup uint64 `json:"sale_group"`
|
||||
ItemType uint64 `json:"item_type"`
|
||||
Amount uint64 `json:"amount"`
|
||||
Recharge bool `json:"recharge"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Currency string `json:"currency"`
|
||||
Cost uint64 `json:"cost"`
|
||||
PaidDiamondPrice uint64 `json:"paid_diamond_price"`
|
||||
}
|
||||
type Manifest struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
GameVersion string `json:"game_version"`
|
||||
Products []Product `json:"products"`
|
||||
}
|
||||
type Catalog struct {
|
||||
version string
|
||||
designs map[gamedata.CashProductKey]gamedata.CashProductDesign
|
||||
quotes map[gamedata.CashProductKey]Product
|
||||
manifest []Product
|
||||
}
|
||||
|
||||
func NewCatalog(version string, design *gamedata.CashCatalog, cfg gameconfig.PurchasesConfig) (*Catalog, error) {
|
||||
validation := gameconfig.Default()
|
||||
validation.Purchases = cfg
|
||||
if err := validation.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if version == "" || design == nil {
|
||||
return nil, fmt.Errorf("commerce: missing cash catalog/version")
|
||||
}
|
||||
c := &Catalog{version: version, designs: map[gamedata.CashProductKey]gamedata.CashProductDesign{}, quotes: map[gamedata.CashProductKey]Product{}}
|
||||
// Select the smallest regular recharge amount; its cash/diamond ratio is
|
||||
// the conservative conversion baseline. GameData provides both quantities.
|
||||
var baseAmount, basePrice uint64
|
||||
for _, p := range design.Products {
|
||||
if !p.Recharge || p.PriceType != 1 {
|
||||
continue
|
||||
}
|
||||
if p.NominalPaidDiamonds == 0 || p.PriceCount == 0 {
|
||||
return nil, fmt.Errorf("commerce: invalid recharge baseline")
|
||||
}
|
||||
if baseAmount == 0 || p.NominalPaidDiamonds < baseAmount || p.NominalPaidDiamonds == baseAmount && p.PriceCount > basePrice {
|
||||
baseAmount = p.NominalPaidDiamonds
|
||||
basePrice = p.PriceCount
|
||||
}
|
||||
}
|
||||
if baseAmount == 0 {
|
||||
return nil, fmt.Errorf("commerce: no regular diamond recharge conversion baseline")
|
||||
}
|
||||
for _, p := range design.Products {
|
||||
if _, exists := c.designs[p.Key]; exists {
|
||||
return nil, fmt.Errorf("commerce: duplicate product %+v", p.Key)
|
||||
}
|
||||
c.designs[p.Key] = p
|
||||
if p.PriceType != 1 {
|
||||
continue
|
||||
} // native wallet goods already use their own path
|
||||
price, err := ceilRatio(p.PriceCount, baseAmount, basePrice)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := Product{GroupID: p.Key.GroupID, ProductID: p.Key.ProductID, SaleGroup: p.Key.SaleGroup, Recharge: p.Recharge, Enabled: true, Currency: "paid_diamonds", ItemType: 2, Amount: price, Cost: price, PaidDiamondPrice: price}
|
||||
if p.Recharge {
|
||||
switch cfg.DiamondRecharge.Currency {
|
||||
case "free":
|
||||
q.Currency = "free"
|
||||
q.ItemType = 0
|
||||
q.Amount = 0
|
||||
case "gold":
|
||||
q.Currency = "gold"
|
||||
q.ItemType = 4
|
||||
q.Amount, err = boundedProduct(p.NominalPaidDiamonds, uint64(cfg.DiamondRecharge.GoldPerPaidDiamond))
|
||||
case "diamonds":
|
||||
q.Currency = "diamonds"
|
||||
q.ItemType = 3
|
||||
q.Amount, err = boundedProduct(p.NominalPaidDiamonds, uint64(cfg.DiamondRecharge.DiamondsPerPaidDiamond))
|
||||
case "ban", "":
|
||||
q.Currency = "ban"
|
||||
q.Enabled = false
|
||||
q.ItemType = 0
|
||||
q.Amount = 0
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("commerce: recharge %+v: %w", p.Key, err)
|
||||
}
|
||||
q.Cost = q.Amount
|
||||
}
|
||||
aliases := []string{p.GoogleSKU}
|
||||
if p.AppleSKU != p.GoogleSKU {
|
||||
aliases = append(aliases, p.AppleSKU)
|
||||
}
|
||||
for _, sku := range aliases {
|
||||
if sku == "" {
|
||||
continue
|
||||
}
|
||||
q.SKU = sku
|
||||
c.manifest = append(c.manifest, q)
|
||||
}
|
||||
q.SKU = p.GoogleSKU
|
||||
if q.SKU == "" {
|
||||
q.SKU = p.AppleSKU
|
||||
}
|
||||
if q.SKU == "" {
|
||||
return nil, fmt.Errorf("commerce: cash product has no SKU")
|
||||
}
|
||||
c.quotes[p.Key] = q
|
||||
}
|
||||
sort.Slice(c.manifest, func(i, j int) bool {
|
||||
a, b := c.manifest[i], c.manifest[j]
|
||||
if a.GroupID != b.GroupID {
|
||||
return a.GroupID < b.GroupID
|
||||
}
|
||||
if a.ProductID != b.ProductID {
|
||||
return a.ProductID < b.ProductID
|
||||
}
|
||||
if a.SaleGroup != b.SaleGroup {
|
||||
return a.SaleGroup < b.SaleGroup
|
||||
}
|
||||
return a.SKU < b.SKU
|
||||
})
|
||||
return c, nil
|
||||
}
|
||||
func ceilRatio(value, numerator, denominator uint64) (uint64, error) {
|
||||
if value == 0 || numerator == 0 || denominator == 0 || value > math.MaxUint64/numerator {
|
||||
return 0, fmt.Errorf("commerce: invalid or overflowing monetary conversion")
|
||||
}
|
||||
n := value * numerator
|
||||
result := n / denominator
|
||||
if n%denominator != 0 {
|
||||
result++
|
||||
}
|
||||
if result == 0 || result > math.MaxInt32 {
|
||||
return 0, fmt.Errorf("commerce: price outside supported wallet range")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func boundedProduct(a, b uint64) (uint64, error) {
|
||||
if a == 0 || b == 0 || a > math.MaxInt32/b {
|
||||
return 0, fmt.Errorf("commerce: purchase cost exceeds supported wallet range")
|
||||
}
|
||||
return a * b, nil
|
||||
}
|
||||
func (c *Catalog) Manifest() Manifest {
|
||||
return Manifest{SchemaVersion: 1, GameVersion: c.version, Products: append([]Product{}, c.manifest...)}
|
||||
}
|
||||
func (c *Catalog) Design(key gamedata.CashProductKey) (gamedata.CashProductDesign, bool) {
|
||||
p, ok := c.designs[key]
|
||||
return p, ok
|
||||
}
|
||||
func (c *Catalog) Designs() []gamedata.CashProductDesign {
|
||||
out := make([]gamedata.CashProductDesign, 0, len(c.designs))
|
||||
for _, p := range c.designs {
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
a, b := out[i].Key, out[j].Key
|
||||
if a.GroupID != b.GroupID {
|
||||
return a.GroupID < b.GroupID
|
||||
}
|
||||
if a.ProductID != b.ProductID {
|
||||
return a.ProductID < b.ProductID
|
||||
}
|
||||
return a.SaleGroup < b.SaleGroup
|
||||
})
|
||||
return out
|
||||
}
|
||||
func (c *Catalog) Quote(key gamedata.CashProductKey, count uint64) (Product, error) {
|
||||
p, ok := c.quotes[key]
|
||||
if !ok {
|
||||
return Product{}, fmt.Errorf("commerce: unknown SDK cash product %+v", key)
|
||||
}
|
||||
if !p.Enabled {
|
||||
return Product{}, fmt.Errorf("commerce: paid-diamond recharge is disabled")
|
||||
}
|
||||
if count == 0 || count > math.MaxInt32 {
|
||||
return Product{}, fmt.Errorf("commerce: invalid purchase count")
|
||||
}
|
||||
if p.Amount != 0 {
|
||||
cost, err := boundedProduct(p.Amount, count)
|
||||
if err != nil {
|
||||
return Product{}, err
|
||||
}
|
||||
p.Cost = cost
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package commerce
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gameconfig"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testDesign() *gamedata.CashCatalog {
|
||||
return &gamedata.CashCatalog{Products: []gamedata.CashProductDesign{
|
||||
{Key: gamedata.CashProductKey{GroupID: 1, ProductID: 1, SaleGroup: 1}, GoogleSKU: "recharge", AppleSKU: "recharge-ios", PriceType: 1, PriceCount: 2200, Recharge: true, NominalPaidDiamonds: 100},
|
||||
{Key: gamedata.CashProductKey{GroupID: 1, ProductID: 2, SaleGroup: 1}, GoogleSKU: "recharge", AppleSKU: "recharge-ios", PriceType: 1, PriceCount: 2200, Recharge: true, NominalPaidDiamonds: 100},
|
||||
{Key: gamedata.CashProductKey{GroupID: 2, ProductID: 1, SaleGroup: 1}, GoogleSKU: "skin", AppleSKU: "skin-ios", PriceType: 1, PriceCount: 5501},
|
||||
{Key: gamedata.CashProductKey{GroupID: 3, ProductID: 1}, GoogleSKU: "native", PriceType: 2, PriceCount: 100},
|
||||
}}
|
||||
}
|
||||
func TestCashPricesAliasesAndIsolation(t *testing.T) {
|
||||
for _, currency := range []string{"free", "gold", "diamonds", "ban", ""} {
|
||||
cfg := gameconfig.Default().Purchases
|
||||
cfg.DiamondRecharge.Currency = currency
|
||||
c, err := NewCatalog("test", testDesign(), cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := c.Manifest()
|
||||
if m.SchemaVersion != 1 || len(m.Products) != 6 {
|
||||
t.Fatalf("manifest %+v", m)
|
||||
}
|
||||
skin, err := c.Quote(gamedata.CashProductKey{GroupID: 2, ProductID: 1, SaleGroup: 1}, 2)
|
||||
if err != nil || skin.Cost != 502 || skin.ItemType != 2 || skin.Currency != "paid_diamonds" {
|
||||
t.Fatalf("ordinary goods affected by %q: %+v %v", currency, skin, err)
|
||||
}
|
||||
recharge, err := c.Quote(gamedata.CashProductKey{GroupID: 1, ProductID: 1, SaleGroup: 1}, 1)
|
||||
if currency == "ban" || currency == "" {
|
||||
if err == nil {
|
||||
t.Fatal("disabled recharge accepted")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := uint64(0)
|
||||
if currency == "gold" {
|
||||
want = 100000
|
||||
}
|
||||
if currency == "diamonds" {
|
||||
want = 100
|
||||
}
|
||||
if recharge.Cost != want {
|
||||
t.Fatalf("currency %s cost=%d", currency, recharge.Cost)
|
||||
}
|
||||
second, _ := c.Quote(gamedata.CashProductKey{GroupID: 1, ProductID: 2, SaleGroup: 1}, 1)
|
||||
if second.Cost != recharge.Cost {
|
||||
t.Fatal("first purchase incorrectly doubled cost")
|
||||
}
|
||||
m.Products[0].SKU = "changed"
|
||||
if c.Manifest().Products[0].SKU == "changed" {
|
||||
t.Fatal("manifest mutable through caller")
|
||||
}
|
||||
if _, err = c.Quote(gamedata.CashProductKey{GroupID: 3, ProductID: 1}, 1); err == nil {
|
||||
t.Fatal("native wallet item intercepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestMonetaryBoundsAndBaseline(t *testing.T) {
|
||||
for _, v := range [][3]uint64{{math.MaxUint64, 2, 1}, {1, 0, 1}, {1, 1, 0}, {math.MaxInt32 + 1, 1, 1}} {
|
||||
if _, err := ceilRatio(v[0], v[1], v[2]); err == nil {
|
||||
t.Fatalf("accepted %v", v)
|
||||
}
|
||||
}
|
||||
d := testDesign()
|
||||
d.Products[0].PriceCount = 2000
|
||||
d.Products[1].PriceCount = 2000
|
||||
c, err := NewCatalog("test", d, gameconfig.Default().Purchases)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, _ := c.Quote(d.Products[2].Key, 1)
|
||||
if p.Cost != 276 {
|
||||
t.Fatalf("baseline not derived dynamically: %+v", p)
|
||||
}
|
||||
if _, err = c.Quote(d.Products[2].Key, math.MaxInt32); err == nil {
|
||||
t.Fatal("overflow accepted")
|
||||
}
|
||||
if _, err = c.Quote(d.Products[2].Key, 0); err == nil {
|
||||
t.Fatal("zero purchase accepted")
|
||||
}
|
||||
d.Products[0].Recharge = false
|
||||
d.Products[1].Recharge = false
|
||||
if _, err = NewCatalog("test", d, gameconfig.Default().Purchases); err == nil {
|
||||
t.Fatal("missing baseline accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package commerce
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/player"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ClearPackageInventory interface{ All() []player.Item }
|
||||
type clearClaim struct{ Kind, GroupID, TicketID, TargetID, Level uint64 }
|
||||
type clearClaimReceipt struct {
|
||||
Claim clearClaim `json:"claim"`
|
||||
Response []byte `json:"response"`
|
||||
}
|
||||
type ClearPackages struct {
|
||||
mu sync.Mutex
|
||||
store stateio.Store
|
||||
economy Economy
|
||||
items ClearPackageInventory
|
||||
design map[clearClaim]gamedata.ClearPackageRewardDesign
|
||||
packCleared func(uint64, uint64) bool
|
||||
towerCleared func(uint64, uint64) bool
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewClearPackages(store stateio.Store, design *gamedata.ClearPackageCatalog, economy Economy, items ClearPackageInventory) (*ClearPackages, error) {
|
||||
if store == nil || design == nil || economy == nil || items == nil {
|
||||
return nil, fmt.Errorf("commerce: invalid clear package dependencies")
|
||||
}
|
||||
s := &ClearPackages{store: store, economy: economy, items: items, design: map[clearClaim]gamedata.ClearPackageRewardDesign{}, now: time.Now}
|
||||
for _, r := range design.Rewards {
|
||||
k := clearClaim{r.Kind, r.GroupID, r.TicketID, r.TargetID, r.Level}
|
||||
if _, ok := s.design[k]; ok {
|
||||
return nil, fmt.Errorf("commerce: duplicate clear reward")
|
||||
}
|
||||
s.design[k] = r
|
||||
}
|
||||
_, err := s.load()
|
||||
return s, err
|
||||
}
|
||||
func (s *ClearPackages) AttachProgress(pack, tower func(uint64, uint64) bool) {
|
||||
s.packCleared = pack
|
||||
s.towerCleared = tower
|
||||
}
|
||||
func (s *ClearPackages) load() (map[string]clearClaimReceipt, error) {
|
||||
v := map[string]clearClaimReceipt{}
|
||||
raw, err := s.store.Load("commerce_clear_claims")
|
||||
if err != nil || raw == nil {
|
||||
return v, err
|
||||
}
|
||||
err = json.Unmarshal(raw, &v)
|
||||
if err == nil && v == nil {
|
||||
err = fmt.Errorf("commerce: invalid clear claim state")
|
||||
}
|
||||
if err == nil {
|
||||
for identity, receipt := range v {
|
||||
if identity != clearClaimID(receipt.Claim) || len(receipt.Response) == 0 {
|
||||
err = fmt.Errorf("commerce: invalid saved clear claim")
|
||||
break
|
||||
}
|
||||
if _, ok := s.design[receipt.Claim]; !ok {
|
||||
err = fmt.Errorf("commerce: unknown saved clear claim")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return v, err
|
||||
}
|
||||
func clearClaimID(c clearClaim) string {
|
||||
return fmt.Sprintf("clear-package:%d:%d:%d:%d:%d", c.Kind, c.GroupID, c.TicketID, c.TargetID, c.Level)
|
||||
}
|
||||
func (s *ClearPackages) entitled(ticket uint64) bool {
|
||||
for _, item := range s.items.All() {
|
||||
if item.Type == 19 && item.ID == ticket && item.Count > 0 && (item.ExpiryTime == 0 || item.ExpiryTime > uint64(s.now().UnixMilli())) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func (s *ClearPackages) Handle(path string, request []byte) (int, []byte, bool, error) {
|
||||
return s.HandleSession(path, request, "")
|
||||
}
|
||||
func (s *ClearPackages) HandleSession(path string, request []byte, session string) (int, []byte, bool, error) {
|
||||
if path != "/ClearPackageReward" {
|
||||
return 0, nil, false, nil
|
||||
}
|
||||
seq, ok, err := wire.Varint(request, 1)
|
||||
if err != nil || !ok || seq == 0 {
|
||||
return 286, nil, true, fmt.Errorf("commerce: invalid clear claim sequence")
|
||||
}
|
||||
kind, _, err := wire.Varint(request, 2)
|
||||
if err != nil || kind > 1 {
|
||||
return 286, nil, true, fmt.Errorf("commerce: invalid clear claim type")
|
||||
}
|
||||
var nested []byte
|
||||
err = wire.Walk(request, func(f wire.Field) error {
|
||||
if f.Number == 3 || f.Number == 4 {
|
||||
if int(f.Number) != int(kind)+3 || f.Type != 2 || nested != nil {
|
||||
return fmt.Errorf("commerce: invalid clear claim row")
|
||||
}
|
||||
nested = append([]byte(nil), f.Value...)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil || nested == nil {
|
||||
return 286, nil, true, fmt.Errorf("commerce: missing clear claim row")
|
||||
}
|
||||
var values [4]uint64
|
||||
for i := range values {
|
||||
values[i], _, err = wire.Varint(nested, i+1)
|
||||
if err != nil {
|
||||
return 286, nil, true, err
|
||||
}
|
||||
}
|
||||
claim := clearClaim{kind, values[0], values[1], values[2], values[3]}
|
||||
d, ok := s.design[claim]
|
||||
if !ok {
|
||||
return 286, nil, true, fmt.Errorf("commerce: unknown clear reward")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
v, err := s.load()
|
||||
if err != nil {
|
||||
return 286, nil, true, err
|
||||
}
|
||||
identity := clearClaimID(claim)
|
||||
if receipt, ok := v[identity]; ok {
|
||||
return 286, append([]byte(nil), receipt.Response...), true, nil
|
||||
}
|
||||
if d.Type == 1 && !s.entitled(d.TicketID) {
|
||||
return 286, nil, true, fmt.Errorf("commerce: clear reward premium ticket required")
|
||||
}
|
||||
proof := s.packCleared
|
||||
if kind == 1 {
|
||||
proof = s.towerCleared
|
||||
}
|
||||
if proof == nil || !proof(d.TargetID, d.Level) {
|
||||
return 286, nil, true, fmt.Errorf("commerce: clear reward progression incomplete")
|
||||
}
|
||||
bundle, err := s.economy.Apply(identity, nil, []gamedata.Reward{{Type: 9, ID: d.RandomBoxID, Count: 1}})
|
||||
if err != nil {
|
||||
return 286, nil, true, err
|
||||
}
|
||||
response := wire.AppendBytes(nil, 1, bundle)
|
||||
v[identity] = clearClaimReceipt{Claim: claim, Response: response}
|
||||
raw, err := json.Marshal(v)
|
||||
if err == nil {
|
||||
err = s.store.Save("commerce_clear_claims", raw)
|
||||
}
|
||||
return 286, response, true, err
|
||||
}
|
||||
|
||||
// RewardDBInfos is attached to PackInfoResponse fields 3 and 4 so reconnects
|
||||
// restore claimed reward buttons from server state.
|
||||
func (s *ClearPackages) RewardDBInfos() (pack, evil [][]byte, err error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
v, err := s.load()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
keys := make([]string, 0, len(v))
|
||||
for k := range v {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
c := v[k].Claim
|
||||
b := wire.AppendVarint(nil, 1, c.GroupID)
|
||||
b = wire.AppendVarint(b, 2, c.TicketID)
|
||||
b = wire.AppendVarint(b, 3, c.TargetID)
|
||||
b = wire.AppendVarint(b, 4, c.Level)
|
||||
if c.Kind == 0 {
|
||||
pack = append(pack, b)
|
||||
} else {
|
||||
evil = append(evil, b)
|
||||
}
|
||||
}
|
||||
return pack, evil, nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package commerce
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/player"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type clearInventory struct{ items []player.Item }
|
||||
|
||||
func (i *clearInventory) All() []player.Item { return i.items }
|
||||
func clearRequest(kind, ticket uint64) []byte {
|
||||
b := wire.AppendVarint(nil, 1, 1)
|
||||
b = wire.AppendVarint(b, 2, kind)
|
||||
row := wire.AppendVarint(nil, 1, 10)
|
||||
row = wire.AppendVarint(row, 2, ticket)
|
||||
row = wire.AppendVarint(row, 3, 2)
|
||||
row = wire.AppendVarint(row, 4, 0)
|
||||
return wire.AppendBytes(b, int(kind)+3, row)
|
||||
}
|
||||
func TestClearPackageRequiresServerProgressAndPremiumEntitlement(t *testing.T) {
|
||||
store := stateio.NewMemory()
|
||||
eco := &purchaseEconomy{}
|
||||
items := &clearInventory{}
|
||||
design := &gamedata.ClearPackageCatalog{Rewards: []gamedata.ClearPackageRewardDesign{{Kind: 0, GroupID: 10, TicketID: 12, TargetID: 2, RandomBoxID: 100, Type: 0}, {Kind: 0, GroupID: 10, TicketID: 77, TargetID: 2, RandomBoxID: 101, Type: 1}, {Kind: 1, GroupID: 10, TicketID: 12, TargetID: 2, RandomBoxID: 102, Type: 0}}}
|
||||
s, err := NewClearPackages(store, design, eco, items)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err = s.Handle("/ClearPackageReward", clearRequest(0, 12)); err == nil || eco.calls != 0 {
|
||||
t.Fatal("client spoofed clear progress")
|
||||
}
|
||||
s.AttachProgress(func(pack, level uint64) bool { return pack == 2 && level == 0 }, nil)
|
||||
if _, _, _, err = s.Handle("/ClearPackageReward", clearRequest(0, 77)); err == nil {
|
||||
t.Fatal("unpaid premium claim accepted")
|
||||
}
|
||||
code, _, ok, err := s.Handle("/ClearPackageReward", clearRequest(0, 12))
|
||||
if err != nil || !ok || code != 286 || eco.rewards[0].ID != 100 {
|
||||
t.Fatal(code, err)
|
||||
}
|
||||
items.items = []player.Item{{Type: 19, ID: 77, Count: 1}}
|
||||
if _, _, _, err = s.Handle("/ClearPackageReward", clearRequest(0, 77)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reloaded, err := NewClearPackages(store, design, eco, items)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err = reloaded.Handle("/ClearPackageReward", clearRequest(0, 77)); err != nil || eco.calls != 2 {
|
||||
t.Fatal("restart duplicated grant", err, eco.calls)
|
||||
}
|
||||
p, e, err := reloaded.RewardDBInfos()
|
||||
if err != nil || len(p) != 2 || len(e) != 0 {
|
||||
t.Fatal(p, e, err)
|
||||
}
|
||||
if _, _, _, err = reloaded.Handle("/ClearPackageReward", clearRequest(1, 12)); err == nil {
|
||||
t.Fatal("unimplemented evil progress granted reward")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package commerce
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/player"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type grantedResolver interface {
|
||||
ResolveGranted([]gamedata.BattleReward) ([]gamedata.BattleReward, error)
|
||||
}
|
||||
type entitlementReceipt struct {
|
||||
Definition []byte `json:"definition"`
|
||||
Bundle []byte `json:"bundle"`
|
||||
}
|
||||
type cashSubscription struct {
|
||||
Start, Expiry int64
|
||||
Claimed uint64
|
||||
LastDay string
|
||||
}
|
||||
type entitlementState struct {
|
||||
Receipts map[string]entitlementReceipt `json:"receipts"`
|
||||
Subscriptions map[string]cashSubscription `json:"subscriptions"`
|
||||
}
|
||||
type EntitlementEconomy struct {
|
||||
mu sync.Mutex
|
||||
store stateio.Store
|
||||
base Economy
|
||||
graph grantedResolver
|
||||
items *player.Inventory
|
||||
design *gamedata.CashEntitlementDesign
|
||||
now func() time.Time
|
||||
resetSeconds int64
|
||||
}
|
||||
|
||||
func NewEntitlementEconomy(store stateio.Store, base Economy, graph grantedResolver, items *player.Inventory, design *gamedata.CashEntitlementDesign) (*EntitlementEconomy, error) {
|
||||
if store == nil || base == nil || graph == nil || items == nil || design == nil {
|
||||
return nil, fmt.Errorf("commerce: missing entitlement dependency")
|
||||
}
|
||||
e := &EntitlementEconomy{store: store, base: base, graph: graph, items: items, design: design, now: time.Now}
|
||||
_, err := e.load()
|
||||
return e, err
|
||||
}
|
||||
func (e *EntitlementEconomy) SetClock(now func() time.Time, resetSeconds int64) {
|
||||
e.now = now
|
||||
e.resetSeconds = resetSeconds
|
||||
}
|
||||
func (e *EntitlementEconomy) day() string {
|
||||
return e.now().UTC().Add(-time.Duration(e.resetSeconds) * time.Second).Format("2006-01-02")
|
||||
}
|
||||
func (e *EntitlementEconomy) load() (entitlementState, error) {
|
||||
s := entitlementState{Receipts: map[string]entitlementReceipt{}, Subscriptions: map[string]cashSubscription{}}
|
||||
raw, err := e.store.Load("commerce_entitlements")
|
||||
if err != nil || raw == nil {
|
||||
return s, err
|
||||
}
|
||||
if err = stateio.RequireExactJSONObject(raw, "receipts", "subscriptions"); err != nil {
|
||||
return s, err
|
||||
}
|
||||
if err = json.Unmarshal(raw, &s); err != nil {
|
||||
return s, err
|
||||
}
|
||||
if s.Receipts == nil || s.Subscriptions == nil {
|
||||
return s, fmt.Errorf("commerce: malformed entitlement state")
|
||||
}
|
||||
for id, r := range s.Receipts {
|
||||
if id == "" || len(r.Definition) != sha256.Size {
|
||||
return s, fmt.Errorf("commerce: malformed entitlement receipt")
|
||||
}
|
||||
}
|
||||
for id, sub := range s.Subscriptions {
|
||||
ticket, err := strconv.ParseUint(id, 10, 64)
|
||||
if err != nil || len(e.design.Attendance[ticket]) == 0 || sub.Start <= 0 || sub.Expiry != 0 && sub.Expiry < sub.Start || sub.Claimed == 0 || sub.LastDay == "" {
|
||||
return s, fmt.Errorf("commerce: malformed subscription")
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
func (e *EntitlementEconomy) save(s entitlementState) error {
|
||||
b, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.store.Save("commerce_entitlements", b)
|
||||
}
|
||||
func (e *EntitlementEconomy) Apply(identity string, costs, rewards []gamedata.Reward) ([]byte, error) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.apply(identity, costs, rewards)
|
||||
}
|
||||
func (e *EntitlementEconomy) apply(identity string, costs, rewards []gamedata.Reward) ([]byte, error) {
|
||||
if identity == "" {
|
||||
return nil, fmt.Errorf("commerce: missing entitlement identity")
|
||||
}
|
||||
definition, _ := json.Marshal(struct{ Costs, Rewards []gamedata.Reward }{costs, rewards})
|
||||
digest := sha256.Sum256(definition)
|
||||
s, err := e.load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r, ok := s.Receipts[identity]; ok {
|
||||
if !bytes.Equal(r.Definition, digest[:]) {
|
||||
return nil, fmt.Errorf("commerce: entitlement identity reused")
|
||||
}
|
||||
return append([]byte(nil), r.Bundle...), nil
|
||||
}
|
||||
input := make([]gamedata.BattleReward, len(rewards))
|
||||
for i, r := range rewards {
|
||||
input[i] = gamedata.BattleReward{Type: r.Type, ID: r.ID, Count: r.Count}
|
||||
}
|
||||
leaves, err := e.graph.ResolveGranted(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Some product boxes include the first attendance reward (draw-ticket
|
||||
// subscriptions); others include only the upfront paid currency. Add only
|
||||
// the missing first-day components on activation, then mark row one claimed.
|
||||
initial := append([]gamedata.BattleReward(nil), leaves...)
|
||||
for _, r := range initial {
|
||||
rows := e.design.Attendance[r.ID]
|
||||
if r.Type != 19 || len(rows) == 0 {
|
||||
continue
|
||||
}
|
||||
sub := s.Subscriptions[strconv.FormatUint(r.ID, 10)]
|
||||
if sub.Start != 0 && (sub.Expiry == 0 || sub.Expiry > e.now().UnixMilli()) {
|
||||
continue
|
||||
}
|
||||
first, resolveErr := e.graph.ResolveGranted([]gamedata.BattleReward{rows[0].Reward})
|
||||
if resolveErr != nil {
|
||||
return nil, resolveErr
|
||||
}
|
||||
available := map[[2]uint64]uint64{}
|
||||
for _, leaf := range leaves {
|
||||
k := [2]uint64{leaf.Type, leaf.ID}
|
||||
available[k] += leaf.Count
|
||||
}
|
||||
for _, leaf := range first {
|
||||
k := [2]uint64{leaf.Type, leaf.ID}
|
||||
if available[k] < leaf.Count {
|
||||
leaf.Count -= available[k]
|
||||
leaves = append(leaves, leaf)
|
||||
}
|
||||
}
|
||||
}
|
||||
var regular []gamedata.Reward
|
||||
var special []player.Item
|
||||
for _, r := range leaves {
|
||||
if r.Count == 0 || r.Count > math.MaxInt32 {
|
||||
return nil, fmt.Errorf("commerce: invalid entitlement quantity")
|
||||
}
|
||||
switch {
|
||||
case r.Type == 62:
|
||||
if !e.design.AvatarSets[r.ID] {
|
||||
return nil, fmt.Errorf("commerce: unknown avatar set %d", r.ID)
|
||||
}
|
||||
special = append(special, player.Item{Type: r.Type, ID: r.ID, Count: r.Count, TimeValue: uint64(e.now().UnixMilli())})
|
||||
case r.Type == 19 && e.design.TicketTypes[r.ID] == 2:
|
||||
if len(e.design.Attendance[r.ID]) == 0 {
|
||||
return nil, fmt.Errorf("commerce: subscription reward schedule missing")
|
||||
}
|
||||
now := e.now().UnixMilli()
|
||||
expiry := int64(e.items.ContentTicketExpiry(r.ID))
|
||||
if expiry < now {
|
||||
expiry = now
|
||||
}
|
||||
if r.Count > uint64((math.MaxInt64-expiry)/(30*86400000)) {
|
||||
return nil, fmt.Errorf("commerce: subscription expiry overflow")
|
||||
}
|
||||
expiry += int64(r.Count) * 30 * 86400000
|
||||
special = append(special, player.Item{Type: 19, ID: r.ID, Count: r.Count, ExpiryTime: uint64(expiry), TimeValue: uint64(e.now().UnixMilli())})
|
||||
key := strconv.FormatUint(r.ID, 10)
|
||||
sub := s.Subscriptions[key]
|
||||
if sub.Start == 0 || sub.Expiry <= now {
|
||||
sub = cashSubscription{Start: now, Claimed: 1, LastDay: e.day()}
|
||||
}
|
||||
sub.Expiry = expiry
|
||||
s.Subscriptions[key] = sub
|
||||
default:
|
||||
regular = append(regular, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count})
|
||||
if r.Type == 19 && e.design.TicketTypes[r.ID] == 3 && len(e.design.Attendance[r.ID]) > 0 {
|
||||
key := strconv.FormatUint(r.ID, 10)
|
||||
if _, exists := s.Subscriptions[key]; !exists {
|
||||
s.Subscriptions[key] = cashSubscription{Start: e.now().UnixMilli(), Claimed: 1, LastDay: e.day()}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
bundle, err := e.base.Apply(identity+":base", costs, regular)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := e.items.GrantCommerceOnce(identity+":special", special)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, i := range items {
|
||||
bundle = wire.AppendBytes(bundle, 1, player.ItemWire(i))
|
||||
bundle = wire.AppendBytes(bundle, 6, player.ItemWire(player.Item{ID: i.ID, Type: i.Type, Count: i.Count}))
|
||||
}
|
||||
s.Receipts[identity] = entitlementReceipt{Definition: append([]byte(nil), digest[:]...), Bundle: append([]byte(nil), bundle...)}
|
||||
if err = e.save(s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bundle, nil
|
||||
}
|
||||
|
||||
// ClaimSubscriptions grants the next row once per server reset day. Missing
|
||||
// login days are not retroactively claimed. First-row purchase rewards are
|
||||
// already present in the product box, matching the client's first-row marker.
|
||||
func (e *EntitlementEconomy) ClaimSubscriptions(identity string) ([]byte, error) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if identity == "" {
|
||||
return nil, fmt.Errorf("commerce: missing attendance receipt identity")
|
||||
}
|
||||
s, err := e.load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
receiptKey := "attendance-reply:" + identity
|
||||
if receipt, ok := s.Receipts[receiptKey]; ok {
|
||||
return append([]byte(nil), receipt.Bundle...), nil
|
||||
}
|
||||
keys := make([]string, 0, len(s.Subscriptions))
|
||||
for k := range s.Subscriptions {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var bundle []byte
|
||||
for _, key := range keys {
|
||||
sub := s.Subscriptions[key]
|
||||
ticket, _ := strconv.ParseUint(key, 10, 64)
|
||||
rows := e.design.Attendance[ticket]
|
||||
if sub.LastDay == e.day() || sub.Expiry != 0 && sub.Expiry <= e.now().UnixMilli() || sub.Expiry == 0 && sub.Claimed >= uint64(len(rows)) {
|
||||
continue
|
||||
}
|
||||
index := sub.Claimed % uint64(len(rows))
|
||||
r := rows[index]
|
||||
grant, err := e.apply(fmt.Sprintf("commerce:attendance:%s:%s", key, e.day()), nil, []gamedata.Reward{{Type: r.Reward.Type, ID: r.Reward.ID, Count: r.Reward.Count}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bundle = append(bundle, grant...)
|
||||
// apply persists its receipt; reload before saving progress so it survives.
|
||||
latest, err := e.load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sub.Claimed++
|
||||
sub.LastDay = e.day()
|
||||
latest.Subscriptions[key] = sub
|
||||
if err = e.save(latest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s = latest
|
||||
}
|
||||
latest, err := e.load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
digest := sha256.Sum256([]byte(receiptKey))
|
||||
latest.Receipts[receiptKey] = entitlementReceipt{Definition: append([]byte(nil), digest[:]...), Bundle: append([]byte(nil), bundle...)}
|
||||
if err = e.save(latest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bundle, nil
|
||||
}
|
||||
func (e *EntitlementEconomy) MergeAttendance(response []byte) ([]byte, error) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
s, err := e.load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := append([]byte(nil), response...)
|
||||
keys := make([]string, 0, len(s.Subscriptions))
|
||||
for k := range s.Subscriptions {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
sub := s.Subscriptions[key]
|
||||
ticket, _ := strconv.ParseUint(key, 10, 64)
|
||||
if sub.Expiry != 0 {
|
||||
b := wire.AppendVarint(nil, 1, ticket)
|
||||
b = wire.AppendVarint(b, 2, uint64(sub.Start))
|
||||
b = wire.AppendVarint(b, 3, uint64(sub.Expiry))
|
||||
out = wire.AppendBytes(out, 3, b)
|
||||
}
|
||||
if typ := e.design.AttendanceTypes[ticket]; typ != 0 {
|
||||
rewards := []byte{}
|
||||
n := sub.Claimed
|
||||
if n > uint64(len(e.design.Attendance[ticket])) {
|
||||
n = uint64(len(e.design.Attendance[ticket]))
|
||||
}
|
||||
for i := uint64(1); i <= n; i++ {
|
||||
rewards = wire.AppendVarint(rewards, 1, i)
|
||||
}
|
||||
entry := wire.AppendVarint(nil, 1, typ)
|
||||
entry = wire.AppendBytes(entry, 2, rewards)
|
||||
out = wire.AppendBytes(out, 7, entry)
|
||||
} else {
|
||||
for i := uint64(1); i <= sub.Claimed; i++ {
|
||||
b := wire.AppendVarint(nil, 1, ticket)
|
||||
b = wire.AppendVarint(b, 2, i)
|
||||
b = wire.AppendVarint(b, 3, 1)
|
||||
out = wire.AppendBytes(out, 4, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package commerce
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/player"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type entitlementGraph struct{ Calls int }
|
||||
|
||||
func (g *entitlementGraph) ResolveGranted(r []gamedata.BattleReward) ([]gamedata.BattleReward, error) {
|
||||
g.Calls++
|
||||
return r, nil
|
||||
}
|
||||
|
||||
type entitlementBase struct {
|
||||
Calls int
|
||||
Rewards []gamedata.Reward
|
||||
}
|
||||
|
||||
func (b *entitlementBase) Apply(_ string, _ []gamedata.Reward, r []gamedata.Reward) ([]byte, error) {
|
||||
b.Calls++
|
||||
b.Rewards = append(b.Rewards, r...)
|
||||
return wire.AppendVarint(nil, 7, 1), nil
|
||||
}
|
||||
func entitlementFixture(t *testing.T) (*EntitlementEconomy, *player.Inventory, *entitlementBase, *entitlementGraph, *time.Time) {
|
||||
t.Helper()
|
||||
store := stateio.NewMemory()
|
||||
items, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := &gamedata.CashEntitlementDesign{AvatarSets: map[uint64]bool{10: true}, TicketTypes: map[uint64]uint64{38: 2, 99: 3}, Attendance: map[uint64][]gamedata.CashAttendanceReward{38: {{ID: 1, Reward: gamedata.BattleReward{Type: 3, Count: 1}}, {ID: 2, Reward: gamedata.BattleReward{Type: 3, Count: 5}}}, 99: {{ID: 1, Reward: gamedata.BattleReward{Type: 3, Count: 1}}, {ID: 2, Reward: gamedata.BattleReward{Type: 3, Count: 7}}}}, AttendanceTypes: map[uint64]uint64{38: 1}}
|
||||
base := &entitlementBase{}
|
||||
graph := &entitlementGraph{}
|
||||
e, err := NewEntitlementEconomy(store, base, graph, items, d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
|
||||
e.SetClock(func() time.Time { return now }, 0)
|
||||
return e, items, base, graph, &now
|
||||
}
|
||||
func TestEntitlementTypedSkinExpiryRetryAndRenewal(t *testing.T) {
|
||||
e, items, base, graph, now := entitlementFixture(t)
|
||||
rewards := []gamedata.Reward{{Type: 62, ID: 10, Count: 1}, {Type: 19, ID: 38, Count: 1}, {Type: 45, ID: 999, Count: 1}}
|
||||
first, err := e.Apply("buy1", nil, rewards)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if items.ContentTicketExpiry(38) != uint64(now.UnixMilli()+30*86400000) || len(base.Rewards) != 2 || base.Rewards[0].Type != 45 {
|
||||
t.Fatal("expiry or prestige dispatch invalid")
|
||||
}
|
||||
again, err := e.Apply("buy1", nil, rewards)
|
||||
if err != nil || !bytes.Equal(first, again) || graph.Calls != 2 || base.Calls != 1 {
|
||||
t.Fatal("retry rerolled or double granted")
|
||||
}
|
||||
if _, err = e.Apply("buy1", nil, nil); err == nil {
|
||||
t.Fatal("identity reuse accepted")
|
||||
}
|
||||
if _, err = e.Apply("invalid", nil, []gamedata.Reward{{Type: 62, ID: 888, Count: 1}}); err == nil || base.Calls != 1 {
|
||||
t.Fatal("unknown skin partially applied")
|
||||
}
|
||||
if _, err = e.Apply("buy2", nil, []gamedata.Reward{{Type: 19, ID: 38, Count: 1}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if items.ContentTicketExpiry(38) != uint64(now.UnixMilli()+60*86400000) {
|
||||
t.Fatal("renewal lost prior period")
|
||||
}
|
||||
merged, err := e.MergeAttendance(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok, _ := wire.Bytes(merged, 3); !ok {
|
||||
t.Fatal("subscription info missing")
|
||||
}
|
||||
if _, ok, _ := wire.Bytes(merged, 7); !ok {
|
||||
t.Fatal("monthly reward map missing")
|
||||
}
|
||||
}
|
||||
func TestSubscriptionDailyClaimResetExpiryAndPermanentCompletion(t *testing.T) {
|
||||
e, _, base, _, now := entitlementFixture(t)
|
||||
_, err := e.Apply("buy", nil, []gamedata.Reward{{Type: 19, ID: 38, Count: 1}, {Type: 19, ID: 99, Count: 1}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
calls := base.Calls
|
||||
if _, err = e.ClaimSubscriptions("login"); err != nil || base.Calls != calls {
|
||||
t.Fatal("purchase day double claim")
|
||||
}
|
||||
*now = now.Add(24 * time.Hour)
|
||||
if _, err = e.ClaimSubscriptions("login2"); err != nil || base.Calls != calls+2 {
|
||||
t.Fatalf("daily claims %v calls%d", err, base.Calls)
|
||||
}
|
||||
calls = base.Calls
|
||||
if _, err = e.ClaimSubscriptions("retry"); err != nil || base.Calls != calls {
|
||||
t.Fatal("daily retry grants again")
|
||||
}
|
||||
*now = now.Add(31 * 24 * time.Hour)
|
||||
if _, err = e.ClaimSubscriptions("expired"); err != nil || base.Calls != calls {
|
||||
t.Fatal("expired or completed claim")
|
||||
}
|
||||
merged, err := e.MergeAttendance(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok, _ := wire.Bytes(merged, 4); !ok {
|
||||
t.Fatal("permanent attendance markers missing")
|
||||
}
|
||||
}
|
||||
func TestSubscriptionFirstRewardAndLostReply(t *testing.T) {
|
||||
e, _, base, _, now := entitlementFixture(t)
|
||||
_, err := e.Apply("covered", nil, []gamedata.Reward{{Type: 19, ID: 38, Count: 1}, {Type: 3, Count: 1}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(base.Rewards) != 1 || base.Rewards[0].Count != 1 {
|
||||
t.Fatalf("first-day reward duplicated: %+v", base.Rewards)
|
||||
}
|
||||
*now = now.Add(24 * time.Hour)
|
||||
first, err := e.ClaimSubscriptions("same-session-request")
|
||||
if err != nil || len(first) == 0 {
|
||||
t.Fatalf("claim %x %v", first, err)
|
||||
}
|
||||
calls := base.Calls
|
||||
replay, err := e.ClaimSubscriptions("same-session-request")
|
||||
if err != nil || !bytes.Equal(first, replay) || base.Calls != calls {
|
||||
t.Fatal("lost reply not replayed exactly")
|
||||
}
|
||||
fresh, err := e.ClaimSubscriptions("new-request-same-day")
|
||||
if err != nil || len(fresh) != 0 || base.Calls != calls {
|
||||
t.Fatal("new same-day request repeated award")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package commerce
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/gameconfig"
|
||||
"bd2server/internal/server/gamedata"
|
||||
)
|
||||
|
||||
// Opt in against the installed release; synthetic tests also run without it.
|
||||
func TestInstalledCashCatalogAndRewardPrograms(t *testing.T) {
|
||||
root := os.Getenv("BD2_REAL_GAMEDATA")
|
||||
if root == "" {
|
||||
t.Skip("BD2_REAL_GAMEDATA not set")
|
||||
}
|
||||
design, err := gamedata.LoadCashCatalog(root, "20260923193640")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog, err := NewCatalog("2.35.10", design, gameconfig.Default().Purchases)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rewards, err := gamedata.LoadCashRewardResolver(root, "20260923193640")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entitlements, err := gamedata.LoadCashEntitlementDesign(root, "20260923193640")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
counts, recharge := 0, 0
|
||||
for _, product := range design.Products {
|
||||
if product.PriceType != 1 {
|
||||
continue
|
||||
}
|
||||
counts++
|
||||
if product.Recharge {
|
||||
recharge++
|
||||
}
|
||||
quote, err := catalog.Quote(product.Key, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("quote %+v: %v", product.Key, err)
|
||||
}
|
||||
if product.Recharge && (quote.ItemType != 0 || quote.Amount != 0) {
|
||||
t.Fatalf("default recharge isn't free: %+v", quote)
|
||||
}
|
||||
if !product.Recharge && (quote.ItemType != 2 || quote.Amount == 0) {
|
||||
t.Fatalf("cash goods aren't paid diamond: %+v", quote)
|
||||
}
|
||||
// Infinite preview products use the gacha delegate, whose selected
|
||||
// costumes replace weighted/empty placeholder reward programs.
|
||||
if product.Key.GroupID == 1100001 {
|
||||
continue
|
||||
}
|
||||
leaves, err := rewards.ResolveGranted([]gamedata.BattleReward{{Type: 9, ID: product.RandomBoxID, Count: 1}})
|
||||
if err != nil || len(leaves) == 0 {
|
||||
t.Fatalf("product %+v: no reward program: %v", product.Key, err)
|
||||
}
|
||||
for _, leaf := range leaves {
|
||||
if leaf.Type == 19 && entitlements.TicketTypes[leaf.ID] == 2 && len(entitlements.Attendance[leaf.ID]) == 0 {
|
||||
t.Fatalf("monthly reward schedule missing: %d", leaf.ID)
|
||||
}
|
||||
if leaf.Type == 62 && !entitlements.AvatarSets[leaf.ID] {
|
||||
t.Fatalf("avatar set missing: %d", leaf.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if counts != 614 || recharge != 14 {
|
||||
t.Fatalf("unexpected cash coverage: goods=%d recharge=%d", counts, recharge)
|
||||
}
|
||||
for _, key := range []gamedata.CashProductKey{{GroupID: 1100001, ProductID: 9100033}, {GroupID: 310001, ProductID: 507}, {GroupID: 310001, ProductID: 518}} {
|
||||
quote, err := catalog.Quote(key, 1)
|
||||
if err != nil || quote.Amount != 1000 || quote.ItemType != 2 {
|
||||
t.Fatalf("sample %+v: %+v %v", key, quote, err)
|
||||
}
|
||||
}
|
||||
t.Logf("Verified all %d cash products, %d recharge variants and %d platform aliases", counts, recharge, len(catalog.Manifest().Products))
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package commerce
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type loginPassProgress struct {
|
||||
Free, Premium uint64
|
||||
LastDay string
|
||||
}
|
||||
type loginPassReceipt struct {
|
||||
Bundle []byte
|
||||
Rewarded map[uint64]bool
|
||||
}
|
||||
type loginPassState struct {
|
||||
Progress map[uint64]loginPassProgress
|
||||
Receipts map[string]loginPassReceipt
|
||||
}
|
||||
type LoginPasses struct {
|
||||
mu sync.Mutex
|
||||
store stateio.Store
|
||||
design *gamedata.LoginPassCatalog
|
||||
economy Economy
|
||||
items ClearPackageInventory
|
||||
available func(uint64) bool
|
||||
now func() time.Time
|
||||
resetSeconds int64
|
||||
}
|
||||
|
||||
func NewLoginPasses(store stateio.Store, design *gamedata.LoginPassCatalog, economy Economy, items ClearPackageInventory, available func(uint64) bool) (*LoginPasses, error) {
|
||||
if store == nil || design == nil || economy == nil || items == nil || available == nil {
|
||||
return nil, fmt.Errorf("commerce: invalid login-pass dependencies")
|
||||
}
|
||||
s := &LoginPasses{store: store, design: design, economy: economy, items: items, available: available, now: time.Now}
|
||||
_, err := s.load()
|
||||
return s, err
|
||||
}
|
||||
func (s *LoginPasses) SetClock(now func() time.Time, resetSeconds int64) {
|
||||
s.now = now
|
||||
s.resetSeconds = resetSeconds
|
||||
}
|
||||
func (s *LoginPasses) load() (loginPassState, error) {
|
||||
v := loginPassState{Progress: map[uint64]loginPassProgress{}, Receipts: map[string]loginPassReceipt{}}
|
||||
raw, err := s.store.Load("commerce_login_passes")
|
||||
if err != nil || raw == nil {
|
||||
return v, err
|
||||
}
|
||||
if err = json.Unmarshal(raw, &v); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if v.Progress == nil || v.Receipts == nil {
|
||||
return v, fmt.Errorf("commerce: invalid login-pass state")
|
||||
}
|
||||
for group, p := range v.Progress {
|
||||
rows, ok := s.design.Groups[group]
|
||||
if !ok || p.Free > uint64(len(rows)) || p.Premium > p.Free || p.Free > 0 && p.LastDay == "" {
|
||||
return v, fmt.Errorf("commerce: invalid login-pass progress")
|
||||
}
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
func (s *LoginPasses) paid(ticket uint64) bool {
|
||||
for _, i := range s.items.All() {
|
||||
if i.Type == 19 && i.ID == ticket && i.Count > 0 && (i.ExpiryTime == 0 || i.ExpiryTime > uint64(s.now().UnixMilli())) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ClaimAndInfo is called by AttendanceHandler inside the account transaction.
|
||||
// Free progression advances once per observed reset day, and buying premium
|
||||
// catches up the already earned rows without advancing the login day count.
|
||||
func (s *LoginPasses) ClaimAndInfo(identity string) ([]byte, [][]byte, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if identity == "" {
|
||||
return nil, nil, fmt.Errorf("commerce: missing login-pass identity")
|
||||
}
|
||||
v, err := s.load()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
keys := make([]uint64, 0, len(s.design.Groups))
|
||||
for group := range s.design.Groups {
|
||||
keys = append(keys, group)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
|
||||
r, seen := v.Receipts[identity]
|
||||
if !seen {
|
||||
r = loginPassReceipt{Rewarded: map[uint64]bool{}}
|
||||
var rewards []gamedata.Reward
|
||||
day := s.now().UTC().Add(-time.Duration(s.resetSeconds) * time.Second).Format("2006-01-02")
|
||||
for _, group := range keys {
|
||||
if !s.available(group) {
|
||||
continue
|
||||
}
|
||||
rows := s.design.Groups[group]
|
||||
if len(rows) == 0 {
|
||||
continue
|
||||
}
|
||||
p := v.Progress[group]
|
||||
if p.LastDay != day && p.Free < uint64(len(rows)) {
|
||||
rewards = append(rewards, rows[p.Free].Free)
|
||||
p.Free++
|
||||
p.LastDay = day
|
||||
r.Rewarded[group] = true
|
||||
}
|
||||
if s.paid(rows[0].TicketID) {
|
||||
for p.Premium < p.Free {
|
||||
rewards = append(rewards, rows[p.Premium].Premium)
|
||||
p.Premium++
|
||||
r.Rewarded[group] = true
|
||||
}
|
||||
}
|
||||
v.Progress[group] = p
|
||||
}
|
||||
if len(rewards) > 0 {
|
||||
r.Bundle, err = s.economy.Apply("login-pass:"+identity, nil, rewards)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
v.Receipts[identity] = r
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err = s.store.Save("commerce_login_passes", raw); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
var infos [][]byte
|
||||
for _, group := range keys {
|
||||
if !s.available(group) {
|
||||
continue
|
||||
}
|
||||
p := v.Progress[group]
|
||||
if p.Free == 0 {
|
||||
continue
|
||||
}
|
||||
b := wire.AppendVarint(nil, 1, group)
|
||||
b = wire.AppendVarint(b, 2, p.Free)
|
||||
if r.Rewarded[group] {
|
||||
b = wire.AppendVarint(b, 3, 1)
|
||||
}
|
||||
infos = append(infos, b)
|
||||
}
|
||||
return append([]byte(nil), r.Bundle...), infos, nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package commerce
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/player"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type loginPassEconomy struct {
|
||||
calls int
|
||||
rewards [][]gamedata.Reward
|
||||
}
|
||||
|
||||
func (e *loginPassEconomy) Apply(_ string, _ []gamedata.Reward, r []gamedata.Reward) ([]byte, error) {
|
||||
e.calls++
|
||||
e.rewards = append(e.rewards, append([]gamedata.Reward(nil), r...))
|
||||
return []byte{8, 1}, nil
|
||||
}
|
||||
func TestLoginPassFreeDaysPaidCatchupAndRestart(t *testing.T) {
|
||||
d := &gamedata.LoginPassCatalog{Groups: map[uint64][]gamedata.LoginPassReward{10: {{ID: 1, TicketID: 77, Free: gamedata.Reward{Type: 9, ID: 100, Count: 1}, Premium: gamedata.Reward{Type: 9, ID: 200, Count: 1}}, {ID: 2, TicketID: 77, Free: gamedata.Reward{Type: 9, ID: 101, Count: 1}, Premium: gamedata.Reward{Type: 9, ID: 201, Count: 1}}}, 20: {{ID: 1, TicketID: 88, Free: gamedata.Reward{Type: 9, ID: 300, Count: 1}, Premium: gamedata.Reward{Type: 9, ID: 400, Count: 1}}}}}
|
||||
store := stateio.NewMemory()
|
||||
eco := &loginPassEconomy{}
|
||||
items := &clearInventory{}
|
||||
available := func(group uint64) bool { return group == 10 }
|
||||
s, err := NewLoginPasses(store, d, eco, items, available)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC)
|
||||
s.SetClock(func() time.Time { return now }, 0)
|
||||
bundle, infos, err := s.ClaimAndInfo("attendance1")
|
||||
if err != nil || eco.calls != 1 || len(eco.rewards[0]) != 1 || eco.rewards[0][0].ID != 100 || len(infos) != 1 {
|
||||
t.Fatal(err, eco.rewards, infos)
|
||||
}
|
||||
replay, _, err := s.ClaimAndInfo("attendance1")
|
||||
if err != nil || !bytes.Equal(bundle, replay) || eco.calls != 1 {
|
||||
t.Fatal("duplicate login grant", err)
|
||||
}
|
||||
_, infos, err = s.ClaimAndInfo("attendance2")
|
||||
rewarded, _, _ := wire.Varint(infos[0], 3)
|
||||
if err != nil || eco.calls != 1 || rewarded != 0 {
|
||||
t.Fatal("same-day advanced")
|
||||
}
|
||||
items.items = []player.Item{{Type: 19, ID: 77, Count: 1}}
|
||||
_, _, err = s.ClaimAndInfo("after-buy")
|
||||
if err != nil || eco.calls != 2 || len(eco.rewards[1]) != 1 || eco.rewards[1][0].ID != 200 {
|
||||
t.Fatal("premium catchup missing", err, eco.rewards)
|
||||
}
|
||||
now = now.Add(48 * time.Hour)
|
||||
_, infos, err = s.ClaimAndInfo("next-login")
|
||||
day, _, _ := wire.Varint(infos[0], 2)
|
||||
if err != nil || day != 2 || eco.calls != 3 || len(eco.rewards[2]) != 2 {
|
||||
t.Fatal("missed day incorrectly advanced", err, day)
|
||||
}
|
||||
s, err = NewLoginPasses(store, d, eco, items, available)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.SetClock(func() time.Time { return now }, 0)
|
||||
_, _, err = s.ClaimAndInfo("restart")
|
||||
if err != nil || eco.calls != 3 {
|
||||
t.Fatal("restart advanced complete pass", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package commerce
|
||||
|
||||
import "bd2server/internal/server/wire"
|
||||
|
||||
type PackInfoHandler struct {
|
||||
World attendanceHandler
|
||||
Claims *ClearPackages
|
||||
}
|
||||
|
||||
func (h PackInfoHandler) Handle(path string, request []byte) (int, []byte, bool, error) {
|
||||
if path != "/PackInfo" {
|
||||
return 0, nil, false, nil
|
||||
}
|
||||
code, response, handled, err := h.World.Handle(path, request)
|
||||
if err != nil || !handled {
|
||||
return code, response, handled, err
|
||||
}
|
||||
pack, evil, err := h.Claims.RewardDBInfos()
|
||||
if err != nil {
|
||||
return code, nil, true, err
|
||||
}
|
||||
var result []byte
|
||||
err = wire.Walk(response, func(field wire.Field) error {
|
||||
if field.Number != 3 && field.Number != 4 {
|
||||
result = append(result, response[field.Start:field.End]...)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return code, nil, true, err
|
||||
}
|
||||
for _, info := range pack {
|
||||
result = wire.AppendBytes(result, 3, info)
|
||||
}
|
||||
for _, info := range evil {
|
||||
result = wire.AppendBytes(result, 4, info)
|
||||
}
|
||||
return code, result, true, nil
|
||||
}
|
||||
@@ -0,0 +1,695 @@
|
||||
package commerce
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/readonly"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
// Economy and the receipt store must participate in the caller's account
|
||||
// transaction. A failed hook, delegate or save then rolls back all purchases.
|
||||
type Economy interface {
|
||||
Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error)
|
||||
}
|
||||
type CountProvider interface{ PurchaseCountDBInfos() [][]byte }
|
||||
type PurchaseDelegate func(gamedata.CashProductKey, []byte) ([]byte, bool, error)
|
||||
type PurchaseHook func(string, gamedata.CashProductDesign, uint64) error
|
||||
type purchaseReceipt struct {
|
||||
Digest string `json:"digest"`
|
||||
Response []byte `json:"response"`
|
||||
}
|
||||
type purchaseCount struct {
|
||||
Count uint64 `json:"count"`
|
||||
Period string `json:"period"`
|
||||
Lifetime uint64 `json:"lifetime"`
|
||||
}
|
||||
type purchaseState struct {
|
||||
Receipts map[string]purchaseReceipt `json:"receipts"`
|
||||
Billing map[string]string `json:"billing"`
|
||||
Counts map[string]purchaseCount `json:"counts"`
|
||||
}
|
||||
type Service struct {
|
||||
mu sync.Mutex
|
||||
catalog *Catalog
|
||||
store stateio.Store
|
||||
economy Economy
|
||||
now func() time.Time
|
||||
resetSeconds int64
|
||||
delegate PurchaseDelegate
|
||||
hook PurchaseHook
|
||||
legacy CountProvider
|
||||
shopProducts [][]byte
|
||||
shopWindows map[gamedata.CashProductKey][2]uint64
|
||||
predecessors map[gamedata.CashProductKey][]gamedata.CashProductKey
|
||||
}
|
||||
|
||||
func NewService(catalog *Catalog, store stateio.Store, economy Economy) (*Service, error) {
|
||||
if catalog == nil || store == nil || economy == nil {
|
||||
return nil, fmt.Errorf("commerce: invalid service dependencies")
|
||||
}
|
||||
s := &Service{catalog: catalog, store: store, economy: economy, now: time.Now}
|
||||
_, err := s.load()
|
||||
return s, err
|
||||
}
|
||||
func (s *Service) AttachDelegate(d PurchaseDelegate) { s.delegate = d }
|
||||
func (s *Service) AttachPurchaseHook(h PurchaseHook) { s.hook = h }
|
||||
func (s *Service) AttachLegacyCounts(p CountProvider) { s.legacy = p }
|
||||
|
||||
// AttachPackageRules enforces the client's type-2/type-8 step and relay
|
||||
// ordering using versioned contentsGroupId/contentsSortId, never SKU numbers.
|
||||
func (s *Service) AttachPackageRules(packages []gamedata.CashPackageDesign) error {
|
||||
rules := map[gamedata.CashProductKey][]gamedata.CashProductKey{}
|
||||
for _, p := range packages {
|
||||
if (p.PackageType != 2 && p.PackageType != 8) || p.ContentsGroupID == 0 || p.ContentsSortID <= 1 {
|
||||
continue
|
||||
}
|
||||
key := gamedata.CashProductKey{GroupID: p.GroupID, ProductID: p.ID, SaleGroup: p.SaleGroup}
|
||||
var prior []gamedata.CashProductKey
|
||||
for _, q := range packages {
|
||||
if q.PackageType == p.PackageType && q.ContentsGroupID == p.ContentsGroupID && q.ContentsSortID == p.ContentsSortID-1 {
|
||||
prior = append(prior, gamedata.CashProductKey{GroupID: q.GroupID, ProductID: q.ID, SaleGroup: q.SaleGroup})
|
||||
}
|
||||
}
|
||||
// Some current-version packages have a sort label above one but an
|
||||
// independent contents group. Only a real same-group predecessor is a
|
||||
// progression rule; do not invent links between adjacent product IDs.
|
||||
if len(prior) == 0 {
|
||||
continue
|
||||
}
|
||||
rules[key] = prior
|
||||
}
|
||||
s.predecessors = rules
|
||||
return nil
|
||||
}
|
||||
func (s *Service) predecessorBought(v purchaseState, key gamedata.CashProductKey) bool {
|
||||
prior := s.predecessors[key]
|
||||
if len(prior) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, p := range prior {
|
||||
if v.Counts[skuKey(p)].Lifetime > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if s.legacy != nil {
|
||||
for _, raw := range s.legacy.PurchaseCountDBInfos() {
|
||||
g, _, _ := wire.Varint(raw, 1)
|
||||
id, _, _ := wire.Varint(raw, 2)
|
||||
sale, _, _ := wire.Varint(raw, 3)
|
||||
n, _, _ := wire.Varint(raw, 4)
|
||||
if n > 0 {
|
||||
for _, p := range prior {
|
||||
if p.GroupID == g && p.ProductID == id && p.SaleGroup == sale {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AttachShopSeed retains versioned dynamic windows and event identities. It
|
||||
// must run before serving sessions; absent windows never authorize timed goods.
|
||||
func (s *Service) AttachShopSeed(seed *readonly.Seed) error {
|
||||
if seed == nil {
|
||||
return fmt.Errorf("commerce: missing shop seed")
|
||||
}
|
||||
_, raw, handled, err := seed.Handle("/CashShopInfo", wire.AppendVarint(nil, 1, 1))
|
||||
if err != nil || !handled {
|
||||
return fmt.Errorf("commerce: invalid shop seed: %w", err)
|
||||
}
|
||||
windows := map[gamedata.CashProductKey][2]uint64{}
|
||||
var products [][]byte
|
||||
err = wire.Walk(raw, func(f wire.Field) error {
|
||||
if f.Number != 1 {
|
||||
return nil
|
||||
}
|
||||
if f.Type != 2 {
|
||||
return fmt.Errorf("commerce: invalid shop product")
|
||||
}
|
||||
g, _, e := wire.Varint(f.Value, 1)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
id, _, e := wire.Varint(f.Value, 2)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
sale, _, e := wire.Varint(f.Value, 3)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
start, _, e := wire.Varint(f.Value, 4)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
end, _, e := wire.Varint(f.Value, 5)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
k := gamedata.CashProductKey{GroupID: g, ProductID: id, SaleGroup: sale}
|
||||
if _, ok := windows[k]; ok {
|
||||
return fmt.Errorf("commerce: duplicate shop schedule")
|
||||
}
|
||||
if end != 0 && start >= end {
|
||||
return fmt.Errorf("commerce: invalid shop window")
|
||||
}
|
||||
windows[k] = [2]uint64{start, end}
|
||||
products = append(products, append([]byte(nil), f.Value...))
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.shopProducts = products
|
||||
s.shopWindows = windows
|
||||
return nil
|
||||
}
|
||||
func (s *Service) available(d gamedata.CashProductDesign) bool {
|
||||
w, ok := s.shopWindows[d.Key]
|
||||
if !ok {
|
||||
return d.TimeLimitType == 0
|
||||
}
|
||||
now := uint64(s.now().UnixMilli())
|
||||
return w[0] <= now && (w[1] == 0 || now < w[1])
|
||||
}
|
||||
func (s *Service) IsAvailable(key gamedata.CashProductKey) bool {
|
||||
d, ok := s.catalog.Design(key)
|
||||
return ok && s.available(d)
|
||||
}
|
||||
func (s *Service) shopInfo() []byte {
|
||||
var response []byte
|
||||
for _, raw := range s.shopProducts {
|
||||
g, _, _ := wire.Varint(raw, 1)
|
||||
id, _, _ := wire.Varint(raw, 2)
|
||||
sale, _, _ := wire.Varint(raw, 3)
|
||||
d, ok := s.catalog.Design(gamedata.CashProductKey{GroupID: g, ProductID: id, SaleGroup: sale})
|
||||
if ok && s.available(d) {
|
||||
response = wire.AppendBytes(response, 1, raw)
|
||||
}
|
||||
}
|
||||
t := s.now().UTC().Add(-time.Duration(s.resetSeconds) * time.Second)
|
||||
day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
|
||||
daily := day.AddDate(0, 0, 1)
|
||||
days := (8 - int(day.Weekday())) % 7
|
||||
if days == 0 {
|
||||
days = 7
|
||||
}
|
||||
weekly := day.AddDate(0, 0, days)
|
||||
monthly := time.Date(t.Year(), t.Month()+1, 1, 0, 0, 0, 0, time.UTC)
|
||||
for i, reset := range []time.Time{daily, weekly, monthly} {
|
||||
response = wire.AppendVarint(response, i+2, uint64(reset.Add(time.Duration(s.resetSeconds)*time.Second).UnixMilli()))
|
||||
}
|
||||
return response
|
||||
}
|
||||
func (s *Service) SetClock(now func() time.Time, resetSeconds int64) {
|
||||
s.now = now
|
||||
s.resetSeconds = resetSeconds
|
||||
}
|
||||
func skuKey(k gamedata.CashProductKey) string {
|
||||
return fmt.Sprintf("%d:%d:%d", k.GroupID, k.ProductID, k.SaleGroup)
|
||||
}
|
||||
func (s *Service) load() (purchaseState, error) {
|
||||
v := purchaseState{Receipts: map[string]purchaseReceipt{}, Billing: map[string]string{}, Counts: map[string]purchaseCount{}}
|
||||
b, e := s.store.Load("commerce")
|
||||
if e != nil || b == nil {
|
||||
return v, e
|
||||
}
|
||||
if e = stateio.RequireExactJSONObject(b, "receipts", "billing", "counts"); e != nil {
|
||||
return v, e
|
||||
}
|
||||
if e = json.Unmarshal(b, &v); e != nil {
|
||||
return v, e
|
||||
}
|
||||
if v.Receipts == nil || v.Billing == nil || v.Counts == nil {
|
||||
return v, fmt.Errorf("commerce: invalid state")
|
||||
}
|
||||
for identity, receipt := range v.Receipts {
|
||||
digest, err := hex.DecodeString(receipt.Digest)
|
||||
if identity == "" || err != nil || len(digest) != sha256.Size || len(receipt.Response) == 0 {
|
||||
return v, fmt.Errorf("commerce: invalid saved receipt")
|
||||
}
|
||||
}
|
||||
for _, identity := range v.Billing {
|
||||
if _, ok := v.Receipts[identity]; !ok {
|
||||
return v, fmt.Errorf("commerce: billing identity has no purchase receipt")
|
||||
}
|
||||
}
|
||||
for key, count := range v.Counts {
|
||||
if key == "" || count.Period == "" || count.Count == 0 || count.Count > math.MaxInt32 || count.Lifetime < count.Count {
|
||||
return v, fmt.Errorf("commerce: invalid saved purchase count")
|
||||
}
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
func (s *Service) period(typ uint64) string {
|
||||
t := s.now().UTC().Add(-time.Duration(s.resetSeconds) * time.Second)
|
||||
switch typ {
|
||||
case 1:
|
||||
return t.Format("2006-01-02")
|
||||
case 2:
|
||||
y, w := t.ISOWeek()
|
||||
return fmt.Sprintf("%d-W%02d", y, w)
|
||||
case 3:
|
||||
return t.Format("2006-01")
|
||||
default:
|
||||
return "account"
|
||||
}
|
||||
}
|
||||
func (s *Service) count(v purchaseState, d gamedata.CashProductDesign) uint64 {
|
||||
c := v.Counts[skuKey(d.Key)]
|
||||
if c.Period != s.period(d.PurchaseLimitType) {
|
||||
return 0
|
||||
}
|
||||
return c.Count
|
||||
}
|
||||
func (s *Service) HandleSession(path string, request []byte, session string) (int, []byte, bool, error) {
|
||||
switch path {
|
||||
case "/CashShopInfo":
|
||||
if s.shopWindows == nil {
|
||||
return 0, nil, false, nil
|
||||
}
|
||||
if seq, ok, err := wire.Varint(request, 1); err != nil || !ok || seq == 0 || seq > math.MaxInt32 {
|
||||
return 60, nil, true, fmt.Errorf("commerce: invalid shop sequence")
|
||||
}
|
||||
return 60, s.shopInfo(), true, nil
|
||||
case "/CashShopPurchaseCountInfo":
|
||||
if seq, ok, err := wire.Varint(request, 1); err != nil || !ok || seq == 0 || seq > math.MaxInt32 {
|
||||
return 432, nil, true, fmt.Errorf("commerce: invalid count sequence")
|
||||
}
|
||||
infos, err := s.purchaseCountDBInfos()
|
||||
if err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
var b []byte
|
||||
for _, i := range infos {
|
||||
b = wire.AppendBytes(b, 1, i)
|
||||
}
|
||||
return 432, b, true, nil
|
||||
case "/CashShopBuy":
|
||||
return s.buy(session, request)
|
||||
default:
|
||||
return 0, nil, false, nil
|
||||
}
|
||||
}
|
||||
func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) {
|
||||
return s.HandleSession(path, request, "")
|
||||
}
|
||||
|
||||
type buyLine struct {
|
||||
Key gamedata.CashProductKey
|
||||
Count uint64
|
||||
}
|
||||
|
||||
func parseBuy(request []byte) (uint64, []buyLine, []string, error) {
|
||||
seq, found, e := wire.Varint(request, 1)
|
||||
if e != nil || !found || seq == 0 || seq > math.MaxInt32 {
|
||||
return 0, nil, nil, fmt.Errorf("commerce: invalid sequence")
|
||||
}
|
||||
group, found, e := wire.Varint(request, 3)
|
||||
if e != nil || !found || group == 0 {
|
||||
return 0, nil, nil, fmt.Errorf("commerce: invalid product group")
|
||||
}
|
||||
var lines []buyLine
|
||||
var billing []string
|
||||
seen := map[gamedata.CashProductKey]bool{}
|
||||
e = wire.Walk(request, func(f wire.Field) error {
|
||||
switch f.Number {
|
||||
case 4:
|
||||
if f.Type != 2 {
|
||||
return fmt.Errorf("commerce: invalid buy info")
|
||||
}
|
||||
id, ok, err := wire.Varint(f.Value, 1)
|
||||
if err != nil || !ok || id == 0 {
|
||||
return fmt.Errorf("commerce: invalid product id")
|
||||
}
|
||||
sale, _, err := wire.Varint(f.Value, 2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, ok, err := wire.Varint(f.Value, 3)
|
||||
if err != nil || !ok || n == 0 || n > math.MaxInt32 {
|
||||
return fmt.Errorf("commerce: invalid buy count")
|
||||
}
|
||||
k := gamedata.CashProductKey{GroupID: group, ProductID: id, SaleGroup: sale}
|
||||
if seen[k] {
|
||||
return fmt.Errorf("commerce: duplicate buy info")
|
||||
}
|
||||
seen[k] = true
|
||||
lines = append(lines, buyLine{k, n})
|
||||
case 7:
|
||||
if f.Type != 2 {
|
||||
return fmt.Errorf("commerce: invalid billing info")
|
||||
}
|
||||
return wire.Walk(f.Value, func(b wire.Field) error {
|
||||
if b.Number == 1 || b.Number == 2 {
|
||||
if b.Type != 2 {
|
||||
return fmt.Errorf("commerce: invalid billing identity")
|
||||
}
|
||||
if len(b.Value) > 16384 {
|
||||
return fmt.Errorf("commerce: excessive billing identity")
|
||||
}
|
||||
if len(b.Value) > 0 {
|
||||
h := sha256.Sum256(b.Value)
|
||||
billing = append(billing, fmt.Sprintf("%d:%x", b.Number, h))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if e == nil && len(lines) == 0 {
|
||||
e = fmt.Errorf("commerce: empty purchase")
|
||||
}
|
||||
return seq, lines, billing, e
|
||||
}
|
||||
func (s *Service) buy(session string, request []byte) (int, []byte, bool, error) {
|
||||
if session == "" {
|
||||
return 61, nil, true, fmt.Errorf("commerce: authenticated session required")
|
||||
}
|
||||
seq, lines, billing, e := parseBuy(request)
|
||||
if e != nil {
|
||||
return 61, nil, true, e
|
||||
}
|
||||
var native, cash bool
|
||||
for _, line := range lines {
|
||||
if design, ok := s.catalog.Design(line.Key); ok {
|
||||
if design.PriceType == 1 {
|
||||
cash = true
|
||||
} else {
|
||||
native = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if native && !cash {
|
||||
return 0, nil, false, nil
|
||||
}
|
||||
if native {
|
||||
return 61, nil, true, fmt.Errorf("commerce: mixed cash and native purchase")
|
||||
}
|
||||
h := sha256.Sum256(request)
|
||||
digest := hex.EncodeToString(h[:])
|
||||
sessionHash := sha256.Sum256([]byte(session))
|
||||
identity := fmt.Sprintf("commerce:%x:%d", sessionHash, seq)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
v, e := s.load()
|
||||
if e != nil {
|
||||
return 61, nil, true, e
|
||||
}
|
||||
if r, ok := v.Receipts[identity]; ok {
|
||||
if r.Digest != digest {
|
||||
return 61, nil, true, fmt.Errorf("commerce: sequence reused with different purchase")
|
||||
}
|
||||
return 61, append([]byte(nil), r.Response...), true, nil
|
||||
}
|
||||
for _, b := range billing {
|
||||
if _, ok := v.Billing[b]; ok {
|
||||
return 61, nil, true, fmt.Errorf("commerce: billing receipt already used")
|
||||
}
|
||||
}
|
||||
// Validate every line before charging the first one.
|
||||
designs := make([]gamedata.CashProductDesign, len(lines))
|
||||
quotes := make([]Product, len(lines))
|
||||
for i, l := range lines {
|
||||
if !s.predecessorBought(v, l.Key) {
|
||||
return 61, nil, true, fmt.Errorf("commerce: preceding package purchase required")
|
||||
}
|
||||
d, ok := s.catalog.Design(l.Key)
|
||||
if !ok {
|
||||
return 61, nil, true, fmt.Errorf("commerce: unknown product")
|
||||
}
|
||||
if !s.available(d) {
|
||||
return 61, nil, true, fmt.Errorf("commerce: product is not currently available")
|
||||
}
|
||||
q, err := s.catalog.Quote(l.Key, l.Count)
|
||||
if err != nil {
|
||||
return 61, nil, true, err
|
||||
}
|
||||
if !q.Enabled {
|
||||
return 61, nil, true, fmt.Errorf("commerce: product disabled")
|
||||
}
|
||||
if l.Count > 1 && d.BulkOrderAvailability == 0 {
|
||||
return 61, nil, true, fmt.Errorf("commerce: bulk purchase disabled")
|
||||
}
|
||||
n := s.count(v, d)
|
||||
if s.legacy != nil {
|
||||
for _, raw := range s.legacy.PurchaseCountDBInfos() {
|
||||
g, _, _ := wire.Varint(raw, 1)
|
||||
id, _, _ := wire.Varint(raw, 2)
|
||||
sale, _, _ := wire.Varint(raw, 3)
|
||||
legacyCount, _, _ := wire.Varint(raw, 4)
|
||||
if g == l.Key.GroupID && id == l.Key.ProductID && sale == l.Key.SaleGroup && legacyCount > n {
|
||||
n = legacyCount
|
||||
}
|
||||
}
|
||||
}
|
||||
if d.PurchaseLimitType > 4 {
|
||||
return 61, nil, true, fmt.Errorf("commerce: unknown purchase limit")
|
||||
}
|
||||
if n > math.MaxInt32-l.Count {
|
||||
return 61, nil, true, fmt.Errorf("commerce: purchase count overflow")
|
||||
}
|
||||
if d.PurchaseLimitType != 0 && (d.PurchaseLimitCount == 0 || n+l.Count > d.PurchaseLimitCount) {
|
||||
return 61, nil, true, fmt.Errorf("commerce: purchase limit exceeded")
|
||||
}
|
||||
designs[i] = d
|
||||
quotes[i] = q
|
||||
}
|
||||
if e = validateAcceptedQuote(request, quotes); e != nil {
|
||||
return 61, nil, true, e
|
||||
}
|
||||
var bundle []byte
|
||||
for i, l := range lines {
|
||||
d, q := designs[i], quotes[i]
|
||||
operation := fmt.Sprintf("%s:%d", identity, i)
|
||||
var costs []gamedata.Reward
|
||||
if q.Cost > 0 {
|
||||
var typ uint64
|
||||
switch q.Currency {
|
||||
case "paid_diamonds":
|
||||
typ = 2
|
||||
case "diamonds":
|
||||
typ = 3
|
||||
case "gold":
|
||||
typ = 4
|
||||
default:
|
||||
return 61, nil, true, fmt.Errorf("commerce: unknown currency %q", q.Currency)
|
||||
}
|
||||
costs = []gamedata.Reward{{Type: typ, Count: q.Cost}}
|
||||
}
|
||||
var special []byte
|
||||
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 {
|
||||
special, handled, e = s.delegate(l.Key, request)
|
||||
if e != nil {
|
||||
return 61, nil, true, e
|
||||
}
|
||||
if handled && (len(lines) != 1 || l.Count != 1) {
|
||||
return 61, nil, true, fmt.Errorf("commerce: special purchase requires one product")
|
||||
}
|
||||
}
|
||||
var rewards []gamedata.Reward
|
||||
if !handled {
|
||||
if d.RandomBoxID == 0 {
|
||||
return 61, nil, true, fmt.Errorf("commerce: product reward missing")
|
||||
}
|
||||
rewards = append(rewards, gamedata.Reward{Type: 9, ID: d.RandomBoxID, Count: l.Count})
|
||||
if d.BonusRandomBoxID != 0 {
|
||||
rewards = append(rewards, gamedata.Reward{Type: 9, ID: d.BonusRandomBoxID, Count: l.Count})
|
||||
}
|
||||
}
|
||||
b, err := s.economy.Apply(operation, costs, rewards)
|
||||
if err != nil {
|
||||
return 61, nil, true, err
|
||||
}
|
||||
bundle = append(bundle, b...)
|
||||
bundle = append(bundle, special...)
|
||||
if s.hook != nil {
|
||||
if err = s.hook(operation, d, l.Count); err != nil {
|
||||
return 61, nil, true, err
|
||||
}
|
||||
}
|
||||
old := v.Counts[skuKey(l.Key)]
|
||||
if old.Lifetime > math.MaxUint64-l.Count {
|
||||
return 61, nil, true, fmt.Errorf("commerce: lifetime purchase count overflow")
|
||||
}
|
||||
v.Counts[skuKey(l.Key)] = purchaseCount{Count: s.count(v, d) + l.Count, Period: s.period(d.PurchaseLimitType), Lifetime: old.Lifetime + l.Count}
|
||||
}
|
||||
response := wire.AppendBytes(nil, 1, bundle)
|
||||
v.Receipts[identity] = purchaseReceipt{digest, response}
|
||||
for _, b := range billing {
|
||||
v.Billing[b] = identity
|
||||
}
|
||||
encoded, e := json.Marshal(v)
|
||||
if e == nil {
|
||||
e = s.store.Save("commerce", encoded)
|
||||
}
|
||||
return 61, response, true, e
|
||||
}
|
||||
|
||||
// The receipt repeats the client-visible quote for stale-catalog detection.
|
||||
// Prices always come from Catalog, and a mismatch never changes the debit.
|
||||
func validateAcceptedQuote(request []byte, quotes []Product) error {
|
||||
var pay, receipt string
|
||||
seen := false
|
||||
err := wire.Walk(request, func(f wire.Field) error {
|
||||
if f.Number != 7 {
|
||||
return nil
|
||||
}
|
||||
if seen || f.Type != 2 {
|
||||
return fmt.Errorf("commerce: invalid billing info")
|
||||
}
|
||||
seen = true
|
||||
return wire.Walk(f.Value, func(b wire.Field) error {
|
||||
if b.Number == 1 {
|
||||
if b.Type != 2 || pay != "" {
|
||||
return fmt.Errorf("commerce: invalid pay id")
|
||||
}
|
||||
pay = string(b.Value)
|
||||
}
|
||||
if b.Number == 2 {
|
||||
if b.Type != 2 || receipt != "" {
|
||||
return fmt.Errorf("commerce: invalid receipt")
|
||||
}
|
||||
receipt = string(b.Value)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parts := strings.Split(receipt, ":")
|
||||
if len(parts) != 4 || parts[0] != "bd2-local-commerce-v1" || parts[1] != pay {
|
||||
return fmt.Errorf("commerce: local billing quote required")
|
||||
}
|
||||
payID, err := strconv.ParseUint(pay, 10, 64)
|
||||
if err != nil || payID == 0 || strconv.FormatUint(payID, 10) != pay {
|
||||
return fmt.Errorf("commerce: invalid local pay id")
|
||||
}
|
||||
typ, err := strconv.ParseUint(parts[2], 10, 64)
|
||||
if err != nil || strconv.FormatUint(typ, 10) != parts[2] {
|
||||
return fmt.Errorf("commerce: invalid accepted quote currency")
|
||||
}
|
||||
amount, err := strconv.ParseUint(parts[3], 10, 64)
|
||||
if err != nil || strconv.FormatUint(amount, 10) != parts[3] {
|
||||
return fmt.Errorf("commerce: invalid accepted quote amount")
|
||||
}
|
||||
var expected uint64
|
||||
for _, q := range quotes {
|
||||
if q.ItemType != typ || q.Cost > math.MaxInt32-expected {
|
||||
return fmt.Errorf("commerce: accepted quote currency mismatch")
|
||||
}
|
||||
expected += q.Cost
|
||||
}
|
||||
if expected != amount {
|
||||
return fmt.Errorf("commerce: purchase quote changed; refresh the shop")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *Service) purchaseCountDBInfos() ([][]byte, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
v, e := s.load()
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
counts := map[gamedata.CashProductKey]uint64{}
|
||||
if s.legacy != nil {
|
||||
for _, raw := range s.legacy.PurchaseCountDBInfos() {
|
||||
g, _, _ := wire.Varint(raw, 1)
|
||||
id, _, _ := wire.Varint(raw, 2)
|
||||
sale, _, _ := wire.Varint(raw, 3)
|
||||
n, _, _ := wire.Varint(raw, 4)
|
||||
counts[gamedata.CashProductKey{GroupID: g, ProductID: id, SaleGroup: sale}] = n
|
||||
}
|
||||
}
|
||||
for _, d := range s.catalog.Designs() {
|
||||
if n := s.count(v, d); n > counts[d.Key] {
|
||||
counts[d.Key] = n
|
||||
}
|
||||
}
|
||||
keys := make([]gamedata.CashProductKey, 0, len(counts))
|
||||
for k := range counts {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
a, b := keys[i], keys[j]
|
||||
if a.GroupID != b.GroupID {
|
||||
return a.GroupID < b.GroupID
|
||||
}
|
||||
if a.ProductID != b.ProductID {
|
||||
return a.ProductID < b.ProductID
|
||||
}
|
||||
return a.SaleGroup < b.SaleGroup
|
||||
})
|
||||
var out [][]byte
|
||||
for _, k := range keys {
|
||||
b := wire.AppendVarint(nil, 1, k.GroupID)
|
||||
b = wire.AppendVarint(b, 2, k.ProductID)
|
||||
b = wire.AppendVarint(b, 3, k.SaleGroup)
|
||||
b = wire.AppendVarint(b, 4, counts[k])
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (s *Service) PurchaseCountDBInfos() [][]byte { v, _ := s.purchaseCountDBInfos(); return v }
|
||||
func (s *Service) HasPurchased(k gamedata.CashProductKey) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
v, e := s.load()
|
||||
if e != nil {
|
||||
return false
|
||||
}
|
||||
return v.Counts[skuKey(k)].Count > 0
|
||||
}
|
||||
|
||||
// ConsumeEntitlement is an authorization callback within the caller's account
|
||||
// transaction. Each successful cash activation consumes one lifetime purchase.
|
||||
// It must not be called recursively from the purchase hook or delegate.
|
||||
func (s *Service) ConsumeEntitlement(k gamedata.CashProductKey) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
v, err := s.load()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
n := v.Counts[skuKey(k)].Lifetime
|
||||
used := map[string]uint64{}
|
||||
raw, err := s.store.Load("commerce_pass_receipts")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if raw != nil {
|
||||
if err = json.Unmarshal(raw, &used); err != nil || used == nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
key := skuKey(k)
|
||||
if used[key] >= n {
|
||||
return false
|
||||
}
|
||||
used[key]++
|
||||
raw, err = json.Marshal(used)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return s.store.Save("commerce_pass_receipts", raw) == nil
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package commerce
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/accountstate"
|
||||
"bd2server/internal/server/gameconfig"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/player"
|
||||
"bd2server/internal/server/readonly"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type purchaseEconomy struct {
|
||||
calls int
|
||||
costs, rewards []gamedata.Reward
|
||||
fail bool
|
||||
}
|
||||
|
||||
func (e *purchaseEconomy) Apply(_ string, c, r []gamedata.Reward) ([]byte, error) {
|
||||
e.calls++
|
||||
e.costs = c
|
||||
e.rewards = r
|
||||
if e.fail {
|
||||
return nil, fmt.Errorf("insufficient balance")
|
||||
}
|
||||
return []byte{8, 1}, nil
|
||||
}
|
||||
func serviceFixture(t *testing.T, limit uint64) (*Service, *purchaseEconomy, *stateio.Memory) {
|
||||
t.Helper()
|
||||
design := &gamedata.CashCatalog{Products: []gamedata.CashProductDesign{
|
||||
{Key: gamedata.CashProductKey{GroupID: 1, ProductID: 1}, GoogleSKU: "topup", PriceType: 1, PriceCount: 2200, RandomBoxID: 10, Recharge: true, NominalPaidDiamonds: 100},
|
||||
{Key: gamedata.CashProductKey{GroupID: 2, ProductID: 1}, GoogleSKU: "goods", PriceType: 1, PriceCount: 22000, RandomBoxID: 20, PurchaseLimitType: limit, PurchaseLimitCount: 1},
|
||||
{Key: gamedata.CashProductKey{GroupID: 2, ProductID: 1, SaleGroup: 1}, GoogleSKU: "goods-sale", PriceType: 1, PriceCount: 11000, RandomBoxID: 21, PurchaseLimitType: 4, PurchaseLimitCount: 1},
|
||||
}}
|
||||
c, err := NewCatalog("test", design, gameconfig.Default().Purchases)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eco := &purchaseEconomy{}
|
||||
store := stateio.NewMemory()
|
||||
s, err := NewService(c, store, eco)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s, eco, store
|
||||
}
|
||||
func buyRequest(seq, group, id, sale uint64, billing string) []byte {
|
||||
b := wire.AppendVarint(nil, 1, seq)
|
||||
b = wire.AppendVarint(b, 3, group)
|
||||
line := wire.AppendVarint(nil, 1, id)
|
||||
line = wire.AppendVarint(line, 2, sale)
|
||||
line = wire.AppendVarint(line, 3, 1)
|
||||
b = wire.AppendBytes(b, 4, line)
|
||||
if billing == "" {
|
||||
billing = fmt.Sprint(seq)
|
||||
}
|
||||
if billing == "pay-1" {
|
||||
billing = "1"
|
||||
}
|
||||
typ, amount := uint64(2), uint64(1000)
|
||||
if sale == 1 {
|
||||
amount = 500
|
||||
}
|
||||
if group == 1 {
|
||||
typ = 0
|
||||
amount = 0
|
||||
}
|
||||
info := wire.AppendBytes(nil, 1, []byte(billing))
|
||||
info = wire.AppendBytes(info, 2, []byte(fmt.Sprintf("bd2-local-commerce-v1:%s:%d:%d", billing, typ, amount)))
|
||||
b = wire.AppendBytes(b, 7, info)
|
||||
return b
|
||||
}
|
||||
func TestPurchaseReplayPersistsAndRejectsCollisions(t *testing.T) {
|
||||
s, eco, store := serviceFixture(t, 0)
|
||||
req := buyRequest(1, 2, 1, 0, "pay-1")
|
||||
code, out, ok, err := s.HandleSession("/CashShopBuy", req, "session")
|
||||
if err != nil || !ok || code != 61 {
|
||||
t.Fatal(code, ok, err)
|
||||
}
|
||||
if eco.calls != 1 || len(eco.costs) != 1 || eco.costs[0].Type != 2 || eco.costs[0].Count != 1000 || eco.rewards[0].ID != 20 {
|
||||
t.Fatal(eco)
|
||||
}
|
||||
s2, err := NewService(s.catalog, store, eco)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, replayed, _, err := s2.HandleSession("/CashShopBuy", req, "session")
|
||||
if err != nil || !bytes.Equal(out, replayed) || eco.calls != 1 {
|
||||
t.Fatal(err, eco.calls)
|
||||
}
|
||||
changed := buyRequest(1, 2, 1, 1, "")
|
||||
if _, _, _, err = s2.HandleSession("/CashShopBuy", changed, "session"); err == nil {
|
||||
t.Fatal("changed sequence accepted")
|
||||
}
|
||||
if _, _, _, err = s2.HandleSession("/CashShopBuy", req, "other-session"); err == nil {
|
||||
t.Fatal("billing identity reused")
|
||||
}
|
||||
}
|
||||
func TestSaleGroupsLimitsAndReset(t *testing.T) {
|
||||
s, eco, _ := serviceFixture(t, 1)
|
||||
now := time.Date(2026, 10, 5, 1, 0, 0, 0, time.UTC)
|
||||
s.SetClock(func() time.Time { return now }, 0)
|
||||
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 0, ""), "s"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(2, 2, 1, 0, ""), "s"); err == nil {
|
||||
t.Fatal("daily limit ignored")
|
||||
}
|
||||
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(3, 2, 1, 1, ""), "s"); err != nil {
|
||||
t.Fatal("sale group should be independent", err)
|
||||
}
|
||||
now = now.Add(24 * time.Hour)
|
||||
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(4, 2, 1, 0, ""), "s"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if eco.calls != 3 {
|
||||
t.Fatal(eco.calls)
|
||||
}
|
||||
}
|
||||
func TestFailedEconomyDoesNotConsumeLimitAndFreeRecharge(t *testing.T) {
|
||||
s, eco, _ := serviceFixture(t, 4)
|
||||
eco.fail = true
|
||||
req := buyRequest(1, 2, 1, 0, "")
|
||||
if _, _, _, err := s.HandleSession("/CashShopBuy", req, "s"); err == nil {
|
||||
t.Fatal("failure accepted")
|
||||
}
|
||||
if s.HasPurchased(gamedata.CashProductKey{GroupID: 2, ProductID: 1}) {
|
||||
t.Fatal("failed buy persisted")
|
||||
}
|
||||
eco.fail = false
|
||||
if _, _, _, err := s.HandleSession("/CashShopBuy", req, "s"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(2, 1, 1, 0, ""), "s"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(eco.costs) != 0 || eco.rewards[0].ID != 10 {
|
||||
t.Fatal("free recharge wrong", eco)
|
||||
}
|
||||
}
|
||||
func TestAuthenticatedSessionRequired(t *testing.T) {
|
||||
s, eco, _ := serviceFixture(t, 0)
|
||||
if _, _, _, err := s.Handle("/CashShopBuy", buyRequest(1, 2, 1, 0, "")); err == nil || eco.calls != 0 {
|
||||
t.Fatal("unauthenticated buy accepted")
|
||||
}
|
||||
}
|
||||
func TestPassEntitlementConsumedOncePerPurchase(t *testing.T) {
|
||||
s, _, _ := serviceFixture(t, 0)
|
||||
k := gamedata.CashProductKey{GroupID: 2, ProductID: 1}
|
||||
if s.ConsumeEntitlement(k) {
|
||||
t.Fatal("unpaid entitlement authorized")
|
||||
}
|
||||
for i := uint64(1); i <= 2; i++ {
|
||||
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(i, 2, 1, 0, ""), "s"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !s.ConsumeEntitlement(k) || s.ConsumeEntitlement(k) {
|
||||
t.Fatal("entitlement count not enforced")
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestRelayRequiresPreviousProduct(t *testing.T) {
|
||||
s, eco, _ := serviceFixture(t, 0)
|
||||
if err := s.AttachPackageRules([]gamedata.CashPackageDesign{{GroupID: 2, ID: 1, PackageType: 8, ContentsGroupID: 7, ContentsSortID: 1}, {GroupID: 2, ID: 1, SaleGroup: 1, PackageType: 8, ContentsGroupID: 7, ContentsSortID: 2}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 1, ""), "s"); err == nil || eco.calls != 0 {
|
||||
t.Fatal("relay step skipped")
|
||||
}
|
||||
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(2, 2, 1, 0, ""), "s"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(3, 2, 1, 1, ""), "s"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleClientQuoteRejectedBeforeEconomy(t *testing.T) {
|
||||
s, eco, _ := serviceFixture(t, 0)
|
||||
req := buyRequest(1, 2, 1, 0, "")
|
||||
req = bytes.ReplaceAll(req, []byte(":2:1000"), []byte(":3:1000"))
|
||||
if _, _, _, err := s.HandleSession("/CashShopBuy", req, "s"); err == nil || eco.calls != 0 {
|
||||
t.Fatal("stale currency accepted")
|
||||
}
|
||||
req = buyRequest(2, 2, 1, 0, "")
|
||||
req = bytes.ReplaceAll(req, []byte(":2:1000"), []byte(":2:9999"))
|
||||
if _, _, _, err := s.HandleSession("/CashShopBuy", req, "s"); err == nil || eco.calls != 0 {
|
||||
t.Fatal("stale amount accepted")
|
||||
}
|
||||
}
|
||||
func TestShopWindowFiltersAndAuthorizesProducts(t *testing.T) {
|
||||
s, eco, _ := serviceFixture(t, 0)
|
||||
now := time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC)
|
||||
s.SetClock(func() time.Time { return now }, 0)
|
||||
seed := &readonly.Seed{Responses: map[string]readonly.Response{"/CashShopInfo": {PacketCode: 60, Fields: []readonly.Field{{Number: 1, Type: 2, Fields: []readonly.Field{{Number: 1, Type: 0, Varint: 2}, {Number: 2, Type: 0, Varint: 1}, {Number: 4, Type: 0, Varint: uint64(now.Add(-time.Hour).UnixMilli())}, {Number: 5, Type: 0, Varint: uint64(now.Add(time.Hour).UnixMilli())}, {Number: 8, Type: 0, Varint: 123}}}}}}}
|
||||
if err := s.AttachShopSeed(seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 0, ""), "s"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now = now.Add(2 * time.Hour)
|
||||
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(2, 2, 1, 0, ""), "s"); err == nil {
|
||||
t.Fatal("expired product accepted")
|
||||
}
|
||||
code, out, ok, err := s.HandleSession("/CashShopInfo", wire.AppendVarint(nil, 1, 1), "s")
|
||||
if err != nil || !ok || code != 60 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
products := 0
|
||||
_ = wire.Walk(out, func(f wire.Field) error {
|
||||
if f.Number == 1 {
|
||||
products++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if products != 0 || eco.calls != 1 {
|
||||
t.Fatal(products, eco.calls)
|
||||
}
|
||||
}
|
||||
|
||||
type walletPurchaseEconomy struct {
|
||||
wallet *player.Wallet
|
||||
store stateio.Store
|
||||
}
|
||||
|
||||
func (e walletPurchaseEconomy) Apply(identity string, costs, rewards []gamedata.Reward) ([]byte, error) {
|
||||
if err := e.wallet.ExchangeOnce(identity, costs, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := json.Marshal(rewards)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = e.store.Save("commerce_test_rewards", raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []byte{8, 1}, nil
|
||||
}
|
||||
func TestSQLiteAccountTransactionRollsBackCostRewardAndReceipt(t *testing.T) {
|
||||
s, _, _ := serviceFixture(t, 4)
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
repo, err := accountstate.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
wallet, err := player.OpenWallet(repo, player.Currency{Jewelry: 1000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = wallet.EnsurePersisted(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err = NewService(s.catalog, repo, walletPurchaseEconomy{wallet, repo})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.AttachPurchaseHook(func(string, gamedata.CashProductDesign, uint64) error { return fmt.Errorf("entitlement failure") })
|
||||
op, err := repo.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err = s.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 0, ""), "s"); err == nil {
|
||||
t.Fatal("hook failure lost")
|
||||
}
|
||||
if err = op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = repo.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if wallet.Snapshot().Jewelry != 1000 {
|
||||
t.Fatal("paid diamonds survived rollback")
|
||||
}
|
||||
for _, name := range []string{"commerce", "commerce_test_rewards"} {
|
||||
raw, err := repo.Load(name)
|
||||
if err != nil || raw != nil {
|
||||
t.Fatal("purchase state survived rollback", name, err)
|
||||
}
|
||||
}
|
||||
s, err = NewService(s.catalog, repo, walletPurchaseEconomy{wallet, repo})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
op, err = repo.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err = s.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 0, ""), "s"); err != nil {
|
||||
_ = op.Rollback()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = op.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wallet.Snapshot().Jewelry != 0 || !s.HasPurchased(gamedata.CashProductKey{GroupID: 2, ProductID: 1}) {
|
||||
t.Fatal("retry did not commit")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user