What changed, and why
stores/game.ts had grown to 2,852 lines holding nine unrelated jobs: the turn pipeline, contact grounding, the in-game Archive, era-aware suggestions, the streamed chronicle, the run economy, persistence, and replay lineage. This PR splits the four self-contained slices into their own Pinia stores and moves two pure helpers to utils/, leaving a focused 1,864-line core — a 35% cut.
Six commits, each independently green (vitest + typecheck + lint + format), so you can review them one checkpoint at a time. The order is low-risk first: dedup a shared parser, delete a dead pipeline, extract pure functions, then the three satellite stores. Two things deserve real scrutiny and get their own sections below: the cross-store pattern (it deliberately breaks a repo norm), and the one change that is NOT a pure move — a staleness bug-fix in the Archive.
The satellite pattern — the architectural decision
The four new stores read core run-state (the run epoch, the objective, the ledger, the run-tagging headers). To reach it they call useGameStore() INSIDE the action body, not at module load — and core reaches back the same way with useEconomyStore() / useChronicleStore() / etc. This one-time breaks the repo's "no store imports another" convention; it's the explicit sign-off item.
Core statically imports the four satellites' useX — but only INVOKES them inside actions.
stores/game.ts · 1864 lines
⋯ 1858 lines hidden (lines 7–1864)
The mutual static import is safe because defineStore is lazy: a store isn't created until its useX() runs, at call time, so there's no module-load cycle. The economy store's header states the contract, and its one write back into core shows the shape — the paywall flag outOfRuns stays in core (it's run-coupled and reset by resetGame), so the satellite clears it through a lazy handle.
notePurchaseReturn (economy) clears core's outOfRuns via a lazy useGameStore().
stores/economy.ts · 193 lines
⋯ 146 lines hidden (lines 1–146)
⋯ 36 lines hidden (lines 158–193)
Step 1 — one SSE parser instead of two
The chronicle stream and the turn stream each carried a verbatim copy of the same byte parser: accumulate a buffer, split complete frames on a blank line, pull the data: line, JSON.parse it, carry a partial frame forward. Extracted to one pure helper; each call site keeps its own reader loop, epoch guards, and frame dispatch (inline for the chronicle, an onFrame callback for the turn).
drainSSEFrames — returns the decoded payloads in order plus the leftover partial frame.
utils/sse.ts · 43 lines
⋯ 18 lines hidden (lines 1–18)
Step 2 — retiring the dead sendMessage pipeline
The store held TWO turn pipelines: the streaming resolveTurn → commitReveal → commitRipple that the UI actually drives (via useCodexConductor), and a legacy non-streaming sendMessage (~251 lines) with ZERO production callers — a hand-maintained mirror whose own comment warned its writes had to land "in exactly the order sendMessage makes them." Deleting it removes that drift hazard and leaves one pipeline.
Its 32 tests ported to resolveTurn with a near-mechanical rename: in the no-window test env resolveTurn takes its inline path, calling commitReveal + commitRipple itself and accepting the same plain-JSON mock via applyPlain — a faithful drop-in. The two tests that asserted sendMessage's internal reveal TIMER cadence were deleted (that pacing is the conductor's job now, and the behavior is already twinned in game-codex.spec.ts). The orphaned REVEAL timings and wait() helper went with it.
Step 3 — pure serializers move next to their types
saveRunSnapshot and saveRunDraft each hand-built their payload inline — roll marks, the dispatch→ledger zip, the ledger trim, Date→ISO stamps — before $fetching it. That build logic moved to utils/run-snapshot.ts, alongside the RunSnapshot / RunDraft types it produces, as two pure functions. The store actions keep their guards, the gameSummary reads, and the epoch-guarded fetch.
snapshotFrom — pure (no this, no store, no fetch); store shapes come in as a plain input.
utils/run-snapshot.ts · 511 lines
⋯ 178 lines hidden (lines 1–178)
⋯ 311 lines hidden (lines 201–511)
hydrateDraft (the resume path) deliberately stayed in the store: its hydration interleaves an async re-ground, a mid-tail epoch guard, and an impure Date revive, so it isn't a clean pure mapping. A new spec pins field-completeness for both builders, including the conditional fields (whenSigned, causalChain, efficiency, staked).
Step 5 — the one change that isn't a pure move: an Archive staleness fix
Every async store action guards against a stale response two ways: the run epoch, and a per-kind sequence counter — so an overlapping call lands newest-only and a dead-run response is dropped. The Archive's studyActiveFigure and askArchive had only the epoch half. A slow study for a moment the player had since moved off could therefore land late, over a newer one, and its finally could clear the newer call's loading flag.
This step adds the missing studySeq / lookupSeq with the canonical shape — capture before the first await, re-check both at every checkpoint.
studyActiveFigure: capture epoch + ++studySeq before the await; bail at each checkpoint unless BOTH still hold.
stores/archive.ts · 214 lines
⋯ 77 lines hidden (lines 1–77)
⋯ 109 lines hidden (lines 106–214)
Step 6 — the chronicle, and staleness across a store boundary
The streamed living-chronicle slice is the riskiest to move: its refreshChronicle is fired from two call sites with different timing, streams into five fields, and is read back by four components, the soundscape, and both serializers. The staleness guard is the load-bearing part — and it now spans two stores. It captures the epoch off core and the seq off the chronicle store, and checks both at every checkpoint, so overlapping rewrites still land newest-only.
The cross-store guard: epoch from the game store, seq from this store — checked together.