Files
bd2/go/internal/server/events/economy.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

436 lines
14 KiB
Go

package events
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 HuntingAP interface {
CanExchangeAP(costs, rewards []gamedata.Reward) error
ExchangeAPOnce(identity string, costs, rewards []gamedata.Reward) error
}
type rewardResolver interface {
Resolve([]gamedata.BattleReward) ([]gamedata.BattleReward, error)
}
type equipmentRoller interface {
RollOptions(uint64) ([]gamedata.EquipmentOptionChoice, []gamedata.EquipmentOptionChoice, *gamedata.EquipmentOptionChoice, error)
}
type economyReceipt struct{ Digest, Definition, Bundle []byte }
type economySnapshot struct {
Balances map[uint64]uint64 `json:"balances"`
Receipts map[string]economyReceipt `json:"receipts"`
}
// Economy dispatches verified static rewards to their owning domains. It is
// called inside the same account transaction as the gameplay operation.
type Economy struct {
ownedDesign map[uint64]map[uint64]bool
mu sync.Mutex
store stateio.Store
items *player.Inventory
wallet *player.Wallet
collection *player.CollectionStore
equipment *player.EquipmentInventory
costumes player.CostumeDesignSource
options equipmentRoller
graph rewardResolver
hunting HuntingAP
prestige map[uint64]uint64
apCaps map[uint64]uint64
resetSeconds int64
now func() time.Time
initial map[uint64]uint64
}
func NewEconomy(store stateio.Store, items *player.Inventory, wallet *player.Wallet, collection *player.CollectionStore, equipment *player.EquipmentInventory, costumes player.CostumeDesignSource, options equipmentRoller, graph rewardResolver, initial map[uint64]uint64) (*Economy, error) {
if store == nil || items == nil || wallet == nil || collection == nil || equipment == nil || costumes == nil || options == nil || graph == nil {
return nil, fmt.Errorf("events: invalid economy configuration")
}
e := &Economy{store: store, items: items, wallet: wallet, collection: collection, equipment: equipment, costumes: costumes, options: options, graph: graph, initial: map[uint64]uint64{}}
for t, n := range initial {
if _, ok := AdditionalCurrencyFields[t]; !ok || n > math.MaxInt32 {
return nil, fmt.Errorf("events: invalid initial currency %d", t)
}
e.initial[t] = n
}
_, err := e.load()
return e, err
}
// AdditionalCurrencyFields maps EElementType to its UserDBInfo field. These
// balances use a gameplay entry, leaving the frozen wallet schema unchanged.
var AdditionalCurrencyFields = map[uint64]int{15: 16, 16: 17, 18: 18, 24: 22, 30: 34, 31: 35, 32: 36, 33: 37, 43: 48, 44: 54, 60: 60}
func (e *Economy) AttachHuntingAP(h HuntingAP) { e.hunting = h }
func (e *Economy) AdditionalCurrencies() (map[int]uint64, error) {
e.mu.Lock()
defer e.mu.Unlock()
s, err := e.load()
if err != nil {
return nil, err
}
if err = e.refreshAP(&s); err != nil {
return nil, err
}
out := map[int]uint64{}
for t, f := range AdditionalCurrencyFields {
out[f] = s.Balances[t]
}
return out, nil
}
func (e *Economy) load() (economySnapshot, error) {
s := economySnapshot{Balances: map[uint64]uint64{}, Receipts: map[string]economyReceipt{}}
for t, n := range e.initial {
s.Balances[t] = n
}
b, err := e.store.Load("event_economy")
if err != nil || b == nil {
return s, err
}
if err = stateio.RequireExactJSONObject(b, "balances", "receipts"); err != nil {
return s, err
}
if err = json.Unmarshal(b, &s); err != nil {
return s, err
}
if s.Balances == nil || s.Receipts == nil {
return s, fmt.Errorf("events: invalid saved economy")
}
for t, n := range s.Balances {
if _, ok := AdditionalCurrencyFields[t]; !ok || n > math.MaxInt32 {
return s, fmt.Errorf("events: invalid saved currency %d", t)
}
}
for id, r := range s.Receipts {
if id == "" || len(r.Digest) != sha256.Size {
return s, fmt.Errorf("events: invalid economy receipt")
}
}
return s, nil
}
func walletCurrency(t uint64) bool {
switch t {
case 2, 3, 4, 12, 20, 22, 68:
return true
}
return false
}
func extraCurrency(t uint64) bool { _, ok := AdditionalCurrencyFields[t]; return ok }
func apCurrency(t uint64) bool { return t == 21 || t == 23 }
func inventoryType(t uint64) bool {
switch t {
case 5, 7, 8, 9, 13, 14, 17, 19, 25, 26, 27, 29, 34, 45, 46, 47, 49:
return true
}
return false
}
func (e *Economy) Apply(identity string, costs, rewards []gamedata.Reward) ([]byte, error) {
definition, _ := json.Marshal(struct{ Costs, Rewards []gamedata.Reward }{costs, rewards})
d := sha256.Sum256(definition)
e.mu.Lock()
s, err := e.load()
if err != nil {
e.mu.Unlock()
return nil, err
}
if r, ok := s.Receipts[identity]; ok {
e.mu.Unlock()
if !bytes.Equal(r.Definition, d[:]) {
return nil, fmt.Errorf("events: reward identity reused with different definition")
}
return append([]byte(nil), r.Bundle...), nil
}
e.mu.Unlock()
var selected []player.Item
// Merge repeated definitions before selecting stacks so the same quantity
// cannot be selected twice from a single inventory entry.
totals := map[[2]uint64]uint64{}
for _, r := range costs {
k := [2]uint64{r.Type, r.ID}
if r.Count == 0 || r.Count > math.MaxInt64 || totals[k] > math.MaxInt64-r.Count {
return nil, fmt.Errorf("events: invalid cost")
}
totals[k] += r.Count
}
keys := make([][2]uint64, 0, len(totals))
for k := range totals {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i][0] != keys[j][0] {
return keys[i][0] < keys[j][0]
}
return keys[i][1] < keys[j][1]
})
for _, k := range keys {
n := totals[k]
if walletCurrency(k[0]) || extraCurrency(k[0]) || apCurrency(k[0]) {
if k[1] != 0 {
return nil, fmt.Errorf("events: currency has item id")
}
selected = append(selected, player.Item{Type: k[0], Count: n})
continue
}
items, err := e.items.SelectMutable(k[0], k[1], n)
if err != nil {
return nil, err
}
selected = append(selected, items...)
}
var rs []gamedata.BattleReward
for _, r := range rewards {
rs = append(rs, gamedata.BattleReward(r))
}
return e.consumeAndGrant(identity, selected, rs, d[:], false)
}
func (e *Economy) ConsumeAndGrant(identity string, consumed []player.Item, rewards []gamedata.BattleReward) ([]byte, error) {
return e.consumeAndGrant(identity, consumed, rewards, nil, false)
}
func (e *Economy) consumeAndGrant(identity string, consumed []player.Item, rewards []gamedata.BattleReward, definition []byte, openBox bool) ([]byte, error) {
e.mu.Lock()
defer e.mu.Unlock()
if identity == "" {
return nil, fmt.Errorf("events: empty reward identity")
}
s, err := e.load()
if err != nil {
return nil, err
}
raw, err := json.Marshal(struct {
Costs []player.Item
Rewards []gamedata.BattleReward
}{consumed, rewards})
if err != nil {
return nil, err
}
digest := sha256.Sum256(raw)
if receipt, ok := s.Receipts[identity]; ok {
if !bytes.Equal(receipt.Digest, digest[:]) {
return nil, fmt.Errorf("events: reward identity reused with different contents")
}
return append([]byte(nil), receipt.Bundle...), nil
}
if err = e.refreshAP(&s); err != nil {
return nil, err
}
var itemCosts []player.Item
var walletCosts, apCosts []gamedata.Reward
for _, c := range consumed {
if c.Count == 0 || c.Count > math.MaxInt32 {
return nil, fmt.Errorf("events: invalid consumption count")
}
r := gamedata.Reward{Type: c.Type, ID: c.ID, Count: c.Count}
switch {
case walletCurrency(c.Type):
if c.ID != 0 || c.InvenIndex != 0 {
return nil, fmt.Errorf("events: invalid currency cost")
}
walletCosts = append(walletCosts, r)
case apCurrency(c.Type):
if c.ID != 0 || c.InvenIndex != 0 {
return nil, fmt.Errorf("events: invalid hunting AP cost")
}
apCosts = append(apCosts, r)
case extraCurrency(c.Type):
if c.ID != 0 || c.InvenIndex != 0 || s.Balances[c.Type] < c.Count {
return nil, fmt.Errorf("events: insufficient currency %d", c.Type)
}
s.Balances[c.Type] -= c.Count
default:
if !inventoryType(c.Type) {
return nil, fmt.Errorf("events: unsupported consumed item type %d", c.Type)
}
itemCosts = append(itemCosts, c)
}
}
if len(itemCosts) > 0 {
if err = e.items.CanConsume(itemCosts); err != nil {
return nil, err
}
}
var expanded []gamedata.BattleReward
if openBox {
expanded, err = e.graph.Resolve(rewards)
} else {
expanded, err = e.resolveGranted(rewards)
}
if err != nil {
return nil, err
}
var walletRewards, apRewards []gamedata.Reward
var itemRewards []gamedata.BattleReward
var costumes []uint64
var equips []player.Equipment
var bundle []byte
for _, r := range expanded {
if r.Count == 0 || r.Count > math.MaxInt32 {
return nil, fmt.Errorf("events: invalid reward count")
}
switch {
case walletCurrency(r.Type):
if r.ID != 0 {
return nil, fmt.Errorf("events: currency reward has item id")
}
walletRewards = append(walletRewards, gamedata.Reward(r))
bundle = wire.AppendBytes(bundle, 1, player.ItemWire(player.Item{Type: r.Type, Count: r.Count}))
case apCurrency(r.Type):
if r.ID != 0 {
return nil, fmt.Errorf("events: invalid AP reward")
}
apRewards = append(apRewards, gamedata.Reward(r))
bundle = wire.AppendBytes(bundle, 1, player.ItemWire(player.Item{Type: r.Type, Count: r.Count}))
case extraCurrency(r.Type):
if r.ID != 0 || s.Balances[r.Type] > math.MaxInt32-r.Count {
return nil, fmt.Errorf("events: currency overflow")
}
s.Balances[r.Type] += r.Count
bundle = wire.AppendBytes(bundle, 1, player.ItemWire(player.Item{Type: r.Type, Count: r.Count}))
case r.Type == 11:
if _, ok := e.costumes.Character(r.ID); !ok || r.Count > 1000 {
return nil, fmt.Errorf("events: unknown or excessive costume reward %d", r.ID)
}
for n := uint64(0); n < r.Count; n++ {
costumes = append(costumes, r.ID)
}
case r.Type == 10:
if r.Count > 1000 {
return nil, fmt.Errorf("events: excessive equipment reward")
}
for n := uint64(0); n < r.Count; n++ {
main, sub, private, rollErr := e.options.RollOptions(r.ID)
if rollErr != nil {
return nil, rollErr
}
entry := player.Equipment{ID: r.ID, Rank: []uint64{0, 0, 0}}
for _, o := range main {
entry.MainOption = append(entry.MainOption, player.EquipmentOption{GroupID: o.GroupID, ID: o.ID})
}
for _, o := range sub {
entry.SubOption = append(entry.SubOption, player.EquipmentOption{GroupID: o.GroupID, ID: o.ID})
}
if private != nil {
entry.PrivateOption = &player.EquipmentOption{GroupID: private.GroupID, ID: private.ID}
}
equips = append(equips, entry)
}
case inventoryType(r.Type):
if r.Type == 47 || r.Type == 49 {
if !e.ownedDesign[r.Type][r.ID] {
return nil, fmt.Errorf("events: unknown owned item design %d:%d", r.Type, r.ID)
}
}
if r.Type == 45 {
if _, ok := e.prestige[r.ID]; !ok {
return nil, fmt.Errorf("events: unknown prestige skin %d", r.ID)
}
}
if r.ID == 0 {
return nil, fmt.Errorf("events: zero inventory reward id")
}
itemRewards = append(itemRewards, r)
default:
return nil, fmt.Errorf("events: unsupported reward type %d", r.Type)
}
}
if err = e.wallet.CanExchange(walletCosts, walletRewards); err != nil {
return nil, err
}
if len(apCosts)+len(apRewards) > 0 {
if e.hunting == nil {
return nil, fmt.Errorf("events: hunting AP runtime unavailable")
}
if err = e.hunting.CanExchangeAP(apCosts, apRewards); err != nil {
return nil, err
}
}
// Validation and random generation finish before the first account write.
if len(itemCosts) > 0 {
if err = e.items.Consume(itemCosts); err != nil {
return nil, err
}
}
if err = e.wallet.ExchangeOnce(identity+":wallet", walletCosts, walletRewards); err != nil {
return nil, err
}
if len(apCosts)+len(apRewards) > 0 {
if err = e.hunting.ExchangeAPOnce(identity+":ap", apCosts, apRewards); err != nil {
return nil, err
}
}
items, err := e.items.GrantOnce(identity+":items", itemRewards)
if err != nil {
return nil, err
}
for _, item := range items {
bundle = wire.AppendBytes(bundle, 1, player.ItemWire(item))
bundle = wire.AppendBytes(bundle, 6, player.ItemWire(player.Item{Type: item.Type, ID: item.ID, Count: item.Count}))
}
if len(costumes) > 0 {
grant, err := e.collection.GrantCostumes(identity+":costumes", costumes, e.costumes)
if err != nil {
return nil, err
}
var overflow uint64
for _, x := range grant.Exchanges {
if x.ExchangeItemType != 20 || x.ExchangeCount > math.MaxInt64-overflow {
return nil, fmt.Errorf("events: invalid costume exchange")
}
overflow += x.ExchangeCount
}
if overflow > 0 {
if _, err = e.wallet.GrantMileageOnce(identity+":overflow", overflow); err != nil {
return nil, err
}
}
bundle = append(bundle, player.CollectionRewardBundle(e.collection, grant)...)
}
for i, entry := range equips {
saved, err := e.equipment.GrantGeneratedOnce(identity+":equipment:"+strconv.Itoa(i), entry)
if err != nil {
return nil, err
}
bundle = wire.AppendBytes(bundle, 4, player.EquipmentWire(saved))
bundle = wire.AppendBytes(bundle, 6, player.ItemWire(player.Item{Type: 10, ID: saved.ID, Count: 1}))
}
s.Receipts[identity] = economyReceipt{Digest: digest[:], Definition: definition, Bundle: bundle}
data, err := json.Marshal(s)
if err != nil {
return nil, err
}
if err = e.store.Save("event_economy", data); err != nil {
return nil, err
}
return bundle, nil
}
func (e *Economy) resolveGranted(rewards []gamedata.BattleReward) ([]gamedata.BattleReward, error) {
if g, ok := e.graph.(interface {
ResolveGranted([]gamedata.BattleReward) ([]gamedata.BattleReward, error)
}); ok {
return g.ResolveGranted(rewards)
}
return e.graph.Resolve(rewards)
}
func (e *Economy) OpenBox(identity string, box player.Item) ([]byte, error) {
return e.consumeAndGrant(identity, []player.Item{box}, []gamedata.BattleReward{{Type: 9, ID: box.ID, Count: box.Count}}, nil, true)
}