Restore event and mini hubs, current shop and hunt calendars, and regular seasons; preserve all published windows. Implement story/quiz routes, native shop purchases, avatar sets, currencies, rank-change items and permanent buffs with rollback and reward coverage tests. Refresh client buff stats and update Cash Shop to 0.1.1.
466 lines
15 KiB
Go
466 lines
15 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
|
|
avatarRewards *gamedata.AvatarRewardDesign
|
|
buffRewards *BuffRewards
|
|
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, 38: 42, 39: 44, 40: 45, 43: 48, 44: 54, 54: 61, 60: 60, 70: 71}
|
|
|
|
func (e *Economy) AttachAvatarRewards(d *gamedata.AvatarRewardDesign) { e.avatarRewards = d }
|
|
|
|
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, 50, 61, 69:
|
|
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
|
|
}
|
|
expanded, err = e.avatarRewards.Expand(expanded)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var walletRewards, apRewards, buffItems []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 == 63:
|
|
if e.buffRewards == nil {
|
|
return nil, fmt.Errorf("events: buff reward runtime unavailable")
|
|
}
|
|
buffItems = append(buffItems, gamedata.Reward(r))
|
|
bundle = wire.AppendBytes(bundle, 6, player.ItemWire(player.Item{Type: r.Type, ID: r.ID, 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 == 69 {
|
|
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 == 49 || r.Type == 50 || r.Type == 61 {
|
|
valid := e.avatarRewards != nil && e.avatarRewards.Items[r.Type][r.ID]
|
|
if !valid && !e.ownedDesign[r.Type][r.ID] {
|
|
return nil, fmt.Errorf("events: unknown avatar member %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 len(buffItems) > 0 {
|
|
if err = e.buffRewards.Validate(buffItems); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
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
|
|
}
|
|
}
|
|
if len(buffItems) > 0 {
|
|
if err = e.buffRewards.GrantOnce(identity+":buff-items", buffItems); 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)
|
|
}
|