What changed, and why

A shared run (#88) already carries its objective in the public projection. This PR lets a viewer play that objective, not just read it: a "Play this objective" button on /r/:token starts a fresh run seeded with the shared run's captured GameObjective, reused verbatim — no model call, no reroll cost, no re-running #71's bounds (the objective was bounded when first composed). That closes a viral loop: play → share → someone replays → shares their run.

The replayed run also records its lineage and shows a one-hop "remixed from" credit. The whole design turns on one decision: lineage is a pointer, not a copy. We store a stable pointer to the parent run and walk it at read time to the parent's current title and opt-in name — so attribution always reflects the source's current privacy choice (un-share → the credit goes anonymous), and no identity is ever copied into the child.

Read the contract first, then the one column it rests on, then the two store methods that write and walk it, then the seam and the UI.

LayerFileWhat it does
Contractutils/run-snapshot.tsRemixCredit + PublicRun.remixedFrom
Schemasupabase/migrations/0007_run_lineage.sqlderived_from_run_id pointer column
Storeserver/utils/run-snapshot-store.tsstamp the pointer · walk one hop
Seamserver/api/run-save.post.ts · stores/game.tscarry the source token · replay state
UIMissionSelect · RunView · EndGameScreen · RemixCreditLine · pages/r/[token].vueseed + render the credit

The contract: a credit that's resolved, not stored

RemixCredit is the one-hop lineage of a replayed run. It is deliberately small: the source's opt-in display name (null when they shared anonymously), the source's objective title, and the source's live share token (for an optional backlink, null once un-shared). It carries no parent run id, no owner, no device. It hangs off PublicRun as an optional remixedFrom — a projection field, exactly like attribution already is, never part of the stored snapshot blob.

RemixCredit + the optional remixedFrom on the public projection.

utils/run-snapshot.ts · 161 lines
utils/run-snapshot.ts161 lines · TypeScript
⋯ 114 lines hidden (lines 1–114)
1/**
2 * The saved-run contract — the immutable, versioned record of a finished run.
3 *
4 * A run is otherwise client-only: the objective, the dispatches, the timeline
5 * ledger, the Chronicle epilogue, and the verdict all live in the Pinia store and
6 * evaporate on reload. This is the persisted form of that end-state: the store builds
7 * it on game end, the server stores it keyed by the run id, and a read-only "your
8 * runs" view replays it. It is a SNAPSHOT, not a live record — in-progress
9 * save/resume is out of scope; a run is short (five messages) and persists at the end.
10 *
11 * Naming: "run" is overloaded. The header gauge's balance ("runs remaining") is a
12 * purchasable CREDIT (the `balances` table); this is a PLAYED run (the game record,
13 * the `run_snapshots` table). Same noun, two states — kept distinct in the schema.
14 *
15 * The shape is VERSIONED (`version`) so a later change can't silently break runs
16 * saved under an older shape: a reader can branch on `version` and migrate. It is a
17 * flat, fully JSON-native projection of the store state — the `Date` timestamps on
18 * the in-memory ledger/thread are dropped (a snapshot needs none of the duration
19 * math), so the stored blob round-trips through jsonb cleanly.
20 *
21 * This module is the SHARED contract (types + version), client-safe by design: it
22 * carries no runtime imports, only type-only ones, so the client store can import the
23 * version + types without pulling any server-only code. The server-side boundary
24 * validator lives separately in `server/utils/run-snapshot.ts`.
25 */
26import type { DiceOutcome } from '~/utils/dice'
27import type { Craft } from '~/utils/craft'
28import type { Anachronism } from '~/server/utils/anachronism'
29import type { GameObjective } from '~/server/utils/objectives'
30import type { ChronicleEntry } from '~/server/utils/openai'
31 
32/** The current saved-run shape. Bump when the shape changes incompatibly; a reader
33 * branches on it to migrate older snapshots rather than mis-reading them. */
34export const RUN_SNAPSHOT_VERSION = 1
35 
36/** Only a finished run is ever saved — the two terminal verdicts. */
37export type RunOutcome = 'victory' | 'defeat'
38 
39/** The valence of a ledger swing (mirrors the store's `Valence`). */
40export type RunValence = 'positive' | 'negative' | 'neutral'
41 
42/** The five efficiency tiers a victory can earn (mirrors the store's rating). */
43export type EfficiencyRating = 'Legendary' | 'Excellent' | 'Great' | 'Good' | 'Standard'
44 
45/** The bit of a roll the read-only view shows: the number and its named outcome. */
46export interface RollMark {
47 diceRoll: number
48 diceOutcome: DiceOutcome
50 
51/** A dispatch keepsake — the player's verbatim message and whom/when it reached. */
52export interface RunDispatch {
53 text: string
54 figure: string
55 era: string
57 
58/** The victory efficiency block (null on defeat) — the same data the end screen shows. */
59export interface RunEfficiency {
60 messagesSaved: number
61 messagesUsed: number
62 efficiencyPercentage: number
63 efficiencyRating: EfficiencyRating
64 isEarlyVictory: boolean
66 
67/** One ledger entry, trimmed to the fields a read-only run view renders (the live
68 * `TimelineEvent` minus its `Date` timestamp and the internals the view ignores). */
69export interface RunLedgerEntry {
70 id: string
71 figureName: string
72 era: string
73 headline: string
74 detail: string
75 diceRoll: number
76 diceOutcome: DiceOutcome
77 progressChange: number
78 /** The pre-amplifier swing — the Spine reads it to show the Δ equation
79 * (`base → final`). Absent on turns that didn't amplify (none to disclose). */
80 baseProgressChange?: number
81 valence: RunValence
82 craft?: Craft
83 anachronism?: Anachronism
84 /** True when this was the run's staked last stand — the Spine's ⚑ badge. */
85 staked?: boolean
87 
88/** The full saved run — everything a read-only view needs to replay the end screen. */
89export interface RunSnapshot {
90 version: number
91 runId: string
92 status: RunOutcome
93 objective: GameObjective
94 objectiveProgress: number
95 messagesUsed: number
96 totalMessages: number
97 bestRoll: RollMark | null
98 worstRoll: RollMark | null
99 efficiency: RunEfficiency | null
100 dispatches: RunDispatch[]
101 timeline: RunLedgerEntry[]
102 chronicle: ChronicleEntry | null
104 
105/** The list projection — what the "your runs" list shows per run, without pulling
106 * the full snapshot blob for every row. */
107export interface RunSummary {
108 runId: string
109 status: RunOutcome
110 title: string
111 progress: number
112 createdAt: string
114 
115/**
116 * "Remixed from" credit (issue #89) — the one-hop lineage of a replayed run: the run it
117 * was seeded from. It is a POINTER WALKED at render time (never a copy stamped into the
118 * child), so it always reflects the source's CURRENT privacy choice — a later un-share
119 * drops `attribution` back to anonymous and `sourceToken` to null. It carries only public
120 * game data + the opt-in display name: never the parent's run id, owner, or device.
121 */
122export interface RemixCredit {
123 /** The source sharer's opt-in display name, or null when they shared anonymously. */
124 attribution: string | null
125 /** The source run's objective title — the objective this run is a replay of. */
126 objectiveTitle: string
127 /** The source's live share token for an optional backlink, or null when un-shared. */
128 sourceToken: string | null
130 
131/**
132 * The PUBLIC, shareable projection of a saved run (issue #88) — the sanitized form
133 * served at an unguessable share URL to anyone, no login. It carries the GAME RECORD
134 * (verdict, ledger, dispatches, Chronicle epilogue) and an optional self-chosen display
135 * name, and DELIBERATELY nothing that identifies the player: no user id, no device id,
136 * no email — and not even the run id (the share token is the only handle). `run.runId`
137 * is blanked for that reason; the read-only view never renders it.
138 */
139export interface PublicRun {
140 run: RunSnapshot
141 /** The sharer's opt-in display name, or null when shared anonymously. */
142 attribution: string | null
143 /** When this run is itself a replay (issue #89), the one-hop "remixed from" credit;
144 * null/absent for an original run. Walked from the parent's live snapshot, so it
145 * honors the source's current privacy opt-in. */
146 remixedFrom?: RemixCredit | null
148 
⋯ 13 lines hidden (lines 149–161)
149/** What the OWNER sees about a run's share status: the live token (null = not shared)
150 * and the current display name. Owner-only — never part of the public projection. */
151export interface ShareState {
152 token: string | null
153 name: string | null
155 
156/** The brag line a share prefills (the X intent + the native sheet) — one source so the
157 * end screen, the saved-run page, and the public page never drift (issue #88). */
158export function buildShareText(objectiveTitle: string, status: RunOutcome, progress: number): string {
159 const verb = status === 'victory' ? 'I rewrote history' : 'I tried to rewrite history'
160 return `${verb} in Revisionist: ${objectiveTitle || 'a historical mission'}${progress}% rewritten.`

One nullable column carries the lineage

The pointer is a single nullable column on run_snapshots: derived_from_run_id, a foreign key to runs(id). It points at the parent run (null = an original, not a replay). ON DELETE SET NULL means a child outlives a deleted parent — the link just goes quiet, never a dangling reference — and the index serves the later "every run remixed from X" spread query.

Why the pointer is a stable run id, not the revocable share token.

supabase/migrations/0007_run_lineage.sql · 20 lines
supabase/migrations/0007_run_lineage.sql20 lines · Transact-SQL
1-- Run lineage / replay (issue #89): a replayed run is a fresh run seeded with a shared
2-- run's captured objective — same objective, new player, new dispatches. Stamp the child
3-- with a pointer back to the run it was remixed from, so a "remixed from" credit can be
4-- shown (walking ONE hop to the parent's current title + opt-in share name) and the
5-- spread of a custom objective can be traced later.
6--
7-- derived_from_run_id — the PARENT run this one was remixed from (null = an original,
8-- not a replay). A STABLE pointer to runs.id, not the parent's
9-- share token (tokens can be revoked / re-minted): the credit is
10-- walked at render time from the parent's live snapshot row, so a
11-- later un-share correctly drops the name back to anonymous.
12-- ON DELETE SET NULL — a child outlives a deleted parent (the
13-- lineage link simply goes quiet), never a dangling reference.
14alter table public.run_snapshots
15 add column if not exists derived_from_run_id uuid references public.runs(id) on delete set null;
16 
17-- Index the pointer so "every run remixed from X" (the spread / viral-tree query, a
18-- later nicety) doesn't sequentially scan every saved run.
19create index if not exists run_snapshots_derived_from_idx
20 on public.run_snapshots (derived_from_run_id);

Writing the pointer — stamped once, never nulled

Lineage is stamped at save time. The client only ever holds the public share token, so saveRun resolves it server-side to the parent's stable run id and writes that. Two safeguards make this robust: it guards self-derivation (parentId !== snapshot.runId), and — the key fix — it sets the column only on a successful resolve and never writes null. So a replay whose source un-shares mid-run keeps the pointer an earlier save stamped, and an ordinary run's upsert is byte-identical to before (the column is simply absent).

Supabase saveRun: resolve the token to a stable id; stamp on success, never null.

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

Reading the credit — walk exactly one hop

getPublicRun reads the run by token (as before), then walks the pointer one hop: fetch the parent's row by run_id and project its current title, share name, and share token into a RemixCredit. toRemixCredit is the single place that decides anonymity — a blank/absent name becomes null ("an anonymous run" in the UI), and the token is carried only for the backlink. The owner-scoped reads (getRun, listRuns) never select the column, so the pointer stays server-side.

toRemixCredit (the anonymity rule) · resolveRemixCredit (the hop) · getPublicRun wiring.

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

The seam: carrying the source token through a run

The token rides from the shared page to the save as a sibling of the snapshot, never inside the immutable blob. On the client, a replay is held in replayState — the seed objective, the source token, and the source's name at seed time. preseedReplay resets first (the share page may follow an abandoned run in the same tab) then records the seed; it survives chooseObjective so it can stamp lineage at save, and is cleared by resetGame so a fresh run carries none of it.

ReplaySeed + preseedReplay / clearReplayState.

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

At save time the store appends remixedFromToken only when replayState is set, and the route uuid-gates it before handing it to saveRun. Best-effort throughout: a malformed or unresolved token is simply ignored.

saveRunSnapshot: the token rides alongside the snapshot, not within it.

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

The route reads the sibling field, uuid-gates it, passes it through.

server/api/run-save.post.ts · 42 lines
server/api/run-save.post.ts42 lines · TypeScript
⋯ 28 lines hidden (lines 1–28)
1/**
2 * POST /api/run-save — persist a finished run's snapshot, owned by the account
3 * subject. The client posts the end-of-run snapshot on victory/defeat; the server
4 * validates it, then stores it keyed by the run id so it survives reload and seeds
5 * the read-only "your runs" view.
6 *
7 * Best-effort by design: the client fires this without blocking the end screen, so a
8 * failure here degrades history, never the game. A degraded run (a non-uuid local id,
9 * never recorded server-side) has nothing to own — it's accepted as a no-op.
10 */
11import { sanitizeSnapshot } from '~/server/utils/run-snapshot'
12import { runSnapshotStore } from '~/server/utils/run-snapshot-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 snapshot = sanitizeSnapshot(body)
22 if (!snapshot) {
23 throw createError({ statusCode: 400, statusMessage: 'Bad Request: invalid run snapshot' })
24 }
25 // A degraded (non-uuid local) run was never recorded server-side — nothing to own.
26 if (!isUuid(snapshot.runId)) {
27 return { saved: false, degraded: true }
28 }
29 // Replay lineage (issue #89): a sibling field, NOT part of the snapshot blob — the
30 // source run's public share token, which the server resolves to the parent's stable
31 // run id. uuid-gated so a malformed value is simply ignored (lineage is best-effort).
32 const rawToken = typeof body?.remixedFromToken === 'string' ? body.remixedFromToken : ''
33 const derivedFromToken = isUuid(rawToken) ? rawToken : undefined
34 const subject = await currentSubject(event)
35 try {
36 await runSnapshotStore().saveRun(subject, snapshot, { derivedFromToken })
37 } catch (error) {
⋯ 5 lines hidden (lines 38–42)
38 console.error('Failed to save run snapshot:', error)
39 throw createError({ statusCode: 500, statusMessage: 'Internal Server Error' })
40 }
41 return { saved: true }
42})

The UI: a focused replay, and one credit line everywhere

MissionSelect grows a replay mode: when replayState is set it shows just the seeded objective, ready to play, with the credit above it — not the full browse/compose briefing. begin() commits the seeded objective verbatim, so replay runs no generation. "Choose a different objective" drops the seed, so an opted-out run is never mislabeled.

The replay block, and begin() preferring the seeded objective.

components/MissionSelect.vue · 242 lines
components/MissionSelect.vue242 lines · Vue
⋯ 4 lines hidden (lines 1–4)
1<template>
2 <!-- Mission briefing: choose (or compose) the run's grand objective, as a ledger. -->
3 <div data-testid="mission-select" class="rv-fade-in max-w-4xl mx-auto">
4 <!-- Replay (issue #89): seeded from a shared run's objective. A focused view — this
5 one objective, ready to play — not the full browse/compose ledger. -->
6 <template v-if="replay">
7 <div class="mb-7">
8 <p class="rv-label">Replay a shared objective</p>
9 <h2 class="rv-serif rv-fg text-2xl sm:text-3xl font-semibold mt-1.5 leading-tight">Take on this very objective</h2>
10 <RemixCreditLine :credit="replayCredit" class="mt-2" />
11 </div>
12 
13 <div data-testid="replay-objective" class="border-t border-b rv-line py-4 flex items-start gap-3 rv-tint"
14 :style="{ borderLeft: '2px solid var(--rv-accent)' }">
15 <span class="w-6 shrink-0 text-center text-lg leading-none select-none" aria-hidden="true">{{ replay.objective.icon }}</span>
16 <span class="min-w-0 flex-1">
17 <span class="flex items-center gap-2 flex-wrap">
18 <h3 class="rv-fg font-semibold">{{ replay.objective.title }}</h3>
19 <span v-if="replay.objective.era" class="rv-label">{{ replay.objective.era }}</span>
20 </span>
21 <!-- whitespace-pre-line: the brief is newline-separated "• " bullet lines (#82). -->
22 <span class="block rv-muted text-sm mt-0.5 leading-relaxed whitespace-pre-line">{{ replay.objective.description }}</span>
23 </span>
24 </div>
25 
26 <div class="mt-5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
27 <button type="button" data-testid="replay-exit" class="rv-tap rv-hover-fg text-xs rv-mono uppercase tracking-wide"
28 @click="exitReplay">
29 ← Choose a different objective
30 </button>
31 <button type="button" data-testid="begin-button" class="rv-btn rv-btn--primary" :disabled="beginning" @click="begin">
32 {{ beginning ? 'Charting the timeline…' : 'Play this objective' }} <span aria-hidden="true"></span>
33 </button>
34 </div>
35 </template>
⋯ 194 lines hidden (lines 36–229)
36 
37 <!-- Normal briefing: choose a curated objective, or compose a fresh one. -->
38 <template v-else>
39 <div class="mb-7">
40 <p class="rv-label">Choose your crusade</p>
41 <h2 class="rv-serif rv-fg text-2xl sm:text-3xl font-semibold mt-1.5 leading-tight">What will you set right?</h2>
42 <p class="rv-muted text-sm mt-2 max-w-xl">
43 Five messages. Anyone in history. One world to remake. Pick the cause that will define your run — or have a fresh one composed.
44 </p>
45 </div>
46 
47 <!-- Objectives as borderless hairline rows (a freshly composed one leads) -->
48 <div role="radiogroup" aria-label="Choose an objective" class="border-t rv-line">
49 <button v-for="obj in options" :key="(obj === fresh ? 'fresh:' : 'curated:') + obj.title" type="button"
50 data-testid="objective-option" role="radio" :aria-checked="isSelected(obj)"
51 class="w-full text-left flex items-start gap-3 py-3 pr-2 pl-3 border-b rv-line transition rv-hover"
52 :class="isSelected(obj) ? 'rv-tint' : ''"
53 :style="{ borderLeft: `2px solid ${isSelected(obj) ? 'var(--rv-accent)' : 'transparent'}` }" @click="select(obj)">
54 <span class="rv-dot mt-1.5" :class="isSelected(obj) ? 'rv-dot--accent' : 'rv-dot--hollow'" aria-hidden="true" />
55 <span class="w-6 shrink-0 text-center text-lg leading-none select-none" aria-hidden="true">{{ obj.icon }}</span>
56 <span class="min-w-0 flex-1">
57 <span class="flex items-center gap-2 flex-wrap">
58 <h3 class="rv-fg font-semibold">{{ obj.title }}</h3>
59 <span v-if="obj.era" class="rv-label">{{ obj.era }}</span>
60 <span v-if="obj === fresh" data-testid="fresh-badge" class="rv-accent text-[10px] uppercase tracking-wider">
61 ✨ freshly composed
62 </span>
63 </span>
64 <!-- whitespace-pre-line: the brief is newline-separated "• " bullet lines (issue #82). -->
65 <span class="block rv-muted text-sm mt-0.5 leading-relaxed whitespace-pre-line">{{ obj.description }}</span>
66 </span>
67 </button>
68 </div>
69 
70 <!-- Steer a fresh composition: two closed-enum dials, set before composing.
71 "No preference" leaves an axis to the model. Closed enums only (no free
72 text), so an out-of-bounds objective can't be steered into existence. -->
73 <fieldset class="mt-6 pt-4 border-t rv-line">
74 <legend class="rv-label">Steer a fresh composition <span class="rv-faint">· optional</span></legend>
75 <div class="mt-2 flex flex-col sm:flex-row gap-3 sm:gap-4">
76 <label class="flex-1 min-w-0 text-sm">
77 <span class="rv-label block mb-1">Era</span>
78 <select v-model="steerEra" data-testid="steer-era" class="rv-select text-sm" aria-label="Steer the era">
79 <option value="">No preference</option>
80 <option v-for="era in OBJECTIVE_ERAS" :key="era" :value="era">{{ era }}</option>
81 </select>
82 </label>
83 <label class="flex-1 min-w-0 text-sm">
84 <span class="rv-label block mb-1">Theme</span>
85 <select v-model="steerTheme" data-testid="steer-theme" class="rv-select text-sm" aria-label="Steer the theme">
86 <option value="">No preference</option>
87 <option v-for="theme in OBJECTIVE_THEMES" :key="theme" :value="theme">{{ theme }}</option>
88 </select>
89 </label>
90 </div>
91 </fieldset>
92 
93 <!-- Actions: compose a fresh objective · begin the run -->
94 <div class="mt-5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
95 <div class="flex flex-col items-start gap-1">
96 <button type="button" data-testid="compose-objective" class="rv-btn text-sm" :disabled="composing" @click="rollFresh">
97 <span aria-hidden="true"></span> {{ composing ? 'Composing a fresh fate…' : 'Compose a fresh objective with AI' }}
98 </button>
99 <p v-if="activeSteer" data-testid="active-steer" class="text-xs rv-muted">Steering: {{ activeSteer }}</p>
100 <p v-if="composeError" data-testid="compose-error" class="text-xs rv-bad">{{ composeError }}</p>
101 </div>
102 
103 <button type="button" data-testid="begin-button" class="rv-btn rv-btn--primary" :disabled="!selected || beginning" @click="begin">
104 {{ beginning ? 'Charting the timeline…' : 'Begin rewriting history' }} <span aria-hidden="true"></span>
105 </button>
106 </div>
107 </template>
108 
109 <!-- Paywall: the free run is spent. The run-packs modal opens automatically on
110 the refused commit; this persistent notice lets the player reopen it if they
111 dismissed it. -->
112 <div v-if="gameStore.outOfRuns" data-testid="paywall" class="mt-4 pt-4 border-t rv-line">
113 <p class="text-sm rv-fg font-semibold">You're out of runs — your free run is spent.</p>
114 <p class="rv-muted text-xs mt-0.5">Grab a pack to keep rewriting history. One-time, never expires.</p>
115 <button type="button" data-testid="open-packs" class="rv-btn rv-btn--primary mt-3"
116 @click="gameStore.openBuyModal()">
117 See run packs <span aria-hidden="true"></span>
118 </button>
119 </div>
120 
121 <!-- Capacity: the global spend cap paused new runs (in-progress runs continue). -->
122 <p v-else-if="gameStore.atCapacity" data-testid="at-capacity" class="text-sm rv-bad mt-3">
123 The timeline is at capacity right now — please try again shortly.
124 </p>
125 
126 <!-- Compliance disclosure, shown at the start of every run (Anthropic Usage
127 Policy: AI disclosure at session start; outputs may be inaccurate; an
128 adults-only posture). -->
129 <p data-testid="ai-disclosure" class="rv-faint text-xs leading-relaxed mt-8 pt-4 border-t rv-line max-w-2xl">
130 The historical figures here are played by AI, not real people, and every reply is
131 AI-generated <span class="rv-fg">alternate history — dramatized fiction, not historical fact</span>;
132 don't rely on it as accurate. Intended for adults (18+).
133 </p>
134 </div>
135</template>
136 
137<script setup lang="ts">
138/**
139 * MissionSelect — the opening briefing. Curated objectives + a live-composed one, as
140 * borderless hairline rows; selection uses identity, not titles. Nothing commits
141 * until Begin, so a freshly composed objective can be previewed and reconsidered.
142 */
143import { ref, shallowRef, computed } from 'vue'
144import { useGameStore } from '~/stores/game'
145import type { RemixCredit } from '~/utils/run-snapshot'
146import {
147 listCuratedObjectives,
148 OBJECTIVE_ERAS,
149 OBJECTIVE_THEMES,
150 type GameObjective,
151 type ObjectiveEra,
152 type ObjectiveTheme
153} from '~/server/utils/objectives'
154 
155const gameStore = useGameStore()
156 
157// Replay mode (issue #89): set when seeded from a shared run's objective. The briefing
158// shows just that objective, ready to play; Begin commits it verbatim (no generation).
159const replay = computed(() => gameStore.replayState)
160// The "remixed from" line above the seeded objective. The title is omitted (the objective
161// is shown right below), so it reads "Remixed from <name>" / "…an anonymous run", linking
162// back to the source when still shared.
163const replayCredit = computed<RemixCredit>(() => ({
164 attribution: replay.value?.sourceAttribution ?? null,
165 objectiveTitle: '',
166 sourceToken: replay.value?.sourceToken ?? null
167}))
168 
169const curated = listCuratedObjectives()
170// shallowRef, not ref: selection is by object identity (isSelected uses ===), and the
171// curated options are plain objects. A deep ref would return a *reactive proxy* on
172// `.value`, so `selected.value === obj` (raw) would be false — the row would highlight
173// on hover but its radio dot / aria-checked would never flip. shallowRef stores the
174// value as-is, keeping identity intact.
175const fresh = shallowRef<GameObjective | null>(null)
176const selected = shallowRef<GameObjective | null>(null)
177// Titles composed this session, fed back as the avoid-list so each reroll lands
178// somewhere new (#95). Resets naturally: MissionSelect is mounted only while there's
179// no objective (v-if in play.vue), so a new run remounts it with an empty list.
180const seenTitles = ref<string[]>([])
181const composing = ref(false)
182const composeError = ref<string | null>(null)
183const beginning = ref(false)
184 
185// Composition steer (closed enums; '' = no preference, left to the model).
186const steerEra = ref<ObjectiveEra | ''>('')
187const steerTheme = ref<ObjectiveTheme | ''>('')
188 
189const options = computed(() => (fresh.value ? [fresh.value, ...curated] : curated))
190 
191// A plain-language echo of the active steer, surfaced by the compose button.
192const activeSteer = computed(() =>
193 [steerEra.value, steerTheme.value].filter(Boolean).join(' · ') || null
195 
196function isSelected(obj: GameObjective) {
197 return selected.value === obj
199 
200function select(obj: GameObjective) {
201 selected.value = obj
203 
204async function rollFresh() {
205 composing.value = true
206 composeError.value = null
207 const objective = await gameStore.fetchAIObjective({
208 era: steerEra.value || undefined,
209 theme: steerTheme.value || undefined
210 }, seenTitles.value)
211 composing.value = false
212 
213 if (objective) {
214 fresh.value = objective
215 selected.value = objective // auto-select the fresh one so Begin is one tap away
216 // Remember it so the next reroll avoids it (a curated fallback lands here too,
217 // so even a fallback won't be re-served).
218 if (!seenTitles.value.includes(objective.title)) seenTitles.value.push(objective.title)
219 } else {
220 composeError.value = 'The archives went quiet — pick one above, or try composing again.'
221 }
223 
224// Leave replay mode for the full browse/compose briefing — this run becomes an original.
225function exitReplay() {
226 gameStore.clearReplayState()
227 selected.value = null
229 
230async function begin() {
231 // In replay mode the seeded objective IS the choice (no browse list rendered);
232 // otherwise it's whatever the player selected. Either way chooseObjective commits
233 // it verbatim — replay runs no generation, just reuses the captured objective.
234 const objective = replay.value?.objective ?? selected.value
235 if (!objective || beginning.value) return
236 beginning.value = true
237 // Charges one run from the balance. If out of runs, chooseObjective raises the
238 // paywall (gameStore.outOfRuns) and opens the run-packs modal; no run starts.
239 await gameStore.chooseObjective(objective)
240 beginning.value = false
242</script>

The share page wires the button to preseedReplay then routes to /play, and passes its own remixedFrom down to RunView (so a shared run that is itself a replay shows its credit). The credit is rendered by one small shared component, RemixCreditLine, reused by RunView (the public + saved pages) and EndGameScreen (the live end screen) — name only where opted in, a backlink only while the source is still shared.

Play this objective → preseed → /play; the page's own remixedFrom.

pages/r/[token].vue · 159 lines
pages/r/[token].vue159 lines · Vue
⋯ 42 lines hidden (lines 1–42)
1<template>
2 <!-- A shared run, public + read-only (issue #88). Reached by an unguessable token, no
3 login. It replays the end screen from the sanitized public projection — the same
4 RunView a saved run uses — adds opt-in attribution, ways to pass it on, and a CTA
5 into a run of your own. Server-rendered so the link unfurls rich in social feeds. -->
6 <div class="min-h-screen rv-bg flex flex-col">
7 <header class="border-b rv-line">
8 <div class="px-5 py-2.5 flex items-center gap-4 max-w-3xl mx-auto w-full">
9 <GameTitle />
10 <button type="button" class="rv-tap rv-hover-fg text-base leading-none shrink-0 ml-auto"
11 :aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'" @click="toggleDark"></button>
12 </div>
13 </header>
14 
15 <main class="flex-1 px-5 py-8">
16 <div class="max-w-4xl mx-auto">
17 <div v-if="pending" data-testid="share-loading" class="rv-faint italic text-sm mt-8">Opening the run…</div>
18 
19 <div v-else-if="!run" data-testid="share-not-found" class="mt-8 border-t rv-line pt-6">
20 <h2 class="rv-serif rv-fg text-2xl font-bold">This run isn't available</h2>
21 <p class="rv-muted text-sm mt-2">The link may be wrong, or the run was un-shared by its owner.</p>
22 <NuxtLink to="/play" data-testid="share-not-found-cta" class="rv-btn rv-btn--primary mt-4 inline-flex">
23 Rewrite your own history <span aria-hidden="true"></span>
24 </NuxtLink>
25 </div>
26 
27 <template v-else>
28 <p v-if="attribution" data-testid="share-attribution" class="rv-label">
29 A run shared by {{ attribution }}
30 </p>
31 <RunView :snapshot="run" :remixed-from="remixedFrom" class="mt-3" />
32 
33 <!-- Pass it on -->
34 <div class="mt-9 border-t rv-line pt-6">
35 <span class="rv-label rv-label--rule">Pass it on</span>
36 <ShareLinks :url="pageUrl" :text="shareText" />
37 </div>
38 
39 <!-- The funnel: turn a viewer into a player. The headline act is REPLAY —
40 start your own run on this very objective (issue #89), credited back to
41 this sharer — with a plain "from scratch" path kept as the alternative. -->
42 <div class="mt-7">
43 <p class="rv-muted text-sm mb-3">Think you can do better? Take on this very objective — your own five messages, your own timeline.</p>
44 <button type="button" data-testid="share-play-cta" class="rv-btn rv-btn--primary text-base inline-flex" @click="playObjective">
45 Play this objective <span aria-hidden="true"></span>
46 </button>
47 <p class="mt-3">
48 <button type="button" data-testid="share-scratch-cta" class="rv-tap rv-hover-fg text-xs rv-mono uppercase tracking-wide" @click="playFresh">
49 or rewrite your own history from scratch →
50 </button>
⋯ 35 lines hidden (lines 51–85)
51 </p>
52 </div>
53 </template>
54 </div>
55 </main>
56 
57 <footer class="border-t rv-line px-5 py-3 text-[11px] rv-faint">
58 <div class="max-w-4xl mx-auto flex items-center gap-2">
59 <span class="rv-mono uppercase tracking-wide">Revisionist</span>
60 <span class="rv-hair-c" aria-hidden="true">·</span>
61 <span class="rv-serif italic">this timeline is written</span>
62 </div>
63 </footer>
64 </div>
65</template>
66 
67<script setup lang="ts">
68/**
69 * The public run page (/r/:token). Fetches the sanitized public projection from
70 * GET /api/share/:token (which 404s an unknown/revoked token → the "not available"
71 * state) and hands its snapshot to RunView — the very component the owner's saved-run
72 * page uses, so a shared run reads exactly like the real end screen. The SEO/unfurl meta
73 * is set with useServerSeoMeta so it's present in the SSR HTML crawlers read, with the
74 * og:image pointed at the per-run share card.
75 */
76import { computed } from 'vue'
77import { buildShareText, type PublicRun } from '~/utils/run-snapshot'
78import { useGameStore } from '~/stores/game'
79 
80const route = useRoute()
81const token = computed(() => String(route.params.token))
82 
83const { data, pending } = await useFetch<PublicRun>(() => `/api/share/${token.value}`)
84const run = computed(() => data.value?.run ?? null)
85const attribution = computed(() => data.value?.attribution ?? null)
86// When THIS shared run is itself a replay, its own "remixed from" credit (issue #89).
87const remixedFrom = computed(() => data.value?.remixedFrom ?? null)
88 
89// Replay (issue #89): seed a fresh run on this run's captured objective, credited back
90// to this sharer, then drop into the briefing. No generation — the objective is reused
91// verbatim; the visitor still commits (and is charged) by pressing Begin.
92const gameStore = useGameStore()
93async function playObjective() {
94 if (!run.value) return
95 gameStore.preseedReplay(run.value.objective, token.value, attribution.value)
96 await navigateTo('/play')
98// The alternative: start from scratch — explicitly an original run, no lineage.
99async function playFresh() {
100 gameStore.clearReplayState()
101 await navigateTo('/play')
103 
⋯ 56 lines hidden (lines 104–159)
104// Absolute URLs for the canonical link + the share card. useRequestURL gives the real
105// origin during SSR (what the crawler fetched) and the window origin on the client.
106const reqUrl = useRequestURL()
107const pageUrl = computed(() => `${reqUrl.origin}/r/${token.value}`)
108const cardUrl = computed(() => `${reqUrl.origin}/api/og/${token.value}`)
109 
110const isVictory = computed(() => run.value?.status === 'victory')
111const verdict = computed(() => (isVictory.value ? 'History Rewritten' : 'The Timeline Held'))
112 
113const shareText = computed(() =>
114 run.value
115 ? buildShareText(run.value.objective.title, run.value.status, run.value.objectiveProgress)
116 : 'Revisionist — rewrite history, one message at a time.'
118 
119const colorMode = useColorMode()
120const isDark = computed(() => colorMode.value === 'dark')
121function toggleDark() {
122 colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
124 
125const metaTitle = computed(() => {
126 const r = run.value
127 if (!r) return 'A shared run — Revisionist'
128 return `${r.objective.title}: ${r.objectiveProgress}% rewritten — Revisionist`
129})
130const metaDescription = computed(() => {
131 const r = run.value
132 if (!r) return 'A run of Revisionist — rewrite history, one 160-character message at a time.'
133 const epilogue = r.chronicle?.title
134 return epilogue ? `${verdict.value} · "${epilogue}"` : `${verdict.value} · ${r.objective.title} (${r.objectiveProgress}% rewritten).`
135})
136 
137// Server-rendered unfurl: title/description + an OG/Twitter card pointed at the per-run
138// share image, so a pasted link looks good in X / Discord / iMessage.
139useServerSeoMeta({
140 ogSiteName: 'Revisionist',
141 ogTitle: metaTitle,
142 description: metaDescription,
143 ogDescription: metaDescription,
144 ogType: 'article',
145 ogUrl: pageUrl,
146 ogImage: cardUrl,
147 ogImageWidth: 1200,
148 ogImageHeight: 630,
149 ogImageType: 'image/png',
150 twitterCard: 'summary_large_image',
151 twitterTitle: metaTitle,
152 twitterDescription: metaDescription,
153 twitterImage: cardUrl
154})
155 
156// The browser tab title (server + client nav); kept out of useServerSeoMeta so the two
157// don't both write <title>.
158useHead({ title: metaTitle })
159</script>

The single credit renderer — anonymity + optional backlink in one place.

components/RemixCreditLine.vue · 23 lines
components/RemixCreditLine.vue23 lines · Vue
1<template>
2 <!-- The one-hop "remixed from" credit (issue #89): this run is a replay of another's
3 shared objective. Honors the source's privacy opt-in — a name only when they chose
4 one ("an anonymous run" otherwise), and a backlink only while they're still shared. -->
5 <p data-testid="remix-credit" class="rv-label">
6 <span aria-hidden="true"></span> Remixed from
7 <NuxtLink v-if="credit.sourceToken" :to="`/r/${credit.sourceToken}`" data-testid="remix-credit-link"
8 class="rv-accent hover:underline">{{ label }}</NuxtLink>
9 <span v-else>{{ label }}</span>
10 </p>
11</template>
12 
13<script setup lang="ts">
14import { computed } from 'vue'
15import type { RemixCredit } from '~/utils/run-snapshot'
16 
17const props = defineProps<{ credit: RemixCredit }>()
18 
19const who = computed(() => props.credit.attribution || 'an anonymous run')
20const label = computed(() =>
21 props.credit.objectiveTitle ? `${who.value}'s “${props.credit.objectiveTitle}` : who.value
23</script>