What changed, and why

A finished run can build progress, then lose ground โ€” a staked last dispatch that crit-fails from a real high point back to a low final %. For that case the live end screen shows a "Peaked at X%" stat (issue #146, closing #129), so the floored final number doesn't read "as if the player did nothing."

That stat was live-session-only. The peak lived on the game store as peakProgress, but the run snapshot โ€” the durable, versioned record a saved (/runs/:id) or shared (/r/:token) run replays from โ€” never carried it. So a snapshot-backed view rebuilt the end-state without a peak and silently dropped the stat, even for a lost run that clearly peaked. Nothing crashed; the number just wasn't there.

This change threads the peak through the whole snapshot path: into the contract, out at save time, back in on every read (with a migration for runs saved before the field existed), and onto the screen. Six small edits, one per layer. The sections below walk them in the order the data flows.

The contract: a new field, and how old runs read it

RunSnapshot is the shared, versioned record โ€” client-safe, no runtime imports, so the store and the server both import it. Three edits here. The version goes 1 โ†’ 2, because the shape changed incompatibly. peakProgress joins the interface, documented as a high-water mark that's always โ‰ฅ objectiveProgress (the peak only rises; the live total can fall). And migrateSnapshot upgrades an older blob on read.

The version bump, the new field, and the read-time migration.

utils/run-snapshot.ts ยท 189 lines
utils/run-snapshot.ts189 lines ยท TypeScript
โ‹ฏ 31 lines hidden (lines 1โ€“31)
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 โ€” in-progress
9 * save/resume is out of scope; a run is short (five messages) and persists at the end.
10 *
11 * Naming: "run" is overloaded. The header gauge's balance ("runs remaining") is a
12 * purchasable CREDIT (the `balances` table); this is a PLAYED run (the game record,
13 * the `run_snapshots` table). Same noun, two states โ€” kept distinct in the schema.
14 *
15 * The shape is VERSIONED (`version`) so a later change can't silently break runs
16 * saved under an older shape: a reader can branch on `version` and migrate. It is a
17 * flat, fully JSON-native projection of the store state โ€” the `Date` timestamps on
18 * the in-memory ledger/thread are dropped (a snapshot needs none of the duration
19 * math), so the stored blob round-trips through jsonb cleanly.
20 *
21 * This module is the SHARED contract (types + version), client-safe by design: it
22 * carries no runtime imports, only type-only ones, so the client store can import the
23 * version + types without pulling any server-only code. The server-side boundary
24 * validator lives separately in `server/utils/run-snapshot.ts`.
25 */
26import type { DiceOutcome } from '~/utils/dice'
27import type { Craft } from '~/utils/craft'
28import type { Anachronism } from '~/server/utils/anachronism'
29import type { GameObjective } from '~/server/utils/objectives'
30import type { ChronicleEntry } from '~/server/utils/openai'
31 
32/** The current saved-run shape. Bump when the shape changes incompatibly; a reader
33 * branches on it to migrate older snapshots rather than mis-reading them.
34 * v2 (#151): added `peakProgress` โ€” the run's high-water mark, persisted so the
35 * "Peaked at X%" stat survives onto snapshot-backed (saved/shared) views. */
36export const RUN_SNAPSHOT_VERSION = 2
37 
โ‹ฏ 56 lines hidden (lines 38โ€“93)
38/** Only a finished run is ever saved โ€” the two terminal verdicts. */
39export type RunOutcome = 'victory' | 'defeat'
40 
41/** The valence of a ledger swing (mirrors the store's `Valence`). */
42export type RunValence = 'positive' | 'negative' | 'neutral'
43 
44/** The five efficiency tiers a victory can earn (mirrors the store's rating). */
45export type EfficiencyRating = 'Legendary' | 'Excellent' | 'Great' | 'Good' | 'Standard'
46 
47/** The bit of a roll the read-only view shows: the number and its named outcome. */
48export interface RollMark {
49 diceRoll: number
50 diceOutcome: DiceOutcome
52 
53/** A dispatch keepsake โ€” the player's verbatim message and whom/when it reached. */
54export interface RunDispatch {
55 text: string
56 figure: string
57 era: string
59 
60/** The victory efficiency block (null on defeat) โ€” the same data the end screen shows. */
61export interface RunEfficiency {
62 messagesSaved: number
63 messagesUsed: number
64 efficiencyPercentage: number
65 efficiencyRating: EfficiencyRating
66 isEarlyVictory: boolean
68 
69/** One ledger entry, trimmed to the fields a read-only run view renders (the live
70 * `TimelineEvent` minus its `Date` timestamp and the internals the view ignores). */
71export interface RunLedgerEntry {
72 id: string
73 figureName: string
74 era: string
75 headline: string
76 detail: string
77 diceRoll: number
78 diceOutcome: DiceOutcome
79 progressChange: number
80 /** The pre-amplifier swing โ€” the Spine reads it to show the ฮ” equation
81 * (`base โ†’ final`). Absent on turns that didn't amplify (none to disclose). */
82 baseProgressChange?: number
83 valence: RunValence
84 craft?: Craft
85 anachronism?: Anachronism
86 /** True when this was the run's staked last stand โ€” the Spine's โš‘ badge. */
87 staked?: boolean
89 
90/** The full saved run โ€” everything a read-only view needs to replay the end screen. */
91export interface RunSnapshot {
92 version: number
93 runId: string
94 status: RunOutcome
95 objective: GameObjective
96 objectiveProgress: number
97 /** The run's high-water mark (0..100) โ€” the highest objectiveProgress it ever
98 * reached. Always โ‰ฅ objectiveProgress (peak only rises; the live total can fall),
99 * so a snapshot-backed view shows "Peaked at X%" exactly when peak > final, the
100 * same rule the live end screen uses (#151). v1 snapshots lack it; `migrateSnapshot`
101 * defaults them to their final objectiveProgress (so peak == final โ†’ stat hidden). */
102 peakProgress: number
โ‹ฏ 10 lines hidden (lines 103โ€“112)
103 messagesUsed: number
104 totalMessages: number
105 bestRoll: RollMark | null
106 worstRoll: RollMark | null
107 efficiency: RunEfficiency | null
108 dispatches: RunDispatch[]
109 timeline: RunLedgerEntry[]
110 chronicle: ChronicleEntry | null
112 
113/**
114 * Upgrade a stored snapshot to the current shape. Snapshots persist under the version
115 * current at save time, so a reader branches on `version` to normalize an older one
116 * rather than mis-reading a field it predates. A current snapshot passes through
117 * untouched; future bumps add their own step here, applied in sequence.
118 *
119 * v1 โ†’ v2: the high-water mark was live-session-only, so a v1 blob carries no
120 * `peakProgress`. Default it to the run's final objectiveProgress โ€” peak == final, which
121 * renders as "no peak shown," exactly how those runs looked before the field existed.
122 */
123export function migrateSnapshot(raw: RunSnapshot): RunSnapshot {
124 if (raw.version >= RUN_SNAPSHOT_VERSION) return raw
125 const peak = (raw as { peakProgress?: unknown }).peakProgress
126 return {
127 ...raw,
128 version: RUN_SNAPSHOT_VERSION,
129 peakProgress: typeof peak === 'number' ? peak : raw.objectiveProgress
130 }
โ‹ฏ 58 lines hidden (lines 132โ€“189)
132 
133/** The list projection โ€” what the "your runs" list shows per run, without pulling
134 * the full snapshot blob for every row. */
135export interface RunSummary {
136 runId: string
137 status: RunOutcome
138 title: string
139 progress: number
140 createdAt: string
142 
143/**
144 * "Remixed from" credit (issue #89) โ€” the one-hop lineage of a replayed run: the run it
145 * was seeded from. It is a POINTER WALKED at render time (never a copy stamped into the
146 * child), so it always reflects the source's CURRENT privacy choice โ€” a later un-share
147 * drops `attribution` back to anonymous and `sourceToken` to null. It carries only public
148 * game data + the opt-in display name: never the parent's run id, owner, or device.
149 */
150export interface RemixCredit {
151 /** The source sharer's opt-in display name, or null when they shared anonymously. */
152 attribution: string | null
153 /** The source run's objective title โ€” the objective this run is a replay of. */
154 objectiveTitle: string
155 /** The source's live share token for an optional backlink, or null when un-shared. */
156 sourceToken: string | null
158 
159/**
160 * The PUBLIC, shareable projection of a saved run (issue #88) โ€” the sanitized form
161 * served at an unguessable share URL to anyone, no login. It carries the GAME RECORD
162 * (verdict, ledger, dispatches, Chronicle epilogue) and an optional self-chosen display
163 * name, and DELIBERATELY nothing that identifies the player: no user id, no device id,
164 * no email โ€” and not even the run id (the share token is the only handle). `run.runId`
165 * is blanked for that reason; the read-only view never renders it.
166 */
167export interface PublicRun {
168 run: RunSnapshot
169 /** The sharer's opt-in display name, or null when shared anonymously. */
170 attribution: string | null
171 /** When this run is itself a replay (issue #89), the one-hop "remixed from" credit;
172 * null/absent for an original run. Walked from the parent's live snapshot, so it
173 * honors the source's current privacy opt-in. */
174 remixedFrom?: RemixCredit | null
176 
177/** What the OWNER sees about a run's share status: the live token (null = not shared)
178 * and the current display name. Owner-only โ€” never part of the public projection. */
179export interface ShareState {
180 token: string | null
181 name: string | null
183 
184/** The brag line a share prefills (the X intent + the native sheet) โ€” one source so the
185 * end screen, the saved-run page, and the public page never drift (issue #88). */
186export function buildShareText(objectiveTitle: string, status: RunOutcome, progress: number): string {
187 const verb = status === 'victory' ? 'I rewrote history' : 'I tried to rewrite history'
188 return `${verb} in Everwhen: ${objectiveTitle || 'a historical mission'} โ€” ${progress}% rewritten.`

Writing it out: the store, and the trust boundary

At end of run the store builds the snapshot and POSTs it. One added line copies the live high-water mark in โ€” and the store comment notes why it's only meaningful here: a reset zeroes peakProgress, so it has to be captured at the verdict, before any reset.

saveRunSnapshot serializes the live peak into the snapshot.

stores/game.ts ยท 1466 lines
stores/game.ts1466 lines ยท TypeScript
โ‹ฏ 1359 lines hidden (lines 1โ€“1359)
1import { defineStore } from 'pinia'
2import type { GameObjective, ObjectiveSteer } from '~/server/utils/objectives'
3import type { Pack } from '~/server/utils/packs'
4import type { GroundedFigure } from '~/server/utils/figure-grounding'
5import { formatContactMoment, type ContactMoment } from '~/utils/contact-moment'
6import type { FigureSuggestion } from '~/server/utils/figure-suggester'
7import type { ChronicleEntry } from '~/server/utils/openai'
8import type { FigureStudy, ArchiveLookup } from '~/server/utils/prompt-builder'
9import type { Anachronism } from '~/server/utils/anachronism'
10import { chainStatus, type ChainStatus } from '~/utils/causal-chain'
11import type { Craft } from '~/utils/craft'
12import type { Continuity } from '~/utils/continuity'
13import type { DiceOutcome } from '~/utils/dice'
14import { generateGameSummary, rateEfficiency, type GameSummary } from '~/utils/game-summary'
15import { TOTAL_MESSAGES, MAX_PROGRESS_SWING, MAX_PROGRESS } from '~/utils/game-config'
16import { RUN_SNAPSHOT_VERSION, type RunSnapshot } from '~/utils/run-snapshot'
17 
18export type Valence = 'positive' | 'negative' | 'neutral'
19 
20/**
21 * A historical figure the player has reached out to. Figures are freeform โ€” the
22 * player can write to anyone, in any era. The AI infers each figure's era and a
23 * short descriptor on first contact, which we cache here for the UI.
24 */
25export interface HistoricalFigure {
26 name: string
27 era: string
28 descriptor: string
30 
31/**
32 * Timeline Ledger entry โ€” a single recorded change to history.
33 *
34 * The ledger is the heart of the game: every resolved turn appends one entry
35 * describing how the world bent, so the player literally watches the timeline
36 * rewrite itself as they play.
37 */
38export interface TimelineEvent {
39 id: string
40 figureName: string
41 era: string
42 headline: string
43 detail: string
44 diceRoll: number
45 diceOutcome: DiceOutcome
46 progressChange: number
47 /** The engine's swing BEFORE the anachronism amplifier โ€” shown so the player
48 * can see exactly what their wager did ("+8 โšก far-ahead โ†’ +11%"). */
49 baseProgressChange?: number
50 valence: Valence
51 /** How anachronistic the player's nudge was โ€” it widened this swing. */
52 anachronism?: Anachronism
53 /** How far this landed from the nearest foothold โ€” it decayed this swing. */
54 causalChain?: ChainStatus
55 /** The Judge's grade of the dispatch that caused this change. */
56 craft?: Craft
57 /** Signed year of the intervention, when the contact was grounded. */
58 whenSigned?: number
59 /** True when this was the run's staked last stand (doubled swing). */
60 staked?: boolean
61 timestamp: Date
63 
64/**
65 * Message Interface โ€” one line in the conversation with a figure.
66 * Dice / outcome / action / impact / progress are recorded on the AI (figure)
67 * turn, since that is the resolved result of the roll.
68 */
69export interface Message {
70 text: string
71 sender: 'user' | 'ai' | 'system'
72 timestamp: Date
73 figureName?: string
74 /** The effective (craft-tilted) roll โ€” what the bands judged. */
75 diceRoll?: number
76 diceOutcome?: DiceOutcome
77 /** The die as it actually landed, before the craft modifier. */
78 naturalRoll?: number
79 /** The Judge's tilt on the roll (ยฑ2..0), and the grade + reason behind it. */
80 rollModifier?: number
81 craft?: Craft
82 craftReason?: string
83 /** Whether the dispatch built on the run's thread (issue #62) โ€” drives the
84 * momentum meter and the 'builds' badge in the reveal. */
85 continuity?: Continuity
86 characterAction?: string
87 timelineImpact?: string
88 progressChange?: number
89 /** The pre-amplifier swing + the wager level, for the reveal's equation. */
90 baseProgressChange?: number
91 /** The PRE-turn momentum that amplified this swing โ€” for the reveal's equation. */
92 momentumAtSwing?: number
93 anachronism?: Anachronism
94 /** The causal-chain decay applied to this swing (for the reveal's equation). */
95 causalChain?: ChainStatus
96 /** True when this turn was the staked last stand. */
97 staked?: boolean
99 
100export type GameStatus = 'playing' | 'victory' | 'defeat'
101 
102/**
103 * Shape returned by `POST /api/send-message` (see `server/api/send-message.post.ts`).
104 * Discriminated by `success`: when `true`, all fields downstream of the dice roll are
105 * guaranteed; when `false`, only `userMessage` + `error` are guaranteed (and the
106 * Character-AI-failure path doesn't even surface a dice roll). The discriminant lets
107 * the consumer destructure without optional chaining or null gymnastics.
108 */
109type SendMessageApiResponse =
110 | {
111 success: true
112 message: string
113 data: {
114 userMessage: string
115 figure: { name: string; era: string; descriptor: string }
116 diceRoll: number
117 diceOutcome: DiceOutcome
118 // Judge-of-craft fields (optional so older mocks/fixtures stay valid;
119 // the live server always sends them).
120 naturalRoll?: number
121 rollModifier?: number
122 craft?: Craft
123 craftReason?: string
124 continuity?: Continuity
125 momentum?: number
126 staked?: boolean
127 characterResponse: { message: string; action: string }
128 timeline: {
129 headline: string
130 detail: string
131 era: string
132 progressChange: number
133 baseProgressChange?: number
134 momentumAtSwing?: number
135 valence: Valence
136 anachronism?: Anachronism
137 causalChain?: ChainStatus
138 }
139 error: null
140 }
141 }
142 | {
143 success: false
144 message: string
145 data: {
146 userMessage: string
147 diceRoll?: number
148 diceOutcome?: DiceOutcome
149 characterResponse?: { message: string; action: string }
150 error: string
151 /** A content-moderation block (not an infra failure): the dispatch was
152 * refused by the detector, the Sentinel, or the model itself. */
153 blocked?: boolean
154 moderationReason?: string
155 }
156 }
157 
158const RATE_LIMIT_MS = 1000 // message cooldown window, in ms
159 
160// Turn-reveal pacing. A resolved turn releases its beats one at a time โ€” a beat of
161// suspense, then the die lands and the figure's reply writes itself in, then the
162// timeline shift, then the ledger node โ€” so the resolution reads as one conducted
163// moment instead of everything firing in the same tick. Each component already
164// animates when its own slice of state changes; sequencing the WRITES sequences the
165// animations, with no component coordination. Tuned so the swing lands with the
166// reply's own "ripple" line (~0.55s into its staged reveal).
167export const REVEAL = { suspense: 450, swing: 550, node: 250 } as const
168const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
169/** Stagger the reveal only in a real browser with motion allowed. SSR and the test
170 * env (no `matchMedia`) collapse to an instant, atomic apply โ€” so the store's
171 * contract (state present the moment sendMessage resolves) is unchanged for tests. */
172function canAnimateReveal(): boolean {
173 return typeof window !== 'undefined'
174 && typeof window.matchMedia === 'function'
175 && !window.matchMedia('(prefers-reduced-motion: reduce)').matches
177 
178function valenceOf(progressChange: number): Valence {
179 if (progressChange > 0) return 'positive'
180 if (progressChange < 0) return 'negative'
181 return 'neutral'
183 
184/** Formats a signed timeline year (AD positive, BCE negative) for display. */
185export function formatContactYear(signed: number): string {
186 return signed < 0 ? `${-signed} BC` : `${signed}`
188 
189/** Human lifespan line for a grounded figure, e.g. "69 BC โ€“ 30 BC" or "1942 โ€“ present". */
190function lifespanText(g: GroundedFigure): string | undefined {
191 if (!g.born) return undefined
192 if (g.died) return `${g.born.display} โ€“ ${g.died.display}`
193 return g.living ? `${g.born.display} โ€“ present` : g.born.display
195 
196/** True when a $fetch error is a 402 (out of runs) โ€” the paywall signal. ofetch
197 * surfaces the status as statusCode and on the response, so check both. */
198function isPaymentRequired(error: unknown): boolean {
199 const e = error as { statusCode?: number; status?: number; response?: { status?: number } }
200 return e?.statusCode === 402 || e?.status === 402 || e?.response?.status === 402
202 
203/** True when a $fetch error is a 503 / at-capacity โ€” the spend cap paused new
204 * runs (distinct from the per-device 402 paywall). */
205function isAtCapacity(error: unknown): boolean {
206 const e = error as { statusCode?: number; status?: number; response?: { status?: number }; data?: { atCapacity?: boolean } }
207 return e?.statusCode === 503 || e?.status === 503 || e?.response?.status === 503 || e?.data?.atCapacity === true
209 
210export type ContactLiveness = 'ok' | 'before-birth' | 'after-death' | 'living' | 'unresolved' | 'unknown'
211 
212/**
213 * A replay seed (issue #89) โ€” when a player starts a run from a shared run's captured
214 * objective. It carries the objective to seed (verbatim, no generation), the SOURCE run's
215 * public share token (rides along to /api/run-save to stamp the lineage pointer), and the
216 * source's display name at seed time (for the "remixed from" credit shown before/after the
217 * run; the public page re-derives the credit live, honoring the source's current opt-in).
218 */
219export interface ReplaySeed {
220 objective: GameObjective
221 sourceToken: string
222 sourceAttribution: string | null
224 
225/**
226 * Game Store โ€” core state for a session of freeform timeline editing.
227 */
228export const useGameStore = defineStore('game', {
229 state: () => ({
230 remainingMessages: TOTAL_MESSAGES,
231 messageHistory: [] as Message[],
232 gameStatus: 'playing' as GameStatus,
233 isLoading: false,
234 error: null as string | null,
235 lastMessageTime: null as number | null,
236 isRateLimited: false,
237 // Staleness plumbing, not run state. runEpoch increments on every reset so an
238 // async result from a dead run can never write into a fresh one; the seq
239 // counters order overlapping requests of the same kind so only the latest
240 // lands (an earlier chronicle rewrite must not overwrite a later one).
241 // Deliberately NOT restored by resetGame โ€” they must survive it to work.
242 runEpoch: 0,
243 chronicleSeq: 0,
244 groundingSeq: 0,
245 // The current run's id โ€” lazily minted on the run's first server call and
246 // cleared by resetGame so each run gets a fresh one. Tags every AI call
247 // (via the x-run-id header) so the gateway's cost telemetry groups per
248 // run: the GTM cost instrument.
249 runId: null as string | null,
250 // The in-flight begin-run, so concurrent first-calls of a run share one
251 // mint (one POST /api/run, one runs row) instead of racing two. Cleared
252 // by resetGame. (Vue leaves a Promise unproxied, so awaiting it is safe.)
253 runIdInflight: null as Promise<string> | null,
254 // True once this run's snapshot is durably saved (POST /api/run-save returned
255 // saved). Gates the end-screen Share affordance (issue #88) so it appears only for
256 // a run that actually exists server-side to share โ€” never for a degraded local id.
257 // Reset per run.
258 runSnapshotSaved: false,
259 // outOfRuns gates the mission screen's paywall (set when a commit is
260 // refused with 402). The run packs offered there are loaded into `packs`.
261 outOfRuns: false,
262 // atCapacity gates the "at capacity" notice (set when a commit is refused
263 // with 503 because the global spend cap paused new runs).
264 atCapacity: false,
265 packs: [] as Pack[],
266 // The device's run balance, for the header gauge + account popover. null
267 // until first loaded (GET /api/balance); the run-commit response also
268 // refreshes it so the gauge stays live without a second round-trip.
269 runsRemaining: null as number | null,
270 freeRuns: 1,
271 deviceRef: '' as string,
272 // Account state (from /api/balance): the signed-in email, and whether the
273 // current user is anonymous (โ†’ must sign in before buying). Drives the
274 // account UI + the sign-in-to-buy gate. false/null in device-fallback mode.
275 accountEmail: null as string | null,
276 accountAnonymous: false,
277 // The run-packs sales modal โ€” opened on demand (header) or automatically
278 // when a commit is refused for being out of runs.
279 buyModalOpen: false,
280 // A one-shot notice after returning from Stripe Checkout ('success' shows a
281 // credited confirmation; 'cancel' a gentle "no charge" note). Cleared by the UI.
282 purchaseNotice: null as 'success' | 'cancel' | null,
283 currentObjective: null as GameObjective | null,
284 // Set when this run is a REPLAY of a shared run's objective (issue #89): seeded
285 // verbatim from the public projection, no generation. Survives chooseObjective so
286 // it can stamp the lineage pointer at save time and credit the source on the end
287 // screen; cleared by resetGame (a fresh "new timeline" carries no lineage).
288 replayState: null as ReplaySeed | null,
289 objectiveProgress: 0,
290 // The run's high-water mark (0..MAX_PROGRESS): the highest objectiveProgress ever
291 // reached this run. Tracked so the end screen can show "peaked at X%" when a run
292 // loses ground from its peak โ€” classically a staked final dispatch that crit-fails
293 // to 0% โ€” instead of the floored final % erasing the player's real effort (#129).
294 peakProgress: 0,
295 // The run-level momentum meter (0..MOMENTUM_MAX): a coherent arc compounds its
296 // gains amplifier; a reset or catastrophe shatters it back to 0 (issue #62).
297 momentum: 0,
298 timelineEvents: [] as TimelineEvent[],
299 figures: [] as HistoricalFigure[],
300 activeFigureName: '' as string,
301 // Grounding for the active contact: real facts + the chosen year to reach them.
302 figureGrounding: null as GroundedFigure | null,
303 groundingLoading: false,
304 contactWhen: null as number | null,
305 /** Optional sub-year refinement of contactWhen โ€” display/prompt flavor
306 * only; every mechanic keeps doing arithmetic on the YEAR (issue #32). */
307 contactMoment: null as ContactMoment | null,
308 // Era-relevant figure suggestions for the current objective (the on-ramp).
309 figureSuggestions: [] as FigureSuggestion[],
310 suggestionsLoading: false,
311 suggestionsFor: '' as string,
312 // The living Chronicle (Layer 3): a prose telling of the altered timeline,
313 // rewritten each turn. Non-blocking โ€” see refreshChronicle.
314 chronicle: null as ChronicleEntry | null,
315 chronicleLoading: false,
316 // The run's epilogue โ€” the TERMINAL telling carrying the verdict โ€” is being
317 // written. Set when the final turn fires its refresh and cleared when that
318 // telling lands or the refresh fails. Distinct from chronicleLoading (any
319 // refresh): it lets the end screen show "the final accountโ€ฆ" instead of
320 // presenting the prior turn's stale, pre-ending telling as the epilogue.
321 epiloguePending: false,
322 // The Archive (prototype): an objective-blind brief on the active figure, so
323 // the player can research who they're reaching without leaving the game. The
324 // brief is moment-specific, so it's cached against the figure AND the year.
325 figureStudy: null as FigureStudy | null,
326 studyLoading: false,
327 studyFor: '' as string,
328 studyWhen: null as number | null,
329 // Archive topic lookup (the "yellow" layer): concrete domain facts on demand.
330 archiveResult: null as ArchiveLookup | null,
331 lookupLoading: false,
332 // A content-moderation block โ€” distinct from `error` (an infra hiccup) so
333 // the UI shows an honest, visibly different banner. One per surface that
334 // takes untrusted input into a model: the dispatch/turn, the Archive lookup,
335 // the Archivist study, and the figure-suggestions step.
336 moderationNotice: null as string | null,
337 archiveNotice: null as string | null,
338 studyNotice: null as string | null,
339 suggestionsNotice: null as string | null
340 }),
341 
342 getters: {
343 /**
344 * Can the player send right now? (messages left, still playing, not
345 * mid-request, not rate-limited)
346 */
347 canSendMessage(): boolean {
348 return this.remainingMessages > 0 &&
349 this.gameStatus === 'playing' &&
350 !this.isLoading &&
351 !this.isRateLimited
352 },
353 
354 /**
355 * The conversation thread with a single figure (their turns + the
356 * player's turns addressed to them). Each figure keeps a coherent,
357 * independent thread.
358 */
359 conversationWith(): (name: string) => Message[] {
360 return (name: string) => this.messageHistory.filter(
361 m => m.figureName === name && m.sender !== 'system'
362 )
363 },
364 
365 /** Total progress, net of setbacks, the player has clawed back. */
366 gameSummary(): GameSummary {
367 return generateGameSummary(this)
368 },
369 
370 /**
371 * Whether the active figure can be reached at the chosen `when`.
372 *
373 * Require grounding (#73): an UNRESOLVED name can't be reached at all โ€”
374 * 'unresolved' (free-form contact is removed; the picker guides you to a real
375 * match). Deceased-only floor (#72): a RESOLVED figure with no confirmed
376 * death is living/undatable and never contactable โ€” 'living', fail closed. A
377 * resolved, deceased figure is gated to their lifetime (before-birth /
378 * after-death). 'unknown' remains only for a resolved, deceased figure we
379 * can't fully place (no birth year, or no contact year chosen yet), which
380 * stays permissible.
381 */
382 contactLiveness(): ContactLiveness {
383 const g = this.figureGrounding
384 if (!g || !g.resolved) return 'unresolved'
385 if (!g.died) return 'living'
386 if (!g.born || this.contactWhen == null) return 'unknown'
387 if (this.contactWhen < g.born.signed) return 'before-birth'
388 if (this.contactWhen > g.died.signed) return 'after-death'
389 return 'ok'
390 },
391 
392 /** Can the chosen contact + when actually be reached? */
393 canContact(): boolean {
394 return this.contactLiveness === 'ok' || this.contactLiveness === 'unknown'
395 },
396 
397 /**
398 * The last stand is offered ONLY when the final dispatch can no longer win
399 * inside the normal per-turn fuse โ€” from any nearer position an unstaked
400 * throw can still land it, and offering the (strictly win-probability-
401 * increasing) doubling there would be a dominance trap, not a decision.
402 */
403 canStake(): boolean {
404 return this.remainingMessages === 1 &&
405 this.gameStatus === 'playing' &&
406 (MAX_PROGRESS - this.objectiveProgress) > MAX_PROGRESS_SWING
407 },
408 
409 /**
410 * The active figure's age at the chosen `when` โ€” so the player never has to
411 * do lifetime math in their head. Null when we lack a birth year or a chosen
412 * year (a resolved figure we can't date, or before a year is chosen). Corrects for the missing year zero
413 * when a life spans the BCโ†’AD boundary (1 BC โ†’ AD 1 is one year, not two).
414 */
415 contactAge(): number | null {
416 const g = this.figureGrounding
417 if (!g?.resolved || !g.born || this.contactWhen == null) return null
418 const bornSigned = g.born.signed
419 const whenSigned = this.contactWhen
420 if (whenSigned < bornSigned) return null // before birth โ€” no meaningful age
421 const crossedZero = bornSigned < 0 && whenSigned > 0 ? 1 : 0
422 return whenSigned - bornSigned - crossedZero
423 },
424 
425 /**
426 * The causal-chain read for the CURRENT contact โ€” the pre-send โณ chip's
427 * source, mirroring what the Timeline Engine will compute. Footholds are the
428 * objective's anchor year plus every dated change already on the ledger; the
429 * gap to the nearest one decays the swing. Null (no chip) for ungrounded
430 * contacts or an anchorless objective with no dated changes yet.
431 */
432 causalChainRead(): ChainStatus | null {
433 const footholds = [
434 this.currentObjective?.anchorYear,
435 ...this.timelineEvents.map(e => e.whenSigned)
436 ]
437 return chainStatus(this.contactWhen, footholds)
438 }
439 },
440 
441 actions: {
442 // ---------- message helpers ----------
443 addUserMessage(text: string, figureName?: string) {
444 if (this.gameStatus !== 'playing') return
445 this.messageHistory.push({
446 text,
447 sender: 'user',
448 timestamp: new Date(),
449 figureName
450 })
451 },
452 
453 addAIMessage(text: string, figureName?: string) {
454 this.messageHistory.push({
455 text,
456 sender: 'ai',
457 timestamp: new Date(),
458 figureName
459 })
460 },
461 
462 addAIMessageWithData(messageData: Partial<Message> & { text: string, sender: 'ai' }) {
463 this.messageHistory.push({
464 text: messageData.text,
465 sender: messageData.sender,
466 timestamp: messageData.timestamp || new Date(),
467 figureName: messageData.figureName,
468 diceRoll: messageData.diceRoll,
469 diceOutcome: messageData.diceOutcome,
470 naturalRoll: messageData.naturalRoll,
471 rollModifier: messageData.rollModifier,
472 craft: messageData.craft,
473 craftReason: messageData.craftReason,
474 continuity: messageData.continuity,
475 characterAction: messageData.characterAction,
476 timelineImpact: messageData.timelineImpact,
477 progressChange: messageData.progressChange,
478 baseProgressChange: messageData.baseProgressChange,
479 momentumAtSwing: messageData.momentumAtSwing,
480 anachronism: messageData.anachronism,
481 causalChain: messageData.causalChain,
482 staked: messageData.staked
483 })
484 },
485 
486 // ---------- figures ----------
487 registerFigure(figure: HistoricalFigure) {
488 const existing = this.figures.find(f => f.name === figure.name)
489 if (existing) {
490 if (figure.era) existing.era = figure.era
491 if (figure.descriptor) existing.descriptor = figure.descriptor
492 } else {
493 this.figures.push({ ...figure })
494 }
495 },
496 
497 setActiveFigure(name: string) {
498 this.activeFigureName = name
499 },
500 
501 /**
502 * Synchronously clears the dossier the moment the contact NAME changes, so
503 * a send racing the grounding debounce/fetch is gated as ungrounded (#73)
504 * instead of wearing the PREVIOUS figure's facts, year, and liveness gate โ€”
505 * and so the wrong year can never be written into the ledger's whenSigned.
506 */
507 clearGrounding() {
508 this.groundingSeq++ // invalidate any lookup still in flight
509 this.figureGrounding = null
510 this.contactWhen = null
511 this.contactMoment = null
512 this.groundingLoading = false
513 this.figureStudy = null
514 this.studyFor = ''
515 this.studyWhen = null
516 this.studyNotice = null
517 },
518 
519 /**
520 * Resolves the active figure against the grounding service and defaults the
521 * "when" to a point inside any known lifetime. Never throws: an unresolved
522 * figure simply clears grounding; an ungrounded contact is then blocked (#73).
523 */
524 async groundActiveFigure(name: string): Promise<void> {
525 const target = (name || '').trim()
526 // A new contact makes any prior Archive study stale.
527 this.figureStudy = null
528 this.studyFor = ''
529 this.studyWhen = null
530 this.studyNotice = null
531 if (!target) {
532 this.groundingSeq++ // invalidate any lookup still in flight
533 this.figureGrounding = null
534 this.contactWhen = null
535 this.contactMoment = null
536 this.groundingLoading = false
537 return
538 }
539 // Out-of-order guard: only the LATEST lookup may land. Without it, a slow
540 // response for the previous name attaches the wrong dossier (and a wrong
541 // figureContext on a fast send) to whatever the player typed next.
542 const epoch = this.runEpoch
543 const seq = ++this.groundingSeq
544 this.groundingLoading = true
545 try {
546 const grounded = await $fetch('/api/figure', { params: { name: target }, headers: await this.aiHeaders() }) as GroundedFigure
547 if (epoch !== this.runEpoch || seq !== this.groundingSeq) return
548 this.figureGrounding = grounded
549 this.contactMoment = null
550 if (grounded?.resolved && grounded.born) {
551 const latest = grounded.died ? grounded.died.signed : new Date().getFullYear()
552 this.contactWhen = Math.round((grounded.born.signed + latest) / 2)
553 } else {
554 this.contactWhen = null
555 }
556 } catch (error) {
557 if (epoch !== this.runEpoch || seq !== this.groundingSeq) return
558 console.error('Figure grounding failed:', error)
559 this.figureGrounding = null
560 this.contactWhen = null
561 this.contactMoment = null
562 } finally {
563 if (epoch === this.runEpoch && seq === this.groundingSeq) this.groundingLoading = false
564 }
565 },
566 
567 /** The current run's id, minted server-side via POST /api/run on first
568 * need. The run is a server fact (a persisted, charged row); the id then
569 * tags every AI call so cost telemetry groups per run, and the gameplay
570 * gate verifies it. Fails CLOSED: if begin-run can't return an id this
571 * REJECTS (no local fallback) โ€” a run we can't back server-side must not
572 * start, or the paywall gate would reject it mid-run anyway. */
573 async ensureRunId(): Promise<string> {
574 if (this.runId) return this.runId
575 // Collapse concurrent first-calls onto ONE begin-run (two overlapping
576 // calls would otherwise mint two rows). The epoch guards a resetGame
577 // mid-flight โ€” a dead run's id must not become the fresh run's.
578 if (!this.runIdInflight) {
579 const epoch = this.runEpoch
580 this.runIdInflight = (async () => {
581 const res = await $fetch('/api/run', { method: 'POST' }) as { runId?: string }
582 if (!res?.runId) throw new Error('begin-run returned no run id')
583 if (epoch === this.runEpoch) {
584 this.runId = res.runId
585 this.runIdInflight = null
586 }
587 return res.runId
588 })().catch((err) => {
589 if (epoch === this.runEpoch) this.runIdInflight = null
590 throw err
591 })
592 }
593 return this.runIdInflight
594 },
595 
596 /** Headers that tag a server call with the current run (the cost
597 * instrument), minting the run id first if needed. */
598 async aiHeaders(base: Record<string, string> = {}): Promise<Record<string, string>> {
599 return { ...base, 'x-run-id': await this.ensureRunId() }
600 },
601 
602 setContactWhen(year: number | null) {
603 // Scrubbing the year keeps any pinned month/day โ€” the pin refines
604 // whichever year is chosen, it doesn't belong to one.
605 this.contactWhen = year
606 },
607 
608 setContactMoment(moment: ContactMoment | null) {
609 this.contactMoment = moment
610 },
611 
612 /**
613 * Studies the active (grounded) figure via the Archivist โ€” an objective-blind
614 * brief on who they are AT THE CHOSEN MOMENT, so the player can research in the
615 * game instead of a browser tab. The brief is moment-specific, so it's cached
616 * against the figure AND the year: move the contact slider and the player can
617 * re-study them at the new moment. Graceful on failure (leaves the prior brief).
618 * Only resolved figures can be studied โ€” an unresolved name has no record.
619 */
620 async studyActiveFigure(): Promise<void> {
621 const g = this.figureGrounding
622 if (!g?.resolved) {
623 this.figureStudy = null
624 return
625 }
626 // Cache hit only when both the figure AND the studied year still match.
627 if (this.studyFor === g.name && this.studyWhen === this.contactWhen && this.figureStudy) return
628 
629 // Capture the moment being studied NOW: the brief describes this figure at
630 // this year. If the slider moves mid-flight, recording the live value would
631 // mislabel the brief and silently suppress the Re-study button.
632 const epoch = this.runEpoch
633 const studiedName = g.name
634 const studiedWhen = this.contactWhen
635 this.studyLoading = true
636 this.studyNotice = null
637 try {
638 const res = await $fetch('/api/research', {
639 method: 'POST',
640 headers: await this.aiHeaders({ 'Content-Type': 'application/json' }),
641 body: {
642 figureName: studiedName,
643 when: studiedWhen != null ? formatContactYear(studiedWhen) : undefined,
644 description: g.description,
645 extract: g.extract
646 }
647 }) as { success?: boolean; study?: FigureStudy; blocked?: boolean; error?: string }
648 if (epoch !== this.runEpoch) return
649 // If the contact changed mid-flight, the stale-study clear in
650 // groundActiveFigure already ran โ€” don't resurrect the old brief.
651 if (res?.blocked && this.figureGrounding?.name === studiedName) {
652 this.studyNotice = res.error || 'That study was blocked by content moderation.'
653 } else if (res?.success && res.study && this.figureGrounding?.name === studiedName) {
654 this.figureStudy = res.study
655 this.studyFor = studiedName
656 this.studyWhen = studiedWhen
657 }
658 } catch (error) {
659 if (epoch !== this.runEpoch) return
660 console.error('Failed to study the figure:', error)
661 } finally {
662 if (epoch === this.runEpoch) this.studyLoading = false
663 }
664 },
665 
666 /**
667 * Asks the Archive about a freeform topic (the "yellow" layer) โ€” concrete
668 * domain facts: what it is, what it takes, and when it first became known
669 * (the player's anachronism read). Graceful on failure. Topic-scoped, so it
670 * persists across figure changes within a run.
671 */
672 async askArchive(query: string): Promise<void> {
673 const q = (query || '').trim()
674 if (!q) return
675 const epoch = this.runEpoch
676 this.lookupLoading = true
677 this.archiveNotice = null
678 try {
679 const res = await $fetch('/api/lookup', {
680 method: 'POST',
681 headers: await this.aiHeaders({ 'Content-Type': 'application/json' }),
682 body: { query: q }
683 }) as { success?: boolean; lookup?: ArchiveLookup; blocked?: boolean; error?: string }
684 if (epoch !== this.runEpoch) return
685 if (res?.blocked) {
686 // The Archive is the sharpest elicitation vector โ€” a block here is
687 // honest and visible, not a silent empty result.
688 this.archiveNotice = res.error || 'That lookup was blocked by content moderation.'
689 } else if (res?.success && res.lookup) {
690 this.archiveResult = res.lookup
691 }
692 } catch (error) {
693 if (epoch !== this.runEpoch) return
694 console.error('Archive lookup failed:', error)
695 } finally {
696 if (epoch === this.runEpoch) this.lookupLoading = false
697 }
698 },
699 
700 /**
701 * Loads era-relevant figure suggestions for the current objective (the
702 * educational on-ramp). Cached per objective; on any failure it leaves the
703 * list empty so the UI falls back to its generic starters.
704 */
705 async loadSuggestions(): Promise<void> {
706 const objective = this.currentObjective
707 if (!objective) {
708 this.figureSuggestions = []
709 this.suggestionsFor = ''
710 return
711 }
712 if (this.suggestionsFor === objective.title && this.figureSuggestions.length) return
713 
714 const epoch = this.runEpoch
715 this.suggestionsLoading = true
716 this.suggestionsNotice = null
717 try {
718 const res = await $fetch('/api/suggestions', {
719 method: 'POST',
720 headers: await this.aiHeaders({ 'Content-Type': 'application/json' }),
721 body: {
722 objective: {
723 title: objective.title,
724 description: objective.description,
725 era: objective.era
726 }
727 }
728 }) as { success?: boolean; suggestions?: FigureSuggestion[]; blocked?: boolean; reason?: string }
729 if (epoch !== this.runEpoch) return
730 // A blocked objective must not masquerade as an empty result โ€” surface it.
731 if (res?.blocked) this.suggestionsNotice = res.reason || 'That objective was blocked by content moderation.'
732 this.figureSuggestions = res?.suggestions ?? []
733 this.suggestionsFor = objective.title
734 } catch (error) {
735 if (epoch !== this.runEpoch) return
736 console.error('Failed to load figure suggestions:', error)
737 this.figureSuggestions = []
738 // Record that this objective WAS asked, even though it failed โ€” the
739 // picker uses suggestionsFor to tell "not yet asked" (skeletons)
740 // from "asked and came up empty" (the honest famous-names fallback).
741 this.suggestionsFor = objective.title
742 } finally {
743 if (epoch === this.runEpoch) this.suggestionsLoading = false
744 }
745 },
746 
747 // ---------- timeline ledger ----------
748 addTimelineEvent(
749 evt: Omit<TimelineEvent, 'id' | 'timestamp' | 'valence'> & { valence?: Valence; timestamp?: Date }
750 ) {
751 this.timelineEvents.push({
752 id: `evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
753 figureName: evt.figureName,
754 era: evt.era,
755 headline: evt.headline,
756 detail: evt.detail,
757 diceRoll: evt.diceRoll,
758 diceOutcome: evt.diceOutcome,
759 progressChange: evt.progressChange,
760 baseProgressChange: evt.baseProgressChange,
761 valence: evt.valence ?? valenceOf(evt.progressChange),
762 anachronism: evt.anachronism,
763 causalChain: evt.causalChain,
764 craft: evt.craft,
765 whenSigned: evt.whenSigned,
766 staked: evt.staked,
767 timestamp: evt.timestamp ?? new Date()
768 })
769 },
770 
771 // ---------- the living chronicle (Layer 3) ----------
772 /**
773 * Re-narrates the Chronicle from the freshly-bent timeline. Fired
774 * non-blocking after a resolved turn (and at game end): the dice/progress
775 * reveal never waits on prose. The WHOLE account is rewritten each turn โ€”
776 * a later change can re-frame how earlier events read.
777 * Graceful: a failed refresh keeps the prior telling, so the panel never
778 * blanks; an empty timeline clears it (nothing to chronicle yet).
779 */
780 async refreshChronicle(): Promise<void> {
781 if (!this.timelineEvents.length) {
782 this.chronicle = null
783 return
784 }
785 // Sequencing: rewrites overlap (each turn fires one, never awaited), so
786 // only the LATEST issued refresh may land โ€” an earlier telling arriving
787 // late must not regress the account (or worse, the epilogue). The epoch
788 // guard keeps a dead run's telling out of a fresh run entirely.
789 const epoch = this.runEpoch
790 const seq = ++this.chronicleSeq
791 this.chronicleLoading = true
792 try {
793 const res = await $fetch('/api/chronicle', {
794 method: 'POST',
795 headers: await this.aiHeaders({ 'Content-Type': 'application/json' }),
796 body: {
797 objective: this.currentObjective,
798 status: this.gameStatus,
799 progress: this.objectiveProgress,
800 timeline: this.timelineEvents.map(e => ({
801 era: e.era,
802 figureName: e.figureName,
803 headline: e.headline,
804 detail: e.detail,
805 progressChange: e.progressChange
806 }))
807 }
808 }) as { success?: boolean; chronicle?: ChronicleEntry }
809 if (epoch !== this.runEpoch || seq !== this.chronicleSeq) return
810 if (res?.success && res.chronicle) this.chronicle = res.chronicle
811 } catch (error) {
812 if (epoch !== this.runEpoch || seq !== this.chronicleSeq) return
813 console.error('Failed to refresh the chronicle:', error)
814 // Keep the prior chronicle โ€” a failed refresh never blanks the panel.
815 } finally {
816 if (epoch === this.runEpoch && seq === this.chronicleSeq) this.chronicleLoading = false
817 }
818 },
819 
820 // ---------- simple setters ----------
821 setLoading(loading: boolean) { this.isLoading = loading },
822 setError(error: string | null) { this.error = error },
823 clearModerationNotice() { this.moderationNotice = null },
824 clearArchiveNotice() { this.archiveNotice = null },
825 
826 // ---------- rate limiting ----------
827 checkRateLimit(): boolean {
828 const now = Date.now()
829 if (this.lastMessageTime && (now - this.lastMessageTime) < RATE_LIMIT_MS) return false
830 return true
831 },
832 
833 setRateLimit() {
834 this.isRateLimited = true
835 const remainingTime = this.lastMessageTime
836 ? Math.max(0, RATE_LIMIT_MS - (Date.now() - this.lastMessageTime))
837 : 0
838 setTimeout(() => { this.isRateLimited = false }, remainingTime || RATE_LIMIT_MS)
839 },
840 
841 // ---------- counter & status ----------
842 decrementMessages() {
843 if (this.remainingMessages > 0) this.remainingMessages--
844 },
845 
846 /**
847 * Rolls back an unresolved turn: refunds the spent message and drops the
848 * dangling user entry, so the counter and history can't drift out of sync.
849 * Used for BOTH failure shapes โ€” a thrown request and a graceful HTTP-200
850 * `success:false` โ€” an infra hiccup must never burn one of the five
851 * dispatches (or, on the last one, convert into an instant unearned defeat).
852 */
853 refundUnresolvedTurn() {
854 if (this.remainingMessages < TOTAL_MESSAGES) this.remainingMessages++
855 for (let i = this.messageHistory.length - 1; i >= 0; i--) {
856 if (this.messageHistory[i].sender === 'user') {
857 this.messageHistory.splice(i, 1)
858 break
859 }
860 }
861 },
862 
863 applyProgress(progressChange: number) {
864 this.objectiveProgress = Math.max(0, Math.min(MAX_PROGRESS, this.objectiveProgress + progressChange))
865 this.peakProgress = Math.max(this.peakProgress, this.objectiveProgress)
866 },
867 
868 /**
869 * Resolves win/lose AFTER a turn's progress has been applied.
870 *
871 * Victory the instant the objective hits 100%; defeat only once the
872 * final message is spent without reaching it. This fixes the old
873 * premature-defeat bug, where status flipped on the last decrement
874 * BEFORE that turn's progress had a chance to land.
875 */
876 resolveGameStatus() {
877 if (this.objectiveProgress >= MAX_PROGRESS) {
878 this.gameStatus = 'victory'
879 } else if (this.remainingMessages <= 0) {
880 this.gameStatus = 'defeat'
881 }
882 },
883 
884 // ---------- the turn ----------
885 /**
886 * Sends a 160-char message to a chosen figure and folds the result back
887 * into the world: the figure replies + acts, the dice decide the swing,
888 * and the Timeline Engine records how history bent.
889 *
890 * Returns `true` only when the turn actually RESOLVED (a ledger entry
891 * landed). A blocked or failed send returns `false` so the composer can
892 * keep the player's crafted words instead of wiping them.
893 *
894 * `opts.stake` arms the last stand โ€” honored only when `canStake` holds
895 * (final message, win out of normal reach; the server doubles the resolved
896 * swing, both ways, past the usual cap).
897 */
898 async sendMessage(text: string, figureName?: string, opts?: { stake?: boolean }): Promise<boolean> {
899 const target = (figureName ?? this.activeFigureName ?? '').trim()
900 const stake = opts?.stake === true && this.canStake
901 
902 if (!this.canSendMessage) return false
903 if (!target) {
904 this.setError('Choose who in history to send your message to first.')
905 return false
906 }
907 if (!this.checkRateLimit()) {
908 this.setRateLimit()
909 this.setError('The timeline needs a moment โ€” wait before sending again.')
910 return false
911 }
912 if (!this.canContact) {
913 const who = this.figureGrounding?.name || target
914 this.setError(
915 this.contactLiveness === 'unresolved'
916 ? (this.figureGrounding?.transient
917 ? `Couldn't reach the record to verify "${target}" โ€” try again in a moment.`
918 : `No historical record found for "${target}" โ€” reach for a real figure (pick a match as you type).`)
919 : this.contactLiveness === 'before-birth'
920 ? `${who} isn't born yet in that year โ€” choose a later moment to reach them.`
921 : this.contactLiveness === 'living'
922 ? (this.figureGrounding?.born
923 ? `${who} is still living โ€” Everwhen only reaches figures from history.`
924 : `${who} couldn't be dated โ€” Everwhen can only reach figures it can place in history.`)
925 : `${who} has died by that year โ€” choose an earlier moment to reach them.`
926 )
927 return false
928 }
929 
930 this.setError(null)
931 this.moderationNotice = null
932 this.setActiveFigure(target)
933 this.addUserMessage(text, target)
934 this.decrementMessages()
935 this.setLoading(true)
936 this.lastMessageTime = Date.now()
937 
938 // If the run is reset while this request is in flight, every write below
939 // would land in a world that no longer exists โ€” the epoch guard drops the
940 // response (no fold-in, no refund: the new run's counter is not ours).
941 const epoch = this.runEpoch
942 const ledgerBefore = this.timelineEvents.length
943 // Capture the contact year NOW: the slider can move while the request is
944 // in flight, and the ledger must record the year this turn was SENT to.
945 const sentWhen = this.contactWhen
946 const sentMoment = this.contactMoment
947 let resolved = false
948 // Decide once whether to stagger the reveal (browser + motion allowed).
949 const animate = canAnimateReveal()
950 
951 try {
952 const response = await $fetch<SendMessageApiResponse>('/api/send-message', {
953 method: 'POST',
954 headers: await this.aiHeaders({ 'Content-Type': 'application/json' }),
955 body: {
956 message: text,
957 figureName: target,
958 when: sentWhen != null ? formatContactMoment(sentWhen, sentMoment) : undefined,
959 whenSigned: sentWhen ?? undefined,
960 // The pinned moment travels as validated integers; the server
961 // re-derives the display string rather than trusting ours.
962 whenMonth: sentWhen != null ? sentMoment?.month : undefined,
963 whenDay: sentWhen != null ? sentMoment?.day : undefined,
964 stake,
965 // The pre-turn momentum the server amplifies the swing by, and
966 // returns advanced (the client stays the system of record).
967 momentum: this.momentum,
968 figureContext: this.figureGrounding?.resolved
969 ? {
970 description: this.figureGrounding.description,
971 lifespan: lifespanText(this.figureGrounding)
972 }
973 : undefined,
974 objective: this.currentObjective,
975 timeline: this.timelineEvents.map(e => ({
976 era: e.era,
977 figureName: e.figureName,
978 headline: e.headline,
979 detail: e.detail,
980 progressChange: e.progressChange,
981 whenSigned: e.whenSigned
982 })),
983 conversationHistory: this.conversationWith(target)
984 }
985 })
986 
987 if (epoch !== this.runEpoch) return false
988 
989 if (response?.success && response.data?.characterResponse) {
990 const { figure, characterResponse, diceRoll, diceOutcome, timeline } = response.data
991 
992 if (figure?.name) {
993 this.registerFigure({
994 name: figure.name,
995 era: figure.era || '',
996 descriptor: figure.descriptor || ''
997 })
998 }
999 
1000 // ---- conducted reveal ----
1001 // A beat of suspense (the die keeps shaking) before the result
1002 // lands, so the resolution has a moment of anticipation.
1003 if (animate) {
1004 await wait(REVEAL.suspense)
1005 if (epoch !== this.runEpoch) return false
1008 // Beat 1 โ€” the die lands and the figure's reply writes itself in
1009 // (its parts stage over ~0.55s via CSS). Flipping loading here
1010 // (mid-sequence) is what lands the die now; the finally's flip
1011 // becomes a no-op.
1012 this.addAIMessageWithData({
1013 text: characterResponse.message,
1014 sender: 'ai',
1015 figureName: target,
1016 timestamp: new Date(),
1017 diceRoll,
1018 diceOutcome,
1019 naturalRoll: response.data.naturalRoll ?? diceRoll,
1020 rollModifier: response.data.rollModifier ?? 0,
1021 craft: response.data.craft,
1022 craftReason: response.data.craftReason,
1023 continuity: response.data.continuity,
1024 characterAction: characterResponse.action,
1025 timelineImpact: timeline?.detail,
1026 progressChange: timeline?.progressChange,
1027 baseProgressChange: timeline?.baseProgressChange,
1028 momentumAtSwing: timeline?.momentumAtSwing,
1029 anachronism: timeline?.anachronism,
1030 causalChain: timeline?.causalChain,
1031 staked: response.data.staked
1032 })
1033 if (animate) this.setLoading(false)
1035 if (timeline) {
1036 // Beat 2 โ€” the timeline shift (the % gauge flies its delta),
1037 // timed to land with the reply's own "ripple" line.
1038 if (animate) {
1039 await wait(REVEAL.swing)
1040 if (epoch !== this.runEpoch) return false
1042 // Use !== undefined so a genuine neutral (0%) turn still
1043 // registers instead of silently vanishing.
1044 if (timeline.progressChange !== undefined) {
1045 this.applyProgress(timeline.progressChange)
1047 // The arc meter is server-authoritative for the result; advance
1048 // it WITH the swing it amplified โ€” and only past the epoch check
1049 // above, so a stale turn never moves the meter (issue #62).
1050 this.momentum = response.data.momentum ?? this.momentum
1052 // Beat 3 โ€” the change drops onto the Spine ledger.
1053 if (animate) {
1054 await wait(REVEAL.node)
1055 if (epoch !== this.runEpoch) return false
1057 this.addTimelineEvent({
1058 figureName: target,
1059 era: timeline.era || figure?.era || (this.currentObjective?.era ?? ''),
1060 headline: timeline.headline,
1061 detail: timeline.detail,
1062 diceRoll,
1063 diceOutcome,
1064 progressChange: timeline.progressChange ?? 0,
1065 baseProgressChange: timeline.baseProgressChange,
1066 valence: timeline.valence,
1067 anachronism: timeline.anachronism,
1068 causalChain: timeline.causalChain,
1069 craft: response.data.craft,
1070 whenSigned: sentWhen ?? undefined,
1071 staked: response.data.staked
1072 })
1073 resolved = true
1075 } else {
1076 // A graceful failure (HTTP 200, success:false): an AI layer gave
1077 // out mid-turn. No ripple landed, so the dispatch is refunded โ€”
1078 // exactly like the thrown path below.
1079 // Narrow to the failure variant (its data carries `blocked`).
1080 const failed = response && !response.success ? response.data : undefined
1081 if (failed?.blocked) {
1082 // A content-moderation block, not an infra hiccup โ€” show an
1083 // honest, distinct banner (not "try again"). The composer
1084 // keeps the player's words (sendMessage returns false).
1085 this.moderationNotice = failed.moderationReason || 'That dispatch was blocked by content moderation and cannot be sent.'
1086 } else {
1087 this.setError(failed?.error || 'History did not answer. Try again.')
1089 this.refundUnresolvedTurn()
1091 } catch (error) {
1092 if (epoch !== this.runEpoch) return false
1093 console.error('Error sending message:', error)
1094 this.setError('The timeline resisted your message. Please try again.')
1095 this.refundUnresolvedTurn()
1096 } finally {
1097 if (epoch === this.runEpoch) {
1098 this.setLoading(false)
1099 this.resolveGameStatus()
1103 // The living chronicle re-narrates the world from the new timeline โ€” but
1104 // only when this turn actually added a change, and never awaited: the turn
1105 // reveal must not wait on prose. By here the status is resolved, so the
1106 // final turn's chronicle carries the victory/defeat framing (the epilogue).
1107 let refresh: Promise<void> | null = null
1108 if (resolved && this.timelineEvents.length > ledgerBefore) {
1109 refresh = this.refreshChronicle()
1110 void refresh
1112 // The run just ended: persist it (best-effort) so it survives reload, then
1113 // re-save once the epilogue's telling lands โ€” the immediate save guards
1114 // against that refresh failing, the second captures the final Chronicle.
1115 if (this.gameStatus === 'victory' || this.gameStatus === 'defeat') {
1116 // This turn's refresh writes the EPILOGUE โ€” the terminal telling that
1117 // carries the verdict. Mark it pending so the end screen shows "the
1118 // final accountโ€ฆ" instead of the prior turn's stale telling. Cleared
1119 // when the telling lands OR the refresh fails (refreshChronicle keeps
1120 // the prior telling on failure) โ€” so the panel reveals or falls back,
1121 // never hanging on the loading state.
1122 if (refresh) {
1123 this.epiloguePending = true
1124 void refresh.finally(() => { if (epoch === this.runEpoch) this.epiloguePending = false })
1126 void this.saveRunSnapshot()
1127 if (refresh) void refresh.finally(() => { void this.saveRunSnapshot() })
1129 return resolved
1130 },
1132 /**
1133 * Seed a replay (issue #89): start a fresh run on a shared run's captured
1134 * objective. Resets first (the share page may follow an abandoned run in the
1135 * same tab) so the run starts clean, THEN records the seed โ€” so the mission
1136 * briefing opens focused on this objective, and the lineage + credit ride
1137 * through to the end. No generation: the objective is reused verbatim, and the
1138 * player still commits (and is charged) by pressing Begin.
1139 */
1140 preseedReplay(objective: GameObjective, sourceToken: string, sourceAttribution: string | null): void {
1141 this.resetGame()
1142 this.replayState = { objective, sourceToken, sourceAttribution }
1143 },
1145 /** Drop the replay seed โ€” the player chose to start from scratch instead, so this
1146 * run is an original (no "remixed from" lineage). */
1147 clearReplayState(): void {
1148 this.replayState = null
1149 },
1151 /**
1152 * Commits a chosen objective and starts the run from a clean slate of
1153 * progress. Used by the mission-select screen for both curated picks and
1154 * freshly composed ones.
1155 */
1156 async chooseObjective(objective: GameObjective): Promise<boolean> {
1157 // Commit = the run is charged here. Mint the run id server-side, then
1158 // spend one run from the device's balance. Fails CLOSED: out of runs โ†’
1159 // raise the paywall; begin-run or commit failing โ†’ surface an error and
1160 // do NOT start. A run that isn't a charged, server-backed run would be
1161 // rejected by the gameplay gate anyway, so starting it only strands the
1162 // player mid-run. The spend cap (not free play) is the outage backstop.
1163 let runId: string
1164 try {
1165 runId = await this.ensureRunId()
1166 } catch (error) {
1167 console.error('Could not begin the run:', error)
1168 this.error = 'Could not start the run. Please try again.'
1169 return false
1171 try {
1172 const res = await $fetch('/api/run-commit', {
1173 method: 'POST',
1174 headers: { 'Content-Type': 'application/json' },
1175 body: { runId }
1176 }) as { runsRemaining?: number }
1177 this.outOfRuns = false
1178 // Keep the header gauge live: the commit returns the post-charge
1179 // balance (โˆ’1 marks a degraded, ungated run โ€” leave the gauge as is).
1180 if (typeof res?.runsRemaining === 'number' && res.runsRemaining >= 0) {
1181 this.runsRemaining = res.runsRemaining
1183 } catch (error) {
1184 if (isPaymentRequired(error)) {
1185 this.outOfRuns = true
1186 this.openBuyModal()
1187 return false
1189 if (isAtCapacity(error)) {
1190 this.atCapacity = true
1191 return false
1193 console.error('Could not charge the run:', error)
1194 this.error = 'Could not start the run. Please try again.'
1195 return false
1197 this.currentObjective = objective
1198 this.objectiveProgress = 0
1199 this.peakProgress = 0
1200 this.momentum = 0
1201 this.error = null
1202 return true
1203 },
1205 /**
1206 * Asks the server to compose a fresh objective with the model, WITHOUT
1207 * committing it โ€” the caller previews it, then commits via chooseObjective.
1208 * Generation lives server-side because it needs the OpenAI key (the client
1209 * has no access to it). An optional steer (era + theme, closed enums) biases
1210 * the composition; the server re-validates it against the enums, so a bad
1211 * value is harmless. `avoid` is the titles already composed this session, so
1212 * a reroll lands somewhere new (#95) โ€” the stateless server only knows what
1213 * the client supplies. Returns null rather than throwing if composing fails,
1214 * so the UI can quietly fall back to the curated pool.
1215 */
1216 async fetchAIObjective(steer: ObjectiveSteer = {}, avoid: string[] = []): Promise<GameObjective | null> {
1217 try {
1218 const query: Record<string, string | string[]> = {}
1219 if (steer.era) query.era = steer.era
1220 if (steer.theme) query.theme = steer.theme
1221 if (avoid.length) query.avoid = avoid
1222 const res = await $fetch('/api/objective', { method: 'GET', query, headers: await this.aiHeaders() }) as {
1223 success?: boolean
1224 objective?: GameObjective
1226 return res?.success && res.objective ? res.objective : null
1227 } catch (error) {
1228 console.error('Failed to compose a fresh objective:', error)
1229 return null
1231 },
1233 /**
1234 * Loads the device's run balance for the header gauge + account popover.
1235 * Grants the free trial on a brand-new device (server-side). Graceful: on
1236 * failure it leaves the prior value (the gauge simply doesn't update).
1237 */
1238 async loadBalance(): Promise<void> {
1239 try {
1240 const res = await $fetch('/api/balance') as { runsRemaining?: number; freeRuns?: number; deviceRef?: string; email?: string | null; isAnonymous?: boolean }
1241 if (typeof res?.runsRemaining === 'number') this.runsRemaining = res.runsRemaining
1242 if (typeof res?.freeRuns === 'number') this.freeRuns = res.freeRuns
1243 if (typeof res?.deviceRef === 'string') this.deviceRef = res.deviceRef
1244 this.accountEmail = res?.email ?? null
1245 this.accountAnonymous = res?.isAnonymous === true
1246 } catch (error) {
1247 console.error('Failed to load balance:', error)
1249 },
1251 /** Opens the run-packs sales modal, loading the catalog first. */
1252 async openBuyModal(): Promise<void> {
1253 this.buyModalOpen = true
1254 await this.loadPacks()
1255 },
1257 /** Closes the sales modal. */
1258 closeBuyModal(): void {
1259 this.buyModalOpen = false
1260 },
1262 /** Records the Stripe-return outcome and refreshes the balance on success
1263 * (the webhook credits asynchronously; re-read so the gauge reflects it). */
1264 async notePurchaseReturn(outcome: 'success' | 'cancel'): Promise<void> {
1265 this.purchaseNotice = outcome
1266 this.buyModalOpen = false
1267 if (outcome === 'success') {
1268 this.outOfRuns = false
1269 await this.loadBalance()
1271 },
1273 /** Dismisses the post-purchase notice. */
1274 clearPurchaseNotice(): void {
1275 this.purchaseNotice = null
1276 },
1278 /** Loads the run-pack catalog for the paywall. Caches only a NON-empty
1279 * result: a transient empty/failed read must not poison the cache and
1280 * strand the modal with zero packs for the rest of the session. */
1281 async loadPacks(): Promise<void> {
1282 if (this.packs.length) return
1283 try {
1284 const res = await $fetch('/api/packs') as { packs?: Pack[] }
1285 const packs = res?.packs ?? []
1286 if (packs.length) this.packs = packs
1287 } catch (error) {
1288 console.error('Failed to load packs:', error)
1290 },
1292 /** Starts checkout for a pack; returns the Stripe Checkout URL to redirect
1293 * to (null on failure). The caller does the redirect. */
1294 async buyPack(packId: string): Promise<string | null> {
1295 try {
1296 const res = await $fetch('/api/checkout', {
1297 method: 'POST',
1298 headers: { 'Content-Type': 'application/json' },
1299 body: { packId }
1300 }) as { url?: string }
1301 return res?.url ?? null
1302 } catch (error) {
1303 console.error('Failed to start checkout:', error)
1304 return null
1306 },
1308 getVictoryEfficiency() {
1309 if (this.gameStatus === 'victory') {
1310 const messagesSaved = this.remainingMessages
1311 const messagesUsed = TOTAL_MESSAGES - this.remainingMessages
1312 const efficiencyPercentage = Math.round((messagesSaved / TOTAL_MESSAGES) * 100)
1314 const efficiencyRating = rateEfficiency(messagesSaved)
1316 return {
1317 messagesSaved,
1318 messagesUsed,
1319 efficiencyPercentage,
1320 efficiencyRating,
1321 isEarlyVictory: messagesSaved > 0
1324 return null
1325 },
1327 /**
1328 * Persist the finished run's snapshot to the player's account, best-effort.
1329 * Captures exactly what the end screen shows โ€” objective, verdict, ledger,
1330 * dispatches, rolls, and the Chronicle epilogue โ€” keyed by the run id, so the
1331 * run survives reload and feeds the read-only "your runs" view. Fires and
1332 * forgets like refreshChronicle: a save failure must never disturb the end
1333 * screen. Only a completed (victory/defeat), server-backed (uuid run id) run
1334 * is saved; a degraded local id is dropped server-side.
1335 */
1336 async saveRunSnapshot(): Promise<void> {
1337 const epoch = this.runEpoch
1338 const status = this.gameStatus
1339 if (status !== 'victory' && status !== 'defeat') return
1340 const objective = this.currentObjective
1341 const runId = this.runId
1342 if (!objective || !runId) return
1344 const summary = this.gameSummary
1345 const events = this.timelineEvents
1346 // The dispatch keepsake โ€” the player's verbatim messages, paired with
1347 // whom/when they reached (the same zip the end screen renders).
1348 const dispatches = this.messageHistory
1349 .filter((m) => m.sender === 'user')
1350 .map((m, i) => ({
1351 text: m.text,
1352 figure: m.figureName || events[i]?.figureName || 'the past',
1353 era: events[i]?.era || ''
1354 }))
1355 const mark = (m: typeof summary.bestRoll) =>
1356 m && m.diceRoll != null && m.diceOutcome != null
1357 ? { diceRoll: m.diceRoll, diceOutcome: m.diceOutcome }
1358 : null
1360 const snapshot: RunSnapshot = {
1361 version: RUN_SNAPSHOT_VERSION,
1362 runId,
1363 status,
1364 objective,
1365 objectiveProgress: this.objectiveProgress,
1366 // The high-water mark, so a snapshot-backed view can show "Peaked at X%"
1367 // (the live store zeroes it on reset, so it's only meaningful here at end).
1368 peakProgress: this.peakProgress,
1369 messagesUsed: TOTAL_MESSAGES - this.remainingMessages,
โ‹ฏ 97 lines hidden (lines 1370โ€“1466)
1370 totalMessages: TOTAL_MESSAGES,
1371 bestRoll: mark(summary.bestRoll),
1372 worstRoll: mark(summary.worstRoll),
1373 efficiency: this.getVictoryEfficiency(),
1374 dispatches,
1375 timeline: events.map((e) => ({
1376 id: e.id,
1377 figureName: e.figureName,
1378 era: e.era,
1379 headline: e.headline,
1380 detail: e.detail,
1381 diceRoll: e.diceRoll,
1382 diceOutcome: e.diceOutcome,
1383 progressChange: e.progressChange,
1384 baseProgressChange: e.baseProgressChange,
1385 valence: e.valence,
1386 craft: e.craft,
1387 anachronism: e.anachronism,
1388 staked: e.staked
1389 })),
1390 chronicle: this.chronicle
1393 // A replay (issue #89) rides its source's share token alongside the snapshot
1394 // (a sibling field, not part of the immutable blob); the server resolves it to
1395 // the parent run id and stamps the lineage pointer. replayState set โŸบ this run
1396 // committed the seeded objective (the briefing clears it on any other choice).
1397 const body = this.replayState
1398 ? { ...snapshot, remixedFromToken: this.replayState.sourceToken }
1399 : snapshot
1401 try {
1402 const res = (await $fetch('/api/run-save', {
1403 method: 'POST',
1404 headers: { 'Content-Type': 'application/json' },
1405 body
1406 })) as { saved?: boolean }
1407 // Mark saved (for the share affordance) only if this run is still current
1408 // and the server actually persisted it (a degraded run returns saved:false).
1409 if (epoch === this.runEpoch && res?.saved) this.runSnapshotSaved = true
1410 } catch (error) {
1411 // Saving the run is a nicety; a failure must not break the end screen.
1412 if (epoch === this.runEpoch) console.error('Could not save the run:', error)
1414 },
1416 resetGame() {
1417 // Tear the epoch first: anything still in flight belongs to the old run
1418 // and must find no purchase here. (The seq counters deliberately survive.)
1419 this.runEpoch++
1420 this.remainingMessages = TOTAL_MESSAGES
1421 this.messageHistory = []
1422 this.gameStatus = 'playing'
1423 this.isLoading = false
1424 this.error = null
1425 this.lastMessageTime = null
1426 this.isRateLimited = false
1427 // A new run gets a fresh id on its next server call; abandon any
1428 // in-flight mint so a dead run's id can't land in the new run.
1429 this.runId = null
1430 this.runIdInflight = null
1431 this.runSnapshotSaved = false
1432 // The paywall / capacity notices re-check on the next commit.
1433 this.outOfRuns = false
1434 this.atCapacity = false
1435 this.currentObjective = null
1436 // A fresh run carries no replay lineage (preseedReplay re-sets it after).
1437 this.replayState = null
1438 this.objectiveProgress = 0
1439 this.peakProgress = 0
1440 this.momentum = 0
1441 this.timelineEvents = []
1442 this.figures = []
1443 this.activeFigureName = ''
1444 this.figureGrounding = null
1445 this.groundingLoading = false
1446 this.contactWhen = null
1447 this.contactMoment = null
1448 this.figureSuggestions = []
1449 this.suggestionsLoading = false
1450 this.suggestionsFor = ''
1451 this.chronicle = null
1452 this.chronicleLoading = false
1453 this.epiloguePending = false
1454 this.figureStudy = null
1455 this.studyLoading = false
1456 this.studyFor = ''
1457 this.studyWhen = null
1458 this.archiveResult = null
1459 this.lookupLoading = false
1460 this.moderationNotice = null
1461 this.archiveNotice = null
1462 this.studyNotice = null
1463 this.suggestionsNotice = null

The POST body is untrusted (a direct call can send anything), so sanitizeSnapshot is the boundary that coerces it. It pulls objectiveProgress into a local, then derives the peak as the max of the final % and the (clamped) incoming peak.

Coerce the peak, floored at the final progress.

server/utils/run-snapshot.ts ยท 178 lines
server/utils/run-snapshot.ts178 lines ยท TypeScript
โ‹ฏ 154 lines hidden (lines 1โ€“154)
1/**
2 * Server-side boundary validation for a saved run snapshot.
3 *
4 * The shared CONTRACT (types + version) lives in `~/utils/run-snapshot` so both the
5 * client store and the server can import it without pulling server-only code into the
6 * client bundle. This module is the server-only half: it coerces an untrusted POST
7 * body into a valid RunSnapshot (a direct POST can send anything), bounding every
8 * field with the same cleanString/cleanArray/clampInt helpers the other routes use.
9 */
10import { cleanString, cleanArray, clampInt } from './validate'
11import {
12 MAX_OBJECTIVE_TITLE_CHARS,
13 MAX_OBJECTIVE_DESC_CHARS,
14 MAX_ERA_CHARS,
15 MAX_FIGURE_NAME_CHARS,
16 MAX_LEDGER_DETAIL_CHARS,
17 MAX_TIMELINE_ENTRIES,
18 MAX_HISTORY_ENTRIES,
19 MAX_LEDGER_SWING
20} from './validate'
21import { sanitizeRunId } from './run-context'
22import { TOTAL_MESSAGES } from '~/utils/game-config'
23import {
24 RUN_SNAPSHOT_VERSION,
25 type RunSnapshot,
26 type RunOutcome,
27 type RunValence,
28 type RunEfficiency,
29 type RunDispatch,
30 type RunLedgerEntry,
31 type RollMark,
32 type EfficiencyRating
33} from '~/utils/run-snapshot'
34import type { DiceOutcome } from '~/utils/dice'
35import type { Craft } from '~/utils/craft'
36import type { Anachronism } from '~/server/utils/anachronism'
37import type { GameObjective } from '~/server/utils/objectives'
38import type { ChronicleEntry } from '~/server/utils/openai'
39 
40// Caps specific to a snapshot's own fields (the shared input caps live in validate.ts).
41const MAX_DISPATCH_CHARS = 320
42const MAX_HEADLINE_CHARS = 200
43const MAX_ICON_CHARS = 16
44const MAX_OUTCOME_CHARS = 24
45const MAX_RATING_CHARS = 24
46const MAX_HINT_CHARS = 24
47const MAX_ID_CHARS = 64
48const MAX_TOTAL_MESSAGES = 20
49const MAX_DICE = 40
50 
51const VALENCES: RunValence[] = ['positive', 'negative', 'neutral']
52const coerceValence = (v: unknown): RunValence =>
53 VALENCES.includes(v as RunValence) ? (v as RunValence) : 'neutral'
54 
55const clampNonNeg = (n: number): number => Math.max(0, n)
56 
57const cleanRoll = (v: unknown): RollMark | null => {
58 if (!v || typeof v !== 'object') return null
59 const m = v as Record<string, unknown>
60 const diceOutcome = cleanString(m.diceOutcome, MAX_OUTCOME_CHARS)
61 if (!diceOutcome) return null
62 return { diceRoll: clampNonNeg(clampInt(m.diceRoll, MAX_DICE)), diceOutcome: diceOutcome as DiceOutcome }
64 
65/**
66 * Coerce an untrusted body into a valid RunSnapshot, or null when it isn't a
67 * completed run we can save. Bounds every field, and stamps the server's own
68 * `version` rather than trusting the client's. Returns null when the run isn't
69 * terminal or has no objective / run id โ€” there is nothing meaningful to persist then.
70 */
71export function sanitizeSnapshot(raw: unknown): RunSnapshot | null {
72 if (!raw || typeof raw !== 'object') return null
73 const r = raw as Record<string, unknown>
74 
75 const status: RunOutcome | null =
76 r.status === 'victory' || r.status === 'defeat' ? r.status : null
77 if (!status) return null
78 
79 const runId = sanitizeRunId(r.runId)
80 if (!runId) return null
81 
82 if (!r.objective || typeof r.objective !== 'object') return null
83 const o = r.objective as Record<string, unknown>
84 const objective: GameObjective = {
85 title: cleanString(o.title, MAX_OBJECTIVE_TITLE_CHARS),
86 description: cleanString(o.description, MAX_OBJECTIVE_DESC_CHARS),
87 era: cleanString(o.era, MAX_ERA_CHARS),
88 icon: cleanString(o.icon, MAX_ICON_CHARS)
89 }
90 if (typeof o.anchorYear === 'number' && Number.isFinite(o.anchorYear)) {
91 objective.anchorYear = Math.round(o.anchorYear)
92 }
93 
94 const totalMessages = clampNonNeg(clampInt(r.totalMessages, MAX_TOTAL_MESSAGES)) || TOTAL_MESSAGES
95 const messagesUsed = Math.min(totalMessages, clampNonNeg(clampInt(r.messagesUsed, MAX_TOTAL_MESSAGES)))
96 
97 let efficiency: RunEfficiency | null = null
98 if (r.efficiency && typeof r.efficiency === 'object') {
99 const e = r.efficiency as Record<string, unknown>
100 efficiency = {
101 messagesSaved: clampNonNeg(clampInt(e.messagesSaved, MAX_TOTAL_MESSAGES)),
102 messagesUsed: clampNonNeg(clampInt(e.messagesUsed, MAX_TOTAL_MESSAGES)),
103 efficiencyPercentage: clampNonNeg(clampInt(e.efficiencyPercentage, 100)),
104 efficiencyRating: (cleanString(e.efficiencyRating, MAX_RATING_CHARS) || 'Standard') as EfficiencyRating,
105 isEarlyVictory: e.isEarlyVictory === true
106 }
107 }
108 
109 const dispatches: RunDispatch[] = cleanArray(r.dispatches, MAX_HISTORY_ENTRIES).map((raw) => {
110 const d = (raw ?? {}) as Record<string, unknown>
111 return {
112 text: cleanString(d.text, MAX_DISPATCH_CHARS),
113 figure: cleanString(d.figure, MAX_FIGURE_NAME_CHARS),
114 era: cleanString(d.era, MAX_ERA_CHARS)
115 }
116 })
117 
118 const timeline: RunLedgerEntry[] = cleanArray(r.timeline, MAX_TIMELINE_ENTRIES).map((raw) => {
119 const t = (raw ?? {}) as Record<string, unknown>
120 const entry: RunLedgerEntry = {
121 id: cleanString(t.id, MAX_ID_CHARS),
122 figureName: cleanString(t.figureName, MAX_FIGURE_NAME_CHARS),
123 era: cleanString(t.era, MAX_ERA_CHARS),
124 headline: cleanString(t.headline, MAX_HEADLINE_CHARS),
125 detail: cleanString(t.detail, MAX_LEDGER_DETAIL_CHARS),
126 diceRoll: clampNonNeg(clampInt(t.diceRoll, MAX_DICE)),
127 diceOutcome: cleanString(t.diceOutcome, MAX_OUTCOME_CHARS) as DiceOutcome,
128 progressChange: clampInt(t.progressChange, MAX_LEDGER_SWING),
129 valence: coerceValence(t.valence)
130 }
131 // The pre-amplifier swing is signed and only present when it amplified; keep
132 // "absent" distinct from a real 0 so the Spine shows the ฮ” equation faithfully.
133 if (typeof t.baseProgressChange === 'number') {
134 entry.baseProgressChange = clampInt(t.baseProgressChange, MAX_LEDGER_SWING)
135 }
136 const craft = cleanString(t.craft, MAX_HINT_CHARS)
137 if (craft) entry.craft = craft as Craft
138 const anachronism = cleanString(t.anachronism, MAX_HINT_CHARS)
139 if (anachronism) entry.anachronism = anachronism as Anachronism
140 if (t.staked === true) entry.staked = true
141 return entry
142 })
143 
144 let chronicle: ChronicleEntry | null = null
145 if (r.chronicle && typeof r.chronicle === 'object') {
146 const c = r.chronicle as Record<string, unknown>
147 chronicle = {
148 title: cleanString(c.title, MAX_HEADLINE_CHARS),
149 paragraphs: cleanArray(c.paragraphs, MAX_HISTORY_ENTRIES)
150 .map((p) => cleanString(p, MAX_OBJECTIVE_DESC_CHARS))
151 .filter(Boolean)
152 }
153 }
154 
155 const objectiveProgress = clampNonNeg(clampInt(r.objectiveProgress, 100))
156 // The high-water mark is a peak, so it can't sit below the final progress โ€” floor it
157 // there. That both defaults an absent peak (an old client / a direct POST) to the
158 // final % (peak == final โ†’ no "Peaked at X%") and rejects a malformed peak < final.
159 const peakProgress = Math.max(objectiveProgress, clampNonNeg(clampInt(r.peakProgress, 100)))
160 
161 return {
162 // The server owns the version stamp โ€” never trust the client's.
163 version: RUN_SNAPSHOT_VERSION,
164 runId,
165 status,
166 objective,
167 objectiveProgress,
168 peakProgress,
169 messagesUsed,
โ‹ฏ 9 lines hidden (lines 170โ€“178)
170 totalMessages,
171 bestRoll: cleanRoll(r.bestRoll),
172 worstRoll: cleanRoll(r.worstRoll),
173 efficiency,
174 dispatches,
175 timeline,
176 chronicle
177 }

Reading it back: every storage boundary migrates

A stored blob comes back from storage essentially raw, so the read side is where an old v1 record gets normalized. The rule is simple: **every read path that returns a full snapshot runs it through migrateSnapshot.** There are exactly three such sites, and all three are covered โ€” so no consumer can ever receive a snapshot with an undefined peak.

toPublicRun (both public reads funnel here), and both getRun adapters.

server/utils/run-snapshot-store.ts ยท 388 lines
server/utils/run-snapshot-store.ts388 lines ยท TypeScript
โ‹ฏ 73 lines hidden (lines 1โ€“73)
1/**
2 * The run-snapshot store โ€” where a finished run becomes a durable, owned record.
3 * POST /api/run-save writes the snapshot here keyed by the run id; the "your runs"
4 * routes (GET /api/runs, GET /api/runs/:id) read it back, scoped to the account
5 * subject so a player only ever sees their own runs.
6 *
7 * Two adapters behind one port, chosen by config โ€” the repo's env-degradation idiom
8 * (cf. runStore, balanceStore):
9 * - in-memory (default): no external dependency, so `npm run dev` and the unit
10 * tests need nothing. History doesn't outlive the process, but the round-trip is
11 * real, which is what the save -> list -> load test exercises.
12 * - Supabase: used when SUPABASE_URL + SUPABASE_SECRET_KEY are configured. Writes
13 * the `run_snapshots` row (and stamps the owner onto the billing `runs` row) via
14 * the service (secret) key, which bypasses RLS โ€” the table has no public policy,
15 * so it is server-only.
16 *
17 * Ownership is by SUBJECT (the Supabase user id, falling back to the device id) โ€” the
18 * same string balances/runs key on. Reads filter by it, so a non-owned run id reads
19 * as absent (the route turns that into a 404, never leaking another subject's run).
20 */
21import { randomUUID } from 'node:crypto'
22import { createClient, type SupabaseClient } from '@supabase/supabase-js'
23import { supabaseConfig } from './supabase'
24import { migrateSnapshot } from '~/utils/run-snapshot'
25import type { RunSnapshot, RunSummary, PublicRun, ShareState, RemixCredit } from '~/utils/run-snapshot'
26 
27/** Save-time extras the snapshot blob itself doesn't carry. */
28export interface SaveRunOptions {
29 /** When this run is a replay (issue #89), the SOURCE run's share token โ€” resolved
30 * server-side to the parent's stable run id and stamped as the lineage pointer. The
31 * client only ever holds the public token (the run id is never exposed to it). */
32 derivedFromToken?: string | null
34 
35export interface RunSnapshotStore {
36 /** Persist (or overwrite) the finished run's snapshot, owned by `subject`. */
37 saveRun(subject: string, snapshot: RunSnapshot, opts?: SaveRunOptions): Promise<void>
38 /** A subject's saved runs, newest first (the list projection). */
39 listRuns(subject: string): Promise<RunSummary[]>
40 /** One saved run by id, only if `subject` owns it; null otherwise. */
41 getRun(subject: string, runId: string): Promise<RunSnapshot | null>
42 /**
43 * Make an owned run public (issue #88): mint a share token if it has none (reuse it
44 * if already shared, so the URL is stable), set the opt-in display name, and return
45 * the live share state. Null when `subject` doesn't own the run (reads as absent).
46 */
47 shareRun(subject: string, runId: string, name: string | null): Promise<ShareState | null>
48 /** Revoke an owned run's share (clear token + name). Idempotent; a no-op otherwise. */
49 unshareRun(subject: string, runId: string): Promise<void>
50 /** The owner's view of a run's share status; null when `subject` doesn't own it. */
51 getShareState(subject: string, runId: string): Promise<ShareState | null>
52 /** Resolve a public share token to its sanitized projection; null when unknown. */
53 getPublicRun(token: string): Promise<PublicRun | null>
55 
56/** Project a snapshot down to the list row. */
57function toSummary(snapshot: RunSnapshot, createdAt: string): RunSummary {
58 return {
59 runId: snapshot.runId,
60 status: snapshot.status,
61 title: snapshot.objective.title,
62 progress: snapshot.objectiveProgress,
63 createdAt
64 }
66 
67/**
68 * Project a stored snapshot down to its PUBLIC, shareable form. The snapshot blob is
69 * already identity-free (the owner lives only in the row's `user_id` column, never in
70 * the data), so the sanitization here is deliberate-and-explicit: blank the run id (the
71 * share token is the only handle a public viewer gets) and surface only the opt-in
72 * display name. This is the boundary the public endpoint serves โ€” never the raw row.
73 */
74export function toPublicRun(snapshot: RunSnapshot, name: string | null, remixedFrom: RemixCredit | null = null): PublicRun {
75 const trimmed = name?.trim()
76 // Normalize the stored blob to the current shape (an older snapshot may predate a
77 // field, e.g. v2's peakProgress) before it crosses to the public viewer.
78 const run = migrateSnapshot(snapshot)
79 return {
80 run: { ...run, runId: '' },
81 attribution: trimmed ? trimmed : null,
82 remixedFrom
83 }
85 
86/**
โ‹ฏ 72 lines hidden (lines 87โ€“158)
87 * Build the one-hop "remixed from" credit (issue #89) from the PARENT run's live fields.
88 * Only public, opt-in data crosses: the parent's display name (anonymous if blank/unset),
89 * its objective title, and its current share token (for an optional backlink, null when
90 * the parent is no longer shared). Never the parent's run id, owner, or device.
91 */
92function toRemixCredit(
93 parent: { title: string; shareName: string | null; shareToken: string | null } | null
94): RemixCredit | null {
95 if (!parent) return null
96 const name = parent.shareName?.trim()
97 return {
98 attribution: name ? name : null,
99 objectiveTitle: parent.title,
100 sourceToken: parent.shareToken ?? null
101 }
103 
104/** Dev/test default โ€” keeps the round-trip real (save -> list -> load) without any
105 * external dependency. Not durable across process restarts; the Supabase adapter is
106 * the one that actually preserves history. */
107export class InMemoryRunSnapshotStore implements RunSnapshotStore {
108 private readonly runs: Array<{
109 subject: string
110 snapshot: RunSnapshot
111 createdAt: string
112 shareToken: string | null
113 shareName: string | null
114 derivedFromRunId: string | null
115 }> = []
116 
117 /** Resolve a source share token to the parent's stable run id (issue #89), guarding
118 * self-derivation. Returns null for an unknown token or no token. */
119 private resolveDerivedRunId(token: string | null | undefined, selfRunId: string): string | null {
120 if (!token) return null
121 const parent = this.runs.find((r) => r.shareToken === token)
122 return parent && parent.snapshot.runId !== selfRunId ? parent.snapshot.runId : null
123 }
124 
125 async saveRun(subject: string, snapshot: RunSnapshot, opts?: SaveRunOptions): Promise<void> {
126 const createdAt = new Date().toISOString()
127 const existing = this.runs.find((r) => r.snapshot.runId === snapshot.runId)
128 if (existing) {
129 // Snapshots are immutable, but the save fires again once the epilogue
130 // lands โ€” overwrite in place (keep the original position/createdAt, and any
131 // share already minted for this run).
132 existing.subject = subject
133 existing.snapshot = snapshot
134 // Stamp the pointer only on a SUCCESSFUL resolve, and never overwrite one
135 // already set with null: it's stable once stamped, so a later un-share (the
136 // token stops resolving) leaves the lineage intact โ€” only the walked credit
137 // goes anonymous (see getPublicRun).
138 const resolved = this.resolveDerivedRunId(opts?.derivedFromToken, snapshot.runId)
139 if (resolved) existing.derivedFromRunId = resolved
140 return
141 }
142 this.runs.push({
143 subject,
144 snapshot,
145 createdAt,
146 shareToken: null,
147 shareName: null,
148 derivedFromRunId: this.resolveDerivedRunId(opts?.derivedFromToken, snapshot.runId)
149 })
150 }
151 
152 async listRuns(subject: string): Promise<RunSummary[]> {
153 return this.runs
154 .filter((r) => r.subject === subject)
155 .map((r) => toSummary(r.snapshot, r.createdAt))
156 .reverse()
157 }
158 
159 async getRun(subject: string, runId: string): Promise<RunSnapshot | null> {
160 const found = this.runs.find((r) => r.snapshot.runId === runId && r.subject === subject)
161 return found ? migrateSnapshot(found.snapshot) : null
162 }
โ‹ฏ 133 lines hidden (lines 163โ€“295)
163 
164 async shareRun(subject: string, runId: string, name: string | null): Promise<ShareState | null> {
165 const found = this.runs.find((r) => r.snapshot.runId === runId && r.subject === subject)
166 if (!found) return null
167 found.shareToken = found.shareToken ?? randomUUID()
168 found.shareName = name
169 return { token: found.shareToken, name: found.shareName }
170 }
171 
172 async unshareRun(subject: string, runId: string): Promise<void> {
173 const found = this.runs.find((r) => r.snapshot.runId === runId && r.subject === subject)
174 if (!found) return
175 found.shareToken = null
176 found.shareName = null
177 }
178 
179 async getShareState(subject: string, runId: string): Promise<ShareState | null> {
180 const found = this.runs.find((r) => r.snapshot.runId === runId && r.subject === subject)
181 return found ? { token: found.shareToken, name: found.shareName } : null
182 }
183 
184 async getPublicRun(token: string): Promise<PublicRun | null> {
185 const found = this.runs.find((r) => r.shareToken === token)
186 if (!found) return null
187 // Walk one hop to the parent's LIVE row for the "remixed from" credit (issue #89),
188 // so the source's current opt-in (name / still-shared) is what surfaces.
189 const parent = found.derivedFromRunId
190 ? this.runs.find((r) => r.snapshot.runId === found.derivedFromRunId) ?? null
191 : null
192 const remixedFrom = toRemixCredit(
193 parent
194 ? { title: parent.snapshot.objective.title, shareName: parent.shareName, shareToken: parent.shareToken }
195 : null
196 )
197 return toPublicRun(found.snapshot, found.shareName, remixedFrom)
198 }
200 
201/** Records each saved run as a `run_snapshots` row via the service (secret) key,
202 * which bypasses RLS โ€” the table has no public policy, so it is server-only. */
203export class SupabaseRunSnapshotStore implements RunSnapshotStore {
204 constructor(private readonly client: SupabaseClient) {}
205 
206 async saveRun(subject: string, snapshot: RunSnapshot, opts?: SaveRunOptions): Promise<void> {
207 // Stamp the owner onto the billing run row too (it carries only device_id
208 // today) so a run's ownership is legible from either table.
209 const owned = await this.client.from('runs').update({ user_id: subject }).eq('id', snapshot.runId)
210 if (owned.error) throw new Error(`runs owner update failed: ${owned.error.message}`)
211 
212 const row: Record<string, unknown> = {
213 run_id: snapshot.runId,
214 user_id: subject,
215 version: snapshot.version,
216 status: snapshot.status,
217 title: snapshot.objective.title,
218 progress: snapshot.objectiveProgress,
219 data: snapshot
220 }
221 // Stamp the lineage pointer ONLY for a replay (issue #89): resolve the source's
222 // public share token to the parent's STABLE run id (a token can be revoked / re-
223 // minted; the id can't), guarding self-derivation. The column is set only on a
224 // SUCCESSFUL resolve and is NEVER written as null โ€” so an ordinary run's upsert is
225 // byte-identical to before, and a replay whose source un-shares before the run ends
226 // keeps the pointer an earlier save stamped (the credit then walks to an anonymized
227 // parent). It's a stable pointer, never overwritten with null.
228 if (opts?.derivedFromToken) {
229 const parentId = await this.resolveDerivedRunId(opts.derivedFromToken)
230 if (parentId && parentId !== snapshot.runId) row.derived_from_run_id = parentId
231 }
232 
233 // Upsert on the run-id PK so the second (post-epilogue) save overwrites the
234 // first rather than erroring โ€” the snapshot is immutable, the write is not.
235 const { error } = await this.client.from('run_snapshots').upsert(row)
236 if (error) throw new Error(`run_snapshots upsert failed: ${error.message}`)
237 }
238 
239 /** Resolve a source share token to the parent's stable run id (issue #89); null when
240 * the token is unknown / revoked. */
241 private async resolveDerivedRunId(token: string): Promise<string | null> {
242 const { data, error } = await this.client
243 .from('run_snapshots')
244 .select('run_id')
245 .eq('share_token', token)
246 .maybeSingle()
247 if (error) throw new Error(`run_snapshots lineage resolve failed: ${error.message}`)
248 return (data?.run_id as string | null) ?? null
249 }
250 
251 /** Walk one hop to the parent's LIVE row for the "remixed from" credit (issue #89) โ€”
252 * its current title, opt-in name, and share token. Null when there's no parent (not
253 * a replay) or the parent has since been deleted. */
254 private async resolveRemixCredit(parentRunId: string | null): Promise<RemixCredit | null> {
255 if (!parentRunId) return null
256 const { data, error } = await this.client
257 .from('run_snapshots')
258 .select('title, share_name, share_token')
259 .eq('run_id', parentRunId)
260 .maybeSingle()
261 if (error) throw new Error(`run_snapshots remix credit failed: ${error.message}`)
262 if (!data) return null
263 return toRemixCredit({
264 title: data.title as string,
265 shareName: (data.share_name as string | null) ?? null,
266 shareToken: (data.share_token as string | null) ?? null
267 })
268 }
269 
270 async listRuns(subject: string): Promise<RunSummary[]> {
271 const { data, error } = await this.client
272 .from('run_snapshots')
273 .select('run_id, status, title, progress, created_at')
274 .eq('user_id', subject)
275 .order('created_at', { ascending: false })
276 if (error) throw new Error(`run_snapshots list failed: ${error.message}`)
277 return ((data ?? []) as Array<{
278 run_id: string
279 status: RunSnapshot['status']
280 title: string
281 progress: number
282 created_at: string
283 }>).map((row) => ({
284 runId: row.run_id,
285 status: row.status,
286 title: row.title,
287 progress: row.progress,
288 createdAt: row.created_at
289 }))
290 }
291 
292 async getRun(subject: string, runId: string): Promise<RunSnapshot | null> {
293 const { data, error } = await this.client
294 .from('run_snapshots')
295 .select('data')
296 .eq('run_id', runId)
297 .eq('user_id', subject)
298 .maybeSingle()
299 if (error) throw new Error(`run_snapshots get failed: ${error.message}`)
300 // Normalize an older stored blob (e.g. a v1 row predating v2's peakProgress) so a
301 // saved-run view always reads a current-shape snapshot.
302 return data?.data ? migrateSnapshot(data.data as RunSnapshot) : null
303 }
โ‹ฏ 85 lines hidden (lines 304โ€“388)
304 
305 async shareRun(subject: string, runId: string, name: string | null): Promise<ShareState | null> {
306 // Mint a token ONLY if the owned run has none yet, in one row-atomic UPDATE (the
307 // `share_token IS NULL` guard). Two concurrent shares can't both mint โ€” the second
308 // sees the first's token and matches no row โ€” so re-sharing keeps one stable URL
309 // and never trips the unique constraint. (A wasted uuid when already shared is
310 // harmless: the guard excludes the row, so it's never written.)
311 const minted = await this.client
312 .from('run_snapshots')
313 .update({ share_token: randomUUID() })
314 .eq('run_id', runId)
315 .eq('user_id', subject)
316 .is('share_token', null)
317 if (minted.error) throw new Error(`run_snapshots share mint failed: ${minted.error.message}`)
318 
319 // Set the name and read back the live token (the freshly-minted one, or a token
320 // from a prior share). Scoped to the owner, so a run the subject doesn't own
321 // matches no row and reads as absent (null) โ€” never shared on their behalf.
322 const { data, error } = await this.client
323 .from('run_snapshots')
324 .update({ share_name: name })
325 .eq('run_id', runId)
326 .eq('user_id', subject)
327 .select('share_token, share_name')
328 .maybeSingle()
329 if (error) throw new Error(`run_snapshots share failed: ${error.message}`)
330 if (!data) return null
331 return { token: data.share_token as string, name: (data.share_name as string | null) ?? null }
332 }
333 
334 async unshareRun(subject: string, runId: string): Promise<void> {
335 const { error } = await this.client
336 .from('run_snapshots')
337 .update({ share_token: null, share_name: null })
338 .eq('run_id', runId)
339 .eq('user_id', subject)
340 if (error) throw new Error(`run_snapshots unshare failed: ${error.message}`)
341 }
342 
343 async getShareState(subject: string, runId: string): Promise<ShareState | null> {
344 const { data, error } = await this.client
345 .from('run_snapshots')
346 .select('share_token, share_name')
347 .eq('run_id', runId)
348 .eq('user_id', subject)
349 .maybeSingle()
350 if (error) throw new Error(`run_snapshots share state failed: ${error.message}`)
351 if (!data) return null
352 return {
353 token: (data.share_token as string | null) ?? null,
354 name: (data.share_name as string | null) ?? null
355 }
356 }
357 
358 async getPublicRun(token: string): Promise<PublicRun | null> {
359 // Looked up by the share token ALONE โ€” no owner filter โ€” because the public page
360 // has no subject. The token is the capability; an unknown/revoked token misses.
361 const { data, error } = await this.client
362 .from('run_snapshots')
363 .select('data, share_name, derived_from_run_id')
364 .eq('share_token', token)
365 .maybeSingle()
366 if (error) throw new Error(`run_snapshots public get failed: ${error.message}`)
367 if (!data) return null
368 const remixedFrom = await this.resolveRemixCredit((data.derived_from_run_id as string | null) ?? null)
369 return toPublicRun(data.data as RunSnapshot, (data.share_name as string | null) ?? null, remixedFrom)
370 }
372 
373let cached: RunSnapshotStore | null = null
374 
375/** The configured run-snapshot store (memoized โ€” reuses one Supabase client). */
376export function runSnapshotStore(): RunSnapshotStore {
377 if (cached) return cached
378 const cfg = supabaseConfig()
379 cached = cfg
380 ? new SupabaseRunSnapshotStore(createClient(cfg.url, cfg.secretKey, { auth: { persistSession: false } }))
381 : new InMemoryRunSnapshotStore()
382 return cached
384 
385/** Test seam: clear the memoized store so the next call re-reads config. */
386export function resetRunSnapshotStore(): void {
387 cached = null

Showing it: the snapshot-backed renderer

A saved or shared run renders through RunView, not EndGameScreen. So the stat lands here, reading the persisted peak instead of the live store, with the same rule and wording as the end screen: show "Peaked at X%" only when the peak sits above the final %.

The stat block, gated by showPeak = snapshot.peakProgress > snapshot.objectiveProgress.

components/RunView.vue ยท 119 lines
components/RunView.vue119 lines ยท Vue
โ‹ฏ 52 lines hidden (lines 1โ€“52)
1<template>
2 <!-- A finished run, read-only โ€” the same verdict / epilogue / ledger the end screen
3 shows, but rendered from a saved snapshot rather than the live store, so a past
4 run reads identically to the moment it ended. Verdict strings, glyphs, and the
5 rv-* tokens are deliberately the same as EndGameScreen's. -->
6 <div data-testid="run-view" class="run-view" :class="isVictory ? 'run--win' : 'run--loss'">
7 <!-- Verdict masthead -->
8 <div class="text-center border-b rv-line pb-6">
9 <div class="verdict-seal" :class="isVictory ? 'is-win' : 'is-loss'" aria-hidden="true">{{ isVictory ? 'โœฆ' : 'โŠ˜' }}</div>
10 <h2 v-if="isVictory" data-testid="run-verdict" class="rv-serif text-3xl sm:text-4xl font-bold rv-fg mt-3">History Rewritten</h2>
11 <h2 v-else data-testid="run-verdict" class="rv-serif text-3xl sm:text-4xl font-bold rv-muted mt-3">The Timeline Held</h2>
12 <p class="text-sm mt-2.5 flex items-center justify-center gap-2 flex-wrap">
13 <span class="rv-muted inline-flex items-center gap-1.5">
14 <span aria-hidden="true">{{ snapshot.objective.icon }}</span>
15 <strong data-testid="run-objective-title" class="font-semibold">{{ snapshot.objective.title || 'Historical Mission' }}</strong>
16 </span>
17 <span class="rv-hair-c" aria-hidden="true">ยท</span>
18 <span class="rv-mono font-bold" :class="isVictory ? 'rv-ok' : 'rv-bad'">
19 <span data-testid="run-progress">{{ snapshot.objectiveProgress }}%</span> rewritten
20 </span>
21 </p>
22 
23 <RemixCreditLine v-if="remixedFrom" :credit="remixedFrom" class="mt-2.5" />
24 
25 <div v-if="snapshot.efficiency" data-testid="run-efficiency" class="mt-3 rv-mono text-sm">
26 <span class="rv-ok font-semibold">{{ victoryPhrase }}</span>
27 <span class="rv-muted"> ยท {{ snapshot.efficiency.messagesUsed }}/{{ snapshot.totalMessages }} used<span v-if="snapshot.efficiency.messagesSaved > 0"> ยท {{ snapshot.efficiency.messagesSaved }} saved</span></span>
28 <span v-if="snapshot.efficiency.isEarlyVictory" class="rv-ok"> ยท ๐ŸŒŸ to spare</span>
29 </div>
30 </div>
31 
32 <!-- The Chronicle's final telling โ€” the epilogue -->
33 <div v-if="snapshot.chronicle" data-testid="run-chronicle" class="mt-6">
34 <span class="rv-label rv-label--rule"><span aria-hidden="true">๐Ÿ“–</span> The Chronicle</span>
35 <article>
36 <h4 data-testid="run-chronicle-title" class="rv-serif text-xl font-semibold rv-fg mb-2.5 leading-tight">{{ snapshot.chronicle.title }}</h4>
37 <p v-for="(para, i) in snapshot.chronicle.paragraphs" :key="i" class="rv-serif rv-muted text-[15px] leading-relaxed mb-2.5 last:mb-0">{{ para }}</p>
38 </article>
39 </div>
40 
41 <!-- The words you sent โ€” the keepsake -->
42 <div v-if="snapshot.dispatches.length" data-testid="run-dispatches" class="mt-7">
43 <span class="rv-label rv-label--rule">The words you sent</span>
44 <ul class="space-y-3.5">
45 <li v-for="(d, i) in snapshot.dispatches" :key="i">
46 <p class="rv-label">to {{ d.figure }}<span v-if="d.era"> ยท {{ d.era }}</span></p>
47 <blockquote class="rv-serif rv-fg text-base leading-relaxed border-l-2 rv-line pl-3 mt-1">"{{ d.text }}"</blockquote>
48 </li>
49 </ul>
50 </div>
51 
52 <!-- Stats -->
53 <div data-testid="run-summary" class="mt-7">
54 <span class="rv-label rv-label--rule">How it played out</span>
55 <div class="flex flex-wrap gap-10">
56 <!-- "Peaked at X%" โ€” shown only when the run slipped from its high point (the
57 snapshot's peak sits above the final %). Same rule and wording as the live
58 end screen (#129), now driven by the persisted peak so a saved/shared lost
59 run shows it too (#151). A clean win never shows it (peak == final). -->
60 <div v-if="showPeak" data-testid="run-peak-progress">
61 <div class="rv-mono text-3xl font-extrabold rv-accent leading-none">{{ snapshot.peakProgress }}%</div>
62 <div class="rv-label mt-1">peaked at</div>
63 </div>
โ‹ฏ 42 lines hidden (lines 64โ€“105)
64 <div data-testid="run-message-efficiency">
65 <div class="rv-mono text-3xl font-extrabold rv-fg leading-none">{{ snapshot.messagesUsed }}</div>
66 <div class="rv-label mt-1">{{ snapshot.messagesUsed === 1 ? 'message' : 'messages' }} sent</div>
67 </div>
68 <div v-if="snapshot.bestRoll" data-testid="run-best-roll">
69 <div class="rv-mono text-3xl font-extrabold rv-ok leading-none">{{ snapshot.bestRoll.diceRoll }}</div>
70 <div class="rv-label mt-1">{{ snapshot.messagesUsed >= 2 ? 'best roll' : 'the roll that did it' }} ยท {{ snapshot.bestRoll.diceOutcome }}</div>
71 </div>
72 <div v-if="snapshot.worstRoll && snapshot.messagesUsed >= 2" data-testid="run-worst-roll">
73 <div class="rv-mono text-3xl font-extrabold rv-bad leading-none">{{ snapshot.worstRoll.diceRoll }}</div>
74 <div class="rv-label mt-1">worst roll ยท {{ snapshot.worstRoll.diceOutcome }}</div>
75 </div>
76 </div>
77 </div>
78 
79 <!-- The history you wrote โ€” the Spine itself, read-only, rendered from the saved
80 snapshot rather than the live store, so a past run reads exactly as it did
81 when it ended: valence dots, โšก/โš‘ badges, the roll, and the ฮ” equation all
82 carry over, identical to EndGameScreen (which renders the same component). -->
83 <div v-if="snapshot.timeline.length > 0" data-testid="run-timeline" class="mt-6">
84 <TimelineLedger readonly :events="snapshot.timeline" />
85 </div>
86 </div>
87</template>
88 
89<script setup lang="ts">
90/**
91 * RunView โ€” a read-only render of a saved run (a RunSnapshot). It mirrors
92 * EndGameScreen's verdict / epilogue / ledger, reading everything from the passed
93 * snapshot rather than the live store, so it can replay a past run (and later, a
94 * shared one). The timeline renders through the shared Spine (TimelineLedger), fed
95 * the snapshot's ledger via :events โ€” the same component the end screen uses, so the
96 * two read identically. RunView itself stays store-free: no AI, no controls.
97 */
98import { computed } from 'vue'
99import type { RunSnapshot, RemixCredit } from '~/utils/run-snapshot'
100 
101// remixedFrom: the one-hop "remixed from" credit when this run is a replay (issue #89),
102// resolved by the read path (the public projection / live store); absent for an original.
103const props = defineProps<{ snapshot: RunSnapshot; remixedFrom?: RemixCredit | null }>()
104 
105const isVictory = computed(() => props.snapshot.status === 'victory')
106 
107// "Peaked at X%" surfaces only when the run lost ground from its high-water mark โ€” the
108// same rule EndGameScreen uses live, here read from the snapshot so a saved/shared run
109// shows it identically (#151). A clean win never shows it (peak == final 100%).
110const showPeak = computed(() => props.snapshot.peakProgress > props.snapshot.objectiveProgress)
โ‹ฏ 9 lines hidden (lines 111โ€“119)
111 
112// The verdict never deflates: a full-message win reads "Hard-won," not "Standard"
113// (matches EndGameScreen's phrasing exactly).
114const victoryPhrase = computed(() => {
115 const rating = props.snapshot.efficiency?.efficiencyRating
116 if (!rating) return 'Victory'
117 return rating === 'Standard' ? 'Hard-won victory' : `${rating} victory`
118})
119</script>

Why you can trust it โ€” the tests

The discriminating cases, each pinned by a test:

- Boundary (sanitizeSnapshot): an absent peak defaults to the final %; a peak < final is floored up to final; an out-of-range peak clamps to 100. - Migration (getRun / getPublicRun of a synthesized v1 blob): reads back with peak == final and version == 2 โ€” proving the old-run "no peak shown" guarantee end to end. - Write (saveRunSnapshot): a lost run that peaked at 70% and slipped to 40% POSTs peakProgress: 70, distinct from the floored objectiveProgress: 40 โ€” so the builder reads the peak, not the final. - Render (RunView): shows "Peaked at 70%" on that slipped defeat; hidden on a clean win.

Full unit suite (927) green, nuxt typecheck clean, and the e2e suite green apart from one pre-existing, unrelated landing.spec locator failure that touches no snapshot or RunView code.