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 byplanetId.- Progress derivation logic for challenges based on scope (
lifetimevs single-run) and condition type (kill_count,events_completed).
READS FROM
ChallengeDefshape imported from../data/challenges— suppliesid,scope,planetId, andcondition(type,target) used bygetProgress.- Bootstrap rows (
ChallengeCompletionRow,PlanetStatsRow) supplied by the caller from thebootstrap_playerresponse. - Finalize-run rows (
ChallengeCompletionResult) supplied by the caller from thefinalize_runresponse.
PUSHES TO
ChallengePopover.tsx— consumesisCompletedandgetProgressfor display.runProgressionService.ts— invokesaddCompletionsandupdatePlanetStatsafterfinalize_runreturns.
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_playeron app open. - Does not track per-run best stats — single-run challenges report
current: 0until completed (then100%). - Does not award gems or apply rewards;
gems_awardedonChallengeCompletionResultis 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 reportpct: 100. Lifetime challenges readcurrentfromplanetStats[planetId]for the matching condition type. Single-run challenges reportcurrent: 0unless completed.loadFromBootstrap(completions, stats)— replaces both signals from bootstrap rows.addCompletions(newCompletions)— additive merge intocompletions; no-op on empty input.updatePlanetStats(planetId, kills, events)— overwriteskillsandeventsfor the planet and incrementsrunsby 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
createwithset/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.
completionsuses a nativeSetfor O(1) membership.addCompletionsclones the set on update for immutability.getProgressclampspctwithMath.min(100, Math.floor((current / target) * 100))and short-circuits completed challenges to100%.updatePlanetStatsoverwriteskills/events(assumed to be authoritative absolute values fromfinalize_run) but accumulatesrunslocally by+1per 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.