What changed, and why

Slice A (#46) made cost-per-run measurable by tagging every AI call with a run id. This makes a run a server fact. Until now a run was purely client state: the browser minted the id. Now POST /api/run mints it and records the run, so the id the cost telemetry groups by points at a real, persisted run — and a per-player run balance can later decrement against it. That balance is the monetization keystone; this PR lays the ground for it without building it yet.

The change is small and has a clear seam. A run store with two adapters (one for dev, one for prod), a one-route endpoint that uses it, a client that calls that endpoint instead of minting locally, and a one-table migration. No balance, no identity, no cost columns — those come later. Cost stays a projection of the telemetry, never a stored value.

The run store: one port, two adapters

The store is a port with two implementations. In-memory is the default, so npm run dev and the unit tests need no external service — it just mints and returns a real id, persisting nothing (a dev run needn't outlive the process). Supabase is the durable one; it inserts the run as a row via the service key, and translates a Supabase error envelope into a thrown error — the one branch the begin-run 500 / client-fallback contract leans on.

The port and its two adapters. The id is minted server-side; cost is never a column here.

server/utils/run-store.ts · 88 lines
server/utils/run-store.ts88 lines · TypeScript
⋯ 19 lines hidden (lines 1–19)
1/**
2 * The run store — where a game run becomes a server-side fact. POST /api/run
3 * mints a run id and records the run here; the client echoes that id back as
4 * x-run-id, which the cost telemetry groups by. On the happy path the recorded
5 * id and the telemetry id are the same; tying them together so a per-player
6 * balance can spend against the row is the next slice's job, not this one's.
7 *
8 * Two adapters behind one port, chosen by config — the repo's env-degradation
9 * idiom (cf. moderationMode, the REVISIONIST_AI_LANE override):
10 * - in-memory (default): no external dependency, so `npm run dev` and the unit
11 * tests need nothing. The run id is real; the "store" is process-local.
12 * - Supabase: used when SUPABASE_URL + SUPABASE_SECRET_KEY are configured;
13 * inserts the run into the `runs` table (see supabase/migrations).
14 *
15 * Cost is derived, never stored here: cost-per-run stays a projection of the
16 * [ai] telemetry, not a column. This table records a run's existence.
17 */
18import { createClient, type SupabaseClient } from '@supabase/supabase-js'
19 
20export interface RunStore {
21 /** Mint a run id, persist the run, and return the id. */
22 createRun(): Promise<string>
24 
25/** A new run id. crypto.randomUUID is always present server-side (Node 19+). */
26function newRunId(): string {
27 return crypto.randomUUID()
29 
30/** Dev default — mints a real run id but persists nothing (a run needn't outlive
31 * the process in dev). The Supabase adapter is the durable one. */
32export class InMemoryRunStore implements RunStore {
33 async createRun(): Promise<string> {
34 return newRunId()
35 }
37 
38/** Records each run as a row in the `runs` table via the service (secret) key,
39 * which bypasses RLS — the table has no public policy, so it is server-only. */
40export class SupabaseRunStore implements RunStore {
41 constructor(private readonly client: SupabaseClient) {}
42 async createRun(): Promise<string> {
43 const id = newRunId()
44 const { error } = await this.client.from('runs').insert({ id })
45 if (error) throw new Error(`runs insert failed: ${error.message}`)
46 return id
47 }
49 
50interface SupabaseConfig {
51 url: string
52 secretKey: string
54 
55/** Supabase creds from runtimeConfig (Nuxt) or raw env (tests/evals in node),
⋯ 33 lines hidden (lines 56–88)
56 * null when either is absent — the signal to stay in-memory. Mirrors
57 * ai-gateway's configuredKey. */
58function supabaseConfig(): SupabaseConfig | null {
59 let url: string | undefined
60 let secretKey: string | undefined
61 try {
62 const cfg = useRuntimeConfig()
63 url = cfg.supabaseUrl as string | undefined
64 secretKey = cfg.supabaseSecretKey as string | undefined
65 } catch {
66 // not in a Nuxt context
67 }
68 url = url || process.env.SUPABASE_URL
69 secretKey = secretKey || process.env.SUPABASE_SECRET_KEY
70 return url && secretKey ? { url, secretKey } : null
72 
73let cached: RunStore | null = null
74 
75/** The configured run store (memoized — reuses one Supabase client). */
76export function runStore(): RunStore {
77 if (cached) return cached
78 const cfg = supabaseConfig()
79 cached = cfg
80 ? new SupabaseRunStore(createClient(cfg.url, cfg.secretKey, { auth: { persistSession: false } }))
81 : new InMemoryRunStore()
82 return cached
84 
85/** Test seam: clear the memoized store so the next runStore() re-reads config. */
86export function resetRunStore(): void {
87 cached = null

Chosen by config — what keeps dev simple

Which adapter runs is decided by config, not a flag: if SUPABASE_URL and SUPABASE_SECRET_KEY are both present it's Supabase, otherwise in-memory. This is the repo's existing env-degradation idiom (the same shape as moderationMode and the AI-lane override), and it's exactly what lets a dev run with nothing wired. The store is memoized so one Supabase client is reused; resetRunStore is the test seam to re-read config.

Creds from runtimeConfig or raw env (null → stay in-memory); memoized factory.

server/utils/run-store.ts · 88 lines
server/utils/run-store.ts88 lines · TypeScript
⋯ 57 lines hidden (lines 1–57)
1/**
2 * The run store — where a game run becomes a server-side fact. POST /api/run
3 * mints a run id and records the run here; the client echoes that id back as
4 * x-run-id, which the cost telemetry groups by. On the happy path the recorded
5 * id and the telemetry id are the same; tying them together so a per-player
6 * balance can spend against the row is the next slice's job, not this one's.
7 *
8 * Two adapters behind one port, chosen by config — the repo's env-degradation
9 * idiom (cf. moderationMode, the REVISIONIST_AI_LANE override):
10 * - in-memory (default): no external dependency, so `npm run dev` and the unit
11 * tests need nothing. The run id is real; the "store" is process-local.
12 * - Supabase: used when SUPABASE_URL + SUPABASE_SECRET_KEY are configured;
13 * inserts the run into the `runs` table (see supabase/migrations).
14 *
15 * Cost is derived, never stored here: cost-per-run stays a projection of the
16 * [ai] telemetry, not a column. This table records a run's existence.
17 */
18import { createClient, type SupabaseClient } from '@supabase/supabase-js'
19 
20export interface RunStore {
21 /** Mint a run id, persist the run, and return the id. */
22 createRun(): Promise<string>
24 
25/** A new run id. crypto.randomUUID is always present server-side (Node 19+). */
26function newRunId(): string {
27 return crypto.randomUUID()
29 
30/** Dev default — mints a real run id but persists nothing (a run needn't outlive
31 * the process in dev). The Supabase adapter is the durable one. */
32export class InMemoryRunStore implements RunStore {
33 async createRun(): Promise<string> {
34 return newRunId()
35 }
37 
38/** Records each run as a row in the `runs` table via the service (secret) key,
39 * which bypasses RLS — the table has no public policy, so it is server-only. */
40export class SupabaseRunStore implements RunStore {
41 constructor(private readonly client: SupabaseClient) {}
42 async createRun(): Promise<string> {
43 const id = newRunId()
44 const { error } = await this.client.from('runs').insert({ id })
45 if (error) throw new Error(`runs insert failed: ${error.message}`)
46 return id
47 }
49 
50interface SupabaseConfig {
51 url: string
52 secretKey: string
54 
55/** Supabase creds from runtimeConfig (Nuxt) or raw env (tests/evals in node),
56 * null when either is absent — the signal to stay in-memory. Mirrors
57 * ai-gateway's configuredKey. */
58function supabaseConfig(): SupabaseConfig | null {
59 let url: string | undefined
60 let secretKey: string | undefined
61 try {
62 const cfg = useRuntimeConfig()
63 url = cfg.supabaseUrl as string | undefined
64 secretKey = cfg.supabaseSecretKey as string | undefined
65 } catch {
66 // not in a Nuxt context
67 }
68 url = url || process.env.SUPABASE_URL
69 secretKey = secretKey || process.env.SUPABASE_SECRET_KEY
70 return url && secretKey ? { url, secretKey } : null
72 
73let cached: RunStore | null = null
74 
75/** The configured run store (memoized — reuses one Supabase client). */
76export function runStore(): RunStore {
77 if (cached) return cached
78 const cfg = supabaseConfig()
79 cached = cfg
80 ? new SupabaseRunStore(createClient(cfg.url, cfg.secretKey, { auth: { persistSession: false } }))
81 : new InMemoryRunStore()
82 return cached
84 
85/** Test seam: clear the memoized store so the next runStore() re-reads config. */
86export function resetRunStore(): void {
87 cached = null

The begin-run endpoint

One route mints the run id and records the run. It takes no input, so there's nothing to validate or inject. A failure is a 500 — and the client treats begin-run as best-effort, so a store outage degrades the ledger, never the turn.

Mint + record + return { runId }; failures are non-fatal to play (see the client).

server/api/run.post.ts · 24 lines
server/api/run.post.ts24 lines · TypeScript
⋯ 12 lines hidden (lines 1–12)
1/**
2 * POST /api/run — begins a run server-side: mints a run id, records the run, and
3 * returns { runId }. The client calls this at the start of a run and sends the id
4 * as x-run-id on every later AI call (the cost-instrument tag). This is the seam
5 * where a run becomes a server fact; the run store decides whether that fact is
6 * durable (Supabase) or process-local (the in-memory dev default).
7 *
8 * A failure here is a 500: the client treats begin-run as best-effort and falls
9 * back to a local id, so a store outage degrades the ledger, never the game.
10 */
11import { runStore } from '~/server/utils/run-store'
12 
13export default defineEventHandler(async (event) => {
14 if (getMethod(event) !== 'POST') {
15 throw createError({ statusCode: 405, statusMessage: 'Method Not Allowed' })
16 }
17 try {
18 const runId = await runStore().createRun()
19 return { runId }
20 } catch (error) {
21 console.error('Failed to begin run:', error)
22 throw createError({ statusCode: 500, statusMessage: 'Internal Server Error' })
23 }
24})

The client mints server-side now

In #46 the client minted the run id locally. Now ensureRunId calls /api/run the first time a run needs an id, then reuses it, and every AI call is tagged with x-run-id as before. Three properties matter. The fallback: if begin-run fails, the client mints a local id so the turn still proceeds and telemetry still groups — the server record is best-effort, the game is not. The in-flight memo: concurrent first-calls share one mint, so two overlapping calls at run start don't create two run rows. And the epoch guard: a resetGame mid-mint abandons the stale mint instead of bleeding the dead run's id into the new one.

ensureRunId: one shared in-flight mint, epoch-guarded, with a local fallback; the now-async aiHeaders.

stores/game.ts · 1064 lines
stores/game.ts1064 lines · TypeScript
⋯ 470 lines hidden (lines 1–470)
1import { defineStore } from 'pinia'
2import type { GameObjective } from '~/server/utils/objective-generator'
3import type { GroundedFigure } from '~/server/utils/figure-grounding'
4import { formatContactMoment, type ContactMoment } from '~/utils/contact-moment'
5import type { FigureSuggestion } from '~/server/utils/figure-suggester'
6import type { ChronicleEntry } from '~/server/utils/openai'
7import type { FigureStudy, ArchiveLookup } from '~/server/utils/prompt-builder'
8import type { Anachronism } from '~/server/utils/anachronism'
9import { chainStatus, type ChainStatus } from '~/utils/causal-chain'
10import type { Craft } from '~/utils/craft'
11import type { DiceOutcome } from '~/utils/dice'
12import { generateGameSummary, rateEfficiency, type GameSummary } from '~/utils/game-summary'
13import { TOTAL_MESSAGES, MAX_PROGRESS_SWING } from '~/utils/game-config'
14 
15export type Valence = 'positive' | 'negative' | 'neutral'
16 
17/**
18 * A historical figure the player has reached out to. Figures are freeform — the
19 * player can write to anyone, in any era. The AI infers each figure's era and a
20 * short descriptor on first contact, which we cache here for the UI.
21 */
22export interface HistoricalFigure {
23 name: string
24 era: string
25 descriptor: string
27 
28/**
29 * Timeline Ledger entry — a single recorded change to history.
30 *
31 * The ledger is the heart of the game: every resolved turn appends one entry
32 * describing how the world bent, so the player literally watches the timeline
33 * rewrite itself as they play.
34 */
35export interface TimelineEvent {
36 id: string
37 figureName: string
38 era: string
39 headline: string
40 detail: string
41 diceRoll: number
42 diceOutcome: DiceOutcome
43 progressChange: number
44 /** The engine's swing BEFORE the anachronism amplifier — shown so the player
45 * can see exactly what their wager did ("+8 ⚡ far-ahead → +11%"). */
46 baseProgressChange?: number
47 valence: Valence
48 /** How anachronistic the player's nudge was — it widened this swing. */
49 anachronism?: Anachronism
50 /** How far this landed from the nearest foothold — it decayed this swing. */
51 causalChain?: ChainStatus
52 /** The Judge's grade of the dispatch that caused this change. */
53 craft?: Craft
54 /** Signed year of the intervention, when the contact was grounded. */
55 whenSigned?: number
56 /** True when this was the run's staked last stand (doubled swing). */
57 staked?: boolean
58 timestamp: Date
60 
61/**
62 * Message Interface — one line in the conversation with a figure.
63 * Dice / outcome / action / impact / progress are recorded on the AI (figure)
64 * turn, since that is the resolved result of the roll.
65 */
66export interface Message {
67 text: string
68 sender: 'user' | 'ai' | 'system'
69 timestamp: Date
70 figureName?: string
71 /** The effective (craft-tilted) roll — what the bands judged. */
72 diceRoll?: number
73 diceOutcome?: DiceOutcome
74 /** The die as it actually landed, before the craft modifier. */
75 naturalRoll?: number
76 /** The Judge's tilt on the roll (±2..0), and the grade + reason behind it. */
77 rollModifier?: number
78 craft?: Craft
79 craftReason?: string
80 characterAction?: string
81 timelineImpact?: string
82 progressChange?: number
83 /** The pre-amplifier swing + the wager level, for the reveal's equation. */
84 baseProgressChange?: number
85 anachronism?: Anachronism
86 /** The causal-chain decay applied to this swing (for the reveal's equation). */
87 causalChain?: ChainStatus
88 /** True when this turn was the staked last stand. */
89 staked?: boolean
91 
92export type GameStatus = 'playing' | 'victory' | 'defeat'
93 
94/**
95 * Shape returned by `POST /api/send-message` (see `server/api/send-message.post.ts`).
96 * Discriminated by `success`: when `true`, all fields downstream of the dice roll are
97 * guaranteed; when `false`, only `userMessage` + `error` are guaranteed (and the
98 * Character-AI-failure path doesn't even surface a dice roll). The discriminant lets
99 * the consumer destructure without optional chaining or null gymnastics.
100 */
101type SendMessageApiResponse =
102 | {
103 success: true
104 message: string
105 data: {
106 userMessage: string
107 figure: { name: string; era: string; descriptor: string }
108 diceRoll: number
109 diceOutcome: DiceOutcome
110 // Judge-of-craft fields (optional so older mocks/fixtures stay valid;
111 // the live server always sends them).
112 naturalRoll?: number
113 rollModifier?: number
114 craft?: Craft
115 craftReason?: string
116 staked?: boolean
117 characterResponse: { message: string; action: string }
118 timeline: {
119 headline: string
120 detail: string
121 era: string
122 progressChange: number
123 baseProgressChange?: number
124 valence: Valence
125 anachronism?: Anachronism
126 causalChain?: ChainStatus
127 }
128 error: null
129 }
130 }
131 | {
132 success: false
133 message: string
134 data: {
135 userMessage: string
136 diceRoll?: number
137 diceOutcome?: DiceOutcome
138 characterResponse?: { message: string; action: string }
139 error: string
140 /** A content-moderation block (not an infra failure): the dispatch was
141 * refused by the detector, the Sentinel, or the model itself. */
142 blocked?: boolean
143 moderationReason?: string
144 }
145 }
146 
147const RATE_LIMIT_MS = 1000 // message cooldown window, in ms
148const MAX_PROGRESS = 100 // objective progress is clamped to 0..MAX_PROGRESS
149 
150function valenceOf(progressChange: number): Valence {
151 if (progressChange > 0) return 'positive'
152 if (progressChange < 0) return 'negative'
153 return 'neutral'
155 
156/** Formats a signed timeline year (AD positive, BCE negative) for display. */
157export function formatContactYear(signed: number): string {
158 return signed < 0 ? `${-signed} BC` : `${signed}`
160 
161/** Human lifespan line for a grounded figure, e.g. "69 BC – 30 BC" or "1942 – present". */
162function lifespanText(g: GroundedFigure): string | undefined {
163 if (!g.born) return undefined
164 if (g.died) return `${g.born.display}${g.died.display}`
165 return g.living ? `${g.born.display} – present` : g.born.display
167 
168/** A fresh run id. Used to tag every server call in one run so the gateway's
169 * cost telemetry groups per run (the GTM cost instrument). randomUUID needs a
170 * secure context (https / localhost — both our cases); the fallback covers the
171 * rare insecure one. Not security-sensitive: it only groups log lines. */
172function generateRunId(): string {
173 try {
174 return crypto.randomUUID()
175 } catch {
176 return `run-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
177 }
179 
180export type ContactLiveness = 'ok' | 'before-birth' | 'after-death' | 'unknown'
181 
182/**
183 * Game Store — core state for a session of freeform timeline editing.
184 */
185export const useGameStore = defineStore('game', {
186 state: () => ({
187 remainingMessages: TOTAL_MESSAGES,
188 messageHistory: [] as Message[],
189 gameStatus: 'playing' as GameStatus,
190 isLoading: false,
191 error: null as string | null,
192 lastMessageTime: null as number | null,
193 isRateLimited: false,
194 // Staleness plumbing, not run state. runEpoch increments on every reset so an
195 // async result from a dead run can never write into a fresh one; the seq
196 // counters order overlapping requests of the same kind so only the latest
197 // lands (an earlier chronicle rewrite must not overwrite a later one).
198 // Deliberately NOT restored by resetGame — they must survive it to work.
199 runEpoch: 0,
200 chronicleSeq: 0,
201 groundingSeq: 0,
202 // The current run's id — lazily minted on the run's first server call and
203 // cleared by resetGame so each run gets a fresh one. Tags every AI call
204 // (via the x-run-id header) so the gateway's cost telemetry groups per
205 // run: the GTM cost instrument.
206 runId: null as string | null,
207 // The in-flight begin-run, so concurrent first-calls of a run share one
208 // mint (one POST /api/run, one runs row) instead of racing two. Cleared
209 // by resetGame. (Vue leaves a Promise unproxied, so awaiting it is safe.)
210 runIdInflight: null as Promise<string> | null,
211 currentObjective: null as GameObjective | null,
212 objectiveProgress: 0,
213 timelineEvents: [] as TimelineEvent[],
214 figures: [] as HistoricalFigure[],
215 activeFigureName: '' as string,
216 // Grounding for the active contact: real facts + the chosen year to reach them.
217 figureGrounding: null as GroundedFigure | null,
218 groundingLoading: false,
219 contactWhen: null as number | null,
220 /** Optional sub-year refinement of contactWhen — display/prompt flavor
221 * only; every mechanic keeps doing arithmetic on the YEAR (issue #32). */
222 contactMoment: null as ContactMoment | null,
223 // Era-relevant figure suggestions for the current objective (the on-ramp).
224 figureSuggestions: [] as FigureSuggestion[],
225 suggestionsLoading: false,
226 suggestionsFor: '' as string,
227 // The living Chronicle (Layer 3): a prose telling of the altered timeline,
228 // rewritten each turn. Non-blocking — see refreshChronicle.
229 chronicle: null as ChronicleEntry | null,
230 chronicleLoading: false,
231 // The Archive (prototype): an objective-blind brief on the active figure, so
232 // the player can research who they're reaching without leaving the game. The
233 // brief is moment-specific, so it's cached against the figure AND the year.
234 figureStudy: null as FigureStudy | null,
235 studyLoading: false,
236 studyFor: '' as string,
237 studyWhen: null as number | null,
238 // Archive topic lookup (the "yellow" layer): concrete domain facts on demand.
239 archiveResult: null as ArchiveLookup | null,
240 lookupLoading: false,
241 // A content-moderation block — distinct from `error` (an infra hiccup) so
242 // the UI shows an honest, visibly different banner. One per surface that
243 // takes untrusted input into a model: the dispatch/turn, the Archive lookup,
244 // the Archivist study, and the figure-suggestions step.
245 moderationNotice: null as string | null,
246 archiveNotice: null as string | null,
247 studyNotice: null as string | null,
248 suggestionsNotice: null as string | null
249 }),
250 
251 getters: {
252 /**
253 * Can the player send right now? (messages left, still playing, not
254 * mid-request, not rate-limited)
255 */
256 canSendMessage(): boolean {
257 return this.remainingMessages > 0 &&
258 this.gameStatus === 'playing' &&
259 !this.isLoading &&
260 !this.isRateLimited
261 },
262 
263 /**
264 * The conversation thread with a single figure (their turns + the
265 * player's turns addressed to them). Each figure keeps a coherent,
266 * independent thread.
267 */
268 conversationWith(): (name: string) => Message[] {
269 return (name: string) => this.messageHistory.filter(
270 m => m.figureName === name && m.sender !== 'system'
271 )
272 },
273 
274 /** Total progress, net of setbacks, the player has clawed back. */
275 gameSummary(): GameSummary {
276 return generateGameSummary(this)
277 },
278 
279 /**
280 * Whether the chosen `when` falls within the active figure's lifetime.
281 * 'unknown' (permissive) whenever we lack solid dates — we never block a
282 * figure we couldn't ground, only one we know you can't reach yet/anymore.
283 */
284 contactLiveness(): ContactLiveness {
285 const g = this.figureGrounding
286 if (!g || !g.resolved || !g.born || this.contactWhen == null) return 'unknown'
287 if (this.contactWhen < g.born.signed) return 'before-birth'
288 const latest = g.died ? g.died.signed : new Date().getFullYear()
289 if (this.contactWhen > latest) return 'after-death'
290 return 'ok'
291 },
292 
293 /** Can the chosen contact + when actually be reached? */
294 canContact(): boolean {
295 return this.contactLiveness === 'ok' || this.contactLiveness === 'unknown'
296 },
297 
298 /**
299 * The last stand is offered ONLY when the final dispatch can no longer win
300 * inside the normal per-turn fuse — from any nearer position an unstaked
301 * throw can still land it, and offering the (strictly win-probability-
302 * increasing) doubling there would be a dominance trap, not a decision.
303 */
304 canStake(): boolean {
305 return this.remainingMessages === 1 &&
306 this.gameStatus === 'playing' &&
307 (MAX_PROGRESS - this.objectiveProgress) > MAX_PROGRESS_SWING
308 },
309 
310 /**
311 * The active figure's age at the chosen `when` — so the player never has to
312 * do lifetime math in their head. Null when we lack a birth year or a chosen
313 * year (unresolved / free-form contact). Corrects for the missing year zero
314 * when a life spans the BC→AD boundary (1 BC → AD 1 is one year, not two).
315 */
316 contactAge(): number | null {
317 const g = this.figureGrounding
318 if (!g?.resolved || !g.born || this.contactWhen == null) return null
319 const bornSigned = g.born.signed
320 const whenSigned = this.contactWhen
321 if (whenSigned < bornSigned) return null // before birth — no meaningful age
322 const crossedZero = bornSigned < 0 && whenSigned > 0 ? 1 : 0
323 return whenSigned - bornSigned - crossedZero
324 },
325 
326 /**
327 * The causal-chain read for the CURRENT contact — the pre-send ⏳ chip's
328 * source, mirroring what the Timeline Engine will compute. Footholds are the
329 * objective's anchor year plus every dated change already on the ledger; the
330 * gap to the nearest one decays the swing. Null (no chip) for ungrounded
331 * contacts or an anchorless objective with no dated changes yet.
332 */
333 causalChainRead(): ChainStatus | null {
334 const footholds = [
335 this.currentObjective?.anchorYear,
336 ...this.timelineEvents.map(e => e.whenSigned)
337 ]
338 return chainStatus(this.contactWhen, footholds)
339 }
340 },
341 
342 actions: {
343 // ---------- message helpers ----------
344 addUserMessage(text: string, figureName?: string) {
345 if (this.gameStatus !== 'playing') return
346 this.messageHistory.push({
347 text,
348 sender: 'user',
349 timestamp: new Date(),
350 figureName
351 })
352 },
353 
354 addAIMessage(text: string, figureName?: string) {
355 this.messageHistory.push({
356 text,
357 sender: 'ai',
358 timestamp: new Date(),
359 figureName
360 })
361 },
362 
363 addAIMessageWithData(messageData: Partial<Message> & { text: string, sender: 'ai' }) {
364 this.messageHistory.push({
365 text: messageData.text,
366 sender: messageData.sender,
367 timestamp: messageData.timestamp || new Date(),
368 figureName: messageData.figureName,
369 diceRoll: messageData.diceRoll,
370 diceOutcome: messageData.diceOutcome,
371 naturalRoll: messageData.naturalRoll,
372 rollModifier: messageData.rollModifier,
373 craft: messageData.craft,
374 craftReason: messageData.craftReason,
375 characterAction: messageData.characterAction,
376 timelineImpact: messageData.timelineImpact,
377 progressChange: messageData.progressChange,
378 baseProgressChange: messageData.baseProgressChange,
379 anachronism: messageData.anachronism,
380 causalChain: messageData.causalChain,
381 staked: messageData.staked
382 })
383 },
384 
385 // ---------- figures ----------
386 registerFigure(figure: HistoricalFigure) {
387 const existing = this.figures.find(f => f.name === figure.name)
388 if (existing) {
389 if (figure.era) existing.era = figure.era
390 if (figure.descriptor) existing.descriptor = figure.descriptor
391 } else {
392 this.figures.push({ ...figure })
393 }
394 },
395 
396 setActiveFigure(name: string) {
397 this.activeFigureName = name
398 },
399 
400 /**
401 * Synchronously clears the dossier the moment the contact NAME changes, so
402 * a send racing the grounding debounce/fetch goes out clean (free-form)
403 * instead of wearing the PREVIOUS figure's facts, year, and liveness gate —
404 * and so the wrong year can never be written into the ledger's whenSigned.
405 */
406 clearGrounding() {
407 this.groundingSeq++ // invalidate any lookup still in flight
408 this.figureGrounding = null
409 this.contactWhen = null
410 this.contactMoment = null
411 this.groundingLoading = false
412 this.figureStudy = null
413 this.studyFor = ''
414 this.studyWhen = null
415 this.studyNotice = null
416 },
417 
418 /**
419 * Resolves the active figure against the grounding service and defaults the
420 * "when" to a point inside any known lifetime. Never throws: an unresolved
421 * figure simply clears grounding, leaving the contact free-form.
422 */
423 async groundActiveFigure(name: string): Promise<void> {
424 const target = (name || '').trim()
425 // A new contact makes any prior Archive study stale.
426 this.figureStudy = null
427 this.studyFor = ''
428 this.studyWhen = null
429 this.studyNotice = null
430 if (!target) {
431 this.groundingSeq++ // invalidate any lookup still in flight
432 this.figureGrounding = null
433 this.contactWhen = null
434 this.contactMoment = null
435 this.groundingLoading = false
436 return
437 }
438 // Out-of-order guard: only the LATEST lookup may land. Without it, a slow
439 // response for the previous name attaches the wrong dossier (and a wrong
440 // figureContext on a fast send) to whatever the player typed next.
441 const epoch = this.runEpoch
442 const seq = ++this.groundingSeq
443 this.groundingLoading = true
444 try {
445 const grounded = await $fetch('/api/figure', { params: { name: target }, headers: await this.aiHeaders() }) as GroundedFigure
446 if (epoch !== this.runEpoch || seq !== this.groundingSeq) return
447 this.figureGrounding = grounded
448 this.contactMoment = null
449 if (grounded?.resolved && grounded.born) {
450 const latest = grounded.died ? grounded.died.signed : new Date().getFullYear()
451 this.contactWhen = Math.round((grounded.born.signed + latest) / 2)
452 } else {
453 this.contactWhen = null
454 }
455 } catch (error) {
456 if (epoch !== this.runEpoch || seq !== this.groundingSeq) return
457 console.error('Figure grounding failed:', error)
458 this.figureGrounding = null
459 this.contactWhen = null
460 this.contactMoment = null
461 } finally {
462 if (epoch === this.runEpoch && seq === this.groundingSeq) this.groundingLoading = false
463 }
464 },
465 
466 /** The current run's id, minted server-side on first need. POST /api/run
467 * makes the run a server fact (a persisted row); the id then tags every
468 * AI call so the cost telemetry groups per run. Best-effort: if begin-run
469 * fails, fall back to a local id so a store outage degrades the ledger,
470 * never the turn — the [ai] lines still group, just without a server row. */
471 async ensureRunId(): Promise<string> {
472 if (this.runId) return this.runId
473 // Collapse concurrent first-calls onto ONE begin-run: without this,
474 // two AI calls overlapping at run start each POST /api/run, minting
475 // two server rows and splitting the run's telemetry across two ids.
476 // The epoch captured here guards a resetGame mid-flight — the dead
477 // run's id must not become the fresh run's (the guard every other
478 // async action in this store carries).
479 if (!this.runIdInflight) {
480 const epoch = this.runEpoch
481 this.runIdInflight = (async () => {
482 let id: string
483 try {
484 const res = await $fetch('/api/run', { method: 'POST' }) as { runId?: string }
485 id = res?.runId || generateRunId()
486 } catch {
487 id = generateRunId()
488 }
489 // Only land the mint if its run still exists; if reset bumped
490 // the epoch, leave this.runId/runIdInflight for the new run.
491 if (epoch === this.runEpoch) {
492 this.runId = id
493 this.runIdInflight = null
494 }
495 return id
496 })()
497 }
498 return this.runIdInflight
499 },
500 
501 /** Headers that tag a server call with the current run (the cost
502 * instrument), minting the run id first if needed. */
503 async aiHeaders(base: Record<string, string> = {}): Promise<Record<string, string>> {
504 return { ...base, 'x-run-id': await this.ensureRunId() }
505 },
506 
⋯ 558 lines hidden (lines 507–1064)
507 setContactWhen(year: number | null) {
508 // Scrubbing the year keeps any pinned month/day — the pin refines
509 // whichever year is chosen, it doesn't belong to one.
510 this.contactWhen = year
511 },
512 
513 setContactMoment(moment: ContactMoment | null) {
514 this.contactMoment = moment
515 },
516 
517 /**
518 * Studies the active (grounded) figure via the Archivist — an objective-blind
519 * brief on who they are AT THE CHOSEN MOMENT, so the player can research in the
520 * game instead of a browser tab. The brief is moment-specific, so it's cached
521 * against the figure AND the year: move the contact slider and the player can
522 * re-study them at the new moment. Graceful on failure (leaves the prior brief).
523 * Only resolved figures can be studied — an unresolved name has no record.
524 */
525 async studyActiveFigure(): Promise<void> {
526 const g = this.figureGrounding
527 if (!g?.resolved) {
528 this.figureStudy = null
529 return
530 }
531 // Cache hit only when both the figure AND the studied year still match.
532 if (this.studyFor === g.name && this.studyWhen === this.contactWhen && this.figureStudy) return
533 
534 // Capture the moment being studied NOW: the brief describes this figure at
535 // this year. If the slider moves mid-flight, recording the live value would
536 // mislabel the brief and silently suppress the Re-study button.
537 const epoch = this.runEpoch
538 const studiedName = g.name
539 const studiedWhen = this.contactWhen
540 this.studyLoading = true
541 this.studyNotice = null
542 try {
543 const res = await $fetch('/api/research', {
544 method: 'POST',
545 headers: await this.aiHeaders({ 'Content-Type': 'application/json' }),
546 body: {
547 figureName: studiedName,
548 when: studiedWhen != null ? formatContactYear(studiedWhen) : undefined,
549 description: g.description,
550 extract: g.extract
551 }
552 }) as { success?: boolean; study?: FigureStudy; blocked?: boolean; error?: string }
553 if (epoch !== this.runEpoch) return
554 // If the contact changed mid-flight, the stale-study clear in
555 // groundActiveFigure already ran — don't resurrect the old brief.
556 if (res?.blocked && this.figureGrounding?.name === studiedName) {
557 this.studyNotice = res.error || 'That study was blocked by content moderation.'
558 } else if (res?.success && res.study && this.figureGrounding?.name === studiedName) {
559 this.figureStudy = res.study
560 this.studyFor = studiedName
561 this.studyWhen = studiedWhen
562 }
563 } catch (error) {
564 if (epoch !== this.runEpoch) return
565 console.error('Failed to study the figure:', error)
566 } finally {
567 if (epoch === this.runEpoch) this.studyLoading = false
568 }
569 },
570 
571 /**
572 * Asks the Archive about a freeform topic (the "yellow" layer) — concrete
573 * domain facts: what it is, what it takes, and when it first became known
574 * (the player's anachronism read). Graceful on failure. Topic-scoped, so it
575 * persists across figure changes within a run.
576 */
577 async askArchive(query: string): Promise<void> {
578 const q = (query || '').trim()
579 if (!q) return
580 const epoch = this.runEpoch
581 this.lookupLoading = true
582 this.archiveNotice = null
583 try {
584 const res = await $fetch('/api/lookup', {
585 method: 'POST',
586 headers: await this.aiHeaders({ 'Content-Type': 'application/json' }),
587 body: { query: q }
588 }) as { success?: boolean; lookup?: ArchiveLookup; blocked?: boolean; error?: string }
589 if (epoch !== this.runEpoch) return
590 if (res?.blocked) {
591 // The Archive is the sharpest elicitation vector — a block here is
592 // honest and visible, not a silent empty result.
593 this.archiveNotice = res.error || 'That lookup was blocked by content moderation.'
594 } else if (res?.success && res.lookup) {
595 this.archiveResult = res.lookup
596 }
597 } catch (error) {
598 if (epoch !== this.runEpoch) return
599 console.error('Archive lookup failed:', error)
600 } finally {
601 if (epoch === this.runEpoch) this.lookupLoading = false
602 }
603 },
604 
605 /**
606 * Loads era-relevant figure suggestions for the current objective (the
607 * educational on-ramp). Cached per objective; on any failure it leaves the
608 * list empty so the UI falls back to its generic starters.
609 */
610 async loadSuggestions(): Promise<void> {
611 const objective = this.currentObjective
612 if (!objective) {
613 this.figureSuggestions = []
614 this.suggestionsFor = ''
615 return
616 }
617 if (this.suggestionsFor === objective.title && this.figureSuggestions.length) return
618 
619 const epoch = this.runEpoch
620 this.suggestionsLoading = true
621 this.suggestionsNotice = null
622 try {
623 const res = await $fetch('/api/suggestions', {
624 method: 'POST',
625 headers: await this.aiHeaders({ 'Content-Type': 'application/json' }),
626 body: {
627 objective: {
628 title: objective.title,
629 description: objective.description,
630 era: objective.era
631 }
632 }
633 }) as { success?: boolean; suggestions?: FigureSuggestion[]; blocked?: boolean; reason?: string }
634 if (epoch !== this.runEpoch) return
635 // A blocked objective must not masquerade as an empty result — surface it.
636 if (res?.blocked) this.suggestionsNotice = res.reason || 'That objective was blocked by content moderation.'
637 this.figureSuggestions = res?.suggestions ?? []
638 this.suggestionsFor = objective.title
639 } catch (error) {
640 if (epoch !== this.runEpoch) return
641 console.error('Failed to load figure suggestions:', error)
642 this.figureSuggestions = []
643 // Record that this objective WAS asked, even though it failed — the
644 // picker uses suggestionsFor to tell "not yet asked" (skeletons)
645 // from "asked and came up empty" (the honest famous-names fallback).
646 this.suggestionsFor = objective.title
647 } finally {
648 if (epoch === this.runEpoch) this.suggestionsLoading = false
649 }
650 },
651 
652 // ---------- timeline ledger ----------
653 addTimelineEvent(
654 evt: Omit<TimelineEvent, 'id' | 'timestamp' | 'valence'> & { valence?: Valence; timestamp?: Date }
655 ) {
656 this.timelineEvents.push({
657 id: `evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
658 figureName: evt.figureName,
659 era: evt.era,
660 headline: evt.headline,
661 detail: evt.detail,
662 diceRoll: evt.diceRoll,
663 diceOutcome: evt.diceOutcome,
664 progressChange: evt.progressChange,
665 baseProgressChange: evt.baseProgressChange,
666 valence: evt.valence ?? valenceOf(evt.progressChange),
667 anachronism: evt.anachronism,
668 causalChain: evt.causalChain,
669 craft: evt.craft,
670 whenSigned: evt.whenSigned,
671 staked: evt.staked,
672 timestamp: evt.timestamp ?? new Date()
673 })
674 },
675 
676 // ---------- the living chronicle (Layer 3) ----------
677 /**
678 * Re-narrates the Chronicle from the freshly-bent timeline. Fired
679 * non-blocking after a resolved turn (and at game end): the dice/progress
680 * reveal never waits on prose. True to the game's name, the WHOLE account is
681 * rewritten each turn — a later change can re-frame how earlier events read.
682 * Graceful: a failed refresh keeps the prior telling, so the panel never
683 * blanks; an empty timeline clears it (nothing to chronicle yet).
684 */
685 async refreshChronicle(): Promise<void> {
686 if (!this.timelineEvents.length) {
687 this.chronicle = null
688 return
689 }
690 // Sequencing: rewrites overlap (each turn fires one, never awaited), so
691 // only the LATEST issued refresh may land — an earlier telling arriving
692 // late must not regress the account (or worse, the epilogue). The epoch
693 // guard keeps a dead run's telling out of a fresh run entirely.
694 const epoch = this.runEpoch
695 const seq = ++this.chronicleSeq
696 this.chronicleLoading = true
697 try {
698 const res = await $fetch('/api/chronicle', {
699 method: 'POST',
700 headers: await this.aiHeaders({ 'Content-Type': 'application/json' }),
701 body: {
702 objective: this.currentObjective,
703 status: this.gameStatus,
704 progress: this.objectiveProgress,
705 timeline: this.timelineEvents.map(e => ({
706 era: e.era,
707 figureName: e.figureName,
708 headline: e.headline,
709 detail: e.detail,
710 progressChange: e.progressChange
711 }))
712 }
713 }) as { success?: boolean; chronicle?: ChronicleEntry }
714 if (epoch !== this.runEpoch || seq !== this.chronicleSeq) return
715 if (res?.success && res.chronicle) this.chronicle = res.chronicle
716 } catch (error) {
717 if (epoch !== this.runEpoch || seq !== this.chronicleSeq) return
718 console.error('Failed to refresh the chronicle:', error)
719 // Keep the prior chronicle — a failed refresh never blanks the panel.
720 } finally {
721 if (epoch === this.runEpoch && seq === this.chronicleSeq) this.chronicleLoading = false
722 }
723 },
724 
725 // ---------- simple setters ----------
726 setLoading(loading: boolean) { this.isLoading = loading },
727 setError(error: string | null) { this.error = error },
728 clearModerationNotice() { this.moderationNotice = null },
729 clearArchiveNotice() { this.archiveNotice = null },
730 
731 // ---------- rate limiting ----------
732 checkRateLimit(): boolean {
733 const now = Date.now()
734 if (this.lastMessageTime && (now - this.lastMessageTime) < RATE_LIMIT_MS) return false
735 return true
736 },
737 
738 setRateLimit() {
739 this.isRateLimited = true
740 const remainingTime = this.lastMessageTime
741 ? Math.max(0, RATE_LIMIT_MS - (Date.now() - this.lastMessageTime))
742 : 0
743 setTimeout(() => { this.isRateLimited = false }, remainingTime || RATE_LIMIT_MS)
744 },
745 
746 // ---------- counter & status ----------
747 decrementMessages() {
748 if (this.remainingMessages > 0) this.remainingMessages--
749 },
750 
751 /**
752 * Rolls back an unresolved turn: refunds the spent message and drops the
753 * dangling user entry, so the counter and history can't drift out of sync.
754 * Used for BOTH failure shapes — a thrown request and a graceful HTTP-200
755 * `success:false` — an infra hiccup must never burn one of the five
756 * dispatches (or, on the last one, convert into an instant unearned defeat).
757 */
758 refundUnresolvedTurn() {
759 if (this.remainingMessages < TOTAL_MESSAGES) this.remainingMessages++
760 for (let i = this.messageHistory.length - 1; i >= 0; i--) {
761 if (this.messageHistory[i].sender === 'user') {
762 this.messageHistory.splice(i, 1)
763 break
764 }
765 }
766 },
767 
768 applyProgress(progressChange: number) {
769 this.objectiveProgress = Math.max(0, Math.min(MAX_PROGRESS, this.objectiveProgress + progressChange))
770 },
771 
772 /**
773 * Resolves win/lose AFTER a turn's progress has been applied.
774 *
775 * Victory the instant the objective hits 100%; defeat only once the
776 * final message is spent without reaching it. This fixes the old
777 * premature-defeat bug, where status flipped on the last decrement
778 * BEFORE that turn's progress had a chance to land.
779 */
780 resolveGameStatus() {
781 if (this.objectiveProgress >= MAX_PROGRESS) {
782 this.gameStatus = 'victory'
783 } else if (this.remainingMessages <= 0) {
784 this.gameStatus = 'defeat'
785 }
786 },
787 
788 // ---------- the turn ----------
789 /**
790 * Sends a 160-char message to a chosen figure and folds the result back
791 * into the world: the figure replies + acts, the dice decide the swing,
792 * and the Timeline Engine records how history bent.
793 *
794 * Returns `true` only when the turn actually RESOLVED (a ledger entry
795 * landed). A blocked or failed send returns `false` so the composer can
796 * keep the player's crafted words instead of wiping them.
797 *
798 * `opts.stake` arms the last stand — honored only when `canStake` holds
799 * (final message, win out of normal reach; the server doubles the resolved
800 * swing, both ways, past the usual cap).
801 */
802 async sendMessage(text: string, figureName?: string, opts?: { stake?: boolean }): Promise<boolean> {
803 const target = (figureName ?? this.activeFigureName ?? '').trim()
804 const stake = opts?.stake === true && this.canStake
805 
806 if (!this.canSendMessage) return false
807 if (!target) {
808 this.setError('Choose who in history to send your message to first.')
809 return false
810 }
811 if (!this.checkRateLimit()) {
812 this.setRateLimit()
813 this.setError('The timeline needs a moment — wait before sending again.')
814 return false
815 }
816 if (!this.canContact) {
817 const who = this.figureGrounding?.name || target
818 this.setError(
819 this.contactLiveness === 'before-birth'
820 ? `${who} isn't born yet in that year — choose a later moment to reach them.`
821 : `${who} has died by that year — choose an earlier moment to reach them.`
822 )
823 return false
824 }
825 
826 this.setError(null)
827 this.moderationNotice = null
828 this.setActiveFigure(target)
829 this.addUserMessage(text, target)
830 this.decrementMessages()
831 this.setLoading(true)
832 this.lastMessageTime = Date.now()
833 
834 // If the run is reset while this request is in flight, every write below
835 // would land in a world that no longer exists — the epoch guard drops the
836 // response (no fold-in, no refund: the new run's counter is not ours).
837 const epoch = this.runEpoch
838 const ledgerBefore = this.timelineEvents.length
839 // Capture the contact year NOW: the slider can move while the request is
840 // in flight, and the ledger must record the year this turn was SENT to.
841 const sentWhen = this.contactWhen
842 const sentMoment = this.contactMoment
843 let resolved = false
844 
845 try {
846 const response = await $fetch<SendMessageApiResponse>('/api/send-message', {
847 method: 'POST',
848 headers: await this.aiHeaders({ 'Content-Type': 'application/json' }),
849 body: {
850 message: text,
851 figureName: target,
852 when: sentWhen != null ? formatContactMoment(sentWhen, sentMoment) : undefined,
853 whenSigned: sentWhen ?? undefined,
854 // The pinned moment travels as validated integers; the server
855 // re-derives the display string rather than trusting ours.
856 whenMonth: sentWhen != null ? sentMoment?.month : undefined,
857 whenDay: sentWhen != null ? sentMoment?.day : undefined,
858 stake,
859 figureContext: this.figureGrounding?.resolved
860 ? {
861 description: this.figureGrounding.description,
862 lifespan: lifespanText(this.figureGrounding)
863 }
864 : undefined,
865 objective: this.currentObjective,
866 timeline: this.timelineEvents.map(e => ({
867 era: e.era,
868 figureName: e.figureName,
869 headline: e.headline,
870 detail: e.detail,
871 progressChange: e.progressChange,
872 whenSigned: e.whenSigned
873 })),
874 conversationHistory: this.conversationWith(target)
875 }
876 })
877 
878 if (epoch !== this.runEpoch) return false
879 
880 if (response?.success && response.data?.characterResponse) {
881 const { figure, characterResponse, diceRoll, diceOutcome, timeline } = response.data
882 
883 if (figure?.name) {
884 this.registerFigure({
885 name: figure.name,
886 era: figure.era || '',
887 descriptor: figure.descriptor || ''
888 })
889 }
890 
891 this.addAIMessageWithData({
892 text: characterResponse.message,
893 sender: 'ai',
894 figureName: target,
895 timestamp: new Date(),
896 diceRoll,
897 diceOutcome,
898 naturalRoll: response.data.naturalRoll ?? diceRoll,
899 rollModifier: response.data.rollModifier ?? 0,
900 craft: response.data.craft,
901 craftReason: response.data.craftReason,
902 characterAction: characterResponse.action,
903 timelineImpact: timeline?.detail,
904 progressChange: timeline?.progressChange,
905 baseProgressChange: timeline?.baseProgressChange,
906 anachronism: timeline?.anachronism,
907 causalChain: timeline?.causalChain,
908 staked: response.data.staked
909 })
910 
911 if (timeline) {
912 this.addTimelineEvent({
913 figureName: target,
914 era: timeline.era || figure?.era || (this.currentObjective?.era ?? ''),
915 headline: timeline.headline,
916 detail: timeline.detail,
917 diceRoll,
918 diceOutcome,
919 progressChange: timeline.progressChange ?? 0,
920 baseProgressChange: timeline.baseProgressChange,
921 valence: timeline.valence,
922 anachronism: timeline.anachronism,
923 causalChain: timeline.causalChain,
924 craft: response.data.craft,
925 whenSigned: sentWhen ?? undefined,
926 staked: response.data.staked
927 })
928 // Use !== undefined so a genuine neutral (0%) turn still
929 // registers instead of silently vanishing.
930 if (timeline.progressChange !== undefined) {
931 this.applyProgress(timeline.progressChange)
932 }
933 resolved = true
934 }
935 } else {
936 // A graceful failure (HTTP 200, success:false): an AI layer gave
937 // out mid-turn. No ripple landed, so the dispatch is refunded —
938 // exactly like the thrown path below.
939 // Narrow to the failure variant (its data carries `blocked`).
940 const failed = response && !response.success ? response.data : undefined
941 if (failed?.blocked) {
942 // A content-moderation block, not an infra hiccup — show an
943 // honest, distinct banner (not "try again"). The composer
944 // keeps the player's words (sendMessage returns false).
945 this.moderationNotice = failed.moderationReason || 'That dispatch was blocked by content moderation and cannot be sent.'
946 } else {
947 this.setError(failed?.error || 'History did not answer. Try again.')
948 }
949 this.refundUnresolvedTurn()
950 }
951 } catch (error) {
952 if (epoch !== this.runEpoch) return false
953 console.error('Error sending message:', error)
954 this.setError('The timeline resisted your message. Please try again.')
955 this.refundUnresolvedTurn()
956 } finally {
957 if (epoch === this.runEpoch) {
958 this.setLoading(false)
959 this.resolveGameStatus()
960 }
961 }
962 
963 // The living chronicle re-narrates the world from the new timeline — but
964 // only when this turn actually added a change, and never awaited: the turn
965 // reveal must not wait on prose. By here the status is resolved, so the
966 // final turn's chronicle carries the victory/defeat framing (the epilogue).
967 if (resolved && this.timelineEvents.length > ledgerBefore) {
968 void this.refreshChronicle()
969 }
970 return resolved
971 },
972 
973 /**
974 * Commits a chosen objective and starts the run from a clean slate of
975 * progress. Used by the mission-select screen for both curated picks and
976 * freshly composed ones.
977 */
978 chooseObjective(objective: GameObjective) {
979 this.currentObjective = objective
980 this.objectiveProgress = 0
981 this.error = null
982 },
983 
984 /**
985 * Asks the server to compose a fresh objective with the model, WITHOUT
986 * committing it — the caller previews it, then commits via chooseObjective.
987 * Generation lives server-side because it needs the OpenAI key (the client
988 * has no access to it). Returns null rather than throwing if composing
989 * fails, so the UI can quietly fall back to the curated pool.
990 */
991 async fetchAIObjective(): Promise<GameObjective | null> {
992 try {
993 const res = await $fetch('/api/objective', { method: 'GET', headers: await this.aiHeaders() }) as {
994 success?: boolean
995 objective?: GameObjective
996 }
997 return res?.success && res.objective ? res.objective : null
998 } catch (error) {
999 console.error('Failed to compose a fresh objective:', error)
1000 return null
1002 },
1004 getVictoryEfficiency() {
1005 if (this.gameStatus === 'victory') {
1006 const messagesSaved = this.remainingMessages
1007 const messagesUsed = TOTAL_MESSAGES - this.remainingMessages
1008 const efficiencyPercentage = Math.round((messagesSaved / TOTAL_MESSAGES) * 100)
1010 const efficiencyRating = rateEfficiency(messagesSaved)
1012 return {
1013 messagesSaved,
1014 messagesUsed,
1015 efficiencyPercentage,
1016 efficiencyRating,
1017 isEarlyVictory: messagesSaved > 0
1020 return null
1021 },
1023 resetGame() {
1024 // Tear the epoch first: anything still in flight belongs to the old run
1025 // and must find no purchase here. (The seq counters deliberately survive.)
1026 this.runEpoch++
1027 this.remainingMessages = TOTAL_MESSAGES
1028 this.messageHistory = []
1029 this.gameStatus = 'playing'
1030 this.isLoading = false
1031 this.error = null
1032 this.lastMessageTime = null
1033 this.isRateLimited = false
1034 // A new run gets a fresh id on its next server call; abandon any
1035 // in-flight mint so a dead run's id can't land in the new run.
1036 this.runId = null
1037 this.runIdInflight = null
1038 this.currentObjective = null
1039 this.objectiveProgress = 0
1040 this.timelineEvents = []
1041 this.figures = []
1042 this.activeFigureName = ''
1043 this.figureGrounding = null
1044 this.groundingLoading = false
1045 this.contactWhen = null
1046 this.contactMoment = null
1047 this.figureSuggestions = []
1048 this.suggestionsLoading = false
1049 this.suggestionsFor = ''
1050 this.chronicle = null
1051 this.chronicleLoading = false
1052 this.figureStudy = null
1053 this.studyLoading = false
1054 this.studyFor = ''
1055 this.studyWhen = null
1056 this.archiveResult = null
1057 this.lookupLoading = false
1058 this.moderationNotice = null
1059 this.archiveNotice = null
1060 this.studyNotice = null
1061 this.suggestionsNotice = null

The table, and how it's reached

The migration is one table, kept to id + created_at — nothing speculative. RLS is on with no policy on purpose: the anon key can touch nothing, while the service (secret) key bypasses RLS — so the table is server-only by construction. The secret key reaches the server through private runtimeConfig and never the client.

id / created_at; RLS on, no policy = service-key-only.

supabase/migrations/0001_create_runs.sql · 15 lines
supabase/migrations/0001_create_runs.sql15 lines · Transact-SQL
1-- runs: one row per game run — the server-side fact a run id anchors to.
2--
3-- Cost-per-run stays a projection of the [ai] telemetry, not a column here; this
4-- table records a run's existence (and, later, its lifecycle and the balance it
5-- spends). The server mints the id and inserts via the service (secret) key.
6--
7-- RLS is on with NO policy on purpose: the anon/publishable key can touch
8-- nothing, while the service/secret key bypasses RLS — exactly the server-only
9-- access the run store needs.
10create table if not exists public.runs (
11 id uuid primary key,
12 created_at timestamptz not null default now()
13);
14 
15alter table public.runs enable row level security;

Supabase creds as private (server-only) runtimeConfig; absent → in-memory.

nuxt.config.ts · 48 lines
nuxt.config.ts48 lines · TypeScript
⋯ 29 lines hidden (lines 1–29)
1/**
2 * Nuxt 3 Configuration
3 * https://nuxt.com/docs/api/configuration/nuxt-config
4 */
5export default defineNuxtConfig({
6 // Ensure compatibility with modern Nuxt features
7 compatibilityDate: '2025-05-15',
8 
9 // Enable devtools for development
10 devtools: { enabled: true },
11 
12 // Register required modules
13 modules: ['@nuxt/ui', '@pinia/nuxt'],
14 
15 // Global CSS imports
16 css: ['~/assets/css/main.css'],
17 
18 // Color mode (via @nuxtjs/color-mode, bundled with @nuxt/ui). classSuffix '' so the
19 // class on <html> is exactly `dark` / `light` — what the .dark token block and
20 // Tailwind's dark: variant key off. Defaulting to `system` respects the OS pref, and
21 // the module's inline no-flash script + cookie persistence replace the old manual
22 // classList toggle (which didn't persist and could FOUC).
23 colorMode: {
24 classSuffix: '',
25 preference: 'system',
26 fallback: 'light'
27 },
28 
29 // Runtime configuration
30 runtimeConfig: {
31 // Private keys (only available on server-side)
32 openaiApiKey: process.env.OPENAI_API_KEY,
33 anthropicApiKey: process.env.ANTHROPIC_API_KEY,
34 // Supabase — the run store (server-side only). Absent → in-memory dev store.
35 supabaseUrl: process.env.SUPABASE_URL,
36 supabaseSecretKey: process.env.SUPABASE_SECRET_KEY,
37 // Public keys (exposed to client-side)
38 public: {}
39 },
⋯ 9 lines hidden (lines 40–48)
40 
41 // The standalone agents/ tooling package has its own deps + tsconfig; keep it
42 // out of the app's type program so `nuxt typecheck` never compiles the loops.
43 typescript: {
44 tsConfig: {
45 exclude: ['../agents']
46 }
47 }
48})