PURPOSE

Per-level reward-track progression store. Each level has a 5-chest staged reward track at 20/40/60/80/100% points. Runs deposit tier-weighted points into the level’s track; when thresholds are crossed, chests become claimable and yield resolved reward cards. Client-authoritative — track progress lives only in the browser.

OWNS

  • tracks: Record<string, LevelTrackData> — map of levelId to { points, claimedChests }.
  • Per-track points value, capped at 100.
  • Per-track claimedChests array of chest indices (0-4) already opened.
  • Storage key ss_level_tracks in localStorage.
  • DEFAULT_TRACK constant { points: 0, claimedChests: [] }.
  • Zustand store instance useLevelTrackStore.

READS FROM

  • LEVEL_REWARD_TRACK from @starship-survivors/data/reward-cards — list of chest definitions with threshold and rewards.
  • LEVEL_TRACK_POINTS_PER_RUN from @starship-survivors/data/reward-cards — tier-keyed point values (falls back to bronze).
  • REWARD_CARD_MAP from @starship-survivors/data/reward-cards — used to resolve cardId references into card objects.
  • ResolvedReward type from @starship-survivors/data/reward-cards.
  • localStorage under key ss_level_tracks (read once at store creation).

PUSHES TO

  • localStorage[ss_level_tracks] — written on every addRunPoints and claimChest mutation.
  • Zustand subscribers via set({ tracks }) after each mutation.
  • Returns ResolvedReward[] from claimChest for the caller to grant client-side.
  • Returns the new point total from addRunPoints.

DOES NOT

  • Does not call the server or sync to Supabase — track progress is purely local.
  • Does not grant rewards itself; only returns the resolved-reward list for the caller.
  • Does not let points exceed 100 — the cap is enforced inside addRunPoints.
  • Does not auto-claim chests when thresholds are crossed — claims are explicit via claimChest.
  • Does not throw on missing tier; falls back to bronze point value.
  • Does not throw on localStorage failure; loadFromStorage returns {} and saveToStorage swallows the error (boundary).
  • Does not reset, delete, or migrate tracks — there is no clear/reset method.
  • Does not validate that a levelId corresponds to a real level.

Signals

  • addRunPoints(levelId, tier) mutates tracks[levelId].points and persists.
  • claimChest(levelId, chestIndex) mutates tracks[levelId].claimedChests and persists.
  • Store re-renders any component subscribed to useLevelTrackStore after either mutation.
  • getClaimableChests and isTrackComplete are pure reads — no signal emitted.

Entry points

  • useLevelTrackStore — the Zustand hook export.
  • addRunPoints(levelId, tier) — accumulate points after a run; returns new point total.
  • getClaimableChests(levelId) — returns indices where points >= threshold and not in claimedChests.
  • claimChest(levelId, chestIndex) — marks the chest claimed and returns resolved rewards; returns [] if out-of-range, already claimed, or not yet unlocked.
  • getTrack(levelId) — returns the current track data or a fresh default copy if missing (does not persist the default).
  • isTrackComplete(levelId) — true when claimedChests.length >= LEVEL_REWARD_TRACK.length.
  • LevelTrackState interface — exported for type usage.

Pattern notes

  • Client-authoritative persistence: loadFromStorage runs once at module init to seed the store; every mutation calls saveToStorage synchronously.
  • Defensive only at boundaries: try/catch wraps localStorage access in both load and save paths; internal logic crashes on bad data.
  • Immutable updates: mutations clone tracks via spread, then clone the inner track before assigning back.
  • ensureTrack mutates the passed tracks map in place to insert a default entry; callers spread state.tracks first so the original store object is not mutated.
  • getTrack does not call ensureTrack — it returns a fresh default copy without persisting, so read-only callers don’t create empty entries in storage.
  • Reward resolution happens at claim time, not at chest definition time — REWARD_CARD_MAP is dereferenced inside claimChest so missing cards are silently dropped from the resolved array.
  • Threshold check uses >=, so a chest at exactly its threshold is claimable.
  • LEVEL_TRACK_POINTS_PER_RUN[tier] || LEVEL_TRACK_POINTS_PER_RUN.bronze uses falsy fallback, so a tier mapped to 0 would also fall back to bronze.