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

  • selectedShipId is owned by useSessionStore (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:
    1. Roll v4 results locally.
    2. RPC perform_pull validates wallet + pity server-side and persists the entity rows.
    3. 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.
    4. usePlayerStore.setPity(rpcResponse.pity) mirrors the server pity map.
    5. For each rolled hull, inventory.grantShipPull(shipId) applies the local mutation and the result’s oldXp / newXp / oldStar / newStar / unlocked fields are patched in-place using a per-batch localXp snapshot 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.