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
stores/game.ts1864 lines · TypeScript
1import { defineStore } from "pinia";
2import { useEconomyStore } from "./economy";
3import { useArchiveStore } from "./archive";
4import { useSuggestionsStore } from "./suggestions";
5import { useChronicleStore } from "./chronicle";
6import type { GameObjective, ObjectiveSteer } from "~/utils/objectives";
⋯ 1858 lines hidden (lines 7–1864)
7import type { GroundedFigure } from "~/server/utils/figure-grounding";
8import {
9 formatContactMoment,
10 type ContactMoment,
11} from "~/utils/contact-moment";
12import type { Anachronism } from "~/utils/anachronism";
13import { chainStatus, type ChainStatus } from "~/utils/causal-chain";
14import type { Craft } from "~/utils/craft";
15import type { Continuity } from "~/utils/continuity";
16import type { DiceOutcome } from "~/utils/dice";
17import {
18 generateGameSummary,
19 rateEfficiency,
20 type GameSummary,
21} from "~/utils/game-summary";
22import {
23 TOTAL_MESSAGES,
24 MAX_PROGRESS_SWING,
25 MAX_PROGRESS,
26} from "~/utils/game-config";
27import { prefersReducedMotionNow } from "~/utils/prefers-reduced-motion";
28import { drainSSEFrames } from "~/utils/sse";
29import { snapshotFrom, draftFrom, type RunDraft } from "~/utils/run-snapshot";
30 
31export type Valence = "positive" | "negative" | "neutral";
32 
33/**
34 * A historical figure the player has reached out to. Figures are freeform — the
35 * player can write to anyone, in any era. The AI infers each figure's era and a
36 * short descriptor on first contact, which we cache here for the UI.
37 */
38export interface HistoricalFigure {
39 name: string;
40 era: string;
41 descriptor: string;
43 
44/**
45 * Timeline Ledger entry — a single recorded change to history.
46 *
47 * The ledger is the heart of the game: every resolved turn appends one entry
48 * describing how the world bent, so the player literally watches the timeline
49 * rewrite itself as they play.
50 */
51export interface TimelineEvent {
52 id: string;
53 figureName: string;
54 era: string;
55 headline: string;
56 detail: string;
57 diceRoll: number;
58 diceOutcome: DiceOutcome;
59 progressChange: number;
60 /** The engine's swing BEFORE the anachronism amplifier — shown so the player
61 * can see exactly what their wager did ("+8 ⚡ far-ahead → +11%"). */
62 baseProgressChange?: number;
63 valence: Valence;
64 /** How anachronistic the player's nudge was — it widened this swing. */
65 anachronism?: Anachronism;
66 /** How far this landed from the nearest foothold — it decayed this swing. */
67 causalChain?: ChainStatus;
68 /** The Judge's grade of the dispatch that caused this change. */
69 craft?: Craft;
70 /** The PRE-turn momentum that amplified this swing — the Spine reads it to show
71 * the ✦ momentum factor in the reveal equation, the same as the thread. */
72 momentumAtSwing?: number;
73 /** Signed year of the intervention, when the contact was grounded. */
74 whenSigned?: number;
75 /** True when this was the run's staked last stand (doubled swing). */
76 staked?: boolean;
77 timestamp: Date;
79 
80/**
81 * Message Interface — one line in the conversation with a figure.
82 * Dice / outcome / action / impact / progress are recorded on the AI (figure)
83 * turn, since that is the resolved result of the roll.
84 */
85export interface Message {
86 text: string;
87 sender: "user" | "ai" | "system";
88 timestamp: Date;
89 figureName?: string;
90 /** The figure's Wikipedia portrait (the picture shown at selection), snapshotted
91 * at send time so the reply leaf can show it. Absent when the figure has no
92 * Wikipedia image — the reply leaf then falls back to the monogram. */
93 figureThumbnail?: string;
94 /** The effective (craft-tilted) roll — what the bands judged. */
95 diceRoll?: number;
96 diceOutcome?: DiceOutcome;
97 /** The die as it actually landed, before the craft modifier. */
98 naturalRoll?: number;
99 /** The Judge's tilt on the roll (±2..0), and the grade + reason behind it. */
100 rollModifier?: number;
101 craft?: Craft;
102 craftReason?: string;
103 /** Whether the dispatch built on the run's thread (issue #62) — drives the
104 * momentum meter and the 'builds' badge in the reveal. */
105 continuity?: Continuity;
106 characterAction?: string;
107 timelineImpact?: string;
108 progressChange?: number;
109 /** The pre-amplifier swing + the wager level, for the reveal's equation. */
110 baseProgressChange?: number;
111 /** The PRE-turn momentum that amplified this swing — for the reveal's equation. */
112 momentumAtSwing?: number;
113 anachronism?: Anachronism;
114 /** The causal-chain decay applied to this swing (for the reveal's equation). */
115 causalChain?: ChainStatus;
116 /** True when this turn was the staked last stand. */
117 staked?: boolean;
119 
120export type GameStatus = "playing" | "victory" | "defeat";
121 
122/** The two deferred turn writes, as the exact payloads their store actions take. */
123type AIMessagePayload = Partial<Message> & { text: string; sender: "ai" };
124type RipplePayload = Omit<TimelineEvent, "id" | "timestamp" | "valence"> & {
125 valence?: Valence;
126 timestamp?: Date;
127};
128 
129/**
130 * A resolved-but-not-yet-revealed turn — held between `resolveTurn` (the network
131 * round-trip, the Sending leaf) and the conductor's per-leaf commits
132 * (`commitReveal` on the Dice leaf, `commitRipple` on the Ripple leaf). It decouples
133 * DATA-presence from the timing of the animation/SFX-bearing writes, so the
134 * page-by-page reveal can fire each store write at its own leaf — preserving the
135 * soundscape's "write == beat" lockstep (and every component's animate-on-change)
136 * for free. Holds plain payloads, never the response type: the deferred writes are
137 * exactly the ones `resolveTurn` commits inline (`commitReveal`/`commitRipple`).
138 */
139export interface PendingTurn {
140 epoch: number;
141 target: string;
142 sentWhen: number | null;
143 ledgerBefore: number;
144 aiMessage: AIMessagePayload;
145 ripple: RipplePayload | null;
146 progressChange: number | undefined;
147 momentum: number;
148 /** Precomputed at resolve (from the shared win/lose rule) so the conductor can
149 * route the Ripple leaf straight to the End screen on a terminal turn — the same
150 * verdict the live commit will set, so the two can never diverge. */
151 terminal: boolean;
152 /** The Chronicle/suggestion re-narration, fired EARLY (at resolve, leaf II) on the
153 * animated path so it composes during the III→VI reading and is ready by VII —
154 * stashed here so commitRipple's two-beat persistence can re-save once it lands.
155 * Null on the inline (!animate) path, where commitRipple fires it against live state. */
156 refresh?: Promise<void> | null;
157 /** Streaming: resolves once the WHOLE turn (reply + ripple + final) has landed in
158 * this pending turn — or it was blocked/errored. The conductor's commits await it,
159 * so the reveal can START on the early `dice` frame while the slow Character +
160 * Timeline calls finish; reaching a commit before its data arrived simply waits
161 * here (a graceful micro-state), never an empty leaf. */
162 ready: Promise<void>;
163 /** True once the whole turn has landed (the `final` frame). The commits skip the
164 * `await ready` when this is set, so they run SYNCHRONOUSLY in the common case (and
165 * in tests / the inline path) — an async fn with no await runs its body in the same
166 * tick, preserving the "state present the moment the commit is called" contract. */
167 resolved: boolean;
168 /** Set by a moderation block / failure mid-stream (after the early reveal): the
169 * store has already refunded + bannered, so the commits must NOT write and the
170 * conductor halts the reveal back to the dispatch. */
171 blocked: boolean;
173 
174/**
175 * Shape returned by `POST /api/send-message` (see `server/api/send-message.post.ts`).
176 * Discriminated by `success`: when `true`, all fields downstream of the dice roll are
177 * guaranteed; when `false`, only `userMessage` + `error` are guaranteed (and the
178 * Character-AI-failure path doesn't even surface a dice roll). The discriminant lets
179 * the consumer destructure without optional chaining or null gymnastics.
180 */
181type SendMessageApiResponse =
182 | {
183 success: true;
184 message: string;
185 data: {
186 userMessage: string;
187 figure: { name: string; era: string; descriptor: string };
188 diceRoll: number;
189 diceOutcome: DiceOutcome;
190 // Judge-of-craft fields (optional so older mocks/fixtures stay valid;
191 // the live server always sends them).
192 naturalRoll?: number;
193 rollModifier?: number;
194 craft?: Craft;
195 craftReason?: string;
196 continuity?: Continuity;
197 momentum?: number;
198 staked?: boolean;
199 characterResponse: { message: string; action: string };
200 timeline: {
201 headline: string;
202 detail: string;
203 era: string;
204 progressChange: number;
205 baseProgressChange?: number;
206 momentumAtSwing?: number;
207 valence: Valence;
208 anachronism?: Anachronism;
209 causalChain?: ChainStatus;
210 };
211 error: null;
212 };
213 }
214 | {
215 success: false;
216 message: string;
217 data: {
218 userMessage: string;
219 diceRoll?: number;
220 diceOutcome?: DiceOutcome;
221 characterResponse?: { message: string; action: string };
222 error: string;
223 /** A content-moderation block (not an infra failure): the dispatch was
224 * refused by the detector, the Sentinel, or the model itself. */
225 blocked?: boolean;
226 moderationReason?: string;
227 };
228 };
229 
230const RATE_LIMIT_MS = 1000; // message cooldown window, in ms
231 
232// Turn-reveal pacing is now owned by the client leaf conductor (useCodexConductor),
233// which turns each reveal page on the player's reading rather than on a timer — so the
234// store no longer schedules the beats itself (the old REVEAL timings + `wait` helper
235// went with the retired inline `sendMessage`, #235). Each component still animates when
236// its own slice of state changes; the conductor sequences the WRITES leaf by leaf via
237// commitReveal/commitRipple, so sequencing the writes sequences the animations for free.
238/** Stagger the reveal only in a real browser with motion allowed. SSR and the test
239 * env (no `matchMedia`) collapse to an instant, atomic apply — so the store's
240 * contract (state present the moment `resolveTurn` resolves) is unchanged for tests. */
241function canAnimateReveal(): boolean {
242 // Keep the env check (SSR/test → no matchMedia → false = instant, atomic apply, the
243 // store's test contract); the shared helper owns the actual reduced-motion read.
244 return (
245 typeof window !== "undefined" &&
246 typeof window.matchMedia === "function" &&
247 !prefersReducedMotionNow()
248 );
250 
251function valenceOf(progressChange: number): Valence {
252 if (progressChange > 0) return "positive";
253 if (progressChange < 0) return "negative";
254 return "neutral";
256 
257/** The single win/lose rule. Shared by `resolveGameStatus` (the live flip at
258 * commitRipple) and the resolve-time `terminal` precompute, so a turn's verdict
259 * and any epilogue framing derived from it can never diverge. Mirrors the original
260 * resolveGameStatus exactly: victory at/over the cap, else defeat once the messages
261 * are spent, else still playing. */
262function deriveStatus(progress: number, remaining: number): GameStatus {
263 if (progress >= MAX_PROGRESS) return "victory";
264 if (remaining <= 0) return "defeat";
265 return "playing";
267 
268/** Formats a signed timeline year (AD positive, BCE negative) for display. */
269export function formatContactYear(signed: number): string {
270 return signed < 0 ? `${-signed} BC` : `${signed}`;
272 
273/** Human lifespan line for a grounded figure, e.g. "69 BC – 30 BC" or "1942 – present". */
274function lifespanText(g: GroundedFigure): string | undefined {
275 if (!g.born) return undefined;
276 if (g.died) return `${g.born.display}${g.died.display}`;
277 return g.living ? `${g.born.display} – present` : g.born.display;
279 
280/** True when a $fetch error is a 402 (out of runs) — the paywall signal. ofetch
281 * surfaces the status as statusCode and on the response, so check both. */
282function isPaymentRequired(error: unknown): boolean {
283 const e = error as {
284 statusCode?: number;
285 status?: number;
286 response?: { status?: number };
287 };
288 return (
289 e?.statusCode === 402 || e?.status === 402 || e?.response?.status === 402
290 );
292 
293/** True when a $fetch error is a 503 / at-capacity — the spend cap paused new
294 * runs (distinct from the per-device 402 paywall). */
295function isAtCapacity(error: unknown): boolean {
296 const e = error as {
297 statusCode?: number;
298 status?: number;
299 response?: { status?: number };
300 data?: { atCapacity?: boolean };
301 };
302 return (
303 e?.statusCode === 503 ||
304 e?.status === 503 ||
305 e?.response?.status === 503 ||
306 e?.data?.atCapacity === true
307 );
309 
310export type ContactLiveness =
311 "ok" | "before-birth" | "after-death" | "living" | "unresolved" | "unknown";
312 
313/**
314 * A replay seed (issue #89) — when a player starts a run from a shared run's captured
315 * objective. It carries the objective to seed (verbatim, no generation), the SOURCE run's
316 * public share token (rides along to /api/run-save to stamp the lineage pointer), and the
317 * source's display name at seed time (for the "remixed from" credit shown before/after the
318 * run; the public page re-derives the credit live, honoring the source's current opt-in).
319 */
320export interface ReplaySeed {
321 objective: GameObjective;
322 sourceToken: string;
323 sourceAttribution: string | null;
325 
326/**
327 * Game Store — core state for a session of freeform timeline editing.
328 */
329export const useGameStore = defineStore("game", {
330 state: () => ({
331 remainingMessages: TOTAL_MESSAGES,
332 messageHistory: [] as Message[],
333 gameStatus: "playing" as GameStatus,
334 isLoading: false,
335 error: null as string | null,
336 lastMessageTime: null as number | null,
337 isRateLimited: false,
338 // Staleness plumbing, not run state. runEpoch increments on every reset so an
339 // async result from a dead run can never write into a fresh one; the seq
340 // counters order overlapping requests of the same kind so only the latest
341 // lands (an earlier chronicle rewrite must not overwrite a later one). The
342 // suggestions/archive slices carry their OWN seq counters (stores/suggestions.ts,
343 // stores/archive.ts) since #235 split them out. Deliberately NOT restored by
344 // resetGame — they must survive it to work.
345 runEpoch: 0,
346 chronicleSeq: 0,
347 groundingSeq: 0,
348 // The current run's id — lazily minted on the run's first server call and
349 // cleared by resetGame so each run gets a fresh one. Tags every AI call
350 // (via the x-run-id header) so the gateway's cost telemetry groups per
351 // run: the GTM cost instrument.
352 runId: null as string | null,
353 // The in-flight begin-run, so concurrent first-calls of a run share one
354 // mint (one POST /api/run, one runs row) instead of racing two. Cleared
355 // by resetGame. (Vue leaves a Promise unproxied, so awaiting it is safe.)
356 runIdInflight: null as Promise<string> | null,
357 // True once this run's snapshot is durably saved (POST /api/run-save returned
358 // saved). Gates the end-screen Share affordance (issue #88) so it appears only for
359 // a run that actually exists server-side to share — never for a degraded local id.
360 // Reset per run.
361 runSnapshotSaved: false,
362 // outOfRuns gates the mission screen's paywall (set when a commit is refused
363 // with 402). The run packs offered there live on the economy store (#235).
364 outOfRuns: false,
365 // atCapacity gates the "at capacity" notice (set when a commit is refused
366 // with 503 because the global spend cap paused new runs).
367 atCapacity: false,
368 // The device/account economy — run balance, packs, account identity, referral
369 // tally, the reroll handle, and the sales modal — was split into stores/economy.ts
370 // (#235): it lives ACROSS runs, so it must survive resetGame, whereas everything
371 // here is run-coupled + epoch-guarded.
372 currentObjective: null as GameObjective | null,
373 // Set when this run is a REPLAY of a shared run's objective (issue #89): seeded
374 // verbatim from the public projection, no generation. Survives chooseObjective so
375 // it can stamp the lineage pointer at save time and credit the source on the end
376 // screen; cleared by resetGame (a fresh "new timeline" carries no lineage).
377 replayState: null as ReplaySeed | null,
378 objectiveProgress: 0,
379 // The run's high-water mark (0..MAX_PROGRESS): the highest objectiveProgress ever
380 // reached this run. Tracked so the end screen can show "peaked at X%" when a run
381 // loses ground from its peak — classically a staked final dispatch that crit-fails
382 // to 0% — instead of the floored final % erasing the player's real effort (#129).
383 peakProgress: 0,
384 // The run-level momentum meter (0..MOMENTUM_MAX): a coherent arc compounds its
385 // gains amplifier; a reset or catastrophe shatters it back to 0 (issue #62).
386 momentum: 0,
387 timelineEvents: [] as TimelineEvent[],
388 figures: [] as HistoricalFigure[],
389 activeFigureName: "" as string,
390 // Grounding for the active contact: real facts + the chosen year to reach them.
391 figureGrounding: null as GroundedFigure | null,
392 /** The typed name `figureGrounding` was resolved FOR — so the send can tell a
393 * fresh grounding from a stale one and only carry the canonical name when it
394 * matches the figure being sent (#186). */
395 groundingFor: "" as string,
396 groundingLoading: false,
397 contactWhen: null as number | null,
398 /** Optional sub-year refinement of contactWhen — display/prompt flavor
399 * only; every mechanic keeps doing arithmetic on the YEAR (issue #32). */
400 contactMoment: null as ContactMoment | null,
401 // The era-relevant figure suggestions (the on-ramp) live on stores/suggestions.ts,
402 // and the living Chronicle (Layer 3) — the prose telling, its streaming buffer,
403 // and the epilogue-pending flag — lives on stores/chronicle.ts, both since #235.
404 // The turn pipeline drives the chronicle via useChronicleStore() (refreshChronicle
405 // + the epiloguePending set/clear); resetGame tears it down through reset().
406 // A turn resolved by `resolveTurn` but not yet revealed leaf-by-leaf by the
407 // conductor — its deferred writes wait here for commitReveal/commitRipple.
408 // Null except mid-reveal; the inline `!animate` path commits without parking here.
409 pendingTurn: null as PendingTurn | null,
410 // The Archive slice (the objective-blind figure study + the "yellow" topic
411 // lookup, with their own block notices) lives on stores/archive.ts since #235.
412 // A content-moderation block on the DISPATCH — distinct from `error` (an infra
413 // hiccup) so the UI shows an honest, visibly different banner. The lookup /
414 // study / suggestions blocks are notices on their own satellite stores now.
415 moderationNotice: null as string | null,
416 }),
417 
418 getters: {
419 /**
420 * This turn's resolved AI message: held in pendingTurn during the animated
421 * reveal, else the latest committed 'ai' message. The single source the reveal
422 * leaves (Judgment/Dice/Reply) and the play announcer read — it was copy-pasted
423 * verbatim in all four (#262), and must stay in lockstep with the pendingTurn/
424 * commit machinery.
425 */
426 revealMessage(): AIMessagePayload | Message | null {
427 if (this.pendingTurn?.aiMessage) return this.pendingTurn.aiMessage;
428 for (let i = this.messageHistory.length - 1; i >= 0; i--) {
429 if (this.messageHistory[i].sender === "ai")
430 return this.messageHistory[i];
431 }
432 return null;
433 },
434 
435 /**
436 * Can the player send right now? (messages left, still playing, not
437 * mid-request, not rate-limited)
438 */
439 canSendMessage(): boolean {
440 return (
441 this.remainingMessages > 0 &&
442 this.gameStatus === "playing" &&
443 !this.isLoading &&
444 !this.isRateLimited
445 );
446 },
447 
448 /**
449 * The conversation thread with a single figure (their turns + the
450 * player's turns addressed to them). Each figure keeps a coherent,
451 * independent thread.
452 */
453 conversationWith(): (name: string) => Message[] {
454 return (name: string) =>
455 this.messageHistory.filter(
456 (m) => m.figureName === name && m.sender !== "system",
457 );
458 },
459 
460 /** Total progress, net of setbacks, the player has clawed back. */
461 gameSummary(): GameSummary {
462 return generateGameSummary(this);
463 },
464 
465 /**
466 * Whether the active figure can be reached at the chosen `when`.
467 *
468 * Require grounding (#73): an UNRESOLVED name can't be reached at all —
469 * 'unresolved' (free-form contact is removed; the picker guides you to a real
470 * match). Deceased-only floor (#72): a RESOLVED figure with no confirmed
471 * death is living/undatable and never contactable — 'living', fail closed. A
472 * resolved, deceased figure is gated to their lifetime (before-birth /
473 * after-death). 'unknown' remains only for a resolved, deceased figure we
474 * can't fully place (no birth year, or no contact year chosen yet), which
475 * stays permissible.
476 */
477 contactLiveness(): ContactLiveness {
478 const g = this.figureGrounding;
479 if (!g || !g.resolved) return "unresolved";
480 if (!g.died) return "living";
481 if (!g.born || this.contactWhen == null) return "unknown";
482 if (this.contactWhen < g.born.signed) return "before-birth";
483 if (this.contactWhen > g.died.signed) return "after-death";
484 return "ok";
485 },
486 
487 /** Can the chosen contact + when actually be reached? */
488 canContact(): boolean {
489 return (
490 this.contactLiveness === "ok" || this.contactLiveness === "unknown"
491 );
492 },
493 
494 /**
495 * The last stand is offered ONLY when the final dispatch can no longer win
496 * inside the normal per-turn fuse — from any nearer position an unstaked
497 * throw can still land it, and offering the (strictly win-probability-
498 * increasing) doubling there would be a dominance trap, not a decision.
499 */
500 canStake(): boolean {
501 return (
502 this.remainingMessages === 1 &&
503 this.gameStatus === "playing" &&
504 MAX_PROGRESS - this.objectiveProgress > MAX_PROGRESS_SWING
505 );
506 },
507 
508 /**
509 * The active figure's age at the chosen `when` — so the player never has to
510 * do lifetime math in their head. Null when we lack a birth year or a chosen
511 * year (a resolved figure we can't date, or before a year is chosen). Corrects for the missing year zero
512 * when a life spans the BC→AD boundary (1 BC → AD 1 is one year, not two).
513 */
514 contactAge(): number | null {
515 const g = this.figureGrounding;
516 if (!g?.resolved || !g.born || this.contactWhen == null) return null;
517 const bornSigned = g.born.signed;
518 const whenSigned = this.contactWhen;
519 if (whenSigned < bornSigned) return null; // before birth — no meaningful age
520 const crossedZero = bornSigned < 0 && whenSigned > 0 ? 1 : 0;
521 return whenSigned - bornSigned - crossedZero;
522 },
523 
524 /**
525 * The causal-chain read for the CURRENT contact — the pre-send ⏳ chip's
526 * source, mirroring what the Timeline Engine will compute. Footholds are the
527 * objective's anchor year plus every dated change already on the ledger; the
528 * gap to the nearest one decays the swing. Null (no chip) for ungrounded
529 * contacts or an anchorless objective with no dated changes yet.
530 */
531 causalChainRead(): ChainStatus | null {
532 const footholds = [
533 this.currentObjective?.anchorYear,
534 ...this.timelineEvents.map((e) => e.whenSigned),
535 ];
536 // Prior changes that ADVANCED the objective are the chain you've built — they
537 // earn the gains-only bonus carried on the read, so the ⏳ chip can preview it.
538 const positiveFootholds = this.timelineEvents
539 .filter((e) => (e.progressChange ?? 0) > 0)
540 .map((e) => e.whenSigned);
541 return chainStatus(this.contactWhen, footholds, positiveFootholds);
542 },
543 },
544 
545 actions: {
546 // ---------- message helpers ----------
547 addUserMessage(text: string, figureName?: string) {
548 if (this.gameStatus !== "playing") return;
549 this.messageHistory.push({
550 text,
551 sender: "user",
552 timestamp: new Date(),
553 figureName,
554 });
555 },
556 
557 addAIMessage(text: string, figureName?: string) {
558 this.messageHistory.push({
559 text,
560 sender: "ai",
561 timestamp: new Date(),
562 figureName,
563 });
564 },
565 
566 addAIMessageWithData(
567 messageData: Partial<Message> & { text: string; sender: "ai" },
568 ) {
569 this.messageHistory.push({
570 text: messageData.text,
571 sender: messageData.sender,
572 timestamp: messageData.timestamp || new Date(),
573 figureName: messageData.figureName,
574 figureThumbnail: messageData.figureThumbnail,
575 diceRoll: messageData.diceRoll,
576 diceOutcome: messageData.diceOutcome,
577 naturalRoll: messageData.naturalRoll,
578 rollModifier: messageData.rollModifier,
579 craft: messageData.craft,
580 craftReason: messageData.craftReason,
581 continuity: messageData.continuity,
582 characterAction: messageData.characterAction,
583 timelineImpact: messageData.timelineImpact,
584 progressChange: messageData.progressChange,
585 baseProgressChange: messageData.baseProgressChange,
586 momentumAtSwing: messageData.momentumAtSwing,
587 anachronism: messageData.anachronism,
588 causalChain: messageData.causalChain,
589 staked: messageData.staked,
590 });
591 },
592 
593 // ---------- figures ----------
594 registerFigure(figure: HistoricalFigure) {
595 const existing = this.figures.find((f) => f.name === figure.name);
596 if (existing) {
597 if (figure.era) existing.era = figure.era;
598 if (figure.descriptor) existing.descriptor = figure.descriptor;
599 } else {
600 this.figures.push({ ...figure });
601 }
602 },
603 
604 setActiveFigure(name: string) {
605 this.activeFigureName = name;
606 },
607 
608 /**
609 * Synchronously clears the dossier the moment the contact NAME changes, so
610 * a send racing the grounding debounce/fetch is gated as ungrounded (#73)
611 * instead of wearing the PREVIOUS figure's facts, year, and liveness gate —
612 * and so the wrong year can never be written into the ledger's whenSigned.
613 */
614 clearGrounding() {
615 this.groundingSeq++; // invalidate any lookup still in flight
616 this.figureGrounding = null;
617 this.groundingFor = "";
618 this.contactWhen = null;
619 this.contactMoment = null;
620 this.groundingLoading = false;
621 // A new contact makes any prior Archive study stale (archive slice, #235).
622 useArchiveStore().clearStudy();
623 },
624 
625 /**
626 * Resolves the active figure against the grounding service and defaults the
627 * "when" to a point inside any known lifetime. Never throws: an unresolved
628 * figure simply clears grounding; an ungrounded contact is then blocked (#73).
629 */
630 async groundActiveFigure(name: string): Promise<void> {
631 const target = (name || "").trim();
632 // A new contact makes any prior Archive study stale (archive slice, #235).
633 useArchiveStore().clearStudy();
634 if (!target) {
635 this.groundingSeq++; // invalidate any lookup still in flight
636 this.figureGrounding = null;
637 this.groundingFor = "";
638 this.contactWhen = null;
639 this.contactMoment = null;
640 this.groundingLoading = false;
641 return;
642 }
643 // Out-of-order guard: only the LATEST lookup may land. Without it, a slow
644 // response for the previous name attaches the wrong dossier (and a wrong
645 // figureContext on a fast send) to whatever the player typed next.
646 const epoch = this.runEpoch;
647 const seq = ++this.groundingSeq;
648 this.groundingLoading = true;
649 try {
650 // Pass the run's objective so the route can disambiguate an ambiguous
651 // name in context (#186): "Congreve" near a rocketry goal → the rocketeer.
652 const obj = this.currentObjective;
653 const params: Record<string, string | number> = { name: target };
654 if (obj?.title) params.objectiveTitle = obj.title;
655 if (obj?.description) params.objectiveDescription = obj.description;
656 if (obj?.era) params.objectiveEra = obj.era;
657 if (typeof obj?.anchorYear === "number")
658 params.anchorYear = obj.anchorYear;
659 const grounded = (await $fetch("/api/figure", {
660 params,
661 headers: await this.aiHeaders(),
662 })) as GroundedFigure;
663 if (epoch !== this.runEpoch || seq !== this.groundingSeq) return;
664 this.figureGrounding = grounded;
665 this.groundingFor = target; // the typed name this dossier is FOR (#186 freshness)
666 this.contactMoment = null;
667 if (grounded?.resolved && grounded.born) {
668 const latest = grounded.died
669 ? grounded.died.signed
670 : new Date().getFullYear();
671 this.contactWhen = Math.round((grounded.born.signed + latest) / 2);
672 } else {
673 this.contactWhen = null;
674 }
675 } catch (error) {
676 if (epoch !== this.runEpoch || seq !== this.groundingSeq) return;
677 console.error("Figure grounding failed:", error);
678 this.figureGrounding = null;
679 this.groundingFor = "";
680 this.contactWhen = null;
681 this.contactMoment = null;
682 } finally {
683 if (epoch === this.runEpoch && seq === this.groundingSeq)
684 this.groundingLoading = false;
685 }
686 },
687 
688 /** The current run's id, minted server-side via POST /api/run on first
689 * need. The run is a server fact (a persisted, charged row); the id then
690 * tags every AI call so cost telemetry groups per run, and the gameplay
691 * gate verifies it. Fails CLOSED: if begin-run can't return an id this
692 * REJECTS (no local fallback) — a run we can't back server-side must not
693 * start, or the paywall gate would reject it mid-run anyway. */
694 async ensureRunId(): Promise<string> {
695 if (this.runId) return this.runId;
696 // Collapse concurrent first-calls onto ONE begin-run (two overlapping
697 // calls would otherwise mint two rows). The epoch guards a resetGame
698 // mid-flight — a dead run's id must not become the fresh run's.
699 if (!this.runIdInflight) {
700 const epoch = this.runEpoch;
701 this.runIdInflight = (async () => {
702 const res = (await $fetch("/api/run", { method: "POST" })) as {
703 runId?: string;
704 };
705 if (!res?.runId) throw new Error("begin-run returned no run id");
706 if (epoch === this.runEpoch) {
707 this.runId = res.runId;
708 this.runIdInflight = null;
709 }
710 return res.runId;
711 })().catch((err) => {
712 if (epoch === this.runEpoch) this.runIdInflight = null;
713 throw err;
714 });
715 }
716 return this.runIdInflight;
717 },
718 
719 /** Headers that tag a server call with the current run (the cost
720 * instrument), minting the run id first if needed. */
721 async aiHeaders(
722 base: Record<string, string> = {},
723 ): Promise<Record<string, string>> {
724 return { ...base, "x-run-id": await this.ensureRunId() };
725 },
726 
727 setContactWhen(year: number | null) {
728 // Scrubbing the year keeps any pinned month/day — the pin refines
729 // whichever year is chosen, it doesn't belong to one.
730 this.contactWhen = year;
731 },
732 
733 setContactMoment(moment: ContactMoment | null) {
734 this.contactMoment = moment;
735 },
736 
737 // ---------- timeline ledger ----------
738 addTimelineEvent(
739 evt: Omit<TimelineEvent, "id" | "timestamp" | "valence"> & {
740 valence?: Valence;
741 timestamp?: Date;
742 },
743 ) {
744 this.timelineEvents.push({
745 id: `evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
746 figureName: evt.figureName,
747 era: evt.era,
748 headline: evt.headline,
749 detail: evt.detail,
750 diceRoll: evt.diceRoll,
751 diceOutcome: evt.diceOutcome,
752 progressChange: evt.progressChange,
753 baseProgressChange: evt.baseProgressChange,
754 valence: evt.valence ?? valenceOf(evt.progressChange),
755 anachronism: evt.anachronism,
756 causalChain: evt.causalChain,
757 craft: evt.craft,
758 momentumAtSwing: evt.momentumAtSwing,
759 whenSigned: evt.whenSigned,
760 staked: evt.staked,
761 timestamp: evt.timestamp ?? new Date(),
762 });
763 },
764 
765 // ---------- simple setters ----------
766 setLoading(loading: boolean) {
767 this.isLoading = loading;
768 },
769 setError(error: string | null) {
770 this.error = error;
771 },
772 clearModerationNotice() {
773 this.moderationNotice = null;
774 },
775 // The Archive notices + panel resets (clearArchiveNotice / clearArchive) live on
776 // stores/archive.ts since #235.
777 
778 // ---------- rate limiting ----------
779 checkRateLimit(): boolean {
780 const now = Date.now();
781 if (this.lastMessageTime && now - this.lastMessageTime < RATE_LIMIT_MS)
782 return false;
783 return true;
784 },
785 
786 setRateLimit() {
787 this.isRateLimited = true;
788 const remainingTime = this.lastMessageTime
789 ? Math.max(0, RATE_LIMIT_MS - (Date.now() - this.lastMessageTime))
790 : 0;
791 setTimeout(() => {
792 this.isRateLimited = false;
793 }, remainingTime || RATE_LIMIT_MS);
794 },
795 
796 // ---------- counter & status ----------
797 decrementMessages() {
798 if (this.remainingMessages > 0) this.remainingMessages--;
799 },
800 
801 /**
802 * Rolls back an unresolved turn: refunds the spent message and drops the
803 * dangling user entry, so the counter and history can't drift out of sync.
804 * Used for BOTH failure shapes — a thrown request and a graceful HTTP-200
805 * `success:false` — an infra hiccup must never burn one of the five
806 * dispatches (or, on the last one, convert into an instant unearned defeat).
807 */
808 refundUnresolvedTurn() {
809 if (this.remainingMessages < TOTAL_MESSAGES) this.remainingMessages++;
810 for (let i = this.messageHistory.length - 1; i >= 0; i--) {
811 if (this.messageHistory[i].sender === "user") {
812 this.messageHistory.splice(i, 1);
813 break;
814 }
815 }
816 },
817 
818 applyProgress(progressChange: number) {
819 this.objectiveProgress = Math.max(
820 0,
821 Math.min(MAX_PROGRESS, this.objectiveProgress + progressChange),
822 );
823 this.peakProgress = Math.max(this.peakProgress, this.objectiveProgress);
824 },
825 
826 /**
827 * Resolves win/lose AFTER a turn's progress has been applied.
828 *
829 * Victory the instant the objective hits 100%; defeat only once the
830 * final message is spent without reaching it. This fixes the old
831 * premature-defeat bug, where status flipped on the last decrement
832 * BEFORE that turn's progress had a chance to land.
833 */
834 resolveGameStatus() {
835 // The single win/lose rule lives in `deriveStatus` (shared with the
836 // resolve-time precompute). Only ever flips TO a terminal status —
837 // never back to playing — so an already-resolved run stays resolved.
838 const status = deriveStatus(
839 this.objectiveProgress,
840 this.remainingMessages,
841 );
842 if (status !== "playing") this.gameStatus = status;
843 },
844 
845 /** The player-facing reason a contact is currently blocked (unresolved /
846 * before-birth / living / after-death) — pure derivation, lifted out of
847 * the turn's guard so the action reads as flow, not message copy. */
848 contactBlockedMessage(target: string): string {
849 const who = this.figureGrounding?.name || target;
850 return this.contactLiveness === "unresolved"
851 ? this.figureGrounding?.transient
852 ? `Couldn't reach the record to verify "${target}" — try again in a moment.`
853 : `No historical record found for "${target}" — reach for a real figure (pick a match as you type).`
854 : this.contactLiveness === "before-birth"
855 ? `${who} isn't born yet in that year — choose a later moment to reach them.`
856 : this.contactLiveness === "living"
857 ? this.figureGrounding?.born
858 ? `${who} is still living — Everwhen only reaches figures from history.`
859 : `${who} couldn't be dated — Everwhen can only reach figures it can place in history.`
860 : `${who} has died by that year — choose an earlier moment to reach them.`;
861 },
862 
863 /** Shape the /api/send-message request body — pure field assembly, lifted out
864 * of `resolveTurn` so the action's flow isn't buried under plumbing. `sentWhen`/
865 * `sentMoment` are captured at send time (the slider can move mid-request). */
866 buildSendBody(
867 text: string,
868 target: string,
869 sentWhen: number | null,
870 sentMoment: ContactMoment | null,
871 stake: boolean,
872 ) {
873 return {
874 message: text,
875 // Send the resolved CANONICAL title (not the raw typed text) so the gate
876 // re-grounds the same person the picker showed — including a #186
877 // disambiguation auto-pick or contextual override — and the role-play
878 // names the right figure. Falls back to the typed name when unresolved
879 // (which the gate then blocks per #73).
880 // Send the resolved CANONICAL title (not the raw typed text) so the gate
881 // re-grounds the same person the picker showed — including a #186
882 // disambiguation auto-pick or contextual override — and the role-play
883 // names the right figure. Guarded on `groundingFor === target`: only use
884 // the canonical name when the grounding is actually FOR the figure being
885 // sent (not a stale lookup for a figure since switched away from); a stale
886 // or in-flight grounding falls back to the typed name, which the gate
887 // re-grounds (and blocks per #73 if unresolved).
888 figureName:
889 (this.figureGrounding?.resolved &&
890 this.groundingFor === target &&
891 this.figureGrounding.name) ||
892 target,
893 when:
894 sentWhen != null
895 ? formatContactMoment(sentWhen, sentMoment)
896 : undefined,
897 whenSigned: sentWhen ?? undefined,
898 // The pinned moment travels as validated integers; the server
899 // re-derives the display string rather than trusting ours.
900 whenMonth: sentWhen != null ? sentMoment?.month : undefined,
901 whenDay: sentWhen != null ? sentMoment?.day : undefined,
902 stake,
903 // The pre-turn momentum the server amplifies the swing by, and
904 // returns advanced (the client stays the system of record).
905 momentum: this.momentum,
906 figureContext: this.figureGrounding?.resolved
907 ? {
908 description: this.figureGrounding.description,
909 lifespan: lifespanText(this.figureGrounding),
910 }
911 : undefined,
912 objective: this.currentObjective,
913 timeline: this.timelineEvents.map((e) => ({
914 era: e.era,
915 figureName: e.figureName,
916 headline: e.headline,
917 detail: e.detail,
918 progressChange: e.progressChange,
919 whenSigned: e.whenSigned,
920 })),
921 conversationHistory: this.conversationWith(target),
922 };
923 },
924 
925 // ---------- the turn ----------
926 
927 /**
928 * The turn — sends a 160-char message to a chosen figure and folds the result
929 * back into the world: the figure replies + acts, the dice decide the swing, and
930 * the Timeline Engine records how history bent.
931 *
932 * Two-phase (the conducted reveal): this action runs the gates + network
933 * round-trip and holds the resolved turn in `pendingTurn`, then the client leaf
934 * conductor fires the animation/SFX-bearing writes leaf by leaf (`commitReveal`
935 * on the Dice leaf, `commitRipple` on the Ripple leaf) — so the page-by-page
936 * reveal paces on the player's reading, never artificial waits, while the
937 * soundscape's "write == beat" lockstep is preserved.
938 *
939 * Returns `true` once a turn is RESOLVED and ready to reveal (the animated path),
940 * OR — under SSR / tests / reduced-motion (`!canAnimateReveal`) — once it has
941 * committed the whole turn atomically inline (it calls `commitReveal` +
942 * `commitRipple` itself), so the store's "all turn state present the moment the
943 * promise resolves" contract holds. A blocked or failed send returns `false` so
944 * the composer keeps the player's crafted words. `opts.stake` arms the last stand
945 * — honored only when `canStake` holds (final message, win out of normal reach;
946 * the server doubles the resolved swing both ways, past the usual cap).
947 */
948 async resolveTurn(
949 text: string,
950 figureName?: string,
951 opts?: { stake?: boolean },
952 ): Promise<boolean> {
953 const target = (figureName ?? this.activeFigureName ?? "").trim();
954 const stake = opts?.stake === true && this.canStake;
955 
956 if (!this.canSendMessage) return false;
957 if (!target) {
958 this.setError("Choose who in history to send your message to first.");
959 return false;
960 }
961 if (!this.checkRateLimit()) {
962 this.setRateLimit();
963 this.setError(
964 "The timeline needs a moment — wait before sending again.",
965 );
966 return false;
967 }
968 if (!this.canContact) {
969 this.setError(this.contactBlockedMessage(target));
970 return false;
971 }
972 
973 this.setError(null);
974 this.moderationNotice = null;
975 useArchiveStore().clearArchive();
976 this.setActiveFigure(target);
977 // Enqueue the player's line while the run is still 'playing' — addUserMessage
978 // no-ops on a terminal status, and refundUnresolvedTurn would then splice the
979 // wrong entry. No status flip happens before here (#Fix9).
980 this.addUserMessage(text, target);
981 this.decrementMessages();
982 this.setLoading(true);
983 this.lastMessageTime = Date.now();
984 
985 // The staleness contract: a reset mid-flight bumps runEpoch, so every write
986 // below finds no purchase (the response is dropped whole).
987 const epoch = this.runEpoch;
988 const ledgerBefore = this.timelineEvents.length;
989 // Capture the contact year NOW (the slider can move while the request is in
990 // flight) — the ledger must record the year this turn was SENT to.
991 const sentWhen = this.contactWhen;
992 const sentMoment = this.contactMoment;
993 // The figure's Wikipedia portrait shown at selection (if any), captured NOW
994 // so the reply leaf shows it even after the active figure changes mid-reveal.
995 const sentThumbnail = this.figureGrounding?.thumbnail;
996 const animate = canAnimateReveal();
997 
998 // Streaming reveal (see PendingTurn.ready). The server emits the turn as SSE
999 // frames (dice → reply → ripple → final), so the reveal STARTS on the early
1000 // `dice` frame while the slow Character + Timeline calls finish behind the
1001 // player reading the Judgment/Dice beats. resolveTurn resolves on the dice
1002 // frame (the conductor's data-ready); the rest streams into pendingTurn, and
1003 // the commits await `ready`. A non-streaming mock returns the old plain JSON,
1004 // converted to the same frames by applyPlain — so tests need no streaming.
1005 let releaseDice: () => void = () => {};
1006 let releaseReady: () => void = () => {};
1007 const diceReady = new Promise<void>((r) => {
1008 releaseDice = r;
1009 });
1010 const ready = new Promise<void>((r) => {
1011 releaseReady = r;
1012 });
1013 let dicedReleased = false;
1014 let readyReleased = false;
1015 const settleDice = () => {
1016 if (!dicedReleased) {
1017 dicedReleased = true;
1018 releaseDice();
1020 };
1021 const settleReady = () => {
1022 if (!readyReleased) {
1023 readyReleased = true;
1024 pending.resolved = true;
1025 releaseReady();
1027 };
1029 const pending: PendingTurn = {
1030 epoch,
1031 target,
1032 sentWhen,
1033 ledgerBefore,
1034 aiMessage: {
1035 text: "",
1036 sender: "ai",
1037 figureName: target,
1038 figureThumbnail: sentThumbnail,
1039 timestamp: new Date(),
1040 } as AIMessagePayload,
1041 ripple: null,
1042 progressChange: undefined,
1043 momentum: this.momentum,
1044 terminal: false,
1045 ready,
1046 resolved: false,
1047 blocked: false,
1048 };
1050 // Refund + banner mid-stream, mirroring the old graceful-failure / thrown paths.
1051 const fail = (o: {
1052 blocked?: boolean;
1053 reason?: string;
1054 error?: string;
1055 }) => {
1056 if (epoch !== this.runEpoch) {
1057 settleDice();
1058 settleReady();
1059 return;
1061 if (o.blocked) {
1062 pending.blocked = true;
1063 this.moderationNotice =
1064 o.reason ||
1065 "That dispatch was blocked by content moderation and cannot be sent.";
1066 } else {
1067 this.setError(o.error || "History did not answer. Try again.");
1069 this.refundUnresolvedTurn();
1070 this.setLoading(false);
1071 this.pendingTurn = null;
1072 settleDice();
1073 settleReady();
1074 };
1076 // Apply one parsed frame onto the pending turn (mutating the REACTIVE copy after
1077 // the dice frame publishes it, so the leaves fill live as data lands).
1078 const onFrame = (msg: Record<string, unknown> | null) => {
1079 if (!msg || epoch !== this.runEpoch) return;
1080 const t = msg.t;
1081 if (t === "dice") {
1082 Object.assign(pending.aiMessage, {
1083 diceRoll: msg.diceRoll,
1084 diceOutcome: msg.diceOutcome,
1085 naturalRoll:
1086 (msg.naturalRoll as number) ?? (msg.diceRoll as number),
1087 rollModifier: (msg.rollModifier as number) ?? 0,
1088 craft: msg.craft,
1089 craftReason: msg.craftReason,
1090 continuity: msg.continuity,
1091 });
1092 this.pendingTurn = pending; // the Judgment + Dice leaves can read now (early reveal)
1093 settleDice();
1094 return;
1096 const pt = this.pendingTurn;
1097 if (!pt || pt.epoch !== epoch) return;
1098 switch (t) {
1099 case "reply": {
1100 pt.aiMessage.text = (msg.message as string) ?? "";
1101 pt.aiMessage.characterAction = msg.action as string;
1102 break;
1104 case "ripple": {
1105 const pc =
1106 typeof msg.progressChange === "number"
1107 ? (msg.progressChange as number)
1108 : 0;
1109 Object.assign(pt.aiMessage, {
1110 timelineImpact: msg.detail,
1111 progressChange: msg.progressChange,
1112 baseProgressChange: msg.baseProgressChange,
1113 momentumAtSwing: msg.momentumAtSwing,
1114 anachronism: msg.anachronism,
1115 causalChain: msg.causalChain,
1116 });
1117 pt.ripple = {
1118 figureName: target,
1119 era: (msg.era as string) || (this.currentObjective?.era ?? ""),
1120 headline: msg.headline as string,
1121 detail: msg.detail as string,
1122 diceRoll: pt.aiMessage.diceRoll!,
1123 diceOutcome: pt.aiMessage.diceOutcome!,
1124 progressChange: pc,
1125 baseProgressChange: msg.baseProgressChange as number | undefined,
1126 valence: msg.valence as Valence,
1127 anachronism: msg.anachronism as Anachronism | undefined,
1128 causalChain: msg.causalChain as ChainStatus | undefined,
1129 craft: pt.aiMessage.craft,
1130 momentumAtSwing: msg.momentumAtSwing as number | undefined,
1131 whenSigned: sentWhen ?? undefined,
1132 staked: pt.aiMessage.staked,
1133 };
1134 pt.progressChange =
1135 msg.progressChange !== undefined
1136 ? (msg.progressChange as number)
1137 : undefined;
1138 const newProgress =
1139 pt.progressChange !== undefined
1140 ? Math.max(
1141 0,
1142 Math.min(
1143 MAX_PROGRESS,
1144 this.objectiveProgress + pt.progressChange,
1145 ),
1147 : this.objectiveProgress;
1148 pt.terminal =
1149 deriveStatus(newProgress, this.remainingMessages) !== "playing";
1150 // Fire the Chronicle/suggestion re-narration from the resolved SNAPSHOT
1151 // (the not-yet-committed node) so it composes during the reveal reading
1152 // — animated path only (the inline path fires from commitRipple).
1153 if (animate && pt.ripple) {
1154 const node: TimelineEvent = {
1155 ...pt.ripple,
1156 id: `evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
1157 valence:
1158 pt.ripple.valence ?? valenceOf(pt.ripple.progressChange),
1159 timestamp: new Date(),
1160 };
1161 const snapshot = {
1162 ledger: [...this.timelineEvents, node],
1163 progress: newProgress,
1164 status: deriveStatus(newProgress, this.remainingMessages),
1165 };
1166 const chronicle = useChronicleStore();
1167 const refresh = chronicle.refreshChronicle(snapshot);
1168 void refresh;
1169 void useSuggestionsStore().refreshSuggestions(snapshot.ledger);
1170 pt.refresh = refresh;
1171 if (pt.terminal) {
1172 chronicle.epiloguePending = true;
1173 void refresh.finally(() => {
1174 if (epoch === this.runEpoch)
1175 chronicle.epiloguePending = false;
1176 });
1179 break;
1181 case "final": {
1182 pt.momentum =
1183 typeof msg.momentum === "number"
1184 ? (msg.momentum as number)
1185 : this.momentum;
1186 pt.aiMessage.staked = msg.staked as boolean | undefined;
1187 if (pt.ripple) pt.ripple.staked = msg.staked as boolean | undefined;
1188 const fig = msg.figure as
1189 { name?: string; era?: string; descriptor?: string } | undefined;
1190 if (fig?.name)
1191 this.registerFigure({
1192 name: fig.name,
1193 era: fig.era || "",
1194 descriptor: fig.descriptor || "",
1195 });
1196 settleReady();
1197 break;
1199 case "blocked":
1200 fail({ blocked: true, reason: msg.reason as string });
1201 break;
1202 case "error":
1203 fail({ error: msg.error as string });
1204 break;
1206 };
1208 // Convert the old plain `{ success, data }` JSON (a non-streaming mock) into the
1209 // same frame sequence, so unit/e2e mocks that return JSON keep working.
1210 const applyPlain = (res: SendMessageApiResponse | null) => {
1211 if (res?.success && res.data?.characterResponse) {
1212 const d = res.data;
1213 onFrame({
1214 t: "dice",
1215 diceRoll: d.diceRoll,
1216 diceOutcome: d.diceOutcome,
1217 naturalRoll: d.naturalRoll,
1218 rollModifier: d.rollModifier,
1219 craft: d.craft,
1220 craftReason: d.craftReason,
1221 continuity: d.continuity,
1222 });
1223 onFrame({
1224 t: "reply",
1225 message: d.characterResponse.message,
1226 action: d.characterResponse.action,
1227 });
1228 if (d.timeline)
1229 onFrame({
1230 t: "ripple",
1231 headline: d.timeline.headline,
1232 detail: d.timeline.detail,
1233 era: d.timeline.era,
1234 progressChange: d.timeline.progressChange,
1235 baseProgressChange: d.timeline.baseProgressChange,
1236 momentumAtSwing: d.timeline.momentumAtSwing,
1237 valence: d.timeline.valence,
1238 anachronism: d.timeline.anachronism,
1239 causalChain: d.timeline.causalChain,
1240 });
1241 onFrame({
1242 t: "final",
1243 momentum: d.momentum,
1244 staked: d.staked,
1245 figure: d.figure,
1246 });
1247 } else {
1248 const failed = res && !res.success ? res.data : undefined;
1249 if (failed?.blocked)
1250 fail({ blocked: true, reason: failed.moderationReason });
1251 else fail({ error: failed?.error });
1253 };
1255 const consume = async (): Promise<void> => {
1256 try {
1257 const res = (await $fetch("/api/send-message", {
1258 method: "POST",
1259 headers: await this.aiHeaders({
1260 "Content-Type": "application/json",
1261 }),
1262 responseType: "stream",
1263 body: this.buildSendBody(text, target, sentWhen, sentMoment, stake),
1264 })) as unknown;
1265 if (epoch !== this.runEpoch) {
1266 settleDice();
1267 settleReady();
1268 return;
1270 const isStream =
1271 !!res &&
1272 typeof (res as ReadableStream<Uint8Array>).getReader === "function";
1273 if (!isStream) {
1274 applyPlain(res as SendMessageApiResponse);
1275 return;
1277 const reader = (res as ReadableStream<Uint8Array>).getReader();
1278 const decoder = new TextDecoder();
1279 let buf = "";
1280 while (true) {
1281 const { done, value } = await reader.read();
1282 if (done) break;
1283 if (epoch !== this.runEpoch) {
1284 try {
1285 await reader.cancel();
1286 } catch {
1287 /* gone */
1289 settleDice();
1290 settleReady();
1291 return;
1293 buf += decoder.decode(value, { stream: true });
1294 const { messages, rest } = drainSSEFrames<Record<
1295 string,
1296 unknown
1297 > | null>(buf);
1298 buf = rest;
1299 for (const msg of messages) {
1300 try {
1301 onFrame(msg);
1302 } catch {
1303 /* skip a garbled frame's dispatch */
1307 // Stream ended. Normal completion / handled block → done. No frames at all
1308 // → a plain-JSON body delivered as a stream. Some frames but no terminal
1309 // (a dropped connection mid-turn) → refund, like the thrown path.
1310 if (readyReleased || pending.blocked) return;
1311 if (!this.pendingTurn) {
1312 try {
1313 applyPlain(JSON.parse(buf.trim()));
1314 } catch {
1315 fail({ error: "History did not answer. Try again." });
1317 } else {
1318 fail({
1319 error: "The timeline resisted your message. Please try again.",
1320 });
1322 } catch (error) {
1323 if (epoch !== this.runEpoch) {
1324 settleDice();
1325 settleReady();
1326 return;
1328 console.error("Error sending message:", error);
1329 fail({
1330 error: "The timeline resisted your message. Please try again.",
1331 });
1333 };
1335 if (!animate) {
1336 // SSR / tests / reduced-motion: drain the whole stream, then commit
1337 // atomically — the "all state present the moment resolveTurn resolves"
1338 // contract is unchanged.
1339 await consume();
1340 if (epoch !== this.runEpoch) return false;
1341 if (pending.blocked || !this.pendingTurn) return false;
1342 await this.commitReveal();
1343 await this.commitRipple();
1344 return pending.ripple != null;
1347 // Animated path: consume in the background; resolve on the `dice` frame so the
1348 // conductor reveals immediately while reply/ripple/final keep streaming in.
1349 void consume();
1350 await diceReady;
1351 if (epoch !== this.runEpoch) return false;
1352 if (pending.blocked || !this.pendingTurn) return false;
1353 return true;
1354 },
1356 /**
1357 * Reveal beat — the figure's reply writes itself in and the die lands
1358 * (`setLoading(false)` is the falling edge DiceRoller + the soundscape watch).
1359 * Fired by the conductor on the Dice leaf. A no-op once the run has moved on
1360 * (epoch guard), so a stale turn never lands in a fresh run.
1361 */
1362 async commitReveal(): Promise<boolean> {
1363 const p = this.pendingTurn;
1364 if (!p || p.epoch !== this.runEpoch) return true; // nothing to commit — benign
1365 // Wait for the reply (and the rest of the turn) to stream in — a graceful
1366 // micro-wait if the player outran the Character/Timeline calls, usually already
1367 // landed by here (skipped once resolved, so this stays synchronous in tests).
1368 if (!p.resolved && !p.blocked) await p.ready;
1369 if (p.blocked) return false; // a real moderation block — the conductor halts the reveal
1370 const cur = this.pendingTurn;
1371 if (!cur || cur !== p || cur.epoch !== this.runEpoch) return true; // reset/cleared — benign
1372 // addAIMessageWithData BEFORE setLoading(false): the soundscape's diceLand fires
1373 // on the loading falling edge and reads the new message's roll/outcome/craft.
1374 this.addAIMessageWithData(cur.aiMessage);
1375 this.setLoading(false);
1376 return true;
1377 },
1379 /**
1380 * Ripple beat — the % gauge flies its delta, momentum advances, and the change
1381 * drops onto the Spine ledger; then win/lose resolves and (reading the now-live,
1382 * just-committed state) the run persists and the Chronicle/suggestions re-narrate
1383 * non-blocking. Fired by the conductor on the Ripple leaf, AFTER commitReveal, so
1384 * the writes land in a single deterministic order. No-op under a bumped epoch.
1385 * This is the second phase — the swing + ledger node + persistence tail — of the
1386 * two-phase reveal (`commitReveal` lands the reply + die first).
1387 */
1388 async commitRipple(): Promise<boolean> {
1389 const p = this.pendingTurn;
1390 if (!p || p.epoch !== this.runEpoch) return true; // nothing to commit — benign
1391 if (!p.resolved && !p.blocked) await p.ready;
1392 if (p.blocked) return false; // a real moderation block — the conductor halts the reveal
1393 const cur = this.pendingTurn;
1394 if (!cur || cur !== p || cur.epoch !== this.runEpoch) return true; // reset/cleared — benign
1395 const { epoch, ledgerBefore } = p;
1396 if (p.progressChange !== undefined) this.applyProgress(p.progressChange);
1397 // The arc meter is server-authoritative for the result; advance it WITH the
1398 // swing it amplified (only past the epoch guard above).
1399 this.momentum = p.momentum;
1400 if (p.ripple) this.addTimelineEvent(p.ripple);
1401 this.resolveGameStatus();
1402 const resolved = p.ripple != null;
1403 // The re-narration fired EARLY (animate path, at resolve) is carried on the
1404 // pending turn; capture it before we clear the slot. Persistence reads LIVE
1405 // (now-committed) state, so it stays here regardless.
1406 const earlyRefresh = p.refresh ?? null;
1407 this.pendingTurn = null;
1409 // Reuse the early-fired telling if there is one; otherwise (the inline path)
1410 // fire it now — never awaited (the reveal waits on neither), reading the
1411 // just-committed live state. By here the status is resolved, so a final turn's
1412 // chronicle carries the verdict (the epilogue).
1413 const chronicle = useChronicleStore();
1414 let refresh: Promise<void> | null = earlyRefresh;
1415 if (
1416 !earlyRefresh &&
1417 resolved &&
1418 this.timelineEvents.length > ledgerBefore
1419 ) {
1420 refresh = chronicle.refreshChronicle();
1421 void refresh;
1422 void useSuggestionsStore().refreshSuggestions();
1424 // Persist AFTER the commit (the draft/snapshot serialize the now-committed
1425 // ledger/progress/momentum/history). Two beats: the immediate save guards a
1426 // hanging refresh; the post-refresh re-save captures the fresh telling.
1427 if (this.gameStatus === "victory" || this.gameStatus === "defeat") {
1428 // The animate path already marked epiloguePending at resolve; the inline
1429 // path marks it here (only when it fired the telling itself).
1430 if (refresh && !earlyRefresh) {
1431 chronicle.epiloguePending = true;
1432 void refresh.finally(() => {
1433 if (epoch === this.runEpoch) chronicle.epiloguePending = false;
1434 });
1436 void this.saveRunSnapshot();
1437 if (refresh)
1438 void refresh.finally(() => {
1439 void this.saveRunSnapshot();
1440 });
1441 // The terminal snapshot is the record now — drop the in-progress draft.
1442 void this.clearRunDraft();
1443 } else if (resolved) {
1444 void this.saveRunDraft();
1445 if (refresh)
1446 void refresh.finally(() => {
1447 void this.saveRunDraft();
1448 });
1450 return true;
1451 },
1453 /**
1454 * Seed a replay (issue #89): start a fresh run on a shared run's captured
1455 * objective. Resets first (the share page may follow an abandoned run in the
1456 * same tab) so the run starts clean, THEN records the seed — so the mission
1457 * briefing opens focused on this objective, and the lineage + credit ride
1458 * through to the end. No generation: the objective is reused verbatim, and the
1459 * player still commits (and is charged) by pressing Begin.
1460 */
1461 preseedReplay(
1462 objective: GameObjective,
1463 sourceToken: string,
1464 sourceAttribution: string | null,
1465 ): void {
1466 this.resetGame();
1467 this.replayState = { objective, sourceToken, sourceAttribution };
1468 },
1470 /** Drop the replay seed — the player chose to start from scratch instead, so this
1471 * run is an original (no "remixed from" lineage). */
1472 clearReplayState(): void {
1473 this.replayState = null;
1474 },
1476 /**
1477 * Commits a chosen objective and starts the run from a clean slate of
1478 * progress. Used by the mission-select screen for both curated picks and
1479 * freshly composed ones.
1480 */
1481 async chooseObjective(objective: GameObjective): Promise<boolean> {
1482 // Commit = the run is charged here. Mint the run id server-side, then
1483 // spend one run from the device's balance. Fails CLOSED: out of runs →
1484 // raise the paywall; begin-run or commit failing → surface an error and
1485 // do NOT start. A run that isn't a charged, server-backed run would be
1486 // rejected by the gameplay gate anyway, so starting it only strands the
1487 // player mid-run. The spend cap (not free play) is the outage backstop.
1488 let runId: string;
1489 try {
1490 runId = await this.ensureRunId();
1491 } catch (error) {
1492 console.error("Could not begin the run:", error);
1493 this.error = "Could not start the run. Please try again.";
1494 return false;
1496 try {
1497 const res = (await $fetch("/api/run-commit", {
1498 method: "POST",
1499 headers: { "Content-Type": "application/json" },
1500 body: { runId },
1501 })) as { runsRemaining?: number };
1502 this.outOfRuns = false;
1503 // Keep the header gauge live: the commit returns the post-charge
1504 // balance (−1 marks a degraded, ungated run — leave the gauge as is).
1505 // The balance lives on the economy store now (#235) — lazy handle.
1506 if (typeof res?.runsRemaining === "number" && res.runsRemaining >= 0) {
1507 useEconomyStore().runsRemaining = res.runsRemaining;
1509 } catch (error) {
1510 if (isPaymentRequired(error)) {
1511 this.outOfRuns = true;
1512 useEconomyStore().openBuyModal();
1513 return false;
1515 if (isAtCapacity(error)) {
1516 this.atCapacity = true;
1517 return false;
1519 console.error("Could not charge the run:", error);
1520 this.error = "Could not start the run. Please try again.";
1521 return false;
1523 this.currentObjective = objective;
1524 this.objectiveProgress = 0;
1525 this.peakProgress = 0;
1526 this.momentum = 0;
1527 this.error = null;
1528 // Persist the charged run the instant it commits (#213). The credit is
1529 // spent here, but the first draft otherwise waits for a resolved turn — so a
1530 // reload while the player browses figures / researches would strand the paid
1531 // run (a fresh runId next session, no refund path). A minimal draft (objective
1532 // + runId + full messages, empty thread) lets resumeDraft drop them back onto
1533 // the SAME run. Fire-and-forget, exactly like the post-turn saves.
1534 void this.saveRunDraft();
1535 // Prefetch the era-relevant figures the instant the objective commits, so
1536 // they're already in flight (often landed) by the time the player reaches
1537 // the dispatch page — not a cold wait as their first impression. The
1538 // FigurePicker onMounted call no-ops while this is in flight (see the
1539 // suggestionsLoadingFor guard in loadSuggestions). The suggester is a
1540 // satellite store now (#235) — drive it via the lazy handle.
1541 void useSuggestionsStore().loadSuggestions();
1542 return true;
1543 },
1545 /**
1546 * Asks the server to compose a fresh objective with the model, WITHOUT
1547 * committing it — the caller previews it, then commits via chooseObjective.
1548 * Generation lives server-side because it needs the OpenAI key (the client
1549 * has no access to it). An optional steer (era + theme, closed enums) biases
1550 * the composition; the server re-validates it against the enums, so a bad
1551 * value is harmless. `avoid` is the titles already composed this session, so
1552 * a reroll lands somewhere new (#95) — the stateless server only knows what
1553 * the client supplies.
1555 * Returns the objective (or null, never throwing, so the UI can quietly fall
1556 * back to the curated pool) alongside `fellBackToCurated` (#127): true only
1557 * when the player steered and the server still returned a curated objective —
1558 * the signal the caller surfaces so a dropped steer doesn't read as a bug.
1559 */
1560 async fetchAIObjective(
1561 steer: ObjectiveSteer = {},
1562 avoid: string[] = [],
1563 ): Promise<{
1564 objective: GameObjective | null;
1565 fellBackToCurated: boolean;
1566 }> {
1567 try {
1568 const query: Record<string, string | string[]> = {};
1569 if (steer.era) query.era = steer.era;
1570 if (steer.theme) query.theme = steer.theme;
1571 if (avoid.length) query.avoid = avoid;
1572 const res = (await $fetch("/api/objective", {
1573 method: "GET",
1574 query,
1575 headers: await this.aiHeaders(),
1576 })) as {
1577 success?: boolean;
1578 objective?: GameObjective;
1579 fellBackToCurated?: boolean;
1580 };
1581 const objective = res?.success && res.objective ? res.objective : null;
1582 // Only report a dropped steer when we actually have an objective; a hard
1583 // failure (null) is the louder compose-error path, not this quiet notice.
1584 return {
1585 objective,
1586 fellBackToCurated: objective ? res.fellBackToCurated === true : false,
1587 };
1588 } catch (error) {
1589 console.error("Failed to compose a fresh objective:", error);
1590 return { objective: null, fellBackToCurated: false };
1592 },
1594 getVictoryEfficiency() {
1595 if (this.gameStatus === "victory") {
1596 const messagesSaved = this.remainingMessages;
1597 const messagesUsed = TOTAL_MESSAGES - this.remainingMessages;
1598 const efficiencyPercentage = Math.round(
1599 (messagesSaved / TOTAL_MESSAGES) * 100,
1600 );
1602 const efficiencyRating = rateEfficiency(messagesSaved);
1604 return {
1605 messagesSaved,
1606 messagesUsed,
1607 efficiencyPercentage,
1608 efficiencyRating,
1609 isEarlyVictory: messagesSaved > 0,
1610 };
1612 return null;
1613 },
1615 /**
1616 * Persist the finished run's snapshot to the player's account, best-effort.
1617 * Captures exactly what the end screen shows — objective, verdict, ledger,
1618 * dispatches, rolls, and the Chronicle epilogue — keyed by the run id, so the
1619 * run survives reload and feeds the read-only "your runs" view. Fires and
1620 * forgets like refreshChronicle: a save failure must never disturb the end
1621 * screen. Only a completed (victory/defeat), server-backed (uuid run id) run
1622 * is saved; a degraded local id is dropped server-side.
1623 */
1624 async saveRunSnapshot(): Promise<void> {
1625 const epoch = this.runEpoch;
1626 const status = this.gameStatus;
1627 if (status !== "victory" && status !== "defeat") return;
1628 const objective = this.currentObjective;
1629 const runId = this.runId;
1630 if (!objective || !runId) return;
1632 // The gameSummary/getVictoryEfficiency getter reads stay here; the pure build
1633 // (dispatch zip, ledger trim, roll marks, Date drop) lives in snapshotFrom.
1634 const summary = this.gameSummary;
1635 const snapshot = snapshotFrom({
1636 runId,
1637 status,
1638 objective,
1639 objectiveProgress: this.objectiveProgress,
1640 peakProgress: this.peakProgress,
1641 messagesUsed: TOTAL_MESSAGES - this.remainingMessages,
1642 totalMessages: TOTAL_MESSAGES,
1643 bestRoll: summary.bestRoll,
1644 worstRoll: summary.worstRoll,
1645 efficiency: this.getVictoryEfficiency(),
1646 messageHistory: this.messageHistory,
1647 timeline: this.timelineEvents,
1648 chronicle: useChronicleStore().chronicle,
1649 });
1651 // A replay (issue #89) rides its source's share token alongside the snapshot
1652 // (a sibling field, not part of the immutable blob); the server resolves it to
1653 // the parent run id and stamps the lineage pointer. replayState set ⟺ this run
1654 // committed the seeded objective (the briefing clears it on any other choice).
1655 const body = this.replayState
1656 ? { ...snapshot, remixedFromToken: this.replayState.sourceToken }
1657 : snapshot;
1659 try {
1660 const res = (await $fetch("/api/run-save", {
1661 method: "POST",
1662 headers: { "Content-Type": "application/json" },
1663 body,
1664 })) as { saved?: boolean };
1665 // Mark saved (for the share affordance) only if this run is still current
1666 // and the server actually persisted it (a degraded run returns saved:false).
1667 if (epoch === this.runEpoch && res?.saved) this.runSnapshotSaved = true;
1668 } catch (error) {
1669 // Saving the run is a nicety; a failure must not break the end screen.
1670 if (epoch === this.runEpoch)
1671 console.error("Could not save the run:", error);
1673 },
1675 /**
1676 * Autosave the in-progress run's draft, best-effort (#152). The live mirror of
1677 * saveRunSnapshot: it serializes the DURABLE live state — totals, the full
1678 * thread (both sides), the un-trimmed ledger (with whenSigned + causalChain),
1679 * the contacted figures, the active contact, the Chronicle, and any replay
1680 * lineage — keyed by the run id, so a refresh or a crash resumes exactly here.
1681 * Fires and forgets: only an in-progress, server-backed (uuid) run is saved;
1682 * the server drops a degraded local id, and any failure is swallowed.
1683 */
1684 async saveRunDraft(): Promise<void> {
1685 if (this.gameStatus !== "playing") return;
1686 const runId = this.runId;
1687 const objective = this.currentObjective;
1688 if (!runId || !objective) return;
1690 const draft = draftFrom({
1691 runId,
1692 objective,
1693 objectiveProgress: this.objectiveProgress,
1694 peakProgress: this.peakProgress,
1695 momentum: this.momentum,
1696 remainingMessages: this.remainingMessages,
1697 messageHistory: this.messageHistory,
1698 timelineEvents: this.timelineEvents,
1699 figures: this.figures,
1700 activeFigureName: this.activeFigureName,
1701 contactWhen: this.contactWhen,
1702 contactMoment: this.contactMoment,
1703 chronicle: useChronicleStore().chronicle,
1704 replayState: this.replayState,
1705 });
1707 try {
1708 await $fetch("/api/run-draft", {
1709 method: "POST",
1710 headers: { "Content-Type": "application/json" },
1711 body: draft,
1712 });
1713 } catch (error) {
1714 // Autosave is a safety net; a failure must never disturb the turn.
1715 console.error("Could not save the run draft:", error);
1717 },
1719 /**
1720 * Clear the run's server-side draft (#152) — fired on terminal save and on
1721 * resetGame (abandon). Takes an explicit run id so resetGame can clear the
1722 * run it is tearing down (runId is nulled there). Idempotent + best-effort.
1723 */
1724 async clearRunDraft(runId?: string): Promise<void> {
1725 const id = runId ?? this.runId;
1726 if (!id) return;
1727 try {
1728 await $fetch("/api/run-draft", {
1729 method: "DELETE",
1730 query: { runId: id },
1731 });
1732 } catch (error) {
1733 console.error("Could not clear the run draft:", error);
1735 },
1737 /**
1738 * Resume an in-progress run from its server-side draft (#152) — the counterpart
1739 * to saveRunDraft, called on page load when the board is empty. Reads the
1740 * subject's latest draft and, when there is one, rehydrates the store so the
1741 * player drops straight back onto the in-progress board: the SAME run id (no new
1742 * credit charged), the totals, the full thread, the ledger, and the figures.
1744 * The active figure is RE-grounded from the live record (the draft carries no
1745 * dossier), then the saved contact year/moment are re-applied — groundActiveFigure
1746 * defaults contactWhen to mid-lifetime, which would otherwise clobber the player's
1747 * chosen year. Best-effort: any failure simply leaves the mission briefing.
1748 */
1749 async resumeDraft(): Promise<boolean> {
1750 // currentObjective set ⟹ a run is already on the board. A replayState seed
1751 // (preseedReplay, from a /r/<token> share link) is an intent to start a FRESH
1752 // charged run — never let a pre-existing server draft clobber it (#152/#89).
1753 if (this.currentObjective || this.replayState) return false;
1754 let draft: RunDraft | null;
1755 try {
1756 const res = (await $fetch("/api/run-draft")) as {
1757 draft?: RunDraft | null;
1758 };
1759 draft = res?.draft ?? null;
1760 } catch (error) {
1761 console.error("Could not load the run draft:", error);
1762 return false;
1764 if (!draft || !draft.currentObjective || !draft.runId) return false;
1765 // A reset, a fresh run, or a replay seed may have begun while the draft was
1766 // loading — never drop a stale run onto a board the player has moved on from.
1767 if (this.currentObjective || this.replayState) return false;
1769 // jsonb is dateless: revive the serialized ISO stamps, falling back to now()
1770 // for any unparseable value rather than minting an Invalid Date.
1771 const revive = (ts: string): Date => {
1772 const d = new Date(ts);
1773 return Number.isNaN(d.getTime()) ? new Date() : d;
1774 };
1776 // Guard the async tail like every other state-writing action: the board (and
1777 // its ↻ reset) appears the instant we rehydrate, so a reset can fire during
1778 // the re-ground below — its stale contact must not land on the fresh run.
1779 const epoch = this.runEpoch;
1780 this.runId = draft.runId;
1781 this.gameStatus = "playing";
1782 this.currentObjective = draft.currentObjective;
1783 this.objectiveProgress = draft.objectiveProgress;
1784 this.peakProgress = draft.peakProgress;
1785 this.momentum = draft.momentum;
1786 this.remainingMessages = draft.remainingMessages;
1787 this.messageHistory = draft.messageHistory.map((m) => ({
1788 ...m,
1789 timestamp: revive(m.timestamp),
1790 }));
1791 this.timelineEvents = draft.timelineEvents.map((e) => ({
1792 ...e,
1793 timestamp: revive(e.timestamp),
1794 }));
1795 this.figures = draft.figures.map((f) => ({ ...f }));
1796 this.activeFigureName = draft.activeFigureName;
1797 useChronicleStore().chronicle = draft.chronicle;
1798 this.replayState = draft.replayState;
1800 // Re-ground the active figure (the draft carries no dossier), then restore the
1801 // saved year/moment OVER the mid-lifetime default grounding picks — unless a
1802 // reset tore the board mid-ground, in which case abort without touching it.
1803 if (this.activeFigureName) {
1804 await this.groundActiveFigure(this.activeFigureName);
1805 if (epoch !== this.runEpoch) return false;
1807 this.contactWhen = draft.contactWhen;
1808 this.contactMoment = draft.contactMoment;
1809 return true;
1810 },
1812 resetGame() {
1813 // Clear the abandoned run's server draft so a reload can't resume it (#152).
1814 // Capture the id before the epoch tear nulls runId below; fire-and-forget.
1815 const abandonedRunId = this.runId;
1816 // Tear the epoch first: anything still in flight belongs to the old run
1817 // and must find no purchase here. (The seq counters deliberately survive.)
1818 this.runEpoch++;
1819 this.remainingMessages = TOTAL_MESSAGES;
1820 this.messageHistory = [];
1821 this.gameStatus = "playing";
1822 this.isLoading = false;
1823 this.error = null;
1824 this.lastMessageTime = null;
1825 this.isRateLimited = false;
1826 // A new run gets a fresh id on its next server call; abandon any
1827 // in-flight mint so a dead run's id can't land in the new run.
1828 this.runId = null;
1829 this.runIdInflight = null;
1830 this.runSnapshotSaved = false;
1831 // The paywall / capacity notices re-check on the next commit.
1832 this.outOfRuns = false;
1833 this.atCapacity = false;
1834 this.currentObjective = null;
1835 // A fresh run carries no replay lineage (preseedReplay re-sets it after).
1836 this.replayState = null;
1837 this.objectiveProgress = 0;
1838 this.peakProgress = 0;
1839 this.momentum = 0;
1840 this.timelineEvents = [];
1841 this.figures = [];
1842 this.activeFigureName = "";
1843 this.figureGrounding = null;
1844 this.groundingFor = "";
1845 this.groundingLoading = false;
1846 this.contactWhen = null;
1847 this.contactMoment = null;
1848 // The suggestions + Archive + Chronicle slices are satellite stores now (#235)
1849 // — tear them down through their own reset() (each keeps its seq counters,
1850 // which, like runEpoch, must survive so an in-flight request finds no purchase).
1851 useSuggestionsStore().reset();
1852 useArchiveStore().reset();
1853 useChronicleStore().reset();
1854 // Drop any half-revealed turn — a reset mid-reveal must leave nothing
1855 // for commitReveal/commitRipple to land in the fresh run (the epoch guard
1856 // also catches it, this is the belt-and-suspenders clear).
1857 this.pendingTurn = null;
1858 this.moderationNotice = null;
1859 // Drop the abandoned run's draft last, off the fresh (already-torn) epoch —
1860 // a no-op when there was no run yet (#152).
1861 if (abandonedRunId) void this.clearRunDraft(abandonedRunId);
1862 },
1863 },
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
stores/economy.ts193 lines · TypeScript
⋯ 146 lines hidden (lines 1–146)
1import { defineStore } from "pinia";
2import { useGameStore } from "./game";
3import type { Pack } from "~/server/utils/packs";
4 
5/**
6 * Economy Store — the device/account slice split out of the game store (#235):
7 * the run balance + account identity, the referral tally, the reroll-only handle,
8 * and the run-packs sales modal + purchase-return notice. This is device/account
9 * state that lives ACROSS runs, so it deliberately survives the game store's
10 * resetGame (which only tears down run-coupled state).
11 *
12 * Cross-store: the run-lifecycle paywall flags (outOfRuns / atCapacity) stay on the
13 * game store — the charge point (chooseObjective) writes this store's balance and
14 * opens the modal via useEconomyStore(), and notePurchaseReturn clears the game
15 * store's outOfRuns via a lazy useGameStore() call inside the action body (Pinia's
16 * supported cross-store pattern — the handle is fetched at call time, so there is
17 * no module-load initialization cycle).
18 */
19export const useEconomyStore = defineStore("economy", {
20 state: () => ({
21 // The run-packs catalog for the paywall, loaded on demand (GET /api/packs).
22 packs: [] as Pack[],
23 // The device's run balance, for the header gauge + account popover. null
24 // until first loaded (GET /api/balance); the run-commit response also
25 // refreshes it so the gauge stays live without a second round-trip.
26 runsRemaining: null as number | null,
27 freeRuns: 1,
28 deviceRef: "" as string,
29 // Account state (from /api/balance): the signed-in email, and whether the
30 // current user is anonymous (→ must sign in before buying). Drives the
31 // account UI + the sign-in-to-buy gate. false/null in device-fallback mode.
32 accountEmail: null as string | null,
33 accountAnonymous: false,
34 // Referral credits earned from sharing (issue #96), from /api/balance — the
35 // passive "+N from sharing" tally in the account popover. Pull-based, never a ping.
36 referralCredits: 0,
37 referralCount: 0,
38 // The account's generated, reroll-only username (#117), from /api/profile. null
39 // until first loaded; lazily minted server-side on that first read. Reroll is the
40 // only way to change it — there is no typed-name path anywhere.
41 username: null as string | null,
42 // The run-packs sales modal — opened on demand (header) or automatically
43 // when a commit is refused for being out of runs.
44 buyModalOpen: false,
45 // A one-shot notice after returning from Stripe Checkout ('success' shows a
46 // credited confirmation; 'cancel' a gentle "no charge" note). Cleared by the UI.
47 purchaseNotice: null as "success" | "cancel" | null,
48 }),
49 
50 actions: {
51 /**
52 * Loads the device's run balance for the header gauge + account popover.
53 * Grants the free trial on a brand-new device (server-side). Graceful: on
54 * failure it leaves the prior value (the gauge simply doesn't update).
55 */
56 async loadBalance(): Promise<void> {
57 try {
58 const res = (await $fetch("/api/balance")) as {
59 runsRemaining?: number;
60 freeRuns?: number;
61 deviceRef?: string;
62 email?: string | null;
63 isAnonymous?: boolean;
64 referralCredits?: number;
65 referralCount?: number;
66 };
67 if (typeof res?.runsRemaining === "number")
68 this.runsRemaining = res.runsRemaining;
69 if (typeof res?.freeRuns === "number") this.freeRuns = res.freeRuns;
70 if (typeof res?.deviceRef === "string") this.deviceRef = res.deviceRef;
71 this.accountEmail = res?.email ?? null;
72 this.accountAnonymous = res?.isAnonymous === true;
73 if (typeof res?.referralCredits === "number")
74 this.referralCredits = res.referralCredits;
75 if (typeof res?.referralCount === "number")
76 this.referralCount = res.referralCount;
77 } catch (error) {
78 console.error("Failed to load balance:", error);
79 }
80 },
81 
82 /**
83 * Claims a pending referral after an email signup (issue #96). Best-effort and
84 * fire-and-forget by design: the credit lands on the SHARER, not this player, so
85 * there's nothing here to surface — the server decides idempotently whether it
86 * qualifies. Never throws into the sign-in flow.
87 */
88 async claimReferral(): Promise<void> {
89 try {
90 await $fetch("/api/referral/claim", { method: "POST" });
91 } catch (error) {
92 console.error("Failed to claim referral:", error);
93 }
94 },
95 
96 /**
97 * Loads the account's generated username (#117) for the profile / settings pages
98 * and the share affordance, minting one server-side on first need. Cached: skips
99 * the round-trip once loaded (a reroll keeps it current). Graceful on failure —
100 * leaves the prior value.
101 */
102 async loadUsername(force = false): Promise<void> {
103 if (this.username && !force) return;
104 try {
105 const res = (await $fetch("/api/profile")) as { username?: string };
106 if (typeof res?.username === "string") this.username = res.username;
107 } catch (error) {
108 console.error("Failed to load username:", error);
109 }
110 },
111 
112 /**
113 * Rerolls the account username to a fresh generated one (#117) — the only way to
114 * change it. The server re-stamps it onto the player's attributed shares too, so
115 * the handle stays consistent everywhere it appears. Returns the new name on
116 * success, or null on failure so the caller can surface a notice.
117 */
118 async rerollUsername(): Promise<string | null> {
119 try {
120 const res = (await $fetch("/api/profile/reroll", {
121 method: "POST",
122 })) as { username?: string };
123 if (typeof res?.username === "string") {
124 this.username = res.username;
125 return res.username;
126 }
127 return null;
128 } catch (error) {
129 console.error("Failed to reroll username:", error);
130 return null;
131 }
132 },
133 
134 /** Opens the run-packs sales modal, loading the catalog first. */
135 async openBuyModal(): Promise<void> {
136 this.buyModalOpen = true;
137 await this.loadPacks();
138 },
139 
140 /** Closes the sales modal. */
141 closeBuyModal(): void {
142 this.buyModalOpen = false;
143 },
144 
145 /** Records the Stripe-return outcome and refreshes the balance on success
146 * (the webhook credits asynchronously; re-read so the gauge reflects it). */
147 async notePurchaseReturn(outcome: "success" | "cancel"): Promise<void> {
148 this.purchaseNotice = outcome;
149 this.buyModalOpen = false;
150 if (outcome === "success") {
151 // The paywall (outOfRuns) lives on the game store — clear it so the mission
152 // screen lifts the moment the purchase credits (cross-store, lazy handle).
153 useGameStore().outOfRuns = false;
154 await this.loadBalance();
155 }
156 },
157 
⋯ 36 lines hidden (lines 158–193)
158 /** Dismisses the post-purchase notice. */
159 clearPurchaseNotice(): void {
160 this.purchaseNotice = null;
161 },
162 
163 /** Loads the run-pack catalog for the paywall. Caches only a NON-empty
164 * result: a transient empty/failed read must not poison the cache and
165 * strand the modal with zero packs for the rest of the session. */
166 async loadPacks(): Promise<void> {
167 if (this.packs.length) return;
168 try {
169 const res = (await $fetch("/api/packs")) as { packs?: Pack[] };
170 const packs = res?.packs ?? [];
171 if (packs.length) this.packs = packs;
172 } catch (error) {
173 console.error("Failed to load packs:", error);
174 }
175 },
176 
177 /** Starts checkout for a pack; returns the Stripe Checkout URL to redirect
178 * to (null on failure). The caller does the redirect. */
179 async buyPack(packId: string): Promise<string | null> {
180 try {
181 const res = (await $fetch("/api/checkout", {
182 method: "POST",
183 headers: { "Content-Type": "application/json" },
184 body: { packId },
185 })) as { url?: string };
186 return res?.url ?? null;
187 } catch (error) {
188 console.error("Failed to start checkout:", error);
189 return null;
190 }
191 },
192 },
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
utils/sse.ts43 lines · TypeScript
⋯ 18 lines hidden (lines 1–18)
1/**
2 * SSE (Server-Sent Events) frame parsing for the streaming AI routes.
3 *
4 * Both the chronicle stream (`refreshChronicle`) and the turn stream (`resolveTurn`)
5 * read a `ReadableStream` chunk by chunk and pull newline-delimited `data:` frames
6 * out of an accumulating text buffer. This is that shared byte-level parser (issue
7 * #235, extracted verbatim from the two copies in stores/game.ts). The frame
8 * *dispatch* — what each message means — stays at the call site; only the "split
9 * complete frames, decode each data: line as JSON" mechanics live here.
10 */
11 
12/**
13 * Drain every COMPLETE SSE frame from `buffer`, returning the decoded `data:`
14 * payloads (parsed as JSON, in arrival order) plus the leftover partial frame to
15 * carry into the next chunk. Frames are delimited by a blank line (`\n\n`). A
16 * frame with no `data:` line, or whose payload isn't valid JSON, is skipped — a
17 * truncated final frame simply stays in `rest` until its bytes arrive.
18 */
19export function drainSSEFrames<T = unknown>(
20 buffer: string,
21): {
22 messages: T[];
23 rest: string;
24} {
25 const messages: T[] = [];
26 let buf = buffer;
27 let nl: number;
28 while ((nl = buf.indexOf("\n\n")) >= 0) {
29 const frame = buf.slice(0, nl);
30 buf = buf.slice(nl + 2);
31 const dataLine = frame
32 .split("\n")
33 .map((l) => l.trim())
34 .find((l) => l.startsWith("data:"));
35 if (!dataLine) continue;
36 try {
37 messages.push(JSON.parse(dataLine.slice(5).trim()) as T);
38 } catch {
39 // partial/garbled frame — skip it (never delivered)
40 }
41 }
42 return { messages, rest: buf };

Step 2 — retiring the dead sendMessage pipeline

The store held TWO turn pipelines: the streaming resolveTurncommitRevealcommitRipple 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
utils/run-snapshot.ts511 lines · TypeScript
⋯ 178 lines hidden (lines 1–178)
1/**
2 * The saved-run contract — the immutable, versioned record of a finished run.
3 *
4 * A run is otherwise client-only: the objective, the dispatches, the timeline
5 * ledger, the Chronicle epilogue, and the verdict all live in the Pinia store and
6 * evaporate on reload. This is the persisted form of that end-state: the store builds
7 * it on game end, the server stores it keyed by the run id, and a read-only "your
8 * runs" view replays it. It is a SNAPSHOT, not a live record — written ONCE, at
9 * victory/defeat, and immutable thereafter.
10 *
11 * The MID-run counterpart is the RunDraft at the foot of this file (#152): the live
12 * state, overwritten every turn, so a refresh or a crash can't lose a half-played run
13 * (every turn is a model call — expensive to redo). Two persistence contracts, two
14 * lifecycles, kept in one client-safe module.
15 *
16 * Naming: "run" is overloaded. The header gauge's balance ("runs remaining") is a
17 * purchasable CREDIT (the `balances` table); this is a PLAYED run (the game record,
18 * the `run_snapshots` table). Same noun, two states — kept distinct in the schema.
19 *
20 * The shape is VERSIONED (`version`) so a later change can't silently break runs
21 * saved under an older shape: a reader can branch on `version` and migrate. It is a
22 * flat, fully JSON-native projection of the store state — the `Date` timestamps on
23 * the in-memory ledger/thread are dropped (a snapshot needs none of the duration
24 * math), so the stored blob round-trips through jsonb cleanly.
25 *
26 * This module is the SHARED contract (types + version), client-safe by design: it
27 * carries no runtime imports, only type-only ones, so the client store can import the
28 * version + types without pulling any server-only code. The server-side boundary
29 * validator lives separately in `server/utils/run-snapshot.ts`.
30 */
31import type { DiceOutcome } from "~/utils/dice";
32import type { Craft } from "~/utils/craft";
33import type { Continuity } from "~/utils/continuity";
34import type { ChainStatus } from "~/utils/causal-chain";
35import type { ContactMoment } from "~/utils/contact-moment";
36import type { Anachronism } from "~/utils/anachronism";
37import type { GameObjective } from "~/utils/objectives";
38import type { ChronicleEntry } from "~/server/utils/openai";
39// Type-only, erased at build — no runtime cycle with the store that imports this
40// module (the same pattern game-summary.ts uses to read the store's live shapes).
41import type { Message, TimelineEvent, HistoricalFigure } from "~/stores/game";
42 
43/** The current saved-run shape. Bump when the shape changes incompatibly; a reader
44 * branches on it to migrate older snapshots rather than mis-reading them.
45 * v2 (#151): added `peakProgress` — the run's high-water mark, persisted so the
46 * "Peaked at X%" stat survives onto snapshot-backed (saved/shared) views. */
47export const RUN_SNAPSHOT_VERSION = 2;
48 
49/** Only a finished run is ever saved — the two terminal verdicts. */
50export type RunOutcome = "victory" | "defeat";
51 
52/** The valence of a ledger swing (mirrors the store's `Valence`). */
53export type RunValence = "positive" | "negative" | "neutral";
54 
55/** The five efficiency tiers a victory can earn (mirrors the store's rating). */
56export type EfficiencyRating =
57 "Legendary" | "Excellent" | "Great" | "Good" | "Standard";
58 
59/** The bit of a roll the read-only view shows: the number and its named outcome. */
60export interface RollMark {
61 diceRoll: number;
62 diceOutcome: DiceOutcome;
64 
65/** A dispatch keepsake — the player's verbatim message and whom/when it reached. */
66export interface RunDispatch {
67 text: string;
68 figure: string;
69 era: string;
71 
72/** The victory efficiency block (null on defeat) — the same data the end screen shows. */
73export interface RunEfficiency {
74 messagesSaved: number;
75 messagesUsed: number;
76 efficiencyPercentage: number;
77 efficiencyRating: EfficiencyRating;
78 isEarlyVictory: boolean;
80 
81/** One ledger entry, trimmed to the fields a read-only run view renders (the live
82 * `TimelineEvent` minus its `Date` timestamp and the internals the view ignores). */
83export interface RunLedgerEntry {
84 id: string;
85 figureName: string;
86 era: string;
87 headline: string;
88 detail: string;
89 diceRoll: number;
90 diceOutcome: DiceOutcome;
91 progressChange: number;
92 /** The pre-amplifier swing — the Spine reads it to show the Δ equation
93 * (`base → final`). Absent on turns that didn't amplify (none to disclose). */
94 baseProgressChange?: number;
95 valence: RunValence;
96 craft?: Craft;
97 /** The PRE-turn momentum that amplified the swing — the detail strip reads it for
98 * the ✦ momentum factor in the Δ equation, so a saved/shared run discloses the
99 * same breakdown a live turn did. */
100 momentumAtSwing?: number;
101 anachronism?: Anachronism;
102 /** How far this landed from the nearest foothold — it decayed the swing. The
103 * detail strip reads the tier for the ⏳ badge that explains a shrunk Δ. */
104 causalChain?: ChainStatus;
105 /** True when this was the run's staked last stand — the Spine's ⚑ badge. */
106 staked?: boolean;
108 
109/** The full saved run — everything a read-only view needs to replay the end screen. */
110export interface RunSnapshot {
111 version: number;
112 runId: string;
113 status: RunOutcome;
114 objective: GameObjective;
115 objectiveProgress: number;
116 /** The run's high-water mark (0..100) — the highest objectiveProgress it ever
117 * reached. Always ≥ objectiveProgress (peak only rises; the live total can fall),
118 * so a snapshot-backed view shows "Peaked at X%" exactly when peak > final, the
119 * same rule the live end screen uses (#151). v1 snapshots lack it; `migrateSnapshot`
120 * defaults them to their final objectiveProgress (so peak == final → stat hidden). */
121 peakProgress: number;
122 messagesUsed: number;
123 totalMessages: number;
124 bestRoll: RollMark | null;
125 worstRoll: RollMark | null;
126 efficiency: RunEfficiency | null;
127 dispatches: RunDispatch[];
128 timeline: RunLedgerEntry[];
129 chronicle: ChronicleEntry | null;
131 
132/**
133 * Upgrade a stored snapshot to the current shape. Snapshots persist under the version
134 * current at save time, so a reader branches on `version` to normalize an older one
135 * rather than mis-reading a field it predates. A current snapshot passes through
136 * untouched; future bumps add their own step here, applied in sequence.
137 *
138 * v1 → v2: the high-water mark was live-session-only, so a v1 blob carries no
139 * `peakProgress`. Default it to the run's final objectiveProgress — peak == final, which
140 * renders as "no peak shown," exactly how those runs looked before the field existed.
141 */
142export function migrateSnapshot(raw: RunSnapshot): RunSnapshot {
143 if (raw.version >= RUN_SNAPSHOT_VERSION) return raw;
144 const peak = (raw as { peakProgress?: unknown }).peakProgress;
145 return {
146 ...raw,
147 version: RUN_SNAPSHOT_VERSION,
148 peakProgress: typeof peak === "number" ? peak : raw.objectiveProgress,
149 };
151 
152/** The store end-state `snapshotFrom` reads to build a terminal snapshot — passed in
153 * so the build is a pure function. `bestRoll`/`worstRoll` are the raw `gameSummary`
154 * rolls (the action keeps that getter read) and `efficiency` is what
155 * `getVictoryEfficiency()` returned; the rest are live store fields. */
156export interface SnapshotInput {
157 runId: string;
158 status: RunOutcome;
159 objective: GameObjective;
160 objectiveProgress: number;
161 peakProgress: number;
162 messagesUsed: number;
163 totalMessages: number;
164 bestRoll: Message | null;
165 worstRoll: Message | null;
166 efficiency: RunEfficiency | null;
167 messageHistory: readonly Message[];
168 timeline: readonly TimelineEvent[];
169 chronicle: ChronicleEntry | null;
171 
172/**
173 * Build the terminal `RunSnapshot` from the store's end-state — the pure projection
174 * half of `saveRunSnapshot` (the action keeps the completed/server-backed guard, the
175 * `gameSummary`/`getVictoryEfficiency` reads, the replay-token wrap, and the `$fetch`).
176 * The in-memory `Date` stamps are dropped (a snapshot needs no duration math); the
177 * player's dispatches are zipped to the ledger by turn order for the whom/when keepsake.
178 */
179export function snapshotFrom(input: SnapshotInput): RunSnapshot {
180 const events = input.timeline;
181 const mark = (m: Message | null): RollMark | null =>
182 m && m.diceRoll != null && m.diceOutcome != null
183 ? { diceRoll: m.diceRoll, diceOutcome: m.diceOutcome }
184 : null;
185 return {
186 version: RUN_SNAPSHOT_VERSION,
187 runId: input.runId,
188 status: input.status,
189 objective: input.objective,
190 objectiveProgress: input.objectiveProgress,
191 // The high-water mark, so a snapshot-backed view can show "Peaked at X%"
192 // (the live store zeroes it on reset, so it's only meaningful at end).
193 peakProgress: input.peakProgress,
194 messagesUsed: input.messagesUsed,
195 totalMessages: input.totalMessages,
196 bestRoll: mark(input.bestRoll),
197 worstRoll: mark(input.worstRoll),
198 efficiency: input.efficiency,
199 // The dispatch keepsake — the player's verbatim messages, paired with
200 // whom/when they reached (the same zip the end screen renders).
⋯ 311 lines hidden (lines 201–511)
201 dispatches: input.messageHistory
202 .filter((m) => m.sender === "user")
203 .map((m, i) => ({
204 text: m.text,
205 figure: m.figureName || events[i]?.figureName || "the past",
206 era: events[i]?.era || "",
207 })),
208 timeline: events.map((e) => ({
209 id: e.id,
210 figureName: e.figureName,
211 era: e.era,
212 headline: e.headline,
213 detail: e.detail,
214 diceRoll: e.diceRoll,
215 diceOutcome: e.diceOutcome,
216 progressChange: e.progressChange,
217 baseProgressChange: e.baseProgressChange,
218 valence: e.valence,
219 craft: e.craft,
220 momentumAtSwing: e.momentumAtSwing,
221 anachronism: e.anachronism,
222 causalChain: e.causalChain,
223 staked: e.staked,
224 })),
225 chronicle: input.chronicle,
226 };
228 
229/** The list projection — what the "your runs" list shows per run, without pulling
230 * the full snapshot blob for every row. */
231export interface RunSummary {
232 runId: string;
233 status: RunOutcome;
234 title: string;
235 progress: number;
236 createdAt: string;
238 
239/**
240 * "Remixed from" credit (issue #89) — the one-hop lineage of a replayed run: the run it
241 * was seeded from. It is a POINTER WALKED at render time (never a copy stamped into the
242 * child), so it always reflects the source's CURRENT privacy choice — a later un-share
243 * drops `attribution` back to anonymous and `sourceToken` to null. It carries only public
244 * game data + the opt-in display name: never the parent's run id, owner, or device.
245 */
246export interface RemixCredit {
247 /** The source sharer's opt-in display name, or null when they shared anonymously. */
248 attribution: string | null;
249 /** The source run's objective title — the objective this run is a replay of. */
250 objectiveTitle: string;
251 /** The source's live share token for an optional backlink, or null when un-shared. */
252 sourceToken: string | null;
254 
255/**
256 * The PUBLIC, shareable projection of a saved run (issue #88) — the sanitized form
257 * served at an unguessable share URL to anyone, no login. It carries the GAME RECORD
258 * (verdict, ledger, dispatches, Chronicle epilogue) and an optional self-chosen display
259 * name, and DELIBERATELY nothing that identifies the player: no user id, no device id,
260 * no email — and not even the run id (the share token is the only handle). `run.runId`
261 * is blanked for that reason; the read-only view never renders it.
262 */
263export interface PublicRun {
264 run: RunSnapshot;
265 /** The sharer's opt-in display name, or null when shared anonymously. */
266 attribution: string | null;
267 /** When this run is itself a replay (issue #89), the one-hop "remixed from" credit;
268 * null/absent for an original run. Walked from the parent's live snapshot, so it
269 * honors the source's current privacy opt-in. */
270 remixedFrom?: RemixCredit | null;
272 
273/** What the OWNER sees about a run's share status: the live token (null = not shared)
274 * and the current display name. Owner-only — never part of the public projection. */
275export interface ShareState {
276 token: string | null;
277 name: string | null;
279 
280/** The brag line a share prefills (the X intent + the native sheet) — one source so the
281 * end screen, the saved-run page, and the public page never drift (issue #88). */
282export function buildShareText(
283 objectiveTitle: string,
284 status: RunOutcome,
285 progress: number,
286): string {
287 const verb =
288 status === "victory" ? "I rewrote history" : "I tried to rewrite history";
289 return `${verb} in Everwhen: ${objectiveTitle || "a historical mission"}${progress}% rewritten.`;
291 
292/* ──────────────────────────────────────────────────────────────────────────────
293 * The in-progress run DRAFT (#152) — the live counterpart to the terminal snapshot.
294 *
295 * The snapshot above is written once and frozen; the draft is its mirror image:
296 * overwritten after every resolved-but-still-playing turn so a refresh or a crash
297 * can't lose a half-played run. It serializes the DURABLE live state the store needs
298 * to drop the player straight back onto the in-progress board, and nothing else:
299 *
300 * • the running totals — objective + progress + peak + momentum + remainingMessages
301 * • the FULL thread, both sides (the snapshot keeps only the player's dispatches; a
302 * resume needs the AI replies too, or it would re-pay for those model calls)
303 * • the UN-trimmed ledger — with `whenSigned` and `causalChain`, which the next
304 * turn's causal-chain dial and the figure world-brief read back
305 * • the contacted figures and the active-contact selection (who & when)
306 * • the live Chronicle and any replay lineage
307 *
308 * It DELIBERATELY omits: transient UI/loading flags, the runEpoch/seq staleness
309 * counters (they must survive a reset, not a reload), and `figureGrounding` — the
310 * active figure is RE-grounded from the live record on resume, so a draft never
311 * carries a stale dossier. The in-memory `Date` stamps on the thread/ledger serialize
312 * to ISO strings here (jsonb is dateless) and are revived on rehydrate.
313 *
314 * Versioned INDEPENDENTLY of the snapshot (RUN_DRAFT_VERSION, not RUN_SNAPSHOT_VERSION):
315 * the two shapes have different lifecycles and evolve on their own clocks, so a
316 * snapshot-only field change must never read as a draft migration, nor the reverse.
317 * ────────────────────────────────────────────────────────────────────────────── */
318 
319/** The current draft shape. Independent of RUN_SNAPSHOT_VERSION on purpose — a draft
320 * and a snapshot are different records. Bump on an incompatible change; a future
321 * reader branches on it to migrate rather than mis-read an older draft. */
322export const RUN_DRAFT_VERSION = 1;
323 
324/** One thread line as it persists in a draft: the live store `Message` with its
325 * `Date` stamp serialized to an ISO string. Both senders are kept (the snapshot's
326 * RunDispatch keeps player dispatches only) — the AI replies are part of "where you
327 * left off," and they cost a model call to regenerate. */
328export interface RunDraftMessage {
329 text: string;
330 sender: "user" | "ai" | "system";
331 timestamp: string;
332 figureName?: string;
333 diceRoll?: number;
334 diceOutcome?: DiceOutcome;
335 naturalRoll?: number;
336 rollModifier?: number;
337 craft?: Craft;
338 craftReason?: string;
339 continuity?: Continuity;
340 characterAction?: string;
341 timelineImpact?: string;
342 progressChange?: number;
343 baseProgressChange?: number;
344 momentumAtSwing?: number;
345 anachronism?: Anachronism;
346 causalChain?: ChainStatus;
347 staked?: boolean;
349 
350/** One ledger entry as it persists in a draft: the FULL live `TimelineEvent` (not the
351 * snapshot's trimmed RunLedgerEntry) — it keeps `whenSigned` and `causalChain`, the
352 * footholds the next turn's causal chain measures against, with its `Date` stamp
353 * serialized to an ISO string. */
354export interface RunDraftLedgerEntry {
355 id: string;
356 figureName: string;
357 era: string;
358 headline: string;
359 detail: string;
360 diceRoll: number;
361 diceOutcome: DiceOutcome;
362 progressChange: number;
363 baseProgressChange?: number;
364 valence: RunValence;
365 anachronism?: Anachronism;
366 causalChain?: ChainStatus;
367 craft?: Craft;
368 momentumAtSwing?: number;
369 whenSigned?: number;
370 staked?: boolean;
371 timestamp: string;
373 
374/** A contacted figure as the store caches it (mirrors the store's HistoricalFigure). */
375export interface RunDraftFigure {
376 name: string;
377 era: string;
378 descriptor: string;
380 
381/** The replay seed (issue #89) as it persists in a draft — so a resumed replay still
382 * stamps its lineage pointer and credits its source on the end screen. */
383export interface RunDraftReplay {
384 objective: GameObjective;
385 sourceToken: string;
386 sourceAttribution: string | null;
388 
389/** The full in-progress draft — everything the store needs to rehydrate a live run. */
390export interface RunDraft {
391 version: number;
392 runId: string;
393 currentObjective: GameObjective;
394 objectiveProgress: number;
395 peakProgress: number;
396 momentum: number;
397 remainingMessages: number;
398 messageHistory: RunDraftMessage[];
399 timelineEvents: RunDraftLedgerEntry[];
400 figures: RunDraftFigure[];
401 activeFigureName: string;
402 contactWhen: number | null;
403 contactMoment: ContactMoment | null;
404 chronicle: ChronicleEntry | null;
405 replayState: RunDraftReplay | null;
407 
408/** Read guard for a persisted draft — the reader the write-side version stamp always
409 * promised (#257). A draft is disposable (the live, overwritten mirror of a run, not
410 * the saved record), so a shape from a bygone RUN_DRAFT_VERSION is DROPPED rather than
411 * rehydrated as if current: resume degrades to "nothing to resume", which the flow
412 * already handles. This is the deliberate counterpart to migrateSnapshot, which
413 * MIGRATES because a snapshot is the permanent record and can't be thrown away. Branch
414 * here to salvage a future bump if a stale draft ever proves worth migrating. */
415export function migrateDraft(raw: RunDraft): RunDraft | null {
416 return raw?.version === RUN_DRAFT_VERSION ? raw : null;
418 
419/** The live store state `draftFrom` reads to serialize an autosave draft — passed in
420 * so the build is a pure function (the action keeps the still-playing/server-backed
421 * guard and the `$fetch`). `objective` is the store's `currentObjective`; everything
422 * else is a live field. */
423export interface DraftInput {
424 runId: string;
425 objective: GameObjective;
426 objectiveProgress: number;
427 peakProgress: number;
428 momentum: number;
429 remainingMessages: number;
430 messageHistory: readonly Message[];
431 timelineEvents: readonly TimelineEvent[];
432 figures: readonly HistoricalFigure[];
433 activeFigureName: string;
434 contactWhen: number | null;
435 contactMoment: ContactMoment | null;
436 chronicle: ChronicleEntry | null;
437 replayState: RunDraftReplay | null;
439 
440/**
441 * Serialize the in-progress `RunDraft` from live store state — the pure projection half
442 * of `saveRunDraft`. It keeps the FULL thread (both sides) and the UN-trimmed ledger
443 * (with `whenSigned` + `causalChain`, the next turn's footholds), and serializes the
444 * in-memory `Date` stamps to ISO strings for jsonb (revived on resume). Pure: it only
445 * reads its input and calls `.toISOString()` on the stamps it is handed — no `now()`.
446 */
447export function draftFrom(input: DraftInput): RunDraft {
448 return {
449 version: RUN_DRAFT_VERSION,
450 runId: input.runId,
451 currentObjective: input.objective,
452 objectiveProgress: input.objectiveProgress,
453 peakProgress: input.peakProgress,
454 momentum: input.momentum,
455 remainingMessages: input.remainingMessages,
456 // The full thread, both sides — the in-memory Date stamp serialized for
457 // jsonb (revived on resume). The AI replies cost a model call to redo.
458 messageHistory: input.messageHistory.map((m) => ({
459 text: m.text,
460 sender: m.sender,
461 timestamp: m.timestamp.toISOString(),
462 figureName: m.figureName,
463 diceRoll: m.diceRoll,
464 diceOutcome: m.diceOutcome,
465 naturalRoll: m.naturalRoll,
466 rollModifier: m.rollModifier,
467 craft: m.craft,
468 craftReason: m.craftReason,
469 continuity: m.continuity,
470 characterAction: m.characterAction,
471 timelineImpact: m.timelineImpact,
472 progressChange: m.progressChange,
473 baseProgressChange: m.baseProgressChange,
474 momentumAtSwing: m.momentumAtSwing,
475 anachronism: m.anachronism,
476 causalChain: m.causalChain,
477 staked: m.staked,
478 })),
479 // The UN-trimmed ledger: keeps whenSigned + causalChain (the footholds
480 // the next turn's causal chain and the world-brief read back).
481 timelineEvents: input.timelineEvents.map((e) => ({
482 id: e.id,
483 figureName: e.figureName,
484 era: e.era,
485 headline: e.headline,
486 detail: e.detail,
487 diceRoll: e.diceRoll,
488 diceOutcome: e.diceOutcome,
489 progressChange: e.progressChange,
490 baseProgressChange: e.baseProgressChange,
491 valence: e.valence,
492 anachronism: e.anachronism,
493 causalChain: e.causalChain,
494 craft: e.craft,
495 momentumAtSwing: e.momentumAtSwing,
496 whenSigned: e.whenSigned,
497 staked: e.staked,
498 timestamp: e.timestamp.toISOString(),
499 })),
500 figures: input.figures.map((f) => ({
501 name: f.name,
502 era: f.era,
503 descriptor: f.descriptor,
504 })),
505 activeFigureName: input.activeFigureName,
506 contactWhen: input.contactWhen,
507 contactMoment: input.contactMoment,
508 chronicle: input.chronicle,
509 replayState: input.replayState,
510 };

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
stores/archive.ts214 lines · TypeScript
⋯ 77 lines hidden (lines 1–77)
1import { defineStore } from "pinia";
2import { useGameStore, formatContactYear } from "./game";
3import type { FigureStudy, ArchiveLookup } from "~/server/utils/prompt-builder";
4 
5/**
6 * Archive Store — the in-game research slice split out of the game store (#235):
7 * the objective-blind brief on the active figure (`studyActiveFigure`) and the
8 * freeform topic lookup (`askArchive`), so the player can research who they're
9 * reaching without leaving for a browser tab.
10 *
11 * Cross-store (the lazy-handle pattern, mirroring stores/economy.ts): the Archive
12 * reads the core run-state it needs — the active figure's grounding, the chosen
13 * contact year, the run epoch, and the run-tagging headers — via `useGameStore()`
14 * INSIDE the action bodies (the handle is fetched at call time, so there is no
15 * module-load init cycle). The game store drives this one the other way:
16 * clearGrounding / groundActiveFigure clear the stale study, the turn pipeline
17 * clears the lookup, and resetGame tears it down — all via useArchiveStore().
18 */
19export const useArchiveStore = defineStore("archive", {
20 state: () => ({
21 // The Archive brief on the active figure, objective-blind and moment-specific,
22 // so it's cached against the figure AND the year (studyFor / studyWhen).
23 figureStudy: null as FigureStudy | null,
24 studyLoading: false,
25 studyFor: "" as string,
26 studyWhen: null as number | null,
27 // Archive topic lookup (the "yellow" layer): concrete domain facts on demand.
28 archiveResult: null as ArchiveLookup | null,
29 lookupLoading: false,
30 // Content-moderation blocks — distinct from an infra error so the UI shows an
31 // honest, visibly different banner. One per untrusted-input surface here: the
32 // Archive lookup and the Archivist study.
33 archiveNotice: null as string | null,
34 studyNotice: null as string | null,
35 // Per-kind seq counters, the staleness shape the game store's other async
36 // actions carry (chronicle/suggestions/grounding). They order overlapping
37 // studies / lookups so only the latest lands — a slow study for figure A is
38 // dropped once the player switches to B and restudies. Deliberately NOT
39 // restored by reset(): they must survive a run tear-down to keep working (#235).
40 studySeq: 0,
41 lookupSeq: 0,
42 }),
43 
44 actions: {
45 /**
46 * Studies the active (grounded) figure via the Archivist — an objective-blind
47 * brief on who they are AT THE CHOSEN MOMENT, so the player can research in the
48 * game instead of a browser tab. The brief is moment-specific, so it's cached
49 * against the figure AND the year: move the contact slider and the player can
50 * re-study them at the new moment. Graceful on failure (leaves the prior brief).
51 * Only resolved figures can be studied — an unresolved name has no record.
52 */
53 async studyActiveFigure(): Promise<void> {
54 const game = useGameStore();
55 const g = game.figureGrounding;
56 if (!g?.resolved) {
57 this.figureStudy = null;
58 return;
59 }
60 // Cache hit only when both the figure AND the studied year still match.
61 if (
62 this.studyFor === g.name &&
63 this.studyWhen === game.contactWhen &&
64 this.figureStudy
65 )
66 return;
67 
68 // Capture the moment being studied NOW: the brief describes this figure at
69 // this year. If the slider moves mid-flight, recording the live value would
70 // mislabel the brief and silently suppress the Re-study button.
71 //
72 // The run epoch + a per-kind seq are the staleness shape every other async
73 // store action uses: capture both before the first await, then bail after
74 // each await unless BOTH still hold. The seq (#235) drops a slow study for
75 // figure A once the player has switched to B and restudied — B's ++studySeq
76 // supersedes A, so A's late response neither lands nor clears B's loading
77 // flag. (The figure-name check below can't catch a restudy of the SAME figure
78 // at a new year; the seq can.)
79 const epoch = game.runEpoch;
80 const seq = ++this.studySeq;
81 const studiedName = g.name;
82 const studiedWhen = game.contactWhen;
83 this.studyLoading = true;
84 this.studyNotice = null;
85 try {
86 const res = (await $fetch("/api/research", {
87 method: "POST",
88 headers: await game.aiHeaders({ "Content-Type": "application/json" }),
89 body: {
90 figureName: studiedName,
91 when:
92 studiedWhen != null ? formatContactYear(studiedWhen) : undefined,
93 description: g.description,
94 extract: g.extract,
95 },
96 })) as {
97 success?: boolean;
98 study?: FigureStudy;
99 blocked?: boolean;
100 error?: string;
101 };
102 if (epoch !== game.runEpoch || seq !== this.studySeq) return;
103 // If the contact changed mid-flight, the stale-study clear in
104 // groundActiveFigure already ran — don't resurrect the old brief.
105 if (res?.blocked && game.figureGrounding?.name === studiedName) {
⋯ 109 lines hidden (lines 106–214)
106 this.studyNotice =
107 res.error || "That study was blocked by content moderation.";
108 } else if (
109 res?.success &&
110 res.study &&
111 game.figureGrounding?.name === studiedName
112 ) {
113 this.figureStudy = res.study;
114 this.studyFor = studiedName;
115 this.studyWhen = studiedWhen;
116 }
117 } catch (error) {
118 if (epoch !== game.runEpoch || seq !== this.studySeq) return;
119 console.error("Failed to study the figure:", error);
120 } finally {
121 if (epoch === game.runEpoch && seq === this.studySeq)
122 this.studyLoading = false;
123 }
124 },
125 
126 /**
127 * Asks the Archive about a freeform topic (the "yellow" layer) — concrete
128 * domain facts: what it is, what it takes, and when it first became known
129 * (the player's anachronism read). Graceful on failure. The result stays put
130 * across figure changes while you compose one dispatch, then clears the moment
131 * that dispatch is sent (`clearArchive`) — each turn starts from fresh research.
132 */
133 async askArchive(query: string): Promise<void> {
134 const q = (query || "").trim();
135 if (!q) return;
136 // The epoch + per-kind seq (#235) are the staleness shape every other async
137 // store action uses: capture both before the first await, then bail after
138 // it unless BOTH still hold, so overlapping lookups keep only the latest.
139 const game = useGameStore();
140 const epoch = game.runEpoch;
141 const seq = ++this.lookupSeq;
142 this.lookupLoading = true;
143 this.archiveNotice = null;
144 try {
145 const res = (await $fetch("/api/lookup", {
146 method: "POST",
147 headers: await game.aiHeaders({ "Content-Type": "application/json" }),
148 body: { query: q },
149 })) as {
150 success?: boolean;
151 lookup?: ArchiveLookup;
152 blocked?: boolean;
153 error?: string;
154 };
155 if (epoch !== game.runEpoch || seq !== this.lookupSeq) return;
156 if (res?.blocked) {
157 // The Archive is the sharpest elicitation vector — a block here is
158 // honest and visible, not a silent empty result.
159 this.archiveNotice =
160 res.error || "That lookup was blocked by content moderation.";
161 } else if (res?.success && res.lookup) {
162 this.archiveResult = res.lookup;
163 }
164 } catch (error) {
165 if (epoch !== game.runEpoch || seq !== this.lookupSeq) return;
166 console.error("Archive lookup failed:", error);
167 } finally {
168 if (epoch === game.runEpoch && seq === this.lookupSeq)
169 this.lookupLoading = false;
170 }
171 },
172 
173 clearArchiveNotice() {
174 this.archiveNotice = null;
175 },
176 
177 // Reset the Archive lookup panel — both the last result and any block banner.
178 // Fired on each dispatch so a new turn starts from fresh research, not the
179 // previous turn's stale lookup.
180 clearArchive() {
181 this.archiveResult = null;
182 this.archiveNotice = null;
183 },
184 
185 /**
186 * Clears the active-figure study — called by the game store's clearGrounding /
187 * groundActiveFigure the instant the contact NAME changes, so a stale brief
188 * (and its block banner) never lingers on a different figure.
189 */
190 clearStudy() {
191 this.figureStudy = null;
192 this.studyFor = "";
193 this.studyWhen = null;
194 this.studyNotice = null;
195 },
196 
197 /**
198 * Restore the Archive to its initial run state — called by the game store's
199 * resetGame. studySeq/lookupSeq deliberately SURVIVE (staleness plumbing): an
200 * in-flight study/lookup from the torn-down run must still find its seq
201 * superseded so it can never write into the fresh run.
202 */
203 reset(): void {
204 this.figureStudy = null;
205 this.studyLoading = false;
206 this.studyFor = "";
207 this.studyWhen = null;
208 this.archiveResult = null;
209 this.lookupLoading = false;
210 this.archiveNotice = null;
211 this.studyNotice = null;
212 },
213 },
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.

stores/chronicle.ts · 238 lines
stores/chronicle.ts238 lines · TypeScript
⋯ 84 lines hidden (lines 1–84)
1import { defineStore } from "pinia";
2import { useGameStore, type TimelineEvent, type GameStatus } from "./game";
3import type { ChronicleEntry } from "~/server/utils/openai";
4import { drainSSEFrames } from "~/utils/sse";
5 
6/**
7 * Chronicle Store — the living Chronicle (Layer 3) split out of the game store
8 * (#235): the prose telling of the altered timeline, rewritten each turn, streamed
9 * as it's written, plus the epilogue-pending flag the end screen reads.
10 *
11 * Cross-store (the lazy-handle pattern, mirroring stores/archive.ts): refreshChronicle
12 * reads the core run-state it needs — the current objective, the landed ledger,
13 * progress, status, the run epoch, and the run-tagging headers — via `useGameStore()`
14 * INSIDE the action body (the handle is fetched at call time, so there is no
15 * module-load init cycle). The game store drives this one the other way: the turn
16 * pipeline (resolveTurn's early fire + commitRipple's inline fire) re-narrates after
17 * each landed change and sets/clears epiloguePending on a terminal turn, and resetGame
18 * tears it down — all via useChronicleStore().
19 */
20export const useChronicleStore = defineStore("chronicle", {
21 state: () => ({
22 // chronicleSeq orders overlapping rewrites (each turn fires one, never awaited)
23 // so only the LATEST issued refresh may land — an earlier telling arriving late
24 // must not regress the account (or the epilogue). Staleness plumbing, not run
25 // state — deliberately NOT restored by reset(): it must survive a run tear-down
26 // so a torn-down run's in-flight rewrite finds its seq superseded (#235).
27 chronicleSeq: 0,
28 // The living Chronicle (Layer 3): a prose telling of the altered timeline,
29 // rewritten each turn. Non-blocking — see refreshChronicle.
30 chronicle: null as ChronicleEntry | null,
31 chronicleLoading: false,
32 // The committed-ledger length the current `chronicle` was generated for. The
33 // Chronicle PAGE shows a loader (not the stale prior telling) until this
34 // matches the live ledger — under the explicit-Continue model a fast player can
35 // reach the page before the ~10s compose finishes, and a one-turn-stale telling
36 // on a focused page reads as disjointed.
37 chronicleForLedgerLen: 0,
38 // Live streaming buffer: the telling as it's being WRITTEN, delta by delta,
39 // so the Chronicle page (and the End screen) can show the prose appearing in
40 // realtime instead of a static loader. `chronicleStreamLen` is the ledger
41 // length the active stream narrates — a render only shows the live buffer
42 // when it matches the live ledger (never a stale prior turn's stream).
43 chronicleStream: "",
44 chronicleStreaming: false,
45 chronicleStreamLen: 0,
46 // The run's epilogue — the TERMINAL telling carrying the verdict — is being
47 // written. Set (by the turn pipeline) when the final turn fires its refresh and
48 // cleared when that telling lands or the refresh fails. Distinct from
49 // chronicleLoading (any refresh): it lets the end screen show "the final
50 // account…" instead of presenting the prior turn's stale, pre-ending telling as
51 // the epilogue.
52 epiloguePending: false,
53 }),
54 
55 actions: {
56 /**
57 * Re-narrates the Chronicle from the freshly-bent timeline. Fired
58 * non-blocking after a resolved turn (and at game end): the dice/progress
59 * reveal never waits on prose. The WHOLE account is rewritten each turn —
60 * a later change can re-frame how earlier events read.
61 * Graceful: a failed refresh keeps the prior telling, so the panel never
62 * blanks; an empty timeline clears it (nothing to chronicle yet).
63 */
64 async refreshChronicle(override?: {
65 ledger: TimelineEvent[];
66 progress: number;
67 status: GameStatus;
68 }): Promise<void> {
69 const game = useGameStore();
70 // The conductor can fire this at resolve with the resolved-but-not-yet-committed
71 // snapshot (ledger/progress/status) so the telling narrates the very turn it
72 // is about; the legacy tail passes nothing and reads the live, just-committed
73 // state. Byte-identical when no override is given.
74 const ledger = override?.ledger ?? game.timelineEvents;
75 const progress = override?.progress ?? game.objectiveProgress;
76 const status = override?.status ?? game.gameStatus;
77 if (!ledger.length) {
78 this.chronicle = null;
79 this.chronicleForLedgerLen = 0;
80 this.chronicleStream = "";
81 this.chronicleStreaming = false;
82 return;
83 }
84 // Sequencing: rewrites overlap (each turn fires one, never awaited), so
85 // only the LATEST issued refresh may land — an earlier telling arriving
86 // late must not regress the account (or worse, the epilogue). The epoch
87 // guard keeps a dead run's telling out of a fresh run entirely.
88 const epoch = game.runEpoch;
89 const seq = ++this.chronicleSeq;
90 // True only while THIS issued refresh is still the newest (and same run).
91 const current = () =>
92 epoch === game.runEpoch && seq === this.chronicleSeq;
93 this.chronicleLoading = true;
94 try {
95 const res = (await $fetch("/api/chronicle", {
⋯ 143 lines hidden (lines 96–238)
96 method: "POST",
97 headers: await game.aiHeaders({ "Content-Type": "application/json" }),
98 // The real endpoint streams the prose as SSE; ask for the raw body
99 // so we can render it being written. A non-streaming mock ignores
100 // this and returns a plain { success, chronicle } object — handled
101 // below — so tests and the e2e suite need no streaming machinery.
102 responseType: "stream",
103 body: {
104 objective: game.currentObjective,
105 status,
106 progress,
107 // The telling AS LAST LANDED, carried forward so the new one REVISES it
108 // rather than re-inventing every particular (issue #191). `this.chronicle`
109 // is still the prior telling here — it's overwritten only when the new one
110 // settles below — so a later change can re-frame earlier eras while names,
111 // dates, and fates stop drifting. Null on turn 1 → the prompt stays
112 // byte-identical to the eval-tuned build.
113 prior: this.chronicle ?? undefined,
114 timeline: ledger.map((e) => ({
115 era: e.era,
116 figureName: e.figureName,
117 headline: e.headline,
118 detail: e.detail,
119 progressChange: e.progressChange,
120 })),
121 },
122 })) as unknown;
123 
124 const isStream =
125 !!res &&
126 typeof (res as ReadableStream<Uint8Array>).getReader === "function";
127 if (isStream) {
128 // SSE path: accumulate prose deltas into the live buffer (so the
129 // page shows it being written), then settle on the terminal frame.
130 if (!current()) {
131 try {
132 await (res as ReadableStream).cancel();
133 } catch {
134 /* already gone */
135 }
136 return;
137 }
138 this.chronicleStream = "";
139 this.chronicleStreaming = true;
140 this.chronicleStreamLen = ledger.length;
141 const reader = (res as ReadableStream<Uint8Array>).getReader();
142 const decoder = new TextDecoder();
143 let buf = "";
144 let final: ChronicleEntry | null = null;
145 while (true) {
146 const { done, value } = await reader.read();
147 if (done) break;
148 // A newer refresh superseded this one — stop, drop, free the
149 // connection. The newest stream owns the buffer now.
150 if (!current()) {
151 try {
152 await reader.cancel();
153 } catch {
154 /* noop */
155 }
156 return;
157 }
158 buf += decoder.decode(value, { stream: true });
159 const { messages, rest } = drainSSEFrames<{
160 t?: string;
161 x?: string;
162 chronicle?: ChronicleEntry;
163 }>(buf);
164 buf = rest;
165 for (const msg of messages) {
166 if (msg?.t === "d" && typeof msg.x === "string") {
167 if (current() && this.chronicleStreamLen === ledger.length)
168 this.chronicleStream += msg.x;
169 } else if (msg?.t === "final" && msg.chronicle) {
170 final = msg.chronicle;
171 } // t:'error' → leave final null; the prior telling is kept
172 }
173 }
174 if (!current()) return;
175 if (!final) {
176 // No SSE `final` frame — tolerate a plain { success, chronicle }
177 // body that arrived as a stream (a non-streaming mock or proxy:
178 // responseType:'stream' returns the raw body for ANY response).
179 // The leftover buffer holds the whole un-framed body.
180 try {
181 const j = JSON.parse(buf.trim()) as {
182 success?: boolean;
183 chronicle?: ChronicleEntry;
184 };
185 if (j?.chronicle && Array.isArray(j.chronicle.paragraphs))
186 final = j.chronicle;
187 } catch {
188 /* not JSON either — keep the prior telling */
189 }
190 }
191 if (
192 final &&
193 Array.isArray(final.paragraphs) &&
194 final.paragraphs.length
195 ) {
196 this.chronicle = final;
197 this.chronicleForLedgerLen = ledger.length;
198 }
199 } else {
200 // Plain JSON path (the test/e2e mock, or any non-streamed response).
201 const json = res as { success?: boolean; chronicle?: ChronicleEntry };
202 if (!current()) return;
203 if (json?.success && json.chronicle) {
204 this.chronicle = json.chronicle;
205 // Stamp the ledger length this telling narrates, so the Chronicle
206 // page can tell a current telling from a stale prior one.
207 this.chronicleForLedgerLen = ledger.length;
208 }
209 }
210 } catch (error) {
211 if (!current()) return;
212 console.error("Failed to refresh the chronicle:", error);
213 // Keep the prior chronicle — a failed refresh never blanks the panel.
214 } finally {
215 if (current()) {
216 this.chronicleLoading = false;
217 this.chronicleStreaming = false;
218 }
219 }
220 },
221 
222 /**
223 * Restore the Chronicle to its initial run state — called by the game store's
224 * resetGame. chronicleSeq deliberately SURVIVES (staleness plumbing, like
225 * runEpoch): an in-flight rewrite from the torn-down run must still find its seq
226 * superseded so it can never write into the fresh run.
227 */
228 reset(): void {
229 this.chronicle = null;
230 this.chronicleForLedgerLen = 0;
231 this.chronicleLoading = false;
232 this.chronicleStream = "";
233 this.chronicleStreaming = false;
234 this.chronicleStreamLen = 0;
235 this.epiloguePending = false;
236 },
237 },
238});