Files
bd2/go/internal/server/player/wallet.go
T

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
}