372 lines
11 KiB
Go
372 lines
11 KiB
Go
package player
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"sync"
|
|
|
|
"bd2server/internal/server/gamedata"
|
|
"bd2server/internal/server/stateio"
|
|
"bd2server/internal/server/versionconfig"
|
|
)
|
|
|
|
// Currency uses the UserDBInfo currency fields: type 3 is free jewelry and
|
|
// type 4 is gold and type 12 is catalyst (天赋神药). Paid jewelry is persisted
|
|
// as well, although quest rewards in the audited tutorial range do not grant it.
|
|
type Currency struct {
|
|
Gold uint64 `json:"gold"`
|
|
FreeJewelry uint64 `json:"free_jewelry"`
|
|
Jewelry uint64 `json:"jewelry"`
|
|
Catalyst uint64 `json:"catalyst"`
|
|
Mileage uint64 `json:"mileage"`
|
|
HopePowder uint64 `json:"hope_powder"`
|
|
EquipMileage uint64 `json:"equip_mileage"`
|
|
EquipMileageExchangeGage uint64 `json:"equip_mileage_exchange_gage"`
|
|
}
|
|
|
|
type walletSnapshot struct {
|
|
Version string `json:"version"`
|
|
Currency
|
|
Granted map[string]bool `json:"-"`
|
|
Spent map[string]bool `json:"-"`
|
|
}
|
|
|
|
type Wallet struct {
|
|
mu sync.Mutex
|
|
store stateio.AtomicEntryStore
|
|
state walletSnapshot
|
|
}
|
|
|
|
func OpenWallet(store stateio.Store, initial Currency) (*Wallet, error) {
|
|
entries, ok := store.(stateio.AtomicEntryStore)
|
|
if !ok {
|
|
return nil, errors.New("player: wallet store is nil")
|
|
}
|
|
s := &Wallet{store: entries, state: walletSnapshot{
|
|
Version: versionconfig.State(), Currency: initial, Granted: map[string]bool{}, Spent: map[string]bool{},
|
|
}}
|
|
b, err := store.Load("wallet")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("player: load wallet: %w", err)
|
|
}
|
|
if b != nil {
|
|
if err := stateio.RequireExactJSONObject(b, "version", "gold", "free_jewelry", "jewelry", "catalyst", "mileage", "hope_powder", "equip_mileage", "equip_mileage_exchange_gage"); err != nil {
|
|
return nil, fmt.Errorf("player: incompatible wallet layout: %w", err)
|
|
}
|
|
var shape map[string]json.RawMessage
|
|
if err := json.Unmarshal(b, &shape); err != nil {
|
|
return nil, errors.New("player: malformed wallet state")
|
|
}
|
|
if _, ok := shape["granted"]; ok {
|
|
return nil, errors.New("player: wallet grant ledger must use entries")
|
|
}
|
|
if _, ok := shape["spent"]; ok {
|
|
return nil, errors.New("player: wallet spend ledger must use entries")
|
|
}
|
|
if err := json.Unmarshal(b, &s.state); err != nil || s.state.Version != versionconfig.State() {
|
|
return nil, errors.New("player: malformed wallet state")
|
|
}
|
|
} else if err := stateio.RequireNoEntries(entries, "wallet", "granted", "spent"); err != nil {
|
|
return nil, fmt.Errorf("player: invalid wallet storage: %w", err)
|
|
}
|
|
s.state.Granted, err = loadBoolEntries(entries, "wallet", "granted")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.state.Spent, err = loadBoolEntries(entries, "wallet", "spent")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func (s *Wallet) EnsurePersisted() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
b, err := s.store.Load("wallet")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if b != nil {
|
|
return nil
|
|
}
|
|
return s.commit(cloneWallet(s.state))
|
|
}
|
|
|
|
func (s *Wallet) Snapshot() Currency {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.state.Currency
|
|
}
|
|
|
|
func (s *Wallet) WasGranted(identity string) bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.state.Granted[identity]
|
|
}
|
|
|
|
func (s *Wallet) CanSpendFreeJewelry(amount uint64) bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return amount > 0 && s.state.FreeJewelry >= amount
|
|
}
|
|
|
|
func (s *Wallet) CanSpendGold(amount uint64) bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return amount > 0 && s.state.Gold >= amount
|
|
}
|
|
|
|
// SpendGoldOnce covers the currency part of a GameData-defined class-up.
|
|
// Its identity is the old character instance/stage so an interrupted request
|
|
// cannot charge the same promotion a second time.
|
|
func (s *Wallet) SpendGoldOnce(identity string, amount uint64) (Currency, error) {
|
|
if identity == "" || amount == 0 {
|
|
return Currency{}, errors.New("player: invalid gold spend")
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.state.Spent[identity] {
|
|
return s.state.Currency, nil
|
|
}
|
|
if s.state.Gold < amount {
|
|
return Currency{}, errors.New("player: insufficient gold")
|
|
}
|
|
next := cloneWallet(s.state)
|
|
next.Gold -= amount
|
|
next.Spent[identity] = true
|
|
if err := s.commit(next, entry("spent", identity, []byte("true"))...); err != nil {
|
|
return Currency{}, err
|
|
}
|
|
return next.Currency, nil
|
|
}
|
|
|
|
func (s *Wallet) WasSpent(identity string) bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.state.Spent[identity]
|
|
}
|
|
|
|
func (s *Wallet) SpendFreeJewelryOnce(identity string, amount uint64) (Currency, error) {
|
|
return s.spendJewelryOnce(identity, amount, false)
|
|
}
|
|
|
|
func (s *Wallet) SpendJewelryOnce(identity string, amount uint64) (Currency, error) {
|
|
return s.spendJewelryOnce(identity, amount, true)
|
|
}
|
|
|
|
func (s *Wallet) spendJewelryOnce(identity string, amount uint64, paid bool) (Currency, error) {
|
|
if identity == "" || amount == 0 {
|
|
return Currency{}, errors.New("player: invalid wallet spend")
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.state.Spent[identity] {
|
|
return s.state.Currency, nil
|
|
}
|
|
next := cloneWallet(s.state)
|
|
if paid {
|
|
if next.Jewelry < amount {
|
|
return Currency{}, errors.New("player: insufficient paid jewelry")
|
|
}
|
|
next.Jewelry -= amount
|
|
} else {
|
|
if next.FreeJewelry < amount {
|
|
return Currency{}, errors.New("player: insufficient free jewelry")
|
|
}
|
|
next.FreeJewelry -= amount
|
|
}
|
|
next.Spent[identity] = true
|
|
if err := s.commit(next, entry("spent", identity, []byte("true"))...); err != nil {
|
|
return Currency{}, err
|
|
}
|
|
return next.Currency, nil
|
|
}
|
|
|
|
// Currencies implements account.CurrencyProvider.
|
|
func (s *Wallet) Currencies() (gold, freeJewelry, jewelry, mileage uint64) {
|
|
c := s.Snapshot()
|
|
return c.Gold, c.FreeJewelry, c.Jewelry, c.Mileage
|
|
}
|
|
|
|
func (s *Wallet) HopePowderBalance() uint64 { return s.Snapshot().HopePowder }
|
|
|
|
func (s *Wallet) CatalystBalance() uint64 { return s.Snapshot().Catalyst }
|
|
|
|
func (s *Wallet) CanSpendCatalyst(amount uint64) bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return amount > 0 && s.state.Catalyst >= amount
|
|
}
|
|
|
|
func (s *Wallet) SpendCatalystOnce(identity string, amount uint64) (Currency, error) {
|
|
if identity == "" || amount == 0 {
|
|
return Currency{}, errors.New("player: invalid catalyst spend")
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.state.Spent[identity] {
|
|
return s.state.Currency, nil
|
|
}
|
|
if s.state.Catalyst < amount {
|
|
return Currency{}, errors.New("player: insufficient catalyst")
|
|
}
|
|
next := cloneWallet(s.state)
|
|
next.Catalyst -= amount
|
|
next.Spent[identity] = true
|
|
if err := s.commit(next, entry("spent", identity, []byte("true"))...); err != nil {
|
|
return Currency{}, err
|
|
}
|
|
return next.Currency, nil
|
|
}
|
|
|
|
func (s *Wallet) EquipmentMileageBalances() (mileage, exchangeGage uint64) {
|
|
c := s.Snapshot()
|
|
return c.EquipMileage, c.EquipMileageExchangeGage
|
|
}
|
|
|
|
// RecordEquipmentSmelting converts the actually consumed refinement material
|
|
// into the official residual gauge and type-68 mileage currency.
|
|
func (s *Wallet) RecordEquipmentSmelting(materialCount, threshold, rewardCount uint64) (Currency, uint64, error) {
|
|
if materialCount == 0 || threshold == 0 || rewardCount == 0 {
|
|
return Currency{}, 0, errors.New("player: invalid equipment smelting mileage")
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.state.EquipMileageExchangeGage >= threshold || math.MaxUint64-s.state.EquipMileageExchangeGage < materialCount {
|
|
return Currency{}, 0, errors.New("player: invalid equipment smelting gauge")
|
|
}
|
|
next := cloneWallet(s.state)
|
|
total := next.EquipMileageExchangeGage + materialCount
|
|
exchanges := total / threshold
|
|
earned := exchanges * rewardCount
|
|
if exchanges != 0 && earned/exchanges != rewardCount || math.MaxUint64-next.EquipMileage < earned {
|
|
return Currency{}, 0, errors.New("player: equipment mileage overflow")
|
|
}
|
|
next.EquipMileageExchangeGage = total % threshold
|
|
next.EquipMileage += earned
|
|
if err := s.commit(next); err != nil {
|
|
return Currency{}, 0, err
|
|
}
|
|
return next.Currency, earned, nil
|
|
}
|
|
|
|
// GrantMileageOnce persists the type-20 currency produced when a duplicate
|
|
// costume is drawn after +5. The gacha grant identity makes recovery after a
|
|
// collection/wallet split commit safe and idempotent.
|
|
func (s *Wallet) GrantMileageOnce(identity string, amount uint64) (Currency, error) {
|
|
if identity == "" || amount == 0 {
|
|
return Currency{}, errors.New("player: invalid mileage grant")
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.state.Granted[identity] {
|
|
return s.state.Currency, nil
|
|
}
|
|
next := cloneWallet(s.state)
|
|
if math.MaxUint64-next.Mileage < amount {
|
|
return Currency{}, errors.New("player: mileage overflow")
|
|
}
|
|
next.Mileage += amount
|
|
next.Granted[identity] = true
|
|
if err := s.commit(next, entry("granted", identity, []byte("true"))...); err != nil {
|
|
return Currency{}, err
|
|
}
|
|
return next.Currency, nil
|
|
}
|
|
|
|
func (s *Wallet) GrantHopePowderOnce(identity string, amount uint64) (Currency, error) {
|
|
if identity == "" || amount == 0 {
|
|
return Currency{}, errors.New("player: invalid hope powder grant")
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.state.Granted[identity] {
|
|
return s.state.Currency, nil
|
|
}
|
|
next := cloneWallet(s.state)
|
|
if math.MaxUint64-next.HopePowder < amount {
|
|
return Currency{}, errors.New("player: hope powder overflow")
|
|
}
|
|
next.HopePowder += amount
|
|
next.Granted[identity] = true
|
|
if err := s.commit(next, entry("granted", identity, []byte("true"))...); err != nil {
|
|
return Currency{}, err
|
|
}
|
|
return next.Currency, nil
|
|
}
|
|
|
|
// GrantQuestOnce applies only currency rewards and is idempotent by the same
|
|
// quest identity used by the entity stores. Unknown reward types are ignored;
|
|
// callers dispatch those to their owning inventory domain.
|
|
func (s *Wallet) GrantQuestOnce(identity string, rewards []gamedata.Reward) (Currency, error) {
|
|
if identity == "" {
|
|
return Currency{}, errors.New("player: missing wallet grant identity")
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.state.Granted[identity] {
|
|
return s.state.Currency, nil
|
|
}
|
|
next := cloneWallet(s.state)
|
|
for _, reward := range rewards {
|
|
switch reward.Type {
|
|
case 2:
|
|
if math.MaxUint64-next.Jewelry < reward.Count {
|
|
return Currency{}, errors.New("player: paid jewelry overflow")
|
|
}
|
|
next.Jewelry += reward.Count
|
|
case 3:
|
|
if math.MaxUint64-next.FreeJewelry < reward.Count {
|
|
return Currency{}, errors.New("player: free jewelry overflow")
|
|
}
|
|
next.FreeJewelry += reward.Count
|
|
case 4:
|
|
if math.MaxUint64-next.Gold < reward.Count {
|
|
return Currency{}, errors.New("player: gold overflow")
|
|
}
|
|
next.Gold += reward.Count
|
|
case 12:
|
|
if math.MaxUint64-next.Catalyst < reward.Count {
|
|
return Currency{}, errors.New("player: catalyst overflow")
|
|
}
|
|
next.Catalyst += reward.Count
|
|
case 20:
|
|
if math.MaxUint64-next.Mileage < reward.Count {
|
|
return Currency{}, errors.New("player: mileage overflow")
|
|
}
|
|
next.Mileage += reward.Count
|
|
}
|
|
}
|
|
next.Granted[identity] = true
|
|
if err := s.commit(next, entry("granted", identity, []byte("true"))...); err != nil {
|
|
return Currency{}, err
|
|
}
|
|
return next.Currency, nil
|
|
}
|
|
|
|
func cloneWallet(in walletSnapshot) walletSnapshot {
|
|
out := walletSnapshot{Version: in.Version, Currency: in.Currency, Granted: make(map[string]bool, len(in.Granted)), Spent: make(map[string]bool, len(in.Spent))}
|
|
for key, value := range in.Granted {
|
|
out.Granted[key] = value
|
|
}
|
|
for key, value := range in.Spent {
|
|
out.Spent[key] = value
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Wallet) commit(next walletSnapshot, changes ...stateio.EntryMutation) error {
|
|
b, err := json.Marshal(next)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.store.SaveWithEntries("wallet", b, changes); err != nil {
|
|
return fmt.Errorf("player: persist wallet: %w", err)
|
|
}
|
|
s.state = next
|
|
return nil
|
|
}
|