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

489 lines
16 KiB
Go

package player
import (
"encoding/json"
"errors"
"fmt"
"reflect"
"sort"
"strconv"
"sync"
"time"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/stateio"
"bd2server/internal/server/versionconfig"
"bd2server/internal/server/wire"
)
// Inventory holds player-owned rewards separately from the immutable starter
// seed. GrantOnce uses a battle identity to prevent double-credit on retries.
type Inventory struct {
mu sync.Mutex
store stateio.AtomicEntryStore
starter *Starter
randomBoxes *gamedata.RandomBoxDesign
owned ownedSnapshot
persisted ownedSnapshot
corePresent bool
}
type ownedSnapshot struct {
Version string `json:"version"`
NextIndex uint64 `json:"next_index"`
Items []Item `json:"-"`
Granted map[string]bool `json:"-"`
GrantItems map[string][]uint64 `json:"-"`
}
func OpenInventory(store stateio.Store, starter *Starter) (*Inventory, error) {
entries, ok := store.(stateio.AtomicEntryStore)
if starter == nil || starter.Validate() != nil || !ok {
return nil, errors.New("player: invalid inventory configuration")
}
s := &Inventory{store: entries, starter: starter, owned: ownedSnapshot{Version: versionconfig.State(), NextIndex: 900000001, Granted: map[string]bool{}, GrantItems: map[string][]uint64{}}}
data, err := store.Load("items")
if err != nil {
return nil, err
}
if data != nil {
s.corePresent = true
if err := stateio.RequireExactJSONObject(data, "version", "next_index"); err != nil {
return nil, fmt.Errorf("player: incompatible inventory layout: %w", err)
}
var shape map[string]json.RawMessage
if err := json.Unmarshal(data, &shape); err != nil {
return nil, fmt.Errorf("player: decode inventory shape: %w", err)
}
for _, name := range []string{"items", "granted", "grant_items"} {
if _, exists := shape[name]; exists {
return nil, fmt.Errorf("player: inventory %s must use entries", name)
}
}
if err := json.Unmarshal(data, &s.owned); err != nil {
return nil, fmt.Errorf("player: decode inventory: %w", err)
}
} else if err := stateio.RequireNoEntries(entries, "items", "items", "granted", "grant_items"); err != nil {
return nil, fmt.Errorf("player: invalid inventory storage: %w", err)
}
if s.owned.Version != versionconfig.State() || s.owned.NextIndex < 900000001 {
return nil, errors.New("player: invalid saved inventory")
}
s.owned.Granted, err = loadBoolEntries(entries, "items", "granted")
if err != nil {
return nil, err
}
rawGrantItems, err := entries.ListEntries("items", "grant_items")
if err != nil {
return nil, err
}
s.owned.GrantItems = make(map[string][]uint64, len(rawGrantItems))
for key, value := range rawGrantItems {
var indices []uint64
if key == "" || json.Unmarshal(value, &indices) != nil {
return nil, fmt.Errorf("player: invalid items grant_items entry %q", key)
}
s.owned.GrantItems[key] = indices
}
rawItems, err := entries.ListEntries("items", "items")
if err != nil {
return nil, err
}
for key, value := range rawItems {
var item Item
index, parseErr := strconv.ParseUint(key, 10, 64)
if parseErr != nil || json.Unmarshal(value, &item) != nil || item.InvenIndex != index {
return nil, fmt.Errorf("player: invalid items entry %q", key)
}
s.owned.Items = append(s.owned.Items, item)
}
sort.Slice(s.owned.Items, func(i, j int) bool { return s.owned.Items[i].InvenIndex < s.owned.Items[j].InvenIndex })
s.persisted = cloneOwnedSnapshot(s.owned)
return s, nil
}
func (s *Inventory) EnsurePersisted() error {
s.mu.Lock()
defer s.mu.Unlock()
data, err := s.store.Load("items")
if err != nil {
return err
}
if data != nil {
return nil
}
return s.commitOwned(cloneOwnedSnapshot(s.owned))
}
func (s *Inventory) Handle(path string, request []byte) (int, []byte, bool, error) {
if path != "/ItemInfo" && path != "/UseRandomBox" {
return 0, nil, false, nil
}
if path == "/UseRandomBox" {
return s.useRandomBox(request)
}
s.mu.Lock()
defer s.mu.Unlock()
items := make([]Item, 0, len(s.starter.Items)+len(s.owned.Items))
items = append(items, s.starter.Items...)
items = append(items, s.owned.Items...)
return (&Starter{Version: versionconfig.State(), Items: items}).Handle(path, request)
}
// AttachRandomBoxes installs the version-validated deterministic RandomBox
// definitions. It is supplied at process startup from real GameData rather
// than accepting a client-supplied reward.
func (s *Inventory) AttachRandomBoxes(design *gamedata.RandomBoxDesign) error {
if s == nil || design == nil {
return errors.New("player: nil random box design")
}
s.mu.Lock()
defer s.mu.Unlock()
s.randomBoxes = design
return nil
}
func (s *Inventory) useRandomBox(request []byte) (int, []byte, bool, error) {
seq, present, err := wire.Varint(request, 1)
if err != nil || !present || seq == 0 {
return 0, nil, true, errors.New("player: UseRandomBox invalid sequence")
}
index, present, err := wire.Varint(request, 2)
if err != nil || !present || index == 0 {
return 0, nil, true, errors.New("player: UseRandomBox invalid inventory index")
}
count, present, err := wire.Varint(request, 3)
if err != nil || !present || count == 0 || count > uint64(^uint32(0)>>1) {
return 0, nil, true, errors.New("player: UseRandomBox invalid use count")
}
s.mu.Lock()
defer s.mu.Unlock()
if s.randomBoxes == nil {
return 0, nil, true, errors.New("player: UseRandomBox design unavailable")
}
boxAt := -1
for i, item := range s.owned.Items {
if item.InvenIndex == index {
boxAt = i
break
}
}
if boxAt < 0 {
return 0, nil, true, fmt.Errorf("player: UseRandomBox unknown inventory index %d", index)
}
box := s.owned.Items[boxAt]
if box.Type != 9 || box.Count < count {
return 0, nil, true, errors.New("player: UseRandomBox item or count mismatch")
}
rewards, err := s.randomBoxes.Open(box.ID, count)
if err != nil {
return 0, nil, true, err
}
next := cloneOwnedSnapshot(s.owned)
if box.Count == count {
next.Items = append(next.Items[:boxAt], next.Items[boxAt+1:]...)
} else {
next.Items[boxAt].Count -= count
}
granted := make([]Item, 0, len(rewards))
for _, reward := range rewards {
if reward.Type == 0 || reward.ID == 0 || reward.Count == 0 {
return 0, nil, true, errors.New("player: UseRandomBox invalid GameData reward")
}
item := Item{InvenIndex: next.NextIndex, ID: reward.ID, Type: reward.Type, Count: reward.Count, TimeValue: uint64(time.Now().UnixMilli())}
next.NextIndex++
next.Items = append(next.Items, item)
granted = append(granted, item)
}
if err := s.commitOwned(next); err != nil {
return 0, nil, true, err
}
s.owned = next
var bundle []byte
for _, item := range granted {
bundle = wire.AppendBytes(bundle, 1, ItemWire(item))
}
return 143, wire.AppendBytes(nil, 1, bundle), true, nil
}
func (s *Inventory) All() []Item {
s.mu.Lock()
defer s.mu.Unlock()
items := make([]Item, 0, len(s.starter.Items)+len(s.owned.Items))
items = append(items, s.starter.Items...)
items = append(items, s.owned.Items...)
return items
}
// SelectMutable returns concrete owned stacks for a server-calculated cost.
// Starter seed items are immutable and deliberately excluded, matching
// Consume. Results preserve inventory order and split the final stack exactly.
func (s *Inventory) SelectMutable(itemType, id, count uint64) ([]Item, error) {
if itemType == 0 || id == 0 || count == 0 {
return nil, errors.New("player: invalid mutable item selection")
}
s.mu.Lock()
defer s.mu.Unlock()
remaining := count
var selected []Item
for _, item := range s.owned.Items {
if item.Type != itemType || item.ID != id || item.Count == 0 {
continue
}
use := min(item.Count, remaining)
copy := item
copy.Count = use
selected = append(selected, copy)
remaining -= use
if remaining == 0 {
return selected, nil
}
}
return nil, fmt.Errorf("player: insufficient mutable item %d/%d: have %d want %d", itemType, id, count-remaining, count)
}
func (s *Inventory) GrantOnce(identity string, rewards []gamedata.BattleReward) ([]Item, error) {
if identity == "" {
return nil, errors.New("player: missing reward identity")
}
s.mu.Lock()
defer s.mu.Unlock()
if s.owned.Granted[identity] {
return nil, nil
}
next := cloneOwnedSnapshot(s.owned)
newItems := make([]Item, 0, len(rewards))
for _, r := range rewards {
if r.ID == 0 || r.Type == 0 || r.Count == 0 {
return nil, errors.New("player: invalid battle reward")
}
item := Item{InvenIndex: next.NextIndex, ID: r.ID, Type: r.Type, Count: r.Count, TimeValue: uint64(time.Now().UnixMilli())}
next.NextIndex++
next.Items = append(next.Items, item)
newItems = append(newItems, item)
next.GrantItems[identity] = append(next.GrantItems[identity], item.InvenIndex)
}
next.Granted[identity] = true
if err := s.commitOwned(next); err != nil {
return nil, err
}
s.owned = next
return newItems, nil
}
func cloneOwnedSnapshot(current ownedSnapshot) ownedSnapshot {
next := ownedSnapshot{Version: current.Version, NextIndex: current.NextIndex,
Items: append([]Item(nil), current.Items...), Granted: make(map[string]bool, len(current.Granted)+1), GrantItems: make(map[string][]uint64, len(current.GrantItems)+1)}
for k, v := range current.Granted {
next.Granted[k] = v
}
for k, v := range current.GrantItems {
next.GrantItems[k] = append([]uint64(nil), v...)
}
return next
}
// commitOwned persists a fully validated next snapshot. Caller holds s.mu.
func (s *Inventory) commitOwned(next ownedSnapshot) error {
data, err := json.Marshal(next)
if err != nil {
return err
}
changes := make([]stateio.EntryMutation, 0)
for key, value := range next.Granted {
if value && !s.persisted.Granted[key] {
changes = append(changes, stateio.EntryMutation{Bucket: "granted", Key: key, Payload: []byte("true")})
}
}
for key, value := range next.GrantItems {
if !equalIndices(value, s.persisted.GrantItems[key]) {
payload, err := json.Marshal(value)
if err != nil {
return err
}
changes = append(changes, stateio.EntryMutation{Bucket: "grant_items", Key: key, Payload: payload})
}
}
before := make(map[uint64]Item, len(s.persisted.Items))
for _, item := range s.persisted.Items {
before[item.InvenIndex] = item
}
for _, item := range next.Items {
old, exists := before[item.InvenIndex]
if !exists || !equalItem(old, item) {
payload, err := json.Marshal(item)
if err != nil {
return err
}
changes = append(changes, stateio.EntryMutation{Bucket: "items", Key: strconv.FormatUint(item.InvenIndex, 10), Payload: payload})
}
delete(before, item.InvenIndex)
}
for index := range before {
changes = append(changes, stateio.EntryMutation{Bucket: "items", Key: strconv.FormatUint(index, 10), Delete: true})
}
if s.corePresent && next.Version == s.persisted.Version && next.NextIndex == s.persisted.NextIndex {
data = nil
}
if err := s.store.SaveWithEntries("items", data, changes); err != nil {
return err
}
s.corePresent = true
s.persisted = cloneOwnedSnapshot(next)
return nil
}
func equalIndices(a, b []uint64) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func equalItem(a, b Item) bool { return reflect.DeepEqual(a, b) }
// GrantedItems returns the stable instances created by a previous GrantOnce.
// Legacy grants made before instance tracking return an empty slice.
func (s *Inventory) GrantedItems(identity string) []Item {
s.mu.Lock()
defer s.mu.Unlock()
indexes := s.owned.GrantItems[identity]
result := make([]Item, 0, len(indexes))
for _, index := range indexes {
for _, item := range s.owned.Items {
if item.InvenIndex == index {
result = append(result, item)
break
}
}
}
return result
}
// WasGranted reports whether GrantOnce committed the identity even when all
// granted item stacks were later consumed. Cash-product purchase counts use
// this durable marker rather than the current inventory contents.
func (s *Inventory) WasGranted(identity string) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.owned.Granted[identity]
}
// Consume atomically removes the requested counts from mutable owned items.
// Starter seed entries are immutable and are deliberately not accepted here.
func (s *Inventory) CanConsume(requested []Item) error {
if len(requested) == 0 {
return errors.New("player: no items to consume")
}
s.mu.Lock()
defer s.mu.Unlock()
remaining := make(map[uint64]Item, len(s.owned.Items))
for _, item := range s.owned.Items {
remaining[item.InvenIndex] = item
}
for _, want := range requested {
if want.InvenIndex == 0 || want.ID == 0 || want.Type == 0 || want.Count == 0 {
return errors.New("player: invalid item consumption")
}
item, ok := remaining[want.InvenIndex]
if !ok || item.ID != want.ID || item.Type != want.Type || item.Count < want.Count {
return fmt.Errorf("player: item %d consumption mismatch", want.InvenIndex)
}
item.Count -= want.Count
remaining[want.InvenIndex] = item
}
return nil
}
func (s *Inventory) Consume(requested []Item) error {
_, err := s.ConsumeAndRefund(requested, nil)
return err
}
// ConsumeAndRefund changes the spent stack and returned growth resources in a
// single inventory save, keeping ItemInfo and RewardInfoBundle consistent.
func (s *Inventory) ConsumeAndRefund(requested []Item, refunds []gamedata.GrowthMaterial) ([]Item, error) {
if len(requested) == 0 {
return nil, errors.New("player: no items to consume")
}
s.mu.Lock()
defer s.mu.Unlock()
next := ownedSnapshot{Version: s.owned.Version, NextIndex: s.owned.NextIndex,
Items: append([]Item(nil), s.owned.Items...), Granted: make(map[string]bool, len(s.owned.Granted)), GrantItems: make(map[string][]uint64, len(s.owned.GrantItems))}
for k, v := range s.owned.Granted {
next.Granted[k] = v
}
for k, v := range s.owned.GrantItems {
next.GrantItems[k] = append([]uint64(nil), v...)
}
for _, want := range requested {
if want.InvenIndex == 0 || want.ID == 0 || want.Type == 0 || want.Count == 0 {
return nil, errors.New("player: invalid item consumption")
}
found := -1
for i, current := range next.Items {
if current.InvenIndex == want.InvenIndex {
found = i
break
}
}
if found < 0 {
return nil, fmt.Errorf("player: item %d is not mutable-owned", want.InvenIndex)
}
current := next.Items[found]
if current.ID != want.ID || current.Type != want.Type || current.Count < want.Count {
return nil, fmt.Errorf("player: item %d consumption mismatch", want.InvenIndex)
}
current.Count -= want.Count
if current.Count == 0 {
next.Items = append(next.Items[:found], next.Items[found+1:]...)
} else {
next.Items[found] = current
}
}
granted := make([]Item, 0, len(refunds))
for _, refund := range refunds {
if refund.ID == 0 || refund.Count == 0 {
return nil, errors.New("player: invalid growth refund")
}
item := Item{InvenIndex: next.NextIndex, ID: refund.ID, Type: 8, Count: refund.Count, TimeValue: uint64(time.Now().UnixMilli())}
next.NextIndex++
next.Items = append(next.Items, item)
granted = append(granted, item)
}
if err := s.commitOwned(next); err != nil {
return nil, err
}
s.owned = next
return granted, nil
}
// ItemWire builds ItemDBInfo for RewardDBInfoBundle and ItemInfoResponse.
func ItemWire(item Item) []byte {
var b []byte
for _, f := range []struct {
n int
v uint64
}{{1, item.InvenIndex}, {2, item.ID}, {3, item.Type}, {4, item.Count}, {5, item.KeepFlag}, {6, item.TimeValue}} {
if f.v != 0 {
b = wire.AppendVarint(b, f.n, f.v)
}
}
if item.ExpiryTime != 0 {
b = wire.AppendVarint(b, 8, item.ExpiryTime)
}
if item.SortID != 0 {
b = wire.AppendVarint(b, 9, item.SortID)
}
if item.UseCount != 0 {
b = wire.AppendVarint(b, 10, item.UseCount)
}
return b
}