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 }