PURPOSE
Zustand store that owns the client-side projection of player identity, profile, and bootstrap lifecycle. Authority lives in Supabase Auth and the bootstrap_player RPC; this store mirrors that server state into React. Drives the App shell gating (loading splash, dead-Supabase splash, welcome modal, recovery toast) and routes all auth-state transitions back through Supabase APIs.
OWNS
user: User | null— Supabase Auth user handle.loading: boolean— true while init is in flight.profile: PlayerProfile | null— client shape{ id, displayName, email, accountState, role, analyticsOptIn, createdAt }projected fromBootstrapPayload['profile'].bootstrapped: boolean— true once the bootstrap RPC has succeeded and stores are hydrated; gates the app shell render.bootstrapError: string | null— message captured when init throws.syncStatus: 'idle' | 'syncing' | 'synced' | 'error'.syncError: string | null— set wheneversyncStatustransitions toerror.save: BootstrapPayload['save'] | null— cloud save blob handed back by bootstrap.pity: Record<string, number>— pity counters from server.showWelcome: boolean— true on the guest-to-claimed magic-link transition; triggers WelcomeModal so the user can pick a callsign.offlineMode: boolean— true when the user opted into single-player after a dead Supabase.recoveryToast: boolean— one-shot CONNECTED toast after offline mode auto-recovers.- Module-scope
recoveryTimerhandle so the recovery interval has single-instance scheduling and can be cleared cleanly. - Account-state vocabulary
AccountState = 'guest' | 'claimed'andSyncStatus = 'idle' | 'syncing' | 'synced' | 'error'.
READS FROM
../services/auth—ensureAnonymousSession,signInAnonymously,getUser,signOut,sendMagicLink,onAuthChange.../services/playerBootstrap—bootstrapPlayer()plus theBootstrapPayloadtype.../services/supabase— the raw client, used only for thesupabase.auth.getSession()recovery probe.zustandcreatefor store construction.@supabase/supabase-jsUsertype.window.location.search— checks for thedevquery param to short-circuit init in dev playground mode.localStorage—welcome_shownflag to fire the welcome modal exactly once per claim.crypto.randomUUID()— generates a stable offline profile id.
PUSHES TO
../../starship-survivors/engine/telemetry/sendersetTelemetryPlayerId— keeps telemetry attribution synced to the current user.../../starship-survivors/engine/telemetry/samplerSampler.setPlayer— same for the sampler.../../starship-survivors/engine/telemetry/diagsetDiagPlayerId— same for diagnostics.window.__PLAYER_STORE_OFFLINE__— global flag set on offline entry and cleared on recovery soinvokeRpc()can short-circuit without importing the store (avoids a circular module graph).localStorage.welcome_shown— written fromdismissWelcome.console.error— bootstrap, re-bootstrap, and sign-out re-init failures.
DOES NOT
- Does not write to Supabase directly — only the auth-service wrappers and
bootstrapPlayermay. - Does not own profile/wallet/inventory creation; those live in the
bootstrap_playerRPC. - Does not own identity — Supabase Auth does. The store cannot mint or upgrade users.
- Does not persist itself via
zustand/persist; cloud is the source of truth, with offline mode as a separate explicit branch. - Does not handle wallet, inventory, missions, or pity logic beyond storing the raw
pitymap andsaveblob. Those domains own their own stores. - Does not retry bootstrap automatically on error. The user must tap Retry Sync, which calls
retrySync→init. - Does not import
invokeRpcor any store that imports it — communication is via thewindowflag.
Signals
bootstrappedflippingfalse → trueis the signal that the app shell can render.syncStatusis the signal surface for Settings UI; transitions:idle → syncing → syncedon success,idle → syncing → erroron failure,error → syncing → syncedon retry.showWelcomebecomingtrueis the signal for the WelcomeModal; dismissed viadismissWelcomewhich also sets thewelcome_shownlocalStorage flag.offlineModebecomingtrueis the signal to the rest of the metagame that all cloud writes must no-op.recoveryToastbecomingtrueis the one-shot signal for the CONNECTED toast; cleared bydismissRecoveryToast.- The
auth.onAuthChangesubscription installed at the end ofinitis the signal channel for magic-link claims and identity swaps. Two branches: identity changed (changedUser.id !== previousUserId) triggers re-bootstrap, or same user gained an email (anonymous-to-claimed upgrade on the same id) triggers re-bootstrap and setsshowWelcome.
Entry points
init()— full lifecycle, called once byApp.tsxon mount. Dev-playground branch short-circuits with a synthetic profile when the URL has thedevquery param. Otherwise: ensure anonymous session, wire telemetry ids, setsyncStatus: 'syncing', runbootstrapPlayer(), hydrate profile/save/pity, setbootstrapped: trueandsyncStatus: 'synced', then install the auth-change subscription.retrySync()— clearssyncError/bootstrapError, setssyncStatus: 'syncing', callsinit()again. Wired to the Settings “Retry Sync” button.signIn()— callsauth.signInAnonymously()thenauth.getUser(). Used independently ofinit.signOut()— callsauth.signOut(), clears user/profile/save/pity, then re-runsinit()to avoid leaving the app stuck on the loading spinner.claimWithEmail(email)— firesauth.sendMagicLink(email). The actual claim completes asynchronously when the user clicks the link and the auth-change handler re-bootstraps.enterOfflineMode()— primes a syntheticoffline-<uuid>guest profile, setsbootstrapped: trueandofflineMode: true, mirrors the window flag, wires telemetry to the offline id, and starts the 60s recovery interval.dismissWelcome()— writeswelcome_shownto localStorage, clearsshowWelcome.dismissRecoveryToast()— clearsrecoveryToast.setProfile(payload),setSyncStatus(status),setPity(map)— narrow setters used by other systems after server responses.
Pattern notes
- Authority model is explicit: Supabase Auth owns identity, the
bootstrap_playerRPC owns row creation, and this store is read-mostly client projection. - Profile shape conversion goes through
profileFromPayload(), which camelCases the snake_case server payload. All paths that hydrate from aBootstrapPayloaduse it. - Init guards against the previous-user race by capturing
previousUserId = get().user?.idbefore callingset({ user: changedUser })— otherwise the equality check would always read the just-replaced value. - Two distinct re-bootstrap branches in the auth-change handler: identity change (different
user.id) versus same-id email upgrade. The second branch setsshowWelcome: trueso the user can pick a callsign. - The dev-playground branch casts
accountState: 'anonymous'even though the public union is'guest' | 'claimed'; the cast is intentional and isolated to that branch. - Offline mode is opt-in only — the splash screen calls
enterOfflineMode()after the user chooses single-player. The store never enters offline mode automatically on a bootstrap failure. - Offline-mode recovery uses a 5-second timeout race around
supabase.auth.getSession()because a partial outage (auth up, DB down) can otherwise hang the probe indefinitely. - The
__PLAYER_STORE_OFFLINE__window flag exists specifically to break the import cycle that would otherwise form between this store andinvokeRpc. - The recovery interval is module-scoped, not store-scoped, so a hot-reload or repeated
enterOfflineMode()call cannot stack multiple intervals. The handler also self-clears ifofflineModeflipped back to false externally. - All telemetry id wiring happens in three places (
setTelemetryPlayerId,Sampler.setPlayer,setDiagPlayerId) and is duplicated acrossinit, the auth-change handler, andenterOfflineMode— staying in lockstep is load-bearing for run attribution. signOutis followed byinitso the app never gets stuck onbootstrapped: falseafter a sign-out.- Error handling is consistent: catch, coerce to a string message, log to console, set both
bootstrapError/syncErrorandsyncStatus: 'error'.