PURPOSE

Hydrated runtime cache of server-owned currency state. Supabase owns persistent truth for all currencies; this Zustand store mirrors that truth, gets populated from the bootstrap_player RPC on app open, and is updated from RPC response payloads after every mutation. Provides synchronous read/write access for UI and gameplay code that cannot await network round-trips.

OWNS

  • useWalletStore — the Zustand store instance (singleton).
  • Currency balances cached in memory: gems (hard, surfaced as “Warp Crystals” in UI), credits (soft, formerly “scrap”), pullTickets (inventory-style ticket count, not a true currency).
  • Lifetime counters: totalGemsSpent, totalPulls.
  • Sync flag: hydrated (false until first replaceFromSnapshot or loadWallet call).
  • Starter state defaults sourced from STARTER_INVENTORY in src/starship-survivors/data/economy.ts (gems: 0, credits: 0, pullTickets: 0).
  • Type exports: WalletSnapshot, TicketSnapshot, WalletState.

READS FROM

  • zustandcreate factory for store construction.
  • ../data/economySTARTER_INVENTORY constant for initial balances and reset target.

No reads from other stores, services, or network layers. Inputs arrive as arguments to its setter actions.

PUSHES TO

Nothing directly. The store is a passive cache: it exposes state and mutators, but does not call services, RPCs, or other stores. Consumers (services, screens, components) subscribe to or read its state.

Known consumers calling into this store include services/playerBootstrap, services/shopService, services/runProgressionService, services/pullService, services/reward-finalizers, stores/modGridStore, plus screens (ShopScreen, ProfileScreen, PrologueScreen, ShipsScreen, UpgradesTab) and components (RevivePrompt, ChallengePopover, V32Shell).

DOES NOT

  • Does not persist to localStorage, IndexedDB, or any client-side storage. The cache is in-memory only.
  • Does not call Supabase, RPCs, or any network code. Mutations are local-only.
  • Does not enforce server authority — earn* and spend* adjust the cache and assume the server will mirror or correct via the next replaceFromSnapshot.
  • Does not track currency history beyond totalGemsSpent and totalPulls.
  • Does not handle warp drives, ship parts, or merge/forge currencies — those were removed in v4.
  • Does not own pull-ticket inventory beyond a count; ticket detail lives elsewhere.

Signals

  • hydrated: boolean — flips to true on first replaceFromSnapshot or loadWallet. Consumers use this to gate UI on cache readiness.
  • gems, credits, pullTickets — read by UI components for balance display and affordability checks.
  • totalGemsSpent — incremented on every successful gem spend (including the spendBulk path).
  • totalPulls — incremented via recordPull.

Entry points

  • replaceFromSnapshot(wallet, tickets?) — server-authoritative replacement. Sets gems and credits from the wallet snapshot (defaults to 0 if undefined), sets pullTickets from the tickets snapshot if provided otherwise preserves the current value, and sets hydrated: true.
  • earnGems(amount), earnCredits(amount), earnPullTickets(amount) — additive local cache update. No clamping, no validation.
  • spendGems(amount), spendCredits(amount), spendPullTickets(amount) — local check-and-deduct. Returns false if balance is insufficient (no mutation), true after deducting. spendGems also bumps totalGemsSpent.
  • canAfford(cost) — pure read; returns true only if all specified components of cost (gems, credits, pullTickets) are within the cached balances.
  • spendBulk(cost) — atomic check-and-deduct across all three currencies. Reads fresh state inside the set callback to avoid TOCTOU races. Returns true only when every specified component was affordable and was deducted; bumps totalGemsSpent by the gem portion.
  • recordPull(count = 1) — increments totalPulls; does not touch balances.
  • loadWallet({ gems, credits, pullTickets }) — bulk hydration setter. Sets the three balances and flips hydrated: true. Used as an alternate hydration path to replaceFromSnapshot.
  • resetToStarter() — restores the starterState (currencies and lifetime counters reset, hydrated returns to false).

Pattern notes

  • Authority model is v4: Supabase is canonical, the store is a hydrated cache. The earn* / spend* actions are described in source as “backward compat” — they update the cache optimistically, but the server’s next replaceFromSnapshot is the source of truth.
  • spendBulk deliberately reads state inside the set updater rather than via a prior get() call. Comment in source flags this as a TOCTOU-avoidance pattern. The individual spend* actions use the get()-then-set() pattern, so they are vulnerable to mid-frame races; bulk callers should prefer spendBulk.
  • replaceFromSnapshot nullish-coalesces wallet.gems and wallet.credits to 0, so a malformed RPC payload silently zeroes balances rather than throwing.
  • replaceFromSnapshot preserves pullTickets when the tickets argument is omitted, because tickets are sourced from a separate snapshot than the wallet itself.
  • recordPull accepts an optional count to record multi-pulls in a single call.
  • WalletSnapshot and TicketSnapshot are the public DTOs returned by RPC functions and consumed by replaceFromSnapshot.
  • Pull tickets are documented in source as an inventory item rather than a currency, even though the cache treats them with currency-style earn / spend semantics.