PURPOSE
Zustand store that tracks which alien artifacts are unlocked as starting-artifact options, which one the player has chosen for the next run, and the highest runtime tier ever reached for each artifact across all past runs. Source of truth for the hub artifact picker. Persists to localStorage only (v1); Supabase persistence is a follow-up so unlocks don’t tie to a single device.
OWNS
unlockedIds: Set<string>— set of unlocked artifact IDs.selectedId: string— currently selected starting artifact; guaranteed to be inunlockedIds.bestTier: Record<string, number>— highest runtime tier (0-4) ever reached for each artifact in any past run. Missing key means never seen;getBestTierreturns -1 for missing.- The
DEFAULT_UNLOCKED_ARTIFACTSconstant (vitality_surge,trigger_happy) — exported as the seed for fresh accounts. - The
REMOVED_ARTIFACTSconstant (force_field) — IDs stripped from legacy localStorage on hydrate so retired artifacts don’t linger. - The
STARTER_BEST_TIERconstant (4) — starters are seeded as legendary so the picker reads as “already mastered” out of the gate. - The three localStorage keys:
starpunk.artifactUnlocks,starpunk.selectedArtifactId,starpunk.artifactBestTier.
READS FROM
localStoragevia internal helpersreadUnlocks,readSelected,readBestTierat store-construction time and again onhydrate().- No other store, no engine module, no data table — fully self-contained.
PUSHES TO
localStoragevia internal helperswriteUnlocks,writeSelected,writeBestTieron every mutation. All writes are wrapped in try/catch so quota or private-mode failures degrade to in-memory-only.- Mutations call
set(...)to update Zustand state; consumers re-render through the standarduseArtifactUnlocksStoreselector subscription.
DOES NOT
- Does not know what an artifact actually does — only IDs as opaque strings.
- Does not validate IDs against an artifact catalog; an unknown ID can be unlocked or selected without complaint (other than
selectrequiring it be present inunlockedIds). - Does not push to Supabase, network, or telemetry. No cloud sync in v1.
- Does not grant artifacts at run start — the engine reads
selectedIdat run-assembly time and grants the artifact itself. - Does not detect new unlocks during a run — the engine writes new unlocks into
MissionResult.progressionand the hub flushes them in viaunlockManyon the run-stats screen. - Does not auto-hydrate on browser focus or storage events. Callers must call
hydrate()if they edit localStorage externally. - Does not migrate retired artifacts away from
selectedIddirectly; thereadSelectedguard rejects anyselectedIdthat matchesREMOVED_ARTIFACTSand falls back to a default.
Signals
unlock(id)— adds one ID. No-op if already unlocked. Persists.unlockMany(ids)— bulk-adds. Persists once. Returns the IDs that were newly unlocked (not previously known).select(id)— setsselectedId. ThrowsartifactUnlocksStore.select: <id> is not unlockedif the ID is not inunlockedIds. Persists.recordBestTier(id, tier)— bumps the recorded best-tier iftieris strictly higher than the existing record. Silently ignores tiers outside[0, 4]. Persists.recordBestTierMany(entries)— bulk variant; only writes if at least one entry beats its prior. Persists once.hydrate()— reload all three slices from localStorage; used on bootstrap and after external edits.resetToDefaults()— clears to seed state (defaults unlocked, first default selected, starter best-tier 4). Dev/test only. Persists.
Entry points
useArtifactUnlocksStore— exported Zustand hook. The default-export-style entry point for all consumers.DEFAULT_UNLOCKED_ARTIFACTS— exported readonly tuple; consumed by hub UI and any caller that wants to display the seed set without instantiating the store.
Pattern notes
- Construction-time hydration: the
create<...>callback callsreadUnlocks(),readSelected(), andreadBestTier()once. This means the store is correct after first import even before any component mounts, but multiple imports across module-graph instances would each re-hydrate (not a concern in this app’s single-bundle build). - Defaults are merged on every read:
readUnlocksalways re-addsDEFAULT_UNLOCKED_ARTIFACTSto the parsed set, protecting against hand-edited localStorage that omits them. - One-shot migration pattern:
REMOVED_ARTIFACTSare stripped from the parsed set on read, then re-written back so the migration only fires once per device. - Selectors live on the state object (
isUnlocked,unlockedList,getBestTier) rather than as separate hooks; this matches the rest of the project’s Zustand stores. - Best-tier bump is monotonic — a lower
tiernever overwrites a higher one. Thetier | 0coercion inreadBestTierforces integer storage even if a caller persisted a float. - All localStorage writes are best-effort; quota or private-mode failures are swallowed and the in-memory state remains authoritative for the session.
- The
STARTER_BEST_TIER = 4seed means the picker treats default starters as already-mastered even on a brand-new device, removing a “mystery” badge state for the seed artifacts.