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,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
|
||||
}
|
||||
Reference in New Issue
Block a user