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 in unlockedIds.
  • bestTier: Record<string, number> — highest runtime tier (0-4) ever reached for each artifact in any past run. Missing key means never seen; getBestTier returns -1 for missing.
  • The DEFAULT_UNLOCKED_ARTIFACTS constant (vitality_surge, trigger_happy) — exported as the seed for fresh accounts.
  • The REMOVED_ARTIFACTS constant (force_field) — IDs stripped from legacy localStorage on hydrate so retired artifacts don’t linger.
  • The STARTER_BEST_TIER constant (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

  • localStorage via internal helpers readUnlocks, readSelected, readBestTier at store-construction time and again on hydrate().
  • No other store, no engine module, no data table — fully self-contained.

PUSHES TO

  • localStorage via internal helpers writeUnlocks, writeSelected, writeBestTier on 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 standard useArtifactUnlocksStore selector 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 select requiring it be present in unlockedIds).
  • Does not push to Supabase, network, or telemetry. No cloud sync in v1.
  • Does not grant artifacts at run start — the engine reads selectedId at run-assembly time and grants the artifact itself.
  • Does not detect new unlocks during a run — the engine writes new unlocks into MissionResult.progression and the hub flushes them in via unlockMany on 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 selectedId directly; the readSelected guard rejects any selectedId that matches REMOVED_ARTIFACTS and 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) — sets selectedId. Throws artifactUnlocksStore.select: <id> is not unlocked if the ID is not in unlockedIds. Persists.
  • recordBestTier(id, tier) — bumps the recorded best-tier if tier is 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 calls readUnlocks(), readSelected(), and readBestTier() 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: readUnlocks always re-adds DEFAULT_UNLOCKED_ARTIFACTS to the parsed set, protecting against hand-edited localStorage that omits them.
  • One-shot migration pattern: REMOVED_ARTIFACTS are 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 tier never overwrites a higher one. The tier | 0 coercion in readBestTier forces 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 = 4 seed means the picker treats default starters as already-mastered even on a brand-new device, removing a “mystery” badge state for the seed artifacts.