Inventory Store — Hydration + Dirty-Tracking
useInventoryStore is the client-side cache of the player’s owned hulls. Supabase player_entities is the canonical backup; the store mirrors a subset for in-session reads. v4 is hull-keyed: one record per hull_class, never per-instance.
Shape
ships: Record<hull_class, ShipInstance> where ShipInstance = { shipId, xp }. shipId equals the hull_class string in v4. xp is the cumulative count of duplicate pulls on that hull (first pull leaves xp = 0). There is no stored star field — star level is derived on demand via starFromXp(xp) from data/ship-progression. There is no claimed field; ownership is “exists in ships map”.
Starter hulls (Industria_Towncar, Junkrats_Tank, Solaris_Cargo) are populated by makeStarter() at store creation and by resetToStarter() for fresh accounts.
Hydration
playerBootstrap.ts builds shipsMap: Record<string, ShipInstance> from the player_entities rows pulled at session start and calls inventoryState.loadInventory(shipsMap). If the map is empty (new account / wiped save), it calls resetToStarter() instead. There is no loadFromBootstrap method on the store — loadInventory is the single hydration entry point, and the bootstrap service owns the row → map conversion.
Mutations
Exactly one write path during gameplay: grantShipPull(hull_class). It throws if the hull is unknown to HULL_CLASSES. If the hull is not present, it inserts { shipId, xp: 0 } and returns { unlocked: true, xpGained: 0 }. If already present, it bumps xp by 1 and returns { unlocked: false, xpGained: 1 }.
The store does not expose a setStar setter — star level is a pure function of xp and cannot be set directly. Anything that wants to advance star must grant xp.
Cross-store reads + writes
selectedShipIdis owned byuseSessionStore(not inventory). Ships screen writes it on hull selection; the engine reads it when assembling a run. The inventory store has no notion of “current” ship.- Pull flow (
services/pullService.ts → executePullPull) is the only place that touches both stores in one transaction:- Roll v4 results locally.
- RPC
perform_pullvalidates wallet + pity server-side and persists the entity rows. - On success,
useWalletStore.replaceFromSnapshot(...)overwrites gems/credits/tickets from the canonical server response — this is a full overwrite, not a delta apply, and it happens every pull regardless of unlock vs xp-gain. usePlayerStore.setPity(rpcResponse.pity)mirrors the server pity map.- For each rolled hull,
inventory.grantShipPull(shipId)applies the local mutation and the result’soldXp / newXp / oldStar / newStar / unlockedfields are patched in-place using a per-batchlocalXpsnapshot so duplicate hulls in the same 10-pull report correct deltas.
There is no “on first claim” branch that writes to wallet — wallet replacement is unconditional per pull batch.
Dirty-tracking model
The store itself is not dirty-tracked. Persistence is server-authoritative: the RPC writes player_entities before the local mutation lands, so the local store is effectively a read-through cache of what’s already durable. usePlayerStore.setSyncStatus('syncing' | 'synced' | 'error') is the closest signal — it brackets the RPC call in executePullPull and surfaces sync state to the UI without per-field diffing on the inventory.
Telemetry
Per rolled hull, pullService emits one of:
V4_PULL_UNLOCK{ shipId, rarity }when the pull was a first unlock.V4_PULL_XP_GAIN{ shipId, oldXp, newXp, starUp }when the pull was a duplicate.
starUp is newStar > oldStar, computed from the per-batch snapshot so star transitions inside a single 10-pull are reported correctly.