metagame/stores
PURPOSE — Zustand stores for the React metagame UI. Holds the client-side projection of server-owned identity and bootstrap state: the authenticated user, the resolved player profile, cloud-save / pity payloads, the bootstrap lifecycle flags the app shell gates on, sync status for the cloud round-trip, and the offline-mode escape hatch with its background recovery loop.
OWNS
- Auth user handle and a loading flag for the initial mount.
- Player profile projection (id, display name, email, account state, role, analytics opt-in, created-at) derived from the bootstrap payload.
- Bootstrap lifecycle flags:
bootstrapped(gates app-shell render),bootstrapError(init failure message). - Sync status state machine (
idle/syncing/synced/error) and its accompanying error message. - Cloud-save payload and pity counter map handed back by the bootstrap RPC.
showWelcomelatch that fires the callsign modal on the guest → claimed transition.offlineModeflag plus itsrecoveryToastcompanion latch.- Module-scope recovery
setIntervalhandle that pings auth on a fixed cadence while offline mode is active. - Synthetic offline profile minted with a
crypto.randomUUID()id so downstream code that keys onprofile.idsees a stable value with no server contact. - Dev-playground short-circuit: when the URL carries the dev flag, the store skips auth + bootstrap and seeds a synthetic profile.
- A window-global mirror of
offlineModeso non-importing modules can short-circuit RPCs without taking a store dependency. - One-shot
localStorageflag (welcome_shown) that suppresses repeat welcome modals across reloads. - Auth subscription installed during init that re-bootstraps on user-id change or anon-to-claimed email upgrade.
READS FROM
metagame/services/authfor anonymous-session creation, current-user lookup, sign-out, magic-link send, and the auth-change subscription.metagame/services/playerBootstrapfor the bootstrap RPC call and theBootstrapPayloadshape (profile, save, pity).metagame/services/supabaseclient for the recovery-loop session probe.window.location.searchfor the dev-playground flag,windowfor the offline-mirror global,localStoragefor the welcome-shown flag,crypto.randomUUID()for the offline profile id.
PUSHES TO
engine/telemetry/senderviasetTelemetryPlayerIdwhenever the active user id changes (init, auth-change subscription, offline entry, sign-out path).engine/telemetry/samplerviaSampler.setPlayeron the same transitions.engine/telemetry/diagviasetDiagPlayerIdon the same transitions.window.__PLAYER_STORE_OFFLINE__flag, written when entering offline mode and cleared when the recovery probe succeeds — read by the RPC layer to no-op cloud writes without circular imports.- React subscribers via Zustand
set; no signals, no event bus.
DOES NOT
- Own identity. Supabase Auth is authoritative; this store only projects the current user object.
- Own profile / wallet / inventory creation. The
bootstrap_playerRPC is authoritative; this store only stamps the returned payload into local fields and hydrates downstream UI state. - Run gameplay state, run definitions, inventory entries, wallet balances, or any other domain data — only the profile / save / pity slice handed back by bootstrap.
- Validate auth tokens, mint magic links, or talk to the OAuth flow — that lives in
metagame/services/auth. - Decide when to gate the app shell — exposes the
bootstrappedflag and lets the shell read it. - Render the welcome modal, sync indicator, or offline splash — exposes the flags those components read.
- Persist save state back to the server — this store only ingests the payload at bootstrap; cloud writes go through the RPC layer.
- Talk to game engine systems beyond pushing the player id into telemetry; engine state lives elsewhere.
Signals fired / Signals watched — none. The store communicates with React via Zustand subscriptions, with Supabase via direct service calls, with telemetry via direct setter calls, and with the RPC layer via a window-global flag. No engine signals are emitted or subscribed.
Entry points
usePlayerStore— the Zustand hook React components select from.init— full mount-time bootstrap: dev-flag short-circuit, anonymous session, bootstrap RPC, telemetry wiring, welcome-modal detection, auth-change subscription install.retrySync— Settings-driven re-run ofinitfrom the error state.signIn— anonymous sign-in path that refreshes the local user handle.signOut— clears profile / save / pity / bootstrapped, then re-runsinitto land on a fresh anon session.claimWithEmail— fires the magic link; completion is observed through the auth-change subscription installed ininit.setProfile— re-projects a fresh bootstrap profile payload into the store.setSyncStatus— external setter for the sync state machine.setPity— overwrites the pity map from a fresh server response.dismissWelcome— sets thewelcome_shownlocalStorage flag and hides the modal.enterOfflineMode— mints the synthetic profile, flips the window-global, wires telemetry, and starts the recovery interval.dismissRecoveryToast— hides the one-shot “CONNECTED” toast after offline recovery.
Pattern notes
- Single store today (
playerStore); the directory is set up for additional Zustand slices but currently holds only this file. - The dev-flag branch in
initreturns early before any auth or RPC work, so playground sessions never touch Supabase. - The auth-change handler captures the previous user id from
get().userbefore callingset({ user })— a re-bootstrap fires on id change, and a separate branch handles the anon-to-claimed case where the id is stable butemailappears for the first time. - Offline mode communicates with the RPC layer through a window-global rather than a direct import, deliberately avoiding a circular module graph between the store and the service layer.
- The recovery interval is held in a module-scope variable (not store state) so it survives store-level resets and can be enforced single-instance; entering offline mode clears any prior handle before scheduling.
- The recovery probe wraps
supabase.auth.getSession()in aPromise.raceagainst a fixed timeout so a partial outage (auth up, DB down) cannot stall the loop. - Telemetry player-id is set in three coordinated calls (sender, sampler, diag) at every identity transition; the trio always moves together.
- The welcome-modal trigger is gated by both the
claimedaccount state and the absence of thewelcome_shownlocalStorage flag, so the modal fires exactly once per claim across reloads.