Files
bd2/go/internal/server/commerce/entitlements.go
T
Flechazo d8771caef2 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.
2026-10-05 11:50:31 +08:00

324 lines
10 KiB
Go

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
}