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 from BootstrapPayload['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 whenever syncStatus transitions to error.
  • 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 recoveryTimer handle so the recovery interval has single-instance scheduling and can be cleared cleanly.
  • Account-state vocabulary AccountState = 'guest' | 'claimed' and SyncStatus = 'idle' | 'syncing' | 'synced' | 'error'.

READS FROM

  • ../services/authensureAnonymousSession, signInAnonymously, getUser, signOut, sendMagicLink, onAuthChange.
  • ../services/playerBootstrapbootstrapPlayer() plus the BootstrapPayload type.
  • ../services/supabase — the raw client, used only for the supabase.auth.getSession() recovery probe.
  • zustand create for store construction.
  • @supabase/supabase-js User type.
  • window.location.search — checks for the dev query param to short-circuit init in dev playground mode.
  • localStoragewelcome_shown flag to fire the welcome modal exactly once per claim.
  • crypto.randomUUID() — generates a stable offline profile id.

PUSHES TO

  • ../../starship-survivors/engine/telemetry/sender setTelemetryPlayerId — keeps telemetry attribution synced to the current user.
  • ../../starship-survivors/engine/telemetry/sampler Sampler.setPlayer — same for the sampler.
  • ../../starship-survivors/engine/telemetry/diag setDiagPlayerId — same for diagnostics.
  • window.__PLAYER_STORE_OFFLINE__ — global flag set on offline entry and cleared on recovery so invokeRpc() can short-circuit without importing the store (avoids a circular module graph).
  • localStorage.welcome_shown — written from dismissWelcome.
  • 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 bootstrapPlayer may.
  • Does not own profile/wallet/inventory creation; those live in the bootstrap_player RPC.
  • 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 pity map and save blob. Those domains own their own stores.
  • Does not retry bootstrap automatically on error. The user must tap Retry Sync, which calls retrySyncinit.
  • Does not import invokeRpc or any store that imports it — communication is via the window flag.

Signals

  • bootstrapped flipping false → true is the signal that the app shell can render.
  • syncStatus is the signal surface for Settings UI; transitions: idle → syncing → synced on success, idle → syncing → error on failure, error → syncing → synced on retry.
  • showWelcome becoming true is the signal for the WelcomeModal; dismissed via dismissWelcome which also sets the welcome_shown localStorage flag.
  • offlineMode becoming true is the signal to the rest of the metagame that all cloud writes must no-op.
  • recoveryToast becoming true is the one-shot signal for the CONNECTED toast; cleared by dismissRecoveryToast.
  • The auth.onAuthChange subscription installed at the end of init is 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 sets showWelcome.

Entry points

  • init() — full lifecycle, called once by App.tsx on mount. Dev-playground branch short-circuits with a synthetic profile when the URL has the dev query param. Otherwise: ensure anonymous session, wire telemetry ids, set syncStatus: 'syncing', run bootstrapPlayer(), hydrate profile/save/pity, set bootstrapped: true and syncStatus: 'synced', then install the auth-change subscription.
  • retrySync() — clears syncError/bootstrapError, sets syncStatus: 'syncing', calls init() again. Wired to the Settings “Retry Sync” button.
  • signIn() — calls auth.signInAnonymously() then auth.getUser(). Used independently of init.
  • signOut() — calls auth.signOut(), clears user/profile/save/pity, then re-runs init() to avoid leaving the app stuck on the loading spinner.
  • claimWithEmail(email) — fires auth.sendMagicLink(email). The actual claim completes asynchronously when the user clicks the link and the auth-change handler re-bootstraps.
  • enterOfflineMode() — primes a synthetic offline-<uuid> guest profile, sets bootstrapped: true and offlineMode: true, mirrors the window flag, wires telemetry to the offline id, and starts the 60s recovery interval.
  • dismissWelcome() — writes welcome_shown to localStorage, clears showWelcome.
  • dismissRecoveryToast() — clears recoveryToast.
  • 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_player RPC 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 a BootstrapPayload use it.
  • Init guards against the previous-user race by capturing previousUserId = get().user?.id before calling set({ 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 sets showWelcome: true so 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 and invokeRpc.
  • 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 if offlineMode flipped back to false externally.
  • All telemetry id wiring happens in three places (setTelemetryPlayerId, Sampler.setPlayer, setDiagPlayerId) and is duplicated across init, the auth-change handler, and enterOfflineMode — staying in lockstep is load-bearing for run attribution.
  • signOut is followed by init so the app never gets stuck on bootstrapped: false after a sign-out.
  • Error handling is consistent: catch, coerce to a string message, log to console, set both bootstrapError/syncError and syncStatus: 'error'.