data/run-history

What flows in

  • MissionResult object (from ./mission-result) — passed to saveRunToHistory() after a run ends.
  • nodeId: string — per-planet key used for personal-best lookups and writes.
  • Raw numeric values for tier / kills / events / level — fed to save*PB() strictly-improving writes.

What flows out

  • RunHistorySummary[] — newest-first list returned by getRunHistory(), capped at 20 entries.
  • RunHistorySummary | null — per-field averages from getRunAverages(history); null if no history.
  • { bestScore, bestKills, bestTime, bestTier, bestDamage } — extremes from getPersonalBests(history).
  • number spawn multiplier from getNewAccountSpawnMult() — 0.5 / 0.7 / 0.9 / 1.0 based on prior-run count.
  • Per-planet PB numbers from getTierPB() / getKillsPB() / getEventsPB() / getLevelPB().
  • boolean from save*PB(nodeId, v)true only when the new value strictly beat the prior PB and the write succeeded.

Key symbols

  • RunHistorySummary interface — 16-field compact snapshot of a run (identity, performance, outcome, combat, progression, economy, exploration).
  • HISTORY_KEY = 'ss_run_history' — localStorage key for the FIFO buffer.
  • MAX_RUNS = 20 — hard cap on stored runs; FIFO drops oldest.
  • TIER_PB_KEY_PREFIX = 'ss_tier_pb_' — per-planet best tier reached.
  • KILLS_PB_KEY_PREFIX = 'ss_kills_pb_' — per-planet best total kills.
  • EVENTS_PB_KEY_PREFIX = 'ss_events_pb_' — per-planet best event count.
  • LEVEL_PB_KEY_PREFIX = 'ss_level_pb_' — per-planet best level reached.
  • readPB(prefix, nodeId) — parses localStorage int, returns 0 on missing / non-finite / non-positive / storage error.
  • writePB(prefix, nodeId, value) — strictly-improving: rejects non-finite, <= 0, or <= prev; swallows storage failure.
  • buildRunSummary(result) — extracts 16 fields from MissionResult and stamps playedAt with new Date().toISOString().
  • saveRunToHistory(result)unshifts new summary, truncates to MAX_RUNS, JSON.stringifylocalStorage.setItem.
  • getRunHistory()JSON.parse from localStorage; returns [] on missing / parse error.
  • getNewAccountSpawnMult() — new-account difficulty dampener; reads getRunHistory().length and maps to spawn-rate multiplier.
  • getRunAverages(history) — sums each numeric field, divides by n, Math.rounds; identity / outcome / rewardTier fields blanked.
  • getPersonalBests(history)Math.max over score, totalKills, timeElapsedSeconds, tierReached, damageDealt.
  • saveTierPB / saveKillsPB / saveEventsPB / saveLevelPB — public wrappers around writePB.
  • getTierPB / getKillsPB / getEventsPB / getLevelPB — public wrappers around readPB.

Where it’s used

  • Post-run stats / reveal screens — read history for comparison and averages.
  • RevealScreen four progress bars — driven by per-planet tier / kills / events / level PBs.
  • LootBag drop-count rule — uses tier PB only (per code comment).
  • Spawn-rate logic — calls getNewAccountSpawnMult() to soften the first ~10 sessions.
  • Mission completion flow — calls saveRunToHistory(result) with the final MissionResult.

Gotchas

  • This is the one data file that touches localStorage directly. All other persistence in the game runs through Supabase or in-memory stores. Anything that needs cross-device sync does not belong here.
  • Browser-only. Any caller that imports this module must run in a browser context — there’s no typeof window guard, but every localStorage access is wrapped in try/catch that swallows failure.
  • Silent failures everywhere. localStorage quota-exceeded, parse errors, missing keys — all swallowed with empty-array / zero defaults. No telemetry, no Sentry breadcrumb, no user-visible signal.
  • FIFO is unshift + length =, not a ring buffer. Newest run is always index 0; oldest is dropped by re-assigning history.length = MAX_RUNS.
  • Strictly-improving PB writes. writePB returns false on stale value — callers that need to know “did I just set a new PB?” should check the return value. Equal-to-prev is treated as stale (not a new PB).
  • readPB accepts only positive integers. A stored 0, negative, NaN, or Infinity reads back as 0. parseInt with radix 10 is used — trailing non-numeric characters are silently truncated.
  • getNewAccountSpawnMult caps at prior-run count of 10+, but the history buffer caps at 20. The comment claims “10 or more reliably means normal rate even on very long accounts” — this is correct only because MAX_RUNS = 20 >= 10. If MAX_RUNS ever drops below 10, the dampener will re-engage for long-tenured players.
  • Averages blank out non-numeric fields. getRunAverages returns a RunHistorySummary shape with playedAt: '', nodeId: '', shipId: '', rewardTier: '', survived: false. Consumers must treat the result as numeric-only.
  • No migration / versioning. If RunHistorySummary ever adds or renames a field, old stored runs will deserialize with undefined for the new field. No schema version is written alongside the data.
  • No multi-tab coordination. Two tabs writing concurrently will last-writer-wins on JSON.stringify(history); no storage event listener.
  • buildRunSummary stamps playedAt at save time, not run-end time. If the call is delayed, the timestamp drifts from actual end-of-run.

EXTRACT-CANDIDATE

This file mixes three concerns:

  1. Run history FIFO buffer (ss_run_history, MAX_RUNS, saveRunToHistory, getRunHistory, getRunAverages, getPersonalBests).
  2. Per-planet personal bests (ss_tier_pb_*, ss_kills_pb_*, ss_events_pb_*, ss_level_pb_* and their readPB / writePB helpers).
  3. New-account difficulty dampener (getNewAccountSpawnMult) — which only reads run-count, not history content.

If localStorage is ever migrated to Supabase / IndexedDB / encrypted bundle, the boundary cuts cleanly along these three axes. A future split could be:

  • data/run-history.ts — FIFO summaries only.
  • data/planet-pbs.ts — strictly-improving per-planet bests with a shared readPB / writePB core.
  • data/new-account-ramp.ts — spawn dampener (one function, one number).

This is also the natural seam for adding storage-failure telemetry: today every try/catch swallows silently, but a single shared safeRead / safeWrite wrapper could emit a Sentry breadcrumb on quota-exceeded.