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 firstreplaceFromSnapshotorloadWalletcall). - Starter state defaults sourced from
STARTER_INVENTORYinsrc/starship-survivors/data/economy.ts(gems: 0,credits: 0,pullTickets: 0). - Type exports:
WalletSnapshot,TicketSnapshot,WalletState.
READS FROM
zustand—createfactory for store construction.../data/economy—STARTER_INVENTORYconstant 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*andspend*adjust the cache and assume the server will mirror or correct via the nextreplaceFromSnapshot. - Does not track currency history beyond
totalGemsSpentandtotalPulls. - 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 totrueon firstreplaceFromSnapshotorloadWallet. 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 thespendBulkpath).totalPulls— incremented viarecordPull.
Entry points
replaceFromSnapshot(wallet, tickets?)— server-authoritative replacement. Setsgemsandcreditsfrom the wallet snapshot (defaults to0if undefined), setspullTicketsfrom the tickets snapshot if provided otherwise preserves the current value, and setshydrated: 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. Returnsfalseif balance is insufficient (no mutation),trueafter deducting.spendGemsalso bumpstotalGemsSpent.canAfford(cost)— pure read; returnstrueonly if all specified components ofcost(gems,credits,pullTickets) are within the cached balances.spendBulk(cost)— atomic check-and-deduct across all three currencies. Reads fresh state inside thesetcallback to avoid TOCTOU races. Returnstrueonly when every specified component was affordable and was deducted; bumpstotalGemsSpentby the gem portion.recordPull(count = 1)— incrementstotalPulls; does not touch balances.loadWallet({ gems, credits, pullTickets })— bulk hydration setter. Sets the three balances and flipshydrated: true. Used as an alternate hydration path toreplaceFromSnapshot.resetToStarter()— restores thestarterState(currencies and lifetime counters reset,hydratedreturns tofalse).
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 nextreplaceFromSnapshotis the source of truth. spendBulkdeliberately reads state inside thesetupdater rather than via a priorget()call. Comment in source flags this as a TOCTOU-avoidance pattern. The individualspend*actions use theget()-then-set()pattern, so they are vulnerable to mid-frame races; bulk callers should preferspendBulk.replaceFromSnapshotnullish-coalesceswallet.gemsandwallet.creditsto0, so a malformed RPC payload silently zeroes balances rather than throwing.replaceFromSnapshotpreservespullTicketswhen theticketsargument is omitted, because tickets are sourced from a separate snapshot than the wallet itself.recordPullaccepts an optional count to record multi-pulls in a single call.WalletSnapshotandTicketSnapshotare the public DTOs returned by RPC functions and consumed byreplaceFromSnapshot.- Pull tickets are documented in source as an inventory item rather than a currency, even though the cache treats them with currency-style
earn/spendsemantics.