PURPOSE

Hydrated runtime cache of challenge completions and lifetime per-planet stats. Supabase owns truth; this store is populated from bootstrap_player on app open and updated from finalize_run RPC responses. Provides completion lookup and progress computation for UI display.

OWNS

  • completions: Set<string> of challenge IDs the player has finished.
  • planetStats: Record<number, { kills, events, runs }> — lifetime cumulative counts keyed by planetId.
  • Progress derivation logic for challenges based on scope (lifetime vs single-run) and condition type (kill_count, events_completed).

READS FROM

  • ChallengeDef shape imported from ../data/challenges — supplies id, scope, planetId, and condition (type, target) used by getProgress.
  • Bootstrap rows (ChallengeCompletionRow, PlanetStatsRow) supplied by the caller from the bootstrap_player response.
  • Finalize-run rows (ChallengeCompletionResult) supplied by the caller from the finalize_run response.

PUSHES TO

  • ChallengePopover.tsx — consumes isCompleted and getProgress for display.
  • runProgressionService.ts — invokes addCompletions and updatePlanetStats after finalize_run returns.

DOES NOT

  • Does not make any network calls or talk to Supabase directly — all hydration and updates are pushed in by callers.
  • Does not persist to local storage; state is in-memory only and rebuilt from bootstrap_player on app open.
  • Does not track per-run best stats — single-run challenges report current: 0 until completed (then 100%).
  • Does not award gems or apply rewards; gems_awarded on ChallengeCompletionResult is consumed elsewhere.
  • Does not validate challenge IDs or planet IDs against the data tables.
  • Does not emit any events or subscriptions beyond Zustand’s built-in store subscriptions.

Signals

  • completions: Set<string> — set of completed challenge IDs.
  • planetStats: Record<number, PlanetStats> — per-planet lifetime { kills, events, runs }.
  • isCompleted(challengeId) — boolean lookup.
  • getProgress(challenge) — returns { current, target, pct }. Completed challenges report pct: 100. Lifetime challenges read current from planetStats[planetId] for the matching condition type. Single-run challenges report current: 0 unless completed.
  • loadFromBootstrap(completions, stats) — replaces both signals from bootstrap rows.
  • addCompletions(newCompletions) — additive merge into completions; no-op on empty input.
  • updatePlanetStats(planetId, kills, events) — overwrites kills and events for the planet and increments runs by one.

Entry points

  • useChallengeStore — Zustand hook export (create<ChallengeState>). Exposed selectors and actions: completions, planetStats, isCompleted, getProgress, loadFromBootstrap, addCompletions, updatePlanetStats.
  • Exported types: ChallengeCompletionRow, PlanetStatsRow, ChallengeCompletionResult.

Pattern notes

  • Zustand create with set/get; no middleware, no persistence, no subscriptions beyond the default store.
  • Authority model: Supabase is the source of truth. The store is a read-through cache hydrated by RPCs; UI never writes here directly.
  • completions uses a native Set for O(1) membership. addCompletions clones the set on update for immutability.
  • getProgress clamps pct with Math.min(100, Math.floor((current / target) * 100)) and short-circuits completed challenges to 100%.
  • updatePlanetStats overwrites kills/events (assumed to be authoritative absolute values from finalize_run) but accumulates runs locally by +1 per call — runs count is incremented client-side rather than fetched.
  • Two-dataset shape (completions + planetStats) mirrors the two arrays returned by bootstrap_player.
  • Single-run challenges intentionally show 0% pre-completion because per-run bests are not tracked client-side.