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
pointsvalue, capped at 100. - Per-track
claimedChestsarray of chest indices (0-4) already opened. - Storage key
ss_level_tracksin localStorage. DEFAULT_TRACKconstant{ points: 0, claimedChests: [] }.- Zustand store instance
useLevelTrackStore.
READS FROM
LEVEL_REWARD_TRACKfrom@starship-survivors/data/reward-cards— list of chest definitions withthresholdandrewards.LEVEL_TRACK_POINTS_PER_RUNfrom@starship-survivors/data/reward-cards— tier-keyed point values (falls back tobronze).REWARD_CARD_MAPfrom@starship-survivors/data/reward-cards— used to resolvecardIdreferences into card objects.ResolvedRewardtype from@starship-survivors/data/reward-cards.localStorageunder keyss_level_tracks(read once at store creation).
PUSHES TO
localStorage[ss_level_tracks]— written on everyaddRunPointsandclaimChestmutation.- Zustand subscribers via
set({ tracks })after each mutation. - Returns
ResolvedReward[]fromclaimChestfor 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
pointsexceed 100 — the cap is enforced insideaddRunPoints. - Does not auto-claim chests when thresholds are crossed — claims are explicit via
claimChest. - Does not throw on missing tier; falls back to
bronzepoint value. - Does not throw on localStorage failure;
loadFromStoragereturns{}andsaveToStorageswallows the error (boundary). - Does not reset, delete, or migrate tracks — there is no clear/reset method.
- Does not validate that a
levelIdcorresponds to a real level.
Signals
addRunPoints(levelId, tier)mutatestracks[levelId].pointsand persists.claimChest(levelId, chestIndex)mutatestracks[levelId].claimedChestsand persists.- Store re-renders any component subscribed to
useLevelTrackStoreafter either mutation. getClaimableChestsandisTrackCompleteare 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 wherepoints >= thresholdand not inclaimedChests.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 whenclaimedChests.length >= LEVEL_REWARD_TRACK.length.LevelTrackStateinterface — exported for type usage.
Pattern notes
- Client-authoritative persistence:
loadFromStorageruns once at module init to seed the store; every mutation callssaveToStoragesynchronously. - Defensive only at boundaries:
try/catchwrapslocalStorageaccess in both load and save paths; internal logic crashes on bad data. - Immutable updates: mutations clone
tracksvia spread, then clone the inner track before assigning back. ensureTrackmutates the passedtracksmap in place to insert a default entry; callers spreadstate.tracksfirst so the original store object is not mutated.getTrackdoes not callensureTrack— 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_MAPis dereferenced insideclaimChestso 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.bronzeuses falsy fallback, so a tier mapped to0would also fall back to bronze.