What changed, and why

A run in progress lived entirely in the Pinia store and vanished on any refresh or crash. That hurt: every turn is a model call, so a stray reload threw away minutes of play and real spend. The only persistence was end-state — saveRunSnapshot() fired once, at victory or defeat.

This adds the missing mid-run save. The server stays stateless per turn (the client is still the system of record), so the fix is storage, not a turn-engine rewrite: after each resolved-but-still-playing turn the client autosaves a draft of the live state to a new server table; on the next page load it reads that draft back and drops the player onto the in-progress board. The draft is keyed to the same subject runs and balances already use — a real Supabase user (anonymous by default), or the device cookie — so an anonymous player's run carries over to their account for free when they sign in with a magic link.

The blast radius is contained. run_snapshots (the finished-run record behind "your runs", share, and replay) is untouched; the draft is a separate table, a separate shape, and a separate set of endpoints. The terminal save path keeps working exactly as before — the draft just rides alongside it.

The draft contract

The draft's shape lives beside RunSnapshot in the client-safe contract module (types only, no runtime imports). It serializes the durable live state and nothing transient: the running totals, the full thread (both sides — the snapshot keeps only the player's dispatches, but the AI replies cost a model call to regenerate), the un-trimmed ledger with whenSigned and causalChain (the footholds the next turn reads back), the contacted figures, the active contact, the Chronicle, and any replay lineage.

It deliberately leaves out the loading flags, the runEpoch/sequence counters (those must survive a reset, not a reload), and figureGrounding — the active figure is re-grounded from the live record on resume, so a draft never carries a stale dossier. The in-memory Date stamps become ISO strings here, since jsonb is dateless.

The design note, the independent version stamp, and the RunDraft shape.

utils/run-snapshot.ts · 312 lines
utils/run-snapshot.ts312 lines · TypeScript
⋯ 205 lines hidden (lines 1–205)
1/**
2 * The saved-run contract — the immutable, versioned record of a finished run.
3 *
4 * A run is otherwise client-only: the objective, the dispatches, the timeline
5 * ledger, the Chronicle epilogue, and the verdict all live in the Pinia store and
6 * evaporate on reload. This is the persisted form of that end-state: the store builds
7 * it on game end, the server stores it keyed by the run id, and a read-only "your
8 * runs" view replays it. It is a SNAPSHOT, not a live record — written ONCE, at
9 * victory/defeat, and immutable thereafter.
10 *
11 * The MID-run counterpart is the RunDraft at the foot of this file (#152): the live
12 * state, overwritten every turn, so a refresh or a crash can't lose a half-played run
13 * (every turn is a model call — expensive to redo). Two persistence contracts, two
14 * lifecycles, kept in one client-safe module.
15 *
16 * Naming: "run" is overloaded. The header gauge's balance ("runs remaining") is a
17 * purchasable CREDIT (the `balances` table); this is a PLAYED run (the game record,
18 * the `run_snapshots` table). Same noun, two states — kept distinct in the schema.
19 *
20 * The shape is VERSIONED (`version`) so a later change can't silently break runs
21 * saved under an older shape: a reader can branch on `version` and migrate. It is a
22 * flat, fully JSON-native projection of the store state — the `Date` timestamps on
23 * the in-memory ledger/thread are dropped (a snapshot needs none of the duration
24 * math), so the stored blob round-trips through jsonb cleanly.
25 *
26 * This module is the SHARED contract (types + version), client-safe by design: it
27 * carries no runtime imports, only type-only ones, so the client store can import the
28 * version + types without pulling any server-only code. The server-side boundary
29 * validator lives separately in `server/utils/run-snapshot.ts`.
30 */
31import type { DiceOutcome } from '~/utils/dice'
32import type { Craft } from '~/utils/craft'
33import type { Continuity } from '~/utils/continuity'
34import type { ChainStatus } from '~/utils/causal-chain'
35import type { ContactMoment } from '~/utils/contact-moment'
36import type { Anachronism } from '~/server/utils/anachronism'
37import type { GameObjective } from '~/server/utils/objectives'
38import type { ChronicleEntry } from '~/server/utils/openai'
39 
40/** The current saved-run shape. Bump when the shape changes incompatibly; a reader
41 * branches on it to migrate older snapshots rather than mis-reading them.
42 * v2 (#151): added `peakProgress` — the run's high-water mark, persisted so the
43 * "Peaked at X%" stat survives onto snapshot-backed (saved/shared) views. */
44export const RUN_SNAPSHOT_VERSION = 2
45 
46/** Only a finished run is ever saved — the two terminal verdicts. */
47export type RunOutcome = 'victory' | 'defeat'
48 
49/** The valence of a ledger swing (mirrors the store's `Valence`). */
50export type RunValence = 'positive' | 'negative' | 'neutral'
51 
52/** The five efficiency tiers a victory can earn (mirrors the store's rating). */
53export type EfficiencyRating = 'Legendary' | 'Excellent' | 'Great' | 'Good' | 'Standard'
54 
55/** The bit of a roll the read-only view shows: the number and its named outcome. */
56export interface RollMark {
57 diceRoll: number
58 diceOutcome: DiceOutcome
60 
61/** A dispatch keepsake — the player's verbatim message and whom/when it reached. */
62export interface RunDispatch {
63 text: string
64 figure: string
65 era: string
67 
68/** The victory efficiency block (null on defeat) — the same data the end screen shows. */
69export interface RunEfficiency {
70 messagesSaved: number
71 messagesUsed: number
72 efficiencyPercentage: number
73 efficiencyRating: EfficiencyRating
74 isEarlyVictory: boolean
76 
77/** One ledger entry, trimmed to the fields a read-only run view renders (the live
78 * `TimelineEvent` minus its `Date` timestamp and the internals the view ignores). */
79export interface RunLedgerEntry {
80 id: string
81 figureName: string
82 era: string
83 headline: string
84 detail: string
85 diceRoll: number
86 diceOutcome: DiceOutcome
87 progressChange: number
88 /** The pre-amplifier swing — the Spine reads it to show the Δ equation
89 * (`base → final`). Absent on turns that didn't amplify (none to disclose). */
90 baseProgressChange?: number
91 valence: RunValence
92 craft?: Craft
93 anachronism?: Anachronism
94 /** True when this was the run's staked last stand — the Spine's ⚑ badge. */
95 staked?: boolean
97 
98/** The full saved run — everything a read-only view needs to replay the end screen. */
99export interface RunSnapshot {
100 version: number
101 runId: string
102 status: RunOutcome
103 objective: GameObjective
104 objectiveProgress: number
105 /** The run's high-water mark (0..100) — the highest objectiveProgress it ever
106 * reached. Always ≥ objectiveProgress (peak only rises; the live total can fall),
107 * so a snapshot-backed view shows "Peaked at X%" exactly when peak > final, the
108 * same rule the live end screen uses (#151). v1 snapshots lack it; `migrateSnapshot`
109 * defaults them to their final objectiveProgress (so peak == final → stat hidden). */
110 peakProgress: number
111 messagesUsed: number
112 totalMessages: number
113 bestRoll: RollMark | null
114 worstRoll: RollMark | null
115 efficiency: RunEfficiency | null
116 dispatches: RunDispatch[]
117 timeline: RunLedgerEntry[]
118 chronicle: ChronicleEntry | null
120 
121/**
122 * Upgrade a stored snapshot to the current shape. Snapshots persist under the version
123 * current at save time, so a reader branches on `version` to normalize an older one
124 * rather than mis-reading a field it predates. A current snapshot passes through
125 * untouched; future bumps add their own step here, applied in sequence.
126 *
127 * v1 → v2: the high-water mark was live-session-only, so a v1 blob carries no
128 * `peakProgress`. Default it to the run's final objectiveProgress — peak == final, which
129 * renders as "no peak shown," exactly how those runs looked before the field existed.
130 */
131export function migrateSnapshot(raw: RunSnapshot): RunSnapshot {
132 if (raw.version >= RUN_SNAPSHOT_VERSION) return raw
133 const peak = (raw as { peakProgress?: unknown }).peakProgress
134 return {
135 ...raw,
136 version: RUN_SNAPSHOT_VERSION,
137 peakProgress: typeof peak === 'number' ? peak : raw.objectiveProgress
138 }
140 
141/** The list projection — what the "your runs" list shows per run, without pulling
142 * the full snapshot blob for every row. */
143export interface RunSummary {
144 runId: string
145 status: RunOutcome
146 title: string
147 progress: number
148 createdAt: string
150 
151/**
152 * "Remixed from" credit (issue #89) — the one-hop lineage of a replayed run: the run it
153 * was seeded from. It is a POINTER WALKED at render time (never a copy stamped into the
154 * child), so it always reflects the source's CURRENT privacy choice — a later un-share
155 * drops `attribution` back to anonymous and `sourceToken` to null. It carries only public
156 * game data + the opt-in display name: never the parent's run id, owner, or device.
157 */
158export interface RemixCredit {
159 /** The source sharer's opt-in display name, or null when they shared anonymously. */
160 attribution: string | null
161 /** The source run's objective title — the objective this run is a replay of. */
162 objectiveTitle: string
163 /** The source's live share token for an optional backlink, or null when un-shared. */
164 sourceToken: string | null
166 
167/**
168 * The PUBLIC, shareable projection of a saved run (issue #88) — the sanitized form
169 * served at an unguessable share URL to anyone, no login. It carries the GAME RECORD
170 * (verdict, ledger, dispatches, Chronicle epilogue) and an optional self-chosen display
171 * name, and DELIBERATELY nothing that identifies the player: no user id, no device id,
172 * no email — and not even the run id (the share token is the only handle). `run.runId`
173 * is blanked for that reason; the read-only view never renders it.
174 */
175export interface PublicRun {
176 run: RunSnapshot
177 /** The sharer's opt-in display name, or null when shared anonymously. */
178 attribution: string | null
179 /** When this run is itself a replay (issue #89), the one-hop "remixed from" credit;
180 * null/absent for an original run. Walked from the parent's live snapshot, so it
181 * honors the source's current privacy opt-in. */
182 remixedFrom?: RemixCredit | null
184 
185/** What the OWNER sees about a run's share status: the live token (null = not shared)
186 * and the current display name. Owner-only — never part of the public projection. */
187export interface ShareState {
188 token: string | null
189 name: string | null
191 
192/** The brag line a share prefills (the X intent + the native sheet) — one source so the
193 * end screen, the saved-run page, and the public page never drift (issue #88). */
194export function buildShareText(objectiveTitle: string, status: RunOutcome, progress: number): string {
195 const verb = status === 'victory' ? 'I rewrote history' : 'I tried to rewrite history'
196 return `${verb} in Everwhen: ${objectiveTitle || 'a historical mission'}${progress}% rewritten.`
198 
199/* ──────────────────────────────────────────────────────────────────────────────
200 * The in-progress run DRAFT (#152) — the live counterpart to the terminal snapshot.
201 *
202 * The snapshot above is written once and frozen; the draft is its mirror image:
203 * overwritten after every resolved-but-still-playing turn so a refresh or a crash
204 * can't lose a half-played run. It serializes the DURABLE live state the store needs
205 * to drop the player straight back onto the in-progress board, and nothing else:
206 *
207 * • the running totals — objective + progress + peak + momentum + remainingMessages
208 * • the FULL thread, both sides (the snapshot keeps only the player's dispatches; a
209 * resume needs the AI replies too, or it would re-pay for those model calls)
210 * • the UN-trimmed ledger — with `whenSigned` and `causalChain`, which the next
211 * turn's causal-chain dial and the figure world-brief read back
212 * • the contacted figures and the active-contact selection (who & when)
213 * • the live Chronicle and any replay lineage
214 *
215 * It DELIBERATELY omits: transient UI/loading flags, the runEpoch/seq staleness
216 * counters (they must survive a reset, not a reload), and `figureGrounding` — the
217 * active figure is RE-grounded from the live record on resume, so a draft never
218 * carries a stale dossier. The in-memory `Date` stamps on the thread/ledger serialize
219 * to ISO strings here (jsonb is dateless) and are revived on rehydrate.
220 *
221 * Versioned INDEPENDENTLY of the snapshot (RUN_DRAFT_VERSION, not RUN_SNAPSHOT_VERSION):
222 * the two shapes have different lifecycles and evolve on their own clocks, so a
223 * snapshot-only field change must never read as a draft migration, nor the reverse.
224 * ────────────────────────────────────────────────────────────────────────────── */
225 
226/** The current draft shape. Independent of RUN_SNAPSHOT_VERSION on purpose — a draft
227 * and a snapshot are different records. Bump on an incompatible change; a future
228 * reader branches on it to migrate rather than mis-read an older draft. */
229export const RUN_DRAFT_VERSION = 1
230 
231/** One thread line as it persists in a draft: the live store `Message` with its
232 * `Date` stamp serialized to an ISO string. Both senders are kept (the snapshot's
233 * RunDispatch keeps player dispatches only) — the AI replies are part of "where you
234 * left off," and they cost a model call to regenerate. */
⋯ 61 lines hidden (lines 235–295)
235export interface RunDraftMessage {
236 text: string
237 sender: 'user' | 'ai' | 'system'
238 timestamp: string
239 figureName?: string
240 diceRoll?: number
241 diceOutcome?: DiceOutcome
242 naturalRoll?: number
243 rollModifier?: number
244 craft?: Craft
245 craftReason?: string
246 continuity?: Continuity
247 characterAction?: string
248 timelineImpact?: string
249 progressChange?: number
250 baseProgressChange?: number
251 momentumAtSwing?: number
252 anachronism?: Anachronism
253 causalChain?: ChainStatus
254 staked?: boolean
256 
257/** One ledger entry as it persists in a draft: the FULL live `TimelineEvent` (not the
258 * snapshot's trimmed RunLedgerEntry) — it keeps `whenSigned` and `causalChain`, the
259 * footholds the next turn's causal chain measures against, with its `Date` stamp
260 * serialized to an ISO string. */
261export interface RunDraftLedgerEntry {
262 id: string
263 figureName: string
264 era: string
265 headline: string
266 detail: string
267 diceRoll: number
268 diceOutcome: DiceOutcome
269 progressChange: number
270 baseProgressChange?: number
271 valence: RunValence
272 anachronism?: Anachronism
273 causalChain?: ChainStatus
274 craft?: Craft
275 whenSigned?: number
276 staked?: boolean
277 timestamp: string
279 
280/** A contacted figure as the store caches it (mirrors the store's HistoricalFigure). */
281export interface RunDraftFigure {
282 name: string
283 era: string
284 descriptor: string
286 
287/** The replay seed (issue #89) as it persists in a draft — so a resumed replay still
288 * stamps its lineage pointer and credits its source on the end screen. */
289export interface RunDraftReplay {
290 objective: GameObjective
291 sourceToken: string
292 sourceAttribution: string | null
294 
295/** The full in-progress draft — everything the store needs to rehydrate a live run. */
296export interface RunDraft {
297 version: number
298 runId: string
299 currentObjective: GameObjective
300 objectiveProgress: number
301 peakProgress: number
302 momentum: number
303 remainingMessages: number
304 messageHistory: RunDraftMessage[]
305 timelineEvents: RunDraftLedgerEntry[]
306 figures: RunDraftFigure[]
307 activeFigureName: string
308 contactWhen: number | null
309 contactMoment: ContactMoment | null
310 chronicle: ChronicleEntry | null
311 replayState: RunDraftReplay | null

Clamp the untrusted draft at the boundary

A draft is POSTed by the client and, on resume, rehydrates the live store — so it is trusted no more than a send-message body. The server-only validator coerces and bounds every field. The lines that matter most are the counter clamps: between turns there is no server-side turn engine, so remainingMessages is purely client-authoritative. A forged draft is the only lever a crafted request has over it.

The posture (top), and the clamps that make a forged draft harmless.

server/utils/run-draft.ts · 273 lines
server/utils/run-draft.ts273 lines · TypeScript
1/**
2 * Server-side boundary validation for an in-progress run draft (#152).
3 *
4 * The shared CONTRACT (types + version) lives in `~/utils/run-snapshot` so both the
5 * client store and the server import it without pulling server-only code into the
6 * client bundle. This is the server-only half: it coerces an untrusted POST body into
7 * a valid RunDraft (a direct POST can send anything), bounding every field with the
8 * same cleanString/cleanArray/clampInt helpers the other routes use.
9 *
10 * A draft is trusted LESS than a snapshot, not more: on resume it REHYDRATES the live
11 * store, and `remainingMessages` / `objectiveProgress` / `momentum` drive real game
12 * logic. The client is the system of record between turns (there is no server turn
13 * engine), so a forged draft is the only lever a crafted request has over those — clamp
14 * them into their legal ranges here so resume can never grant extra turns (each is a
15 * paid model call) or instant-win progress.
16 */
17import {
18 cleanString,
⋯ 168 lines hidden (lines 19–186)
19 cleanArray,
20 clampInt,
21 MAX_ERA_CHARS,
22 MAX_FIGURE_NAME_CHARS,
23 MAX_LEDGER_DETAIL_CHARS,
24 MAX_LEDGER_SWING,
25 MAX_OBJECTIVE_DESC_CHARS,
26 MAX_OBJECTIVE_TITLE_CHARS,
27 MAX_SHARE_NAME_CHARS,
28 MAX_TIMELINE_ENTRIES,
29 MAX_HISTORY_ENTRIES
30} from './validate'
31import { sanitizeRunId } from './run-context'
32import { toAnachronism } from './anachronism'
33import { TOTAL_MESSAGES, MAX_PROGRESS } from '~/utils/game-config'
34import { MOMENTUM_MAX } from '~/utils/momentum'
35import { toCraft } from '~/utils/craft'
36import { toContinuity } from '~/utils/continuity'
37import { toContactMoment } from '~/utils/contact-moment'
38import { CHAIN_TIERS, type ChainStatus, type ChainTier } from '~/utils/causal-chain'
39import { DiceOutcome } from '~/utils/dice'
40import {
41 RUN_DRAFT_VERSION,
42 type RunDraft,
43 type RunDraftMessage,
44 type RunDraftLedgerEntry,
45 type RunDraftFigure,
46 type RunDraftReplay,
47 type RunValence
48} from '~/utils/run-snapshot'
49import type { GameObjective } from '~/server/utils/objectives'
50import type { ChronicleEntry } from '~/server/utils/openai'
51 
52// Caps specific to the draft's own fields (the shared input caps live in validate.ts).
53const MAX_MSG_TEXT_CHARS = 320
54const MAX_ACTION_CHARS = 320
55const MAX_CRAFT_REASON_CHARS = 240
56const MAX_HEADLINE_CHARS = 200
57const MAX_DESCRIPTOR_CHARS = 200
58const MAX_ICON_CHARS = 16
59const MAX_OUTCOME_CHARS = 24
60const MAX_TS_CHARS = 40
61const MAX_ID_CHARS = 64
62const MAX_DICE = 40
63// A signed contact/anchor year (BC negative … AD positive). Mirrors the bound the
64// other routes clamp `whenSigned` to (server/api/send-message.post.ts).
65const MAX_YEAR = 12000
66 
67const VALENCES: RunValence[] = ['positive', 'negative', 'neutral']
68const coerceValence = (v: unknown): RunValence =>
69 VALENCES.includes(v as RunValence) ? (v as RunValence) : 'neutral'
70 
71const clampNonNeg = (n: number): number => Math.max(0, n)
72 
73const DICE_OUTCOMES = Object.values(DiceOutcome) as string[]
74const cleanOutcome = (v: unknown): DiceOutcome | undefined => {
75 const s = cleanString(v, MAX_OUTCOME_CHARS)
76 return DICE_OUTCOMES.includes(s) ? (s as DiceOutcome) : undefined
78 
79/** A finite signed int clamped to ±bound, or undefined when absent — keeps an
80 * optional field's "absent" distinct from a real 0 (clampInt alone returns 0). */
81const optInt = (v: unknown, bound: number): number | undefined =>
82 typeof v === 'number' && Number.isFinite(v) ? clampInt(v, bound) : undefined
83 
84/** Coerce an untrusted causal-chain read, or undefined when it isn't one. The factor
85 * is a multiplier in [0, 1]; the gap is years (non-negative); the tier is an enum. */
86const cleanChain = (v: unknown): ChainStatus | undefined => {
87 if (!v || typeof v !== 'object') return undefined
88 const c = v as Record<string, unknown>
89 const tier = c.tier as ChainTier
90 if (!CHAIN_TIERS.includes(tier)) return undefined
91 const factor = typeof c.factor === 'number' && Number.isFinite(c.factor)
92 ? Math.max(0, Math.min(1, c.factor))
93 : 1
94 return { gap: clampNonNeg(clampInt(c.gap, MAX_YEAR)), factor, tier }
96 
97/** Coerce one untrusted thread line into a bounded RunDraftMessage. Optional fields
98 * are set only when present, so an absent grade/roll stays absent on rehydrate. */
99function cleanMessage(raw: unknown): RunDraftMessage {
100 const m = (raw ?? {}) as Record<string, unknown>
101 const sender: RunDraftMessage['sender'] =
102 m.sender === 'user' || m.sender === 'ai' || m.sender === 'system' ? m.sender : 'system'
103 const msg: RunDraftMessage = {
104 text: cleanString(m.text, MAX_MSG_TEXT_CHARS),
105 sender,
106 timestamp: cleanString(m.timestamp, MAX_TS_CHARS)
107 }
108 const figureName = cleanString(m.figureName, MAX_FIGURE_NAME_CHARS)
109 if (figureName) msg.figureName = figureName
110 const diceRoll = optInt(m.diceRoll, MAX_DICE)
111 if (diceRoll !== undefined) msg.diceRoll = clampNonNeg(diceRoll)
112 const diceOutcome = cleanOutcome(m.diceOutcome)
113 if (diceOutcome) msg.diceOutcome = diceOutcome
114 const naturalRoll = optInt(m.naturalRoll, MAX_DICE)
115 if (naturalRoll !== undefined) msg.naturalRoll = clampNonNeg(naturalRoll)
116 const rollModifier = optInt(m.rollModifier, MAX_DICE)
117 if (rollModifier !== undefined) msg.rollModifier = rollModifier
118 if (typeof m.craft === 'string') msg.craft = toCraft(m.craft)
119 const craftReason = cleanString(m.craftReason, MAX_CRAFT_REASON_CHARS)
120 if (craftReason) msg.craftReason = craftReason
121 if (typeof m.continuity === 'string') msg.continuity = toContinuity(m.continuity)
122 const characterAction = cleanString(m.characterAction, MAX_ACTION_CHARS)
123 if (characterAction) msg.characterAction = characterAction
124 const timelineImpact = cleanString(m.timelineImpact, MAX_LEDGER_DETAIL_CHARS)
125 if (timelineImpact) msg.timelineImpact = timelineImpact
126 const progressChange = optInt(m.progressChange, MAX_LEDGER_SWING)
127 if (progressChange !== undefined) msg.progressChange = progressChange
128 const baseProgressChange = optInt(m.baseProgressChange, MAX_LEDGER_SWING)
129 if (baseProgressChange !== undefined) msg.baseProgressChange = baseProgressChange
130 const momentumAtSwing = optInt(m.momentumAtSwing, MOMENTUM_MAX)
131 if (momentumAtSwing !== undefined) msg.momentumAtSwing = clampNonNeg(momentumAtSwing)
132 if (typeof m.anachronism === 'string') msg.anachronism = toAnachronism(m.anachronism)
133 const chain = cleanChain(m.causalChain)
134 if (chain) msg.causalChain = chain
135 if (m.staked === true) msg.staked = true
136 return msg
138 
139/** Coerce one untrusted ledger entry into a bounded RunDraftLedgerEntry. */
140function cleanLedgerEntry(raw: unknown): RunDraftLedgerEntry {
141 const t = (raw ?? {}) as Record<string, unknown>
142 const entry: RunDraftLedgerEntry = {
143 id: cleanString(t.id, MAX_ID_CHARS),
144 figureName: cleanString(t.figureName, MAX_FIGURE_NAME_CHARS),
145 era: cleanString(t.era, MAX_ERA_CHARS),
146 headline: cleanString(t.headline, MAX_HEADLINE_CHARS),
147 detail: cleanString(t.detail, MAX_LEDGER_DETAIL_CHARS),
148 diceRoll: clampNonNeg(clampInt(t.diceRoll, MAX_DICE)),
149 diceOutcome: cleanOutcome(t.diceOutcome) ?? DiceOutcome.NEUTRAL,
150 progressChange: clampInt(t.progressChange, MAX_LEDGER_SWING),
151 valence: coerceValence(t.valence),
152 timestamp: cleanString(t.timestamp, MAX_TS_CHARS)
153 }
154 const baseProgressChange = optInt(t.baseProgressChange, MAX_LEDGER_SWING)
155 if (baseProgressChange !== undefined) entry.baseProgressChange = baseProgressChange
156 if (typeof t.craft === 'string') entry.craft = toCraft(t.craft)
157 if (typeof t.anachronism === 'string') entry.anachronism = toAnachronism(t.anachronism)
158 const chain = cleanChain(t.causalChain)
159 if (chain) entry.causalChain = chain
160 const whenSigned = optInt(t.whenSigned, MAX_YEAR)
161 if (whenSigned !== undefined) entry.whenSigned = whenSigned
162 if (t.staked === true) entry.staked = true
163 return entry
165 
166/** Coerce an untrusted objective into the bounded GameObjective shape. */
167function cleanObjective(raw: Record<string, unknown>): GameObjective {
168 const objective: GameObjective = {
169 title: cleanString(raw.title, MAX_OBJECTIVE_TITLE_CHARS),
170 description: cleanString(raw.description, MAX_OBJECTIVE_DESC_CHARS),
171 era: cleanString(raw.era, MAX_ERA_CHARS),
172 icon: cleanString(raw.icon, MAX_ICON_CHARS)
173 }
174 if (typeof raw.anchorYear === 'number' && Number.isFinite(raw.anchorYear)) {
175 objective.anchorYear = Math.round(raw.anchorYear)
176 }
177 return objective
179 
180/**
181 * Coerce an untrusted body into a valid RunDraft, or null when there is nothing
182 * resumable: no run id, or no objective (a run with no objective has no board to drop
183 * back onto). Bounds every field and stamps the server's own `version` — never the
184 * client's. The run-id is sanitized here; the route additionally gates it to a real
185 * uuid (the `run_drafts.run_id` FK is uuid).
186 */
187export function sanitizeDraft(raw: unknown): RunDraft | null {
188 if (!raw || typeof raw !== 'object') return null
189 const r = raw as Record<string, unknown>
190 
191 const runId = sanitizeRunId(r.runId)
192 if (!runId) return null
193 
194 if (!r.currentObjective || typeof r.currentObjective !== 'object') return null
195 const currentObjective = cleanObjective(r.currentObjective as Record<string, unknown>)
196 
197 // The counters that drive game logic on resume — clamped into their legal ranges so
198 // a forged draft can't grant free turns or instant-win progress.
199 const objectiveProgress = clampNonNeg(clampInt(r.objectiveProgress, MAX_PROGRESS))
200 // The peak is a high-water mark, so it can't sit below the current progress.
201 const peakProgress = Math.max(objectiveProgress, clampNonNeg(clampInt(r.peakProgress, MAX_PROGRESS)))
202 const momentum = Math.min(MOMENTUM_MAX, clampNonNeg(clampInt(r.momentum, MOMENTUM_MAX)))
203 const remainingMessages = Math.min(TOTAL_MESSAGES, clampNonNeg(clampInt(r.remainingMessages, TOTAL_MESSAGES)))
204 
⋯ 69 lines hidden (lines 205–273)
205 const messageHistory = cleanArray(r.messageHistory, MAX_HISTORY_ENTRIES).map(cleanMessage)
206 const timelineEvents = cleanArray(r.timelineEvents, MAX_TIMELINE_ENTRIES).map(cleanLedgerEntry)
207 const figures: RunDraftFigure[] = cleanArray(r.figures, MAX_HISTORY_ENTRIES)
208 .map((raw) => {
209 const f = (raw ?? {}) as Record<string, unknown>
210 return {
211 name: cleanString(f.name, MAX_FIGURE_NAME_CHARS),
212 era: cleanString(f.era, MAX_ERA_CHARS),
213 descriptor: cleanString(f.descriptor, MAX_DESCRIPTOR_CHARS)
214 }
215 })
216 .filter((f) => f.name)
217 
218 const activeFigureName = cleanString(r.activeFigureName, MAX_FIGURE_NAME_CHARS)
219 const contactWhen = typeof r.contactWhen === 'number' && Number.isFinite(r.contactWhen)
220 ? clampInt(r.contactWhen, MAX_YEAR)
221 : null
222 // Re-derive the pinned moment from validated integers (the same posture as
223 // send-message: never trust a client-formatted date).
224 const m = (r.contactMoment ?? {}) as Record<string, unknown>
225 const contactMoment = toContactMoment(m.month, m.day)
226 
227 let chronicle: ChronicleEntry | null = null
228 if (r.chronicle && typeof r.chronicle === 'object') {
229 const c = r.chronicle as Record<string, unknown>
230 chronicle = {
231 title: cleanString(c.title, MAX_HEADLINE_CHARS),
232 paragraphs: cleanArray(c.paragraphs, MAX_HISTORY_ENTRIES)
233 .map((p) => cleanString(p, MAX_OBJECTIVE_DESC_CHARS))
234 .filter(Boolean)
235 }
236 }
237 
238 // The replay seed (issue #89) is kept only when intact — a source token AND the
239 // captured objective; a partial seed couldn't stamp lineage anyway.
240 let replayState: RunDraftReplay | null = null
241 if (r.replayState && typeof r.replayState === 'object') {
242 const s = r.replayState as Record<string, unknown>
243 const sourceToken = cleanString(s.sourceToken, MAX_ID_CHARS)
244 if (sourceToken && s.objective && typeof s.objective === 'object') {
245 replayState = {
246 objective: cleanObjective(s.objective as Record<string, unknown>),
247 sourceToken,
248 sourceAttribution: typeof s.sourceAttribution === 'string'
249 ? cleanString(s.sourceAttribution, MAX_SHARE_NAME_CHARS)
250 : null
251 }
252 }
253 }
254 
255 return {
256 // The server owns the version stamp — never trust the client's.
257 version: RUN_DRAFT_VERSION,
258 runId,
259 currentObjective,
260 objectiveProgress,
261 peakProgress,
262 momentum,
263 remainingMessages,
264 messageHistory,
265 timelineEvents,
266 figures,
267 activeFigureName,
268 contactWhen,
269 contactMoment,
270 chronicle,
271 replayState
272 }

The draft store

Storage follows the repo's env-degradation idiom exactly: one port, two adapters chosen by config. In-memory by default, so npm run dev and the unit tests need nothing; Supabase when the URL + secret key are present. The interface is just three verbs — upsert the draft, read the subject's latest, delete one — and reads are scoped to the owner, so a player only ever resumes their own run.

The port, and the durable read: the subject's latest draft, newest first.

server/utils/run-draft-store.ts · 125 lines
server/utils/run-draft-store.ts125 lines · TypeScript
⋯ 26 lines hidden (lines 1–26)
1/**
2 * The run-draft store — where an IN-PROGRESS run is parked so a refresh or a crash
3 * can't lose it (#152). POST /api/run-draft upserts the draft here keyed by the run
4 * id; GET /api/run-draft reads back the subject's latest in-progress draft to resume;
5 * DELETE /api/run-draft clears it (also cleared on terminal save). The terminal,
6 * immutable record lives in the separate run-snapshot store — a draft is the live,
7 * overwritten mirror, never the saved run.
8 *
9 * Two adapters behind one port, chosen by config — the repo's env-degradation idiom
10 * (cf. runSnapshotStore, runStore):
11 * - in-memory (default): no external dependency, so `npm run dev` and the unit tests
12 * need nothing. Drafts don't outlive the process, but the round-trip (save -> read
13 * latest -> delete) is real, which is what the resume path rests on.
14 * - Supabase: used when SUPABASE_URL + SUPABASE_SECRET_KEY are configured. Upserts
15 * the `run_drafts` row via the service (secret) key, which bypasses RLS — the table
16 * has no public policy, so it is server-only.
17 *
18 * Ownership is by SUBJECT (the Supabase user id, falling back to the device id) — the
19 * same string balances/runs/snapshots key on. Reads filter by it, so a player only
20 * ever resumes their OWN run; an anonymous draft carries over to the account for free
21 * on a magic-link sign-in (same subject id).
22 */
23import { createClient, type SupabaseClient } from '@supabase/supabase-js'
24import { supabaseConfig } from './supabase'
25import type { RunDraft } from '~/utils/run-snapshot'
26 
27export interface RunDraftStore {
28 /** Persist (or overwrite) the in-progress draft, owned by `subject`, keyed by run id. */
29 saveDraft(subject: string, draft: RunDraft): Promise<void>
30 /** The subject's most-recently-updated draft, or null when they have none. A
31 * player has a single active run (starting one spends a credit), so "latest"
32 * is the one to resume. */
33 getLatestDraft(subject: string): Promise<RunDraft | null>
34 /** Drop a draft the subject owns. Idempotent; a no-op when absent / not theirs. */
35 deleteDraft(subject: string, runId: string): Promise<void>
37 
⋯ 47 lines hidden (lines 38–84)
38/** Dev/test default — keeps the round-trip real without any external dependency. A
39 * monotonic tick orders "latest" deterministically (clock ties can't reorder it). */
40export class InMemoryRunDraftStore implements RunDraftStore {
41 private tick = 0
42 private readonly drafts: Array<{ subject: string; runId: string; draft: RunDraft; updatedAt: number }> = []
43 
44 async saveDraft(subject: string, draft: RunDraft): Promise<void> {
45 const updatedAt = ++this.tick
46 const existing = this.drafts.find((d) => d.runId === draft.runId)
47 if (existing) {
48 existing.subject = subject
49 existing.draft = draft
50 existing.updatedAt = updatedAt
51 return
52 }
53 this.drafts.push({ subject, runId: draft.runId, draft, updatedAt })
54 }
55 
56 async getLatestDraft(subject: string): Promise<RunDraft | null> {
57 const owned = this.drafts.filter((d) => d.subject === subject)
58 if (!owned.length) return null
59 return owned.reduce((a, b) => (b.updatedAt > a.updatedAt ? b : a)).draft
60 }
61 
62 async deleteDraft(subject: string, runId: string): Promise<void> {
63 const i = this.drafts.findIndex((d) => d.runId === runId && d.subject === subject)
64 if (i >= 0) this.drafts.splice(i, 1)
65 }
67 
68/** Records each draft as a `run_drafts` row via the service (secret) key, which
69 * bypasses RLS — the table has no public policy, so it is server-only. */
70export class SupabaseRunDraftStore implements RunDraftStore {
71 constructor(private readonly client: SupabaseClient) {}
72 
73 async saveDraft(subject: string, draft: RunDraft): Promise<void> {
74 // Upsert on the run-id PK so each turn's autosave overwrites the prior one
75 // rather than erroring. `updated_at` is stamped explicitly (a column default
76 // fires only on insert, not on the update half of an upsert) so "latest" stays
77 // accurate across the run.
78 const { error } = await this.client.from('run_drafts').upsert({
79 run_id: draft.runId,
80 user_id: subject,
81 version: draft.version,
82 data: draft,
83 updated_at: new Date().toISOString()
84 })
85 if (error) throw new Error(`run_drafts upsert failed: ${error.message}`)
86 }
87 
88 async getLatestDraft(subject: string): Promise<RunDraft | null> {
89 const { data, error } = await this.client
90 .from('run_drafts')
91 .select('data')
92 .eq('user_id', subject)
93 .order('updated_at', { ascending: false })
94 .limit(1)
95 .maybeSingle()
96 if (error) throw new Error(`run_drafts get failed: ${error.message}`)
97 return (data?.data as RunDraft | undefined) ?? null
98 }
99 
⋯ 26 lines hidden (lines 100–125)
100 async deleteDraft(subject: string, runId: string): Promise<void> {
101 const { error } = await this.client
102 .from('run_drafts')
103 .delete()
104 .eq('run_id', runId)
105 .eq('user_id', subject)
106 if (error) throw new Error(`run_drafts delete failed: ${error.message}`)
107 }
109 
110let cached: RunDraftStore | null = null
111 
112/** The configured run-draft store (memoized — reuses one Supabase client). */
113export function runDraftStore(): RunDraftStore {
114 if (cached) return cached
115 const cfg = supabaseConfig()
116 cached = cfg
117 ? new SupabaseRunDraftStore(createClient(cfg.url, cfg.secretKey, { auth: { persistSession: false } }))
118 : new InMemoryRunDraftStore()
119 return cached
121 
122/** Test seam: clear the memoized store so the next call re-reads config. */
123export function resetRunDraftStore(): void {
124 cached = null

Autosave, clear, and resume — woven into the store

Three new actions sit beside saveRunSnapshot. **saveRunDraft** mirrors it for a still-playing run, serializing Date stamps to ISO strings. **clearRunDraft** drops the draft (it takes an explicit run id so resetGame can clear the run it is tearing down). **resumeDraft** reads the latest draft and rehydrates the store.

The wiring lives in the sendMessage tail. The terminal branch (victory/defeat) gains a clearRunDraft() next to the snapshot save; a new still-playing branch autosaves — an immediate write (in case the chronicle refresh hangs) plus a re-save once the fresh Chronicle lands, the same two-beat shape the terminal save already uses.

resumeDraft is the subtle one. It reuses the draft's runId (so no second credit is charged), revives the ISO stamps to real Dates, then re-grounds the active figure and restores the saved contact year over the mid-lifetime default that grounding would otherwise pick. Read the two guards on it closely — they are covered in the callout.

The turn-tail wiring, saveRunDraft's serialize, all of resumeDraft, and resetGame's clear.

stores/game.ts · 1651 lines
stores/game.ts1651 lines · TypeScript
⋯ 1125 lines hidden (lines 1–1125)
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, RUN_DRAFT_VERSION, type RunSnapshot, type RunDraft } 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() })
1128 // The terminal snapshot is the record now — drop the in-progress draft
1129 // so a reload can't resume a finished run (#152).
1130 void this.clearRunDraft()
1131 } else if (resolved) {
1132 // Still playing: autosave the live state (best-effort, fire-and-forget)
1133 // so a refresh or crash resumes exactly here (#152). The immediate save
1134 // guards against the chronicle refresh hanging; the post-refresh re-save
1135 // captures this turn's freshly-narrated Chronicle — same two-beat shape
1136 // as the terminal saveRunSnapshot above.
1137 void this.saveRunDraft()
1138 if (refresh) void refresh.finally(() => { void this.saveRunDraft() })
⋯ 296 lines hidden (lines 1140–1435)
1140 return resolved
1141 },
1143 /**
1144 * Seed a replay (issue #89): start a fresh run on a shared run's captured
1145 * objective. Resets first (the share page may follow an abandoned run in the
1146 * same tab) so the run starts clean, THEN records the seed — so the mission
1147 * briefing opens focused on this objective, and the lineage + credit ride
1148 * through to the end. No generation: the objective is reused verbatim, and the
1149 * player still commits (and is charged) by pressing Begin.
1150 */
1151 preseedReplay(objective: GameObjective, sourceToken: string, sourceAttribution: string | null): void {
1152 this.resetGame()
1153 this.replayState = { objective, sourceToken, sourceAttribution }
1154 },
1156 /** Drop the replay seed — the player chose to start from scratch instead, so this
1157 * run is an original (no "remixed from" lineage). */
1158 clearReplayState(): void {
1159 this.replayState = null
1160 },
1162 /**
1163 * Commits a chosen objective and starts the run from a clean slate of
1164 * progress. Used by the mission-select screen for both curated picks and
1165 * freshly composed ones.
1166 */
1167 async chooseObjective(objective: GameObjective): Promise<boolean> {
1168 // Commit = the run is charged here. Mint the run id server-side, then
1169 // spend one run from the device's balance. Fails CLOSED: out of runs →
1170 // raise the paywall; begin-run or commit failing → surface an error and
1171 // do NOT start. A run that isn't a charged, server-backed run would be
1172 // rejected by the gameplay gate anyway, so starting it only strands the
1173 // player mid-run. The spend cap (not free play) is the outage backstop.
1174 let runId: string
1175 try {
1176 runId = await this.ensureRunId()
1177 } catch (error) {
1178 console.error('Could not begin the run:', error)
1179 this.error = 'Could not start the run. Please try again.'
1180 return false
1182 try {
1183 const res = await $fetch('/api/run-commit', {
1184 method: 'POST',
1185 headers: { 'Content-Type': 'application/json' },
1186 body: { runId }
1187 }) as { runsRemaining?: number }
1188 this.outOfRuns = false
1189 // Keep the header gauge live: the commit returns the post-charge
1190 // balance (−1 marks a degraded, ungated run — leave the gauge as is).
1191 if (typeof res?.runsRemaining === 'number' && res.runsRemaining >= 0) {
1192 this.runsRemaining = res.runsRemaining
1194 } catch (error) {
1195 if (isPaymentRequired(error)) {
1196 this.outOfRuns = true
1197 this.openBuyModal()
1198 return false
1200 if (isAtCapacity(error)) {
1201 this.atCapacity = true
1202 return false
1204 console.error('Could not charge the run:', error)
1205 this.error = 'Could not start the run. Please try again.'
1206 return false
1208 this.currentObjective = objective
1209 this.objectiveProgress = 0
1210 this.peakProgress = 0
1211 this.momentum = 0
1212 this.error = null
1213 return true
1214 },
1216 /**
1217 * Asks the server to compose a fresh objective with the model, WITHOUT
1218 * committing it — the caller previews it, then commits via chooseObjective.
1219 * Generation lives server-side because it needs the OpenAI key (the client
1220 * has no access to it). An optional steer (era + theme, closed enums) biases
1221 * the composition; the server re-validates it against the enums, so a bad
1222 * value is harmless. `avoid` is the titles already composed this session, so
1223 * a reroll lands somewhere new (#95) — the stateless server only knows what
1224 * the client supplies. Returns null rather than throwing if composing fails,
1225 * so the UI can quietly fall back to the curated pool.
1226 */
1227 async fetchAIObjective(steer: ObjectiveSteer = {}, avoid: string[] = []): Promise<GameObjective | null> {
1228 try {
1229 const query: Record<string, string | string[]> = {}
1230 if (steer.era) query.era = steer.era
1231 if (steer.theme) query.theme = steer.theme
1232 if (avoid.length) query.avoid = avoid
1233 const res = await $fetch('/api/objective', { method: 'GET', query, headers: await this.aiHeaders() }) as {
1234 success?: boolean
1235 objective?: GameObjective
1237 return res?.success && res.objective ? res.objective : null
1238 } catch (error) {
1239 console.error('Failed to compose a fresh objective:', error)
1240 return null
1242 },
1244 /**
1245 * Loads the device's run balance for the header gauge + account popover.
1246 * Grants the free trial on a brand-new device (server-side). Graceful: on
1247 * failure it leaves the prior value (the gauge simply doesn't update).
1248 */
1249 async loadBalance(): Promise<void> {
1250 try {
1251 const res = await $fetch('/api/balance') as { runsRemaining?: number; freeRuns?: number; deviceRef?: string; email?: string | null; isAnonymous?: boolean }
1252 if (typeof res?.runsRemaining === 'number') this.runsRemaining = res.runsRemaining
1253 if (typeof res?.freeRuns === 'number') this.freeRuns = res.freeRuns
1254 if (typeof res?.deviceRef === 'string') this.deviceRef = res.deviceRef
1255 this.accountEmail = res?.email ?? null
1256 this.accountAnonymous = res?.isAnonymous === true
1257 } catch (error) {
1258 console.error('Failed to load balance:', error)
1260 },
1262 /** Opens the run-packs sales modal, loading the catalog first. */
1263 async openBuyModal(): Promise<void> {
1264 this.buyModalOpen = true
1265 await this.loadPacks()
1266 },
1268 /** Closes the sales modal. */
1269 closeBuyModal(): void {
1270 this.buyModalOpen = false
1271 },
1273 /** Records the Stripe-return outcome and refreshes the balance on success
1274 * (the webhook credits asynchronously; re-read so the gauge reflects it). */
1275 async notePurchaseReturn(outcome: 'success' | 'cancel'): Promise<void> {
1276 this.purchaseNotice = outcome
1277 this.buyModalOpen = false
1278 if (outcome === 'success') {
1279 this.outOfRuns = false
1280 await this.loadBalance()
1282 },
1284 /** Dismisses the post-purchase notice. */
1285 clearPurchaseNotice(): void {
1286 this.purchaseNotice = null
1287 },
1289 /** Loads the run-pack catalog for the paywall. Caches only a NON-empty
1290 * result: a transient empty/failed read must not poison the cache and
1291 * strand the modal with zero packs for the rest of the session. */
1292 async loadPacks(): Promise<void> {
1293 if (this.packs.length) return
1294 try {
1295 const res = await $fetch('/api/packs') as { packs?: Pack[] }
1296 const packs = res?.packs ?? []
1297 if (packs.length) this.packs = packs
1298 } catch (error) {
1299 console.error('Failed to load packs:', error)
1301 },
1303 /** Starts checkout for a pack; returns the Stripe Checkout URL to redirect
1304 * to (null on failure). The caller does the redirect. */
1305 async buyPack(packId: string): Promise<string | null> {
1306 try {
1307 const res = await $fetch('/api/checkout', {
1308 method: 'POST',
1309 headers: { 'Content-Type': 'application/json' },
1310 body: { packId }
1311 }) as { url?: string }
1312 return res?.url ?? null
1313 } catch (error) {
1314 console.error('Failed to start checkout:', error)
1315 return null
1317 },
1319 getVictoryEfficiency() {
1320 if (this.gameStatus === 'victory') {
1321 const messagesSaved = this.remainingMessages
1322 const messagesUsed = TOTAL_MESSAGES - this.remainingMessages
1323 const efficiencyPercentage = Math.round((messagesSaved / TOTAL_MESSAGES) * 100)
1325 const efficiencyRating = rateEfficiency(messagesSaved)
1327 return {
1328 messagesSaved,
1329 messagesUsed,
1330 efficiencyPercentage,
1331 efficiencyRating,
1332 isEarlyVictory: messagesSaved > 0
1335 return null
1336 },
1338 /**
1339 * Persist the finished run's snapshot to the player's account, best-effort.
1340 * Captures exactly what the end screen shows — objective, verdict, ledger,
1341 * dispatches, rolls, and the Chronicle epilogue — keyed by the run id, so the
1342 * run survives reload and feeds the read-only "your runs" view. Fires and
1343 * forgets like refreshChronicle: a save failure must never disturb the end
1344 * screen. Only a completed (victory/defeat), server-backed (uuid run id) run
1345 * is saved; a degraded local id is dropped server-side.
1346 */
1347 async saveRunSnapshot(): Promise<void> {
1348 const epoch = this.runEpoch
1349 const status = this.gameStatus
1350 if (status !== 'victory' && status !== 'defeat') return
1351 const objective = this.currentObjective
1352 const runId = this.runId
1353 if (!objective || !runId) return
1355 const summary = this.gameSummary
1356 const events = this.timelineEvents
1357 // The dispatch keepsake — the player's verbatim messages, paired with
1358 // whom/when they reached (the same zip the end screen renders).
1359 const dispatches = this.messageHistory
1360 .filter((m) => m.sender === 'user')
1361 .map((m, i) => ({
1362 text: m.text,
1363 figure: m.figureName || events[i]?.figureName || 'the past',
1364 era: events[i]?.era || ''
1365 }))
1366 const mark = (m: typeof summary.bestRoll) =>
1367 m && m.diceRoll != null && m.diceOutcome != null
1368 ? { diceRoll: m.diceRoll, diceOutcome: m.diceOutcome }
1369 : null
1371 const snapshot: RunSnapshot = {
1372 version: RUN_SNAPSHOT_VERSION,
1373 runId,
1374 status,
1375 objective,
1376 objectiveProgress: this.objectiveProgress,
1377 // The high-water mark, so a snapshot-backed view can show "Peaked at X%"
1378 // (the live store zeroes it on reset, so it's only meaningful here at end).
1379 peakProgress: this.peakProgress,
1380 messagesUsed: TOTAL_MESSAGES - this.remainingMessages,
1381 totalMessages: TOTAL_MESSAGES,
1382 bestRoll: mark(summary.bestRoll),
1383 worstRoll: mark(summary.worstRoll),
1384 efficiency: this.getVictoryEfficiency(),
1385 dispatches,
1386 timeline: events.map((e) => ({
1387 id: e.id,
1388 figureName: e.figureName,
1389 era: e.era,
1390 headline: e.headline,
1391 detail: e.detail,
1392 diceRoll: e.diceRoll,
1393 diceOutcome: e.diceOutcome,
1394 progressChange: e.progressChange,
1395 baseProgressChange: e.baseProgressChange,
1396 valence: e.valence,
1397 craft: e.craft,
1398 anachronism: e.anachronism,
1399 staked: e.staked
1400 })),
1401 chronicle: this.chronicle
1404 // A replay (issue #89) rides its source's share token alongside the snapshot
1405 // (a sibling field, not part of the immutable blob); the server resolves it to
1406 // the parent run id and stamps the lineage pointer. replayState set ⟺ this run
1407 // committed the seeded objective (the briefing clears it on any other choice).
1408 const body = this.replayState
1409 ? { ...snapshot, remixedFromToken: this.replayState.sourceToken }
1410 : snapshot
1412 try {
1413 const res = (await $fetch('/api/run-save', {
1414 method: 'POST',
1415 headers: { 'Content-Type': 'application/json' },
1416 body
1417 })) as { saved?: boolean }
1418 // Mark saved (for the share affordance) only if this run is still current
1419 // and the server actually persisted it (a degraded run returns saved:false).
1420 if (epoch === this.runEpoch && res?.saved) this.runSnapshotSaved = true
1421 } catch (error) {
1422 // Saving the run is a nicety; a failure must not break the end screen.
1423 if (epoch === this.runEpoch) console.error('Could not save the run:', error)
1425 },
1427 /**
1428 * Autosave the in-progress run's draft, best-effort (#152). The live mirror of
1429 * saveRunSnapshot: it serializes the DURABLE live state — totals, the full
1430 * thread (both sides), the un-trimmed ledger (with whenSigned + causalChain),
1431 * the contacted figures, the active contact, the Chronicle, and any replay
1432 * lineage — keyed by the run id, so a refresh or a crash resumes exactly here.
1433 * Fires and forgets: only an in-progress, server-backed (uuid) run is saved;
1434 * the server drops a degraded local id, and any failure is swallowed.
1435 */
1436 async saveRunDraft(): Promise<void> {
1437 if (this.gameStatus !== 'playing') return
1438 const runId = this.runId
1439 const objective = this.currentObjective
1440 if (!runId || !objective) return
1442 const draft: RunDraft = {
1443 version: RUN_DRAFT_VERSION,
1444 runId,
1445 currentObjective: objective,
1446 objectiveProgress: this.objectiveProgress,
1447 peakProgress: this.peakProgress,
1448 momentum: this.momentum,
1449 remainingMessages: this.remainingMessages,
1450 // The full thread, both sides — the in-memory Date stamp serialized for
1451 // jsonb (revived on resume). The AI replies cost a model call to redo.
1452 messageHistory: this.messageHistory.map((m) => ({
1453 text: m.text,
1454 sender: m.sender,
1455 timestamp: m.timestamp.toISOString(),
1456 figureName: m.figureName,
1457 diceRoll: m.diceRoll,
1458 diceOutcome: m.diceOutcome,
1459 naturalRoll: m.naturalRoll,
1460 rollModifier: m.rollModifier,
1461 craft: m.craft,
1462 craftReason: m.craftReason,
⋯ 77 lines hidden (lines 1463–1539)
1463 continuity: m.continuity,
1464 characterAction: m.characterAction,
1465 timelineImpact: m.timelineImpact,
1466 progressChange: m.progressChange,
1467 baseProgressChange: m.baseProgressChange,
1468 momentumAtSwing: m.momentumAtSwing,
1469 anachronism: m.anachronism,
1470 causalChain: m.causalChain,
1471 staked: m.staked
1472 })),
1473 // The UN-trimmed ledger: keeps whenSigned + causalChain (the footholds
1474 // the next turn's causal chain and the world-brief read back).
1475 timelineEvents: this.timelineEvents.map((e) => ({
1476 id: e.id,
1477 figureName: e.figureName,
1478 era: e.era,
1479 headline: e.headline,
1480 detail: e.detail,
1481 diceRoll: e.diceRoll,
1482 diceOutcome: e.diceOutcome,
1483 progressChange: e.progressChange,
1484 baseProgressChange: e.baseProgressChange,
1485 valence: e.valence,
1486 anachronism: e.anachronism,
1487 causalChain: e.causalChain,
1488 craft: e.craft,
1489 whenSigned: e.whenSigned,
1490 staked: e.staked,
1491 timestamp: e.timestamp.toISOString()
1492 })),
1493 figures: this.figures.map((f) => ({ name: f.name, era: f.era, descriptor: f.descriptor })),
1494 activeFigureName: this.activeFigureName,
1495 contactWhen: this.contactWhen,
1496 contactMoment: this.contactMoment,
1497 chronicle: this.chronicle,
1498 replayState: this.replayState
1501 try {
1502 await $fetch('/api/run-draft', {
1503 method: 'POST',
1504 headers: { 'Content-Type': 'application/json' },
1505 body: draft
1506 })
1507 } catch (error) {
1508 // Autosave is a safety net; a failure must never disturb the turn.
1509 console.error('Could not save the run draft:', error)
1511 },
1513 /**
1514 * Clear the run's server-side draft (#152) — fired on terminal save and on
1515 * resetGame (abandon). Takes an explicit run id so resetGame can clear the
1516 * run it is tearing down (runId is nulled there). Idempotent + best-effort.
1517 */
1518 async clearRunDraft(runId?: string): Promise<void> {
1519 const id = runId ?? this.runId
1520 if (!id) return
1521 try {
1522 await $fetch('/api/run-draft', { method: 'DELETE', query: { runId: id } })
1523 } catch (error) {
1524 console.error('Could not clear the run draft:', error)
1526 },
1528 /**
1529 * Resume an in-progress run from its server-side draft (#152) — the counterpart
1530 * to saveRunDraft, called on page load when the board is empty. Reads the
1531 * subject's latest draft and, when there is one, rehydrates the store so the
1532 * player drops straight back onto the in-progress board: the SAME run id (no new
1533 * credit charged), the totals, the full thread, the ledger, and the figures.
1535 * The active figure is RE-grounded from the live record (the draft carries no
1536 * dossier), then the saved contact year/moment are re-applied — groundActiveFigure
1537 * defaults contactWhen to mid-lifetime, which would otherwise clobber the player's
1538 * chosen year. Best-effort: any failure simply leaves the mission briefing.
1539 */
1540 async resumeDraft(): Promise<boolean> {
1541 // currentObjective set ⟹ a run is already on the board. A replayState seed
1542 // (preseedReplay, from a /r/<token> share link) is an intent to start a FRESH
1543 // charged run — never let a pre-existing server draft clobber it (#152/#89).
1544 if (this.currentObjective || this.replayState) return false
1545 let draft: RunDraft | null
1546 try {
1547 const res = (await $fetch('/api/run-draft')) as { draft?: RunDraft | null }
1548 draft = res?.draft ?? null
1549 } catch (error) {
1550 console.error('Could not load the run draft:', error)
1551 return false
1553 if (!draft || !draft.currentObjective || !draft.runId) return false
1554 // A reset, a fresh run, or a replay seed may have begun while the draft was
1555 // loading — never drop a stale run onto a board the player has moved on from.
1556 if (this.currentObjective || this.replayState) return false
1558 // jsonb is dateless: revive the serialized ISO stamps, falling back to now()
1559 // for any unparseable value rather than minting an Invalid Date.
1560 const revive = (ts: string): Date => {
1561 const d = new Date(ts)
1562 return Number.isNaN(d.getTime()) ? new Date() : d
1565 // Guard the async tail like every other state-writing action: the board (and
1566 // its ↻ reset) appears the instant we rehydrate, so a reset can fire during
1567 // the re-ground below — its stale contact must not land on the fresh run.
1568 const epoch = this.runEpoch
1569 this.runId = draft.runId
1570 this.gameStatus = 'playing'
1571 this.currentObjective = draft.currentObjective
1572 this.objectiveProgress = draft.objectiveProgress
1573 this.peakProgress = draft.peakProgress
1574 this.momentum = draft.momentum
1575 this.remainingMessages = draft.remainingMessages
1576 this.messageHistory = draft.messageHistory.map((m) => ({ ...m, timestamp: revive(m.timestamp) }))
1577 this.timelineEvents = draft.timelineEvents.map((e) => ({ ...e, timestamp: revive(e.timestamp) }))
1578 this.figures = draft.figures.map((f) => ({ ...f }))
1579 this.activeFigureName = draft.activeFigureName
1580 this.chronicle = draft.chronicle
1581 this.replayState = draft.replayState
1583 // Re-ground the active figure (the draft carries no dossier), then restore the
1584 // saved year/moment OVER the mid-lifetime default grounding picks — unless a
1585 // reset tore the board mid-ground, in which case abort without touching it.
1586 if (this.activeFigureName) {
1587 await this.groundActiveFigure(this.activeFigureName)
1588 if (epoch !== this.runEpoch) return false
1590 this.contactWhen = draft.contactWhen
1591 this.contactMoment = draft.contactMoment
1592 return true
1593 },
⋯ 2 lines hidden (lines 1594–1595)
1595 resetGame() {
1596 // Clear the abandoned run's server draft so a reload can't resume it (#152).
1597 // Capture the id before the epoch tear nulls runId below; fire-and-forget.
1598 const abandonedRunId = this.runId
1599 // Tear the epoch first: anything still in flight belongs to the old run
1600 // and must find no purchase here. (The seq counters deliberately survive.)
1601 this.runEpoch++
⋯ 44 lines hidden (lines 1602–1645)
1602 this.remainingMessages = TOTAL_MESSAGES
1603 this.messageHistory = []
1604 this.gameStatus = 'playing'
1605 this.isLoading = false
1606 this.error = null
1607 this.lastMessageTime = null
1608 this.isRateLimited = false
1609 // A new run gets a fresh id on its next server call; abandon any
1610 // in-flight mint so a dead run's id can't land in the new run.
1611 this.runId = null
1612 this.runIdInflight = null
1613 this.runSnapshotSaved = false
1614 // The paywall / capacity notices re-check on the next commit.
1615 this.outOfRuns = false
1616 this.atCapacity = false
1617 this.currentObjective = null
1618 // A fresh run carries no replay lineage (preseedReplay re-sets it after).
1619 this.replayState = null
1620 this.objectiveProgress = 0
1621 this.peakProgress = 0
1622 this.momentum = 0
1623 this.timelineEvents = []
1624 this.figures = []
1625 this.activeFigureName = ''
1626 this.figureGrounding = null
1627 this.groundingLoading = false
1628 this.contactWhen = null
1629 this.contactMoment = null
1630 this.figureSuggestions = []
1631 this.suggestionsLoading = false
1632 this.suggestionsFor = ''
1633 this.chronicle = null
1634 this.chronicleLoading = false
1635 this.epiloguePending = false
1636 this.figureStudy = null
1637 this.studyLoading = false
1638 this.studyFor = ''
1639 this.studyWhen = null
1640 this.archiveResult = null
1641 this.lookupLoading = false
1642 this.moderationNotice = null
1643 this.archiveNotice = null
1644 this.studyNotice = null
1645 this.suggestionsNotice = null
1646 // Drop the abandoned run's draft last, off the fresh (already-torn) epoch —
1647 // a no-op when there was no run yet (#152).
1648 if (abandonedRunId) void this.clearRunDraft(abandonedRunId)
⋯ 2 lines hidden (lines 1650–1651)

Resume on load, and the finished-run safety net

The page kicks off resume from onMounted, after the balance load and any magic-link return settle, and only when the board is empty. It is auto-resume: the player lands back on the in-progress board, and the header's existing ↻ "new timeline" control is the abandon path (it clears the draft through resetGame).

One guarded call at the end of the mount sequence.

pages/play.vue · 625 lines
pages/play.vue625 lines · Vue
⋯ 461 lines hidden (lines 1–461)
1<template>
2 <div class="min-h-screen lg:h-screen ew-bg flex flex-col lg:overflow-hidden">
3 <!-- HUD: wordmark · objective mission-strip · gauge · status · dark · new-timeline -->
4 <header class="border-b ew-line">
5 <!-- On phones the bar wraps: controls stay on the top line, and the objective
6 (with its foldable brief) drops to its own full-width line so the brief
7 reads at full width instead of a thin column. Single row from sm up. -->
8 <div class="px-3 sm:px-5 py-2.5 flex flex-wrap items-center gap-x-2 gap-y-1.5 sm:flex-nowrap sm:gap-4">
9 <GameTitle />
10 
11 <div v-if="gameStore.currentObjective" class="order-last basis-full min-w-0 sm:order-none sm:basis-auto sm:flex-1">
12 <ObjectiveDisplay data-testid="objective-display" />
13 </div>
14 <div v-else class="flex-1" />
15 
16 <div v-if="gameStore.currentObjective" class="flex items-center gap-1.5 sm:gap-2 shrink-0 ml-auto sm:ml-0">
17 <MessagesCounter data-testid="messages-counter" />
18 <!-- One mis-tap must not erase five AI-resolved turns: with a run in
19 progress the reset arms a tiny inline confirm (auto-reverts); an
20 untouched run still resets in one tap. -->
21 <span v-if="resetArmed" class="flex items-center gap-1.5">
22 <span class="text-[11px] ew-warn">abandon this history?</span>
23 <button type="button" data-testid="reset-confirm" class="ew-tap ew-hover-fg text-sm leading-none ew-warn"
24 aria-label="Yes — abandon this history" @click="performReset"></button>
25 <button type="button" data-testid="reset-cancel" class="ew-tap ew-hover-fg text-sm leading-none"
26 aria-label="Keep playing" @click="disarmReset"></button>
27 </span>
28 <button v-else type="button" data-testid="reset-button" class="ew-tap ew-hover-fg text-base leading-none"
29 title="New timeline" aria-label="New timeline" @click="handleResetGame"></button>
30 </div>
31 
32 <!-- Runs gauge — always visible (briefing + board), and the entry to the
33 account popover / the run-packs modal. -->
34 <RunsBalance class="shrink-0" :class="gameStore.currentObjective ? '' : 'ml-auto'" />
35 
36 <!-- Sound on/off — beside the dark toggle, persisted the same way. The svg is
37 decorative (aria-hidden); the button carries the state-reflecting name. -->
38 <button type="button" data-testid="mute-toggle" class="ew-tap ew-hover-fg leading-none shrink-0"
39 :aria-label="audio.muted ? 'Unmute sound effects' : 'Mute sound effects'"
40 :title="audio.muted ? 'Sound off' : 'Sound on'" :aria-pressed="!audio.muted" @click="toggleMute">
41 <svg width="17" height="17" viewBox="0 0 24 24" fill="none" aria-hidden="true">
42 <path d="M4 9h3l5-4v14l-5-4H4z" fill="currentColor" stroke="currentColor"
43 stroke-width="1.6" stroke-linejoin="round" />
44 <template v-if="!audio.muted">
45 <path d="M16.5 8.5a5 5 0 0 1 0 7" stroke="currentColor" stroke-width="1.8"
46 stroke-linecap="round" />
47 <path d="M19 6a8.5 8.5 0 0 1 0 12" stroke="currentColor" stroke-width="1.8"
48 stroke-linecap="round" />
49 </template>
50 <template v-else>
51 <path d="M16.5 9.5l5 5m0-5l-5 5" stroke="currentColor" stroke-width="1.8"
52 stroke-linecap="round" />
53 </template>
54 </svg>
55 </button>
56 
57 <button type="button" class="ew-tap ew-hover-fg text-base leading-none shrink-0"
58 :aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'" @click="toggleDark"></button>
59 </div>
60 </header>
61 
62 <!-- Post-checkout banner: the missing feedback after returning from Stripe — a
63 credited confirmation, or a gentle "no charge" on cancel. Self-dismisses. -->
64 <div v-if="gameStore.purchaseNotice" data-testid="purchase-notice" role="status" aria-live="polite"
65 class="border-b ew-line px-5 py-2.5 text-sm flex items-center gap-2"
66 :class="gameStore.purchaseNotice === 'success' ? 'ew-tint' : ''">
67 <span aria-hidden="true">{{ gameStore.purchaseNotice === 'success' ? '✓' : '○' }}</span>
68 <span class="ew-fg">
69 <template v-if="gameStore.purchaseNotice === 'success'">
70 Payment received — your runs have been added to your balance.
71 </template>
72 <template v-else>Checkout canceled — you weren't charged.</template>
73 </span>
74 <button type="button" data-testid="purchase-notice-close" class="ew-tap ew-hover-fg ml-auto shrink-0"
75 aria-label="Dismiss" @click="gameStore.clearPurchaseNotice()"></button>
76 </div>
77 
78 <!-- Waiting mode: a thin indeterminate sweep while a turn resolves — the whole
79 board reads as "history is being rewritten," in concert with the shaking die. -->
80 <div v-if="gameStore.isLoading" class="ew-loading-bar" role="status">
81 <span class="sr-only">Resolving your move — history is being rewritten…</span>
82 </div>
83 
84 <!-- At lg the board owns the viewport and scrolls inside its own panes, so main is
85 clipped (lg:overflow-hidden). The mission briefing has no such inner scroll, so
86 on a short viewport that clipping hid the Begin button under the footer — give
87 the briefing a scrollable main instead. -->
88 <main class="px-5 py-6 pb-24 md:pb-6 flex-1"
89 :class="gameStore.currentObjective ? 'lg:min-h-0 lg:py-0 lg:overflow-hidden' : 'lg:overflow-y-auto'">
90 <!-- Briefing: choose (or compose) an objective before the run begins -->
91 <MissionSelect v-if="!gameStore.currentObjective" />
92 
93 <template v-else>
94 <!-- The board, only while the run is live — once it ends, the end-screen
95 takeover fully replaces it (no board bleeding under the verdict).
96 On phones the three zones become one-at-a-time panels driven by the
97 bottom tab bar; from md up every zone is shown together as before. -->
98 <template v-if="gameStore.gameStatus === 'playing'">
99 <!-- THE WORKSPACE — at lg the board splits into two panes that fit the viewport:
100 a scrolling read-model (roll · shift · Spine · Chronicle · Thread) on the
101 left, and the act-model (the compose dock) pinned on the right, so the move
102 is never buried below the read. Below lg this wrapper is inert and the zones
103 fall back to the stacked page (md) / phone tab panels (sm). -->
104 <div class="ew-fade-in lg:h-full lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(360px,440px)] lg:grid-rows-1">
105 <!-- LEFT pane — the read-model. At lg it's a flex column the height of the
106 pane: the board/Spine on top at its natural height, then the story zone
107 flexes to fill the rest, so the Chronicle & Thread are full-height panels
108 (each scrolls inside itself) rather than a single scrolling stack. -->
109 <div class="lg:min-h-0 lg:min-w-0 lg:flex lg:flex-col lg:overflow-hidden lg:pr-6 lg:py-6">
110 <!-- ZONE 1 — the board state: the roll + the shift, then the Spine -->
111 <section class="ew-panel space-y-4 lg:space-y-6 lg:flex-none" :class="[{ 'ew-dim': isFirstTurn }, mobileTab === 'board' ? '' : 'hidden', 'md:block']">
112 <div class="grid sm:grid-cols-2 gap-4">
113 <div class="sm:border-r ew-line sm:pr-6 py-1">
114 <span class="ew-label mb-2 block">Dice of fate</span>
115 <DiceRoller />
116 <!-- The bands, disclosed: the player can always see how fate maps to
117 swing — the same table the Timeline Engine is instructed with. -->
118 <details data-testid="roll-legend" class="mt-2 text-[11px]">
119 <summary class="ew-faint cursor-pointer select-none hover:underline">how fate is weighed</summary>
120 <ul class="mt-1.5 space-y-0.5 ew-mono ew-muted">
121 <li v-for="row in rollLegend" :key="row.label" class="flex justify-between gap-3">
122 <span>{{ row.range }} · {{ row.label }}</span><span :class="row.cls">{{ row.swing }}</span>
123 </li>
124 </ul>
125 <p class="ew-faint mt-1.5 leading-snug">✒ craft tilts the roll (±2) · ⚡ anachronism widens the result — gains gently, losses hard · ⚑ a staked final dispatch doubles everything</p>
126 </details>
127 </div>
128 <div class="sm:pl-2 flex flex-col justify-center gap-3">
129 <ProgressTracker />
130 <MomentumMeter />
131 </div>
132 </div>
133 <TimelineLedger />
134 </section>
135 
136 <!-- ZONE 2 — the story: the living chronicle · the conversation thread. At lg
137 this flexes to fill the rest of the pane and lays the two out side by side
138 as equal full-height columns (md keeps the two-up grid in the page flow). -->
139 <section class="ew-panel mt-9 lg:mt-10 gap-x-8 gap-y-2" :class="[{ 'ew-dim': isFirstTurn }, mobileTab === 'story' ? 'grid' : 'hidden', 'md:grid md:grid-cols-2', 'lg:grid-rows-1 lg:flex-1 lg:min-h-0']">
140 <Chronicle :force-open="isMd" />
141 <!-- Thread: a <div> from md up (see Chronicle for why a <details> can't fill the
142 column height), a collapsible <details> on phones. -->
143 <component :is="isMd ? 'div' : 'details'" class="ew-rail lg:h-full lg:min-h-0 lg:flex lg:flex-col" :open="isMd ? null : threadOpen" @toggle="onThreadToggle">
144 <summary class="ew-summary" :class="{ 'ew-summary--static': isMd }">
145 <span class="ew-label">Thread<span class="normal-case tracking-normal ew-faint font-normal"> · your messages<span v-if="gameStore.activeFigureName"> · {{ gameStore.activeFigureName }}</span></span></span>
146 </summary>
147 <div class="pt-2 lg:flex-1 lg:min-h-0 lg:overflow-y-auto">
148 <MessageHistory data-testid="message-history" />
149 </div>
150 </component>
151 </section>
152 </div><!-- /LEFT pane -->
153 
154 <!-- RIGHT pane — the act-model, pinned beside the read column at lg -->
155 <div class="lg:min-h-0 lg:overflow-y-auto lg:border-l ew-line lg:pl-6 lg:py-6">
156 <!-- ZONE 3 — your turn: the compose dock (the page-stack divider is dropped at
157 lg, where it's a column of its own, not a section below the read). -->
158 <section class="ew-panel mt-9 border-t ew-line pt-5 lg:mt-0 lg:border-t-0 lg:pt-0" :class="[mobileTab === 'compose' ? '' : 'hidden', 'md:block']">
159 <div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
160 <span class="ew-label">Send a message into the past</span>
161 <!-- The wager, read BEFORE the roll: when the Archive has stamped a
162 known-since and a contact year is chosen, the chip translates the
163 reach into the same ⚡ tiers the spine uses. Otherwise the general
164 principle is stated — on the first turn too, where it teaches most. -->
165 <span v-if="wagerRead" data-testid="wager-chip" class="text-[11px]"
166 :class="wagerRead.tier === 'in-period' ? 'ew-muted' : 'ew-warn'">
167 <span aria-hidden="true">{{ wagerRead.pips }}</span> {{ wagerRead.text }}
168 </span>
169 <span v-else data-testid="wager-line" class="text-[11px] ew-warn"><span aria-hidden="true"></span> the further your words reach beyond their era, the wider the timeline swings — gains gently, losses hard</span>
170 <!-- The chain, read BEFORE the roll: how far this moment lands from your
171 nearest foothold (the objective's era or a change you've made). A
172 lone leap into the deep past is diffuse (warned); landing on a
173 foothold is at-hinge, full force (a positive read) — so good
174 chaining is confirmed, not silent. -->
175 <span v-if="chainRead" data-testid="chain-chip" class="text-[11px]"
176 :class="chainRead.tier === 'at-hinge' ? 'ew-ok' : chainRead.tier === 'diffuse' ? 'ew-warn' : 'ew-muted'">
177 <span aria-hidden="true"></span> {{ chainRead.text }}
178 </span>
179 </div>
180 
181 <p v-if="isFirstTurn" class="ew-accent text-sm mb-4">
182 Reach someone in history, write up to 160 characters, and send — one message can bend the whole timeline.
183 </p>
184 
185 <div class="grid md:grid-cols-2 lg:grid-cols-1 gap-x-8 gap-y-6">
186 <div>
187 <span class="ew-label block mb-2"><span class="ew-accent">1</span> · choose who &amp; when</span>
188 <FigurePicker v-model="figure" :disabled="!gameStore.canSendMessage" />
189 </div>
190 
191 <div class="space-y-3 ew-line lg:border-t lg:pt-6">
192 <span class="ew-label block mb-2"><span class="ew-accent">2</span> · write &amp; send</span>
193 <MessageInput ref="messageInputRef" />
194 <!-- The last stand: offered only when the final dispatch can no
195 longer win inside the normal per-turn cap — a true last resort,
196 never a free doubling from a winnable position. -->
197 <div v-if="gameStore.canStake" data-testid="stake-control"
198 class="border rounded-sm px-2.5 py-2 flex items-start gap-2"
199 :class="stakeArmed ? 'stake-armed' : 'ew-line'">
200 <input id="stake-toggle" v-model="stakeArmed" type="checkbox" class="mt-0.5"
201 :style="{ accentColor: 'var(--ew-accent)' }" />
202 <label for="stake-toggle" class="text-[11px] leading-snug cursor-pointer">
203 <span class="ew-warn font-semibold uppercase tracking-wide"><span aria-hidden="true"></span> Stake the timeline</span>
204 <span class="ew-muted block">Your final dispatch cuts twice as deep — both ways — and can move the whole meter. The last stand.</span>
205 </label>
206 </div>
207 <div class="flex items-center gap-3 flex-wrap">
208 <SendButton data-testid="send-button" :input-valid="canSend" @click="handleSendMessage" />
209 <div v-if="gameStore.error" data-testid="error-alert" role="status" aria-live="polite"
210 class="flex items-center gap-2 text-xs flex-1 min-w-0 border-l-2 ew-line pl-2 py-0.5">
211 <span class="ew-label shrink-0">notice</span>
212 <span class="ew-muted truncate">{{ gameStore.error }}</span>
213 <button type="button" data-testid="error-close" class="ew-tap ew-hover-fg shrink-0 ml-auto" aria-label="Dismiss notice" @click="gameStore.setError(null)"></button>
214 </div>
215 <div v-if="gameStore.moderationNotice" data-testid="moderation-alert" role="alert" aria-live="assertive"
216 class="flex items-start gap-2 text-xs flex-1 min-w-0 border-l-2 border-red-500/70 bg-red-500/5 pl-2 py-1 text-red-300">
217 <span class="shrink-0 font-semibold uppercase tracking-wide">⚠ blocked</span>
218 <span class="min-w-0">{{ gameStore.moderationNotice }}</span>
219 <button type="button" data-testid="moderation-close" class="ew-tap shrink-0 ml-auto hover:text-red-100" aria-label="Dismiss" @click="gameStore.clearModerationNotice()"></button>
220 </div>
221 </div>
222 <ArchiveLookup />
223 </div>
224 </div>
225 </section>
226 </div><!-- /RIGHT pane -->
227 </div><!-- /workspace -->
228 </template>
229 </template>
230 </main>
231 
232 <!-- Phone-only tab bar: the three zones as fixed panels you switch between, so the
233 board never becomes one infinite scroll and your next move is always one tap
234 away. Hidden from md up (where every zone is shown at once). -->
235 <!-- A labelled nav of view-switch buttons (NOT an ARIA tablist: there's no roving
236 tabindex / arrow-key tabset here, and the zones are simple show/hide regions).
237 The active view is conveyed with aria-current, the honest, complete pattern. -->
238 <nav v-if="gameStore.currentObjective && gameStore.gameStatus === 'playing'"
239 class="md:hidden fixed bottom-0 inset-x-0 z-30 border-t ew-line ew-bg grid grid-cols-3"
240 aria-label="Switch board view">
241 <button v-for="t in mobileTabs" :key="t.id" type="button" :aria-current="mobileTab === t.id ? 'true' : undefined"
242 class="mobile-tab ew-press" :class="{ 'is-active': mobileTab === t.id }" @click="mobileTab = t.id">
243 <span class="text-lg leading-none" aria-hidden="true">{{ t.glyph }}</span>
244 <span class="ew-label flex items-center gap-1">
245 {{ t.label }}
246 <span v-if="t.id === 'compose' && yourMove" class="ew-dot ew-dot--accent" aria-hidden="true" />
247 </span>
248 </button>
249 </nav>
250 
251 <!-- A ledger running-foot: header + footer frame the page as a document.
252 (Hidden on phones, where the tab bar is the foot.) -->
253 <footer class="border-t ew-line px-5 py-3 hidden md:block text-[11px] ew-faint">
254 <div class="flex items-center gap-2">
255 <span class="ew-mono uppercase tracking-wide">Everwhen</span>
256 <span class="ew-hair-c" aria-hidden="true">·</span>
257 <span class="ew-mono">a history you are writing</span>
258 <span class="ml-auto ew-serif italic">the past is not fixed</span>
259 </div>
260 <!-- Persistent compliance line (AI disclosure + fiction + adults-only). -->
261 <p data-testid="footer-disclosure" class="mt-1.5">
262 Figures are AI, not real people · replies are AI-generated fiction, not historical fact · for adults (18+)
263 </p>
264 </footer>
265 
266 <EndGameScreen @play-again="performReset" />
267 
268 <!-- The run-packs store: opened from the header/account or automatically when a
269 commit is refused for being out of runs. Mounted once, here. -->
270 <RunPacksModal />
271 
272 <!-- The guided first run (issue #60): a skippable, non-blocking coach-mark that
273 teaches a brand-new player the core loop in context, then gets out of the
274 way. Client-only — it reads localStorage and positions against live rects. -->
275 <ClientOnly><CoachMark /></ClientOnly>
276 
277 <!-- Developer mode (issue #105): a floating panel to traverse stages and force
278 outcomes through the real mechanics. Self-gates on import.meta.dev /
279 public.devMode — tree-shaken out of a normal production build. -->
280 <ClientOnly><DevPanel /></ClientOnly>
281 </div>
282</template>
283 
284<script setup lang="ts">
285/**
286 * Main game page — Everwhen, "The Spine Console" layout.
287 *
288 * A full-bleed board grouped into three super-zones — board-state (roll + shift +
289 * Spine), story (chronicle · thread), and your-turn (the compose dock) — separated
290 * by space, not chrome. State lives in the store; this only arranges it.
291 */
292import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
293import { useGameStore, formatContactYear } from '~/stores/game'
294import { useCoachingStore } from '~/stores/coaching'
295import { useAudioStore } from '~/stores/audio'
296import { useGameSoundscape } from '~/composables/useGameSoundscape'
297import { SWING_BANDS } from '~/utils/swing-bands'
298import { DiceOutcome, CRIT_FAIL_MAX, FAILURE_MAX, NEUTRAL_MAX, CRIT_SUCCESS_MIN } from '~/utils/dice'
299import { parseKnownSinceYear, wagerTier } from '~/utils/known-since'
300import { CHAIN_HINT } from '~/utils/causal-chain'
301import { ANACHRONISM_LABEL } from '~/server/utils/anachronism'
302import { readAuthReturnParams, hasAuthReturn, finalizeAuthReturn } from '~/utils/auth-return'
303 
304const gameStore = useGameStore()
305 
306// The soundscape (issue #92): this one call wires every game transition to its cue
307// (decoupled — the store stays sound-agnostic) and returns the engine handle for the
308// few UI-driven cues. The audio store holds the persisted mute/volume the header
309// control toggles. Both survive resetGame() untouched.
310const audio = useAudioStore()
311const soundscape = useGameSoundscape()
312 
313// The disclosed band table — rendered from the same constants the Timeline
314// Engine is instructed with, so the legend can't lie.
315const fmtSwing = (b: { min: number; max: number }) =>
316 `${b.min >= 0 ? '+' : ''}${b.min}${b.max >= 0 ? '+' : ''}${b.max}%`
317const rollLegend = [
318 { range: `${CRIT_SUCCESS_MIN}–20`, label: 'Critical Success', swing: fmtSwing(SWING_BANDS[DiceOutcome.CRITICAL_SUCCESS]), cls: 'ew-ok' },
319 { range: `${NEUTRAL_MAX + 1}${CRIT_SUCCESS_MIN - 1}`, label: 'Success', swing: fmtSwing(SWING_BANDS[DiceOutcome.SUCCESS]), cls: 'ew-ok' },
320 { range: `${FAILURE_MAX + 1}${NEUTRAL_MAX}`, label: 'Neutral', swing: fmtSwing(SWING_BANDS[DiceOutcome.NEUTRAL]), cls: 'ew-muted' },
321 { range: `${CRIT_FAIL_MAX + 1}${FAILURE_MAX}`, label: 'Failure', swing: fmtSwing(SWING_BANDS[DiceOutcome.FAILURE]), cls: 'ew-warn' },
322 { range: `1–${CRIT_FAIL_MAX}`, label: 'Critical Failure', swing: fmtSwing(SWING_BANDS[DiceOutcome.CRITICAL_FAILURE]), cls: 'ew-bad' }
324 
325// The pre-send wager chip: Archive known-since × chosen contact year → ⚡ tier.
326const wagerRead = computed(() => {
327 const lookup = gameStore.archiveResult
328 const when = gameStore.contactWhen
329 if (!lookup?.knownSince || when == null) return null
330 const knownYear = parseKnownSinceYear(lookup.knownSince)
331 if (knownYear == null) return null
332 const tier = wagerTier(knownYear, when)
333 const pips = tier === 'impossible' ? '⚡⚡⚡' : tier === 'far-ahead' ? '⚡⚡' : tier === 'ahead' ? '⚡' : '✓'
334 // Hedged copy: this is a year-gap compass, not the engine's verdict.
335 const text = tier === 'in-period'
336 ? `${lookup.topic}: already known by ${formatContactYear(when)} — steady ground`
337 : `${lookup.topic}: reads ≈ ${ANACHRONISM_LABEL[tier].toLowerCase()} for ${formatContactYear(when)} — a wider swing, losses harder than gains`
338 return { tier, pips, text }
339})
340 
341// The pre-send chain chip: how far the chosen moment lands from the nearest
342// foothold (the objective's era or a prior change). Surfaced for every tier —
343// at-hinge reads as a win (full force), diffuse as a caution — so good chaining
344// gets the same confirmation a caution does, not silence. A null status now
345// means only "no chain to read" (anchorless objective / no footholds yet).
346const chainRead = computed(() => {
347 const s = gameStore.causalChainRead
348 if (!s) return null
349 return { tier: s.tier, text: CHAIN_HINT[s.tier] }
350})
351 
352// The last stand, armed by the player on their final dispatch. Arming it is the
353// wager committed — a tension swell marks the doubling.
354const stakeArmed = ref(false)
355watch(stakeArmed, (armed) => { if (armed) soundscape.play('stake') })
356 
357const figure = ref('')
358watch(figure, (name) => gameStore.setActiveFigure((name || '').trim()))
359 
360const messageInputRef = ref()
361const messageInputValid = computed(() => messageInputRef.value?.isValid ?? false)
362const canSend = computed(() => messageInputValid.value && figure.value.trim().length > 0 && gameStore.canContact)
363 
364// The very first turn: the read-model is empty, so quiet it and lead the eye to the dock.
365const isFirstTurn = computed(() => gameStore.timelineEvents.length === 0)
366 
367// Phone tab bar: the three zones as switchable panels (md+ shows them all at once, so
368// this state is inert there). The run opens on the move; the instant a turn is rolling
369// we flip to the Board so the die + shift + spine land as the payoff beat, then the
370// player taps back to Compose. A nudge dot marks Compose when it's their move.
371type MobileTab = 'board' | 'story' | 'compose'
372const mobileTabs = [
373 { id: 'board', glyph: '🎲', label: 'Board' },
374 { id: 'story', glyph: '📖', label: 'Story' },
375 { id: 'compose', glyph: '✎', label: 'Compose' }
376] as const
377const mobileTab = ref<MobileTab>('compose')
378const yourMove = computed(() => gameStore.canSendMessage && !gameStore.isLoading && mobileTab.value !== 'compose')
379watch(() => gameStore.isLoading, (loading) => { if (loading) mobileTab.value = 'board' })
380watch(() => gameStore.currentObjective, (obj) => { if (obj) mobileTab.value = 'compose' })
381 
382// Thread rail (below md): open by default (the figure's replies are the game's most
383// charming content — collapsed-by-default made them too easy to miss). A fold is the
384// player's explicit choice now, so nothing force-reopens it. From md up it's forced
385// open as a full panel beside the Chronicle (isMd), so its toggle is inert there —
386// guard the handler so the native toggle event can't flip our own state.
387const threadOpen = ref(true)
388function onThreadToggle(e: Event) { if (!isMd.value) threadOpen.value = (e.target as HTMLDetailsElement).open }
389 
390// When a run begins, drop the cursor straight into the "who" field.
391function focusFigure() {
392 nextTick(() => (document.querySelector('[data-testid="figure-input"]') as HTMLElement | null)?.focus())
394watch(() => gameStore.currentObjective, (obj) => { if (obj) focusFigure() })
395 
396// Lock body scroll while the end-screen takeover is up (so the board can't peek).
397watch(() => gameStore.gameStatus, (s) => {
398 if (typeof document === 'undefined') return
399 document.body.style.overflow = (s === 'victory' || s === 'defeat') ? 'hidden' : ''
400})
401 
402// md+ breakpoint — from md up every zone is shown together, so the Chronicle is
403// forced open as the prose payoff rather than a tap-to-open rail (below md it stays
404// a collapsible rail in the Story tab); tracked reactively.
405const isMd = ref(false)
406let mdMql: MediaQueryList | null = null
407function syncMd() { isMd.value = mdMql?.matches ?? false }
408onMounted(() => {
409 mdMql = window.matchMedia('(min-width: 768px)')
410 syncMd()
411 mdMql.addEventListener('change', syncMd)
412})
413onUnmounted(() => { mdMql?.removeEventListener('change', syncMd) })
414 
415// Load the device's run balance for the header gauge, and handle the return from
416// Stripe Checkout: ?purchase=success|cancel drives the banner + a balance refresh,
417// then the query is stripped so a later refresh can't re-fire it.
418const route = useRoute()
419const router = useRouter()
420const supabase = useSupabaseClient()
421onMounted(async () => {
422 // A magic-link / email-change sign-in lands back here carrying an artifact to
423 // consume (?code= the Supabase client auto-exchanges; ?token_hash= we verify;
424 // ?error= means the link failed). On success the anonymous account upgrades in
425 // place — same id, runs carried over — so re-read the balance (the account view
426 // is server-derived) and strip the query so a reload can't re-fire a spent link.
427 const authReturn = readAuthReturnParams(route.query)
428 if (hasAuthReturn(authReturn)) {
429 const result = await finalizeAuthReturn(authReturn, {
430 settle: async () => {
431 const { data } = await supabase.auth.getSession()
432 return Boolean(data.session && !data.session.user.is_anonymous)
433 },
434 verifyOtp: (params) => supabase.auth.verifyOtp(params)
435 })
436 if (result.status === 'error') {
437 console.warn('[auth] sign-in link could not be finalized:', authReturn.error, authReturn.errorDescription)
438 }
439 await gameStore.loadBalance()
440 router.replace({ query: {} })
441 } else {
442 const purchase = route.query.purchase
443 if (purchase === 'success' || purchase === 'cancel') {
444 await gameStore.notePurchaseReturn(purchase)
445 router.replace({ query: {} })
446 // The webhook credits asynchronously and can lag Stripe's redirect, so the
447 // first read may predate the credit. Poll briefly so the header gauge
448 // converges to the credited total without a manual reload — stopping as soon
449 // as it changes, or after a few tries (the credit is guaranteed by the
450 // webhook + its retries regardless). The banner asserts no specific count.
451 if (purchase === 'success') {
452 const before = gameStore.runsRemaining
453 for (let i = 0; i < 4; i++) {
454 await new Promise((r) => setTimeout(r, 1500))
455 await gameStore.loadBalance()
456 if (gameStore.runsRemaining !== before) break
457 }
458 }
459 } else {
460 await gameStore.loadBalance()
461 }
462 }
463 
464 // Resume an in-progress run (#152) — once the balance load + any magic-link return
465 // have settled (a magic-link sign-in carries an anonymous run over to the account),
466 // and only when nothing is already on the board. Auto-resume: the player drops back
467 // onto the in-progress board, with the header ↻ "new timeline" as the abandon path.
468 if (!gameStore.currentObjective) await gameStore.resumeDraft()
469})
⋯ 156 lines hidden (lines 470–625)
470 
471// Dark-mode toggle via @nuxtjs/color-mode (bundled with @nuxt/ui): persists across
472// reloads, respects the OS preference, and ships a no-flash inline script — replacing
473// the old manual classList toggle that did none of those.
474const colorMode = useColorMode()
475const isDark = computed(() => colorMode.value === 'dark')
476function toggleDark() {
477 colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
479 
480// Sound on/off (issue #92), persisted like the dark toggle. The click is itself a
481// user gesture, so unmuting unlocks the audio context here and chirps a tick to
482// confirm sound is live; muting falls silent (the cue would be inaudible anyway).
483function toggleMute() {
484 audio.toggleMute()
485 if (!audio.muted) {
486 soundscape.unlock()
487 soundscape.play('uiTick')
488 }
490 
491const handleSendMessage = async () => {
492 if (!messageInputRef.value) return
493 const messageText = messageInputRef.value.message?.trim()
494 const target = figure.value.trim()
495 if (!messageText || !messageInputRef.value.isValid || !target) return
496 
497 // Clear the composer only when the turn actually resolved — a blocked or
498 // failed send keeps the player's crafted 160 characters for another try.
499 const resolved = await gameStore.sendMessage(messageText, target, { stake: stakeArmed.value })
500 if (resolved) {
501 stakeArmed.value = false
502 if (messageInputRef.value) messageInputRef.value.message = ''
503 }
505 
506// The header reset arms a confirm when a run is in progress (and auto-disarms);
507// performReset is the single unified reset path — the end screen's Play Again
508// lands here too, so every reset clears the figure input and composer alike.
509const resetArmed = ref(false)
510let resetArmTimer: ReturnType<typeof setTimeout> | null = null
511 
512const handleResetGame = () => {
513 // One tap only when there is truly nothing to lose: no resolved turns AND no
514 // words or contact in progress (a typed first dispatch is work too).
515 const composerDirty = !!messageInputRef.value?.message?.trim() || !!figure.value.trim()
516 if (gameStore.timelineEvents.length === 0 && !composerDirty) {
517 performReset()
518 return
519 }
520 resetArmed.value = true
521 if (resetArmTimer) clearTimeout(resetArmTimer)
522 resetArmTimer = setTimeout(() => { resetArmed.value = false }, 3500)
524 
525function disarmReset() {
526 resetArmed.value = false
527 if (resetArmTimer) clearTimeout(resetArmTimer)
529 
530function performReset() {
531 disarmReset()
532 stakeArmed.value = false
533 gameStore.resetGame()
534 figure.value = ''
535 if (messageInputRef.value) messageInputRef.value.message = ''
536 focusFigure()
538 
539onUnmounted(() => { if (resetArmTimer) clearTimeout(resetArmTimer) })
540 
541// ── Guided first run (issue #60) ──────────────────────────────────────────
542// A teaching layer for a brand-new player's first game. This page is the single
543// orchestration site: it reads the game and drives the coaching store one way,
544// so the store stays game-agnostic and survives resetGame() untouched. The board
545// never blocks — the coach-mark only points; the player plays through it.
546const coaching = useCoachingStore()
547const autoStartCtx = () => ({
548 hasObjective: !!gameStore.currentObjective,
549 isPlaying: gameStore.gameStatus === 'playing',
550 isFirstTurn: gameStore.timelineEvents.length === 0
551})
552onMounted(() => {
553 audio.hydrate()
554 coaching.hydrate()
555 coaching.maybeAutoStart(autoStartCtx())
556})
557// A run beginning cues a brand-new player's first beat; tearing the board down
558// (New timeline) ends a tour left running rather than stranding it over an empty board.
559watch(() => gameStore.currentObjective, (obj) => {
560 if (obj) coaching.maybeAutoStart(autoStartCtx())
561 else coaching.stop()
562})
563// Replays can begin mid-run, so the reveal + overstay beats key off turns resolved
564// SINCE the tour started, not absolute counts (a fresh first run baselines at 0).
565const tourBaseTurns = ref(0)
566watch(() => coaching.isRunning, (running) => {
567 if (running) tourBaseTurns.value = gameStore.timelineEvents.length
568})
569// Auto-advance on the real action. Picking who & when clears the first beat; the
570// reading beats (the dispatch, the wager) advance on the card's own Next; the
571// send beat waits for an actual resolved turn below.
572watch(
573 () => !!gameStore.figureGrounding?.resolved && gameStore.contactWhen != null,
574 (ready) => { if (ready) coaching.advanceFrom('who-when') }
576// The first turn resolved since the tour began is the payoff beat; advanceTo is
577// monotonic, so it also fast-forwards a player who sent before pressing Next on the
578// readers. One turn further and the loop has plainly landed, so a tour still running
579// has overstayed — it bows out on its own (the engaged reader pressed Done already).
580watch(() => gameStore.timelineEvents.length, (n) => {
581 if (!coaching.isRunning) return
582 if (n >= tourBaseTurns.value + 2) coaching.complete()
583 else if (n > tourBaseTurns.value) coaching.advanceTo('roll-swing')
584})
585// On each new beat, bring its host control into view on phones (md+ shows every
586// zone at once, so this is inert there). Only on a real step change — never yanks
587// a player who tapped elsewhere mid-beat back.
588watch(() => coaching.activeStep?.hostTab, (tab) => {
589 if (tab && !isMd.value) mobileTab.value = tab
590})
591 
592useHead({
593 title: 'Everwhen — Rewrite History',
594 meta: [
595 { name: 'description', content: 'Send 160-character messages to anyone in history. A D20 and an AI decide how the timeline bends.' }
596 ]
597})
598</script>
599 
600<style scoped>
601/* Phone tab bar — letterpress, not chrome: a hairline-topped strip, the active tab
602 marked by the one accent and a terracotta top rule. ≥44px tall for the thumb. */
603.mobile-tab {
604 display: flex;
605 flex-direction: column;
606 align-items: center;
607 justify-content: center;
608 gap: 2px;
609 padding: 8px 0 9px;
610 color: var(--ew-faint);
611 border-top: 2px solid transparent;
612 transition: color var(--ew-dur-1) var(--ew-ease), border-color var(--ew-dur-1) var(--ew-ease);
614.mobile-tab .ew-label { color: inherit; }
615.mobile-tab.is-active {
616 color: var(--ew-accent);
617 border-top-color: var(--ew-accent);
619 
620/* The armed last stand — a quiet accent wash, not a flashing alarm. */
621.stake-armed {
622 border-color: var(--ew-accent);
623 background: color-mix(in srgb, var(--ew-accent) 7%, transparent);
625</style>

The read endpoint adds a belt to the autosave's suspenders. Clearing a draft on terminal save is best-effort and fire-and-forget; should it ever miss, a finished run's draft could linger. So the GET refuses to hand back a draft whose run already saved a terminal snapshot — the snapshot store is the source of truth for "done".

Read the subject's latest draft, then refuse it if the run already finished.

server/api/run-draft.get.ts · 24 lines
server/api/run-draft.get.ts24 lines · TypeScript
1/**
2 * GET /api/run-draft — the current subject's latest in-progress draft, for resume
3 * on load (#152). Server-side only and read-safe: the subject (Supabase user id,
4 * else the device id) is derived from the request, never trusted from the client, so
5 * a player only ever resumes their OWN run. Mirrors runs/index.get's subject-scoped read.
6 *
7 * Returns `{ draft: null }` when there's nothing to resume.
8 */
9import { currentSubject } from '~/server/utils/auth'
10import { runDraftStore } from '~/server/utils/run-draft-store'
11import { runSnapshotStore } from '~/server/utils/run-snapshot-store'
12 
13export default defineEventHandler(async (event) => {
14 const subject = await currentSubject(event)
15 const draft = await runDraftStore().getLatestDraft(subject)
16 if (!draft) return { draft: null }
17 // Safety net: a run that already saved a terminal snapshot is FINISHED — never
18 // resume it, even if the draft's clear-on-terminal didn't land (it's a best-effort
19 // fire-and-forget). The snapshot store is the source of truth for "done", so one
20 // owner-scoped lookup makes resume robust against a missed clear.
21 const finished = await runSnapshotStore().getRun(subject, draft.runId)
22 if (finished) return { draft: null }
23 return { draft }
24})

The table and the write/clear endpoints

The migration adds run_drafts: one row per run, run_id PK referencing runs(id), upserted per turn, RLS on with no policy (service-key only) like every other table. An index on (user_id, updated_at desc) serves the "latest draft" read.

supabase/migrations/0008_run_drafts.sql · 22 lines
supabase/migrations/0008_run_drafts.sql22 lines · Transact-SQL
1-- run_drafts: the in-progress run autosave (#152) — the live counterpart to the
2-- immutable run_snapshots record. A snapshot is written ONCE, at victory/defeat; a
3-- draft is overwritten after every still-playing turn so a refresh or a crash can't
4-- lose a half-played run (each turn is a model call, expensive to redo). One row per
5-- run, upserted on the run-id PK; cleared on terminal save and on abandon/reset.
6-- Versioned jsonb so a later shape change can't break an in-flight draft.
7-- RLS on with no policy: service-key-only, like runs / run_snapshots.
8 
9create table if not exists public.run_drafts (
10 run_id uuid primary key references public.runs(id) on delete cascade,
11 user_id text not null,
12 version int not null,
13 data jsonb not null,
14 updated_at timestamptz not null default now()
15);
16 
17alter table public.run_drafts enable row level security;
18 
19-- Resume reads the subject's latest in-progress draft (newest updated_at first); index
20-- that access path so it doesn't sequentially scan every draft.
21create index if not exists run_drafts_user_idx
22 on public.run_drafts (user_id, updated_at desc);

The write and clear routes are thin and mirror the existing run-save / run-share conventions: POST validates with sanitizeDraft, gates the run id to a real uuid (a degraded local id has no runs row to anchor the FK), and upserts under currentSubject; DELETE is uuid-gated, owner-scoped, and idempotent.

server/api/run-draft.post.ts · 38 lines
server/api/run-draft.post.ts38 lines · TypeScript
1/**
2 * POST /api/run-draft — autosave an in-progress run's draft, owned by the account
3 * subject (#152). The client fires this after each resolved-but-still-playing turn;
4 * the server validates + clamps it (it's client-trusted, same posture as send-message)
5 * and upserts it keyed by the run id, so a refresh or a crash can resume the run.
6 *
7 * Best-effort by design: the client fires it fire-and-forget, so a failure degrades
8 * resume, never the turn. A degraded run (a non-uuid local id, never recorded
9 * server-side) has no runs row for the FK — it's accepted as a no-op.
10 */
11import { sanitizeDraft } from '~/server/utils/run-draft'
12import { runDraftStore } from '~/server/utils/run-draft-store'
13import { currentSubject } from '~/server/utils/auth'
14import { isUuid } from '~/server/utils/uuid'
15 
16export default defineEventHandler(async (event) => {
17 if (getMethod(event) !== 'POST') {
18 throw createError({ statusCode: 405, statusMessage: 'Method Not Allowed' })
19 }
20 const body = await readBody(event)
21 const draft = sanitizeDraft(body)
22 if (!draft) {
23 throw createError({ statusCode: 400, statusMessage: 'Bad Request: invalid run draft' })
24 }
25 // A degraded (non-uuid local) run was never recorded server-side — there is no
26 // runs row to anchor the FK, so nothing to draft against.
27 if (!isUuid(draft.runId)) {
28 return { saved: false, degraded: true }
29 }
30 const subject = await currentSubject(event)
31 try {
32 await runDraftStore().saveDraft(subject, draft)
33 } catch (error) {
34 console.error('Failed to save run draft:', error)
35 throw createError({ statusCode: 500, statusMessage: 'Internal Server Error' })
36 }
37 return { saved: true }
38})
server/api/run-draft.delete.ts · 21 lines
server/api/run-draft.delete.ts21 lines · TypeScript
1/**
2 * DELETE /api/run-draft?runId=… — clear one of the current subject's in-progress
3 * drafts (#152): fired on terminal save (the run is finished) and on abandon/reset
4 * (the player started over). Idempotent and privacy-quiet — clearing a draft that
5 * isn't there (or isn't the subject's) is a no-op that still answers ok, so it never
6 * reveals whether the run existed or who owned it. The id is uuid-shape-gated before
7 * any query so a malformed value never hits the DB.
8 */
9import { runDraftStore } from '~/server/utils/run-draft-store'
10import { currentSubject } from '~/server/utils/auth'
11import { isUuid } from '~/server/utils/uuid'
12 
13export default defineEventHandler(async (event) => {
14 const runId = String(getQuery(event).runId ?? '')
15 if (!isUuid(runId)) {
16 throw createError({ statusCode: 404, statusMessage: 'Not Found' })
17 }
18 const subject = await currentSubject(event)
19 await runDraftStore().deleteDraft(subject, runId)
20 return { ok: true }
21})

Verification

The full suite is green: 979 unit tests pass and nuxt typecheck is clean. Four new specs carry the feature — the validator's clamps and coercion, both store adapters plus their Supabase query shapes, the three store actions (autosave / clear / resume, including the replay-seed guard), and the endpoint contract.

Beyond the suite, an adversarial multi-agent review read the branch across security, resume correctness, lifecycle, and contract-consistency lenses, then verified each finding independently. It surfaced one real cross-feature bug — the replay-seed clobber in the section above — which is now fixed and pinned by a test.