PURPOSE

Hydrated runtime cache of per-planet XP and reward claim state. Supabase is the source of truth; this Zustand store mirrors it for synchronous UI reads. Populated from bootstrap_player on app open and updated from finalize_run / claim_planet_reward RPC responses.

OWNS

  • xp: Record<number, number> — cumulative XP per planet, keyed by numeric planet_id.
  • claims: Record<number, Set<number>> — set of claimed reward levels per planet.
  • Derived selectors: getXp, getLevel, isRewardClaimed, canClaimReward, hasAnyClaim.
  • Mutators: setXp (absolute replace), addXp (optimistic local add), addClaim, loadFromBootstrap (full hydrate).

READS FROM

  • getLevelForXp from ../data/planet-progression — maps cumulative XP to a level (0–10) for a given planet.
  • PlanetId type from ../data/planet-config.
  • Server-shaped rows PlanetXpRow and PlanetRewardClaimRow supplied by the bootstrap caller.

PUSHES TO

  • Nothing directly. The store is a passive cache. UI components (PlanetProgressBar, PlanetTrackViewer) subscribe and re-render on state changes.

DOES NOT

  • Does not call Supabase or any network API.
  • Does not persist to localStorage or any local cache.
  • Does not compute or award XP — runProgressionService calls setXp/addXp after the server returns.
  • Does not validate XP totals or reject claim levels — trusts the caller.
  • Does not handle authentication, multi-account swapping, or store reset on logout.
  • Does not own the XP-to-level curve — defers to getLevelForXp.

Signals

  • xp map updates on setXp, addXp, and loadFromBootstrap.
  • claims map updates on addClaim and loadFromBootstrap.
  • addClaim clones the existing Set before insertion so subscribers see a new reference.
  • setXp and addXp spread state.xp to produce a new outer object on every write.
  • getXp defaults missing planets to 0. getLevel does the same before delegating to getLevelForXp.
  • canClaimReward returns false if the requested level exceeds the planet’s current level, else true iff the level is not in the claims set.
  • hasAnyClaim scans levels 1..currentLevel and returns true on the first unclaimed level.

Entry points

  • usePlanetProgressStore — the Zustand hook; default export pattern is the named hook.
  • loadFromBootstrap(xpRows, claimRows) — wholesale replace of both maps from bootstrap_player payload.
  • setXp(planetId, total) — server-confirmed XP write after finalize_run.
  • addXp(planetId, amount) — optimistic pre-server XP bump.
  • addClaim(planetId, level) — record a successful claim_planet_reward.
  • Read-only selectors are called directly off the store inside components.

Pattern notes

  • Numeric planet_id keys are used internally; PlanetId (the typed union) is accepted at the surface and coerced via object indexing.
  • claims values are Set<number> rather than arrays — guarantees uniqueness and O(1) has lookups for reward checks.
  • loadFromBootstrap rebuilds the entire Set per planet from row stream; later duplicate rows in the same payload are absorbed by Set semantics.
  • Authority model is server-truth + runtime mirror; there is no merge/conflict logic. The latest server payload wins via setXp (replace) and any local addXp is overwritten on the next server confirmation.
  • All derived state is computed on read inside selectors; no memoization. Callers re-derive levels on every read.
  • PlanetXpRow and PlanetRewardClaimRow are exported so the bootstrap service can type its parse step against the same shape.