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.
203 lines
6.1 KiB
Go
203 lines
6.1 KiB
Go
// 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
|
|
}
|