What changed, and why

This is the spine that turns a run into a currency. #46 measured cost-per-run; #47 made a run a server row. This makes a run a thing you spend: a new device gets one free run, the balance is charged when the player commits to an objective, and out of runs raises a paywall. It's the heart of "sell runs" — Stripe packs and a global spend cap are the next two vertebrae.

Five small pieces: a balance store (the ledger), an httpOnly device cookie (the trial identity), a commit endpoint (the gate), a gated chooseObjective on the client, and a one-function migration. No accounts, no payment yet.

The ledger — one port, two adapters

The balance store mirrors the run store: in-memory by default (dev + tests need nothing), Supabase when configured. A brand-new device is granted FREE_RUNS; chargeRun spends one run, and charged:false is the paywall. Idempotency is keyed to (device, run), not the run alone — so a second device can't ride another's already-charged run for free (chargedRuns maps a run id to the device that paid).

The port, the free grant, and the in-memory adapter (mirrors the SQL function).

server/utils/balance-store.ts · 116 lines
server/utils/balance-store.ts116 lines · TypeScript
⋯ 18 lines hidden (lines 1–18)
1/**
2 * The run balance — the ledger that makes a run a thing you spend. A brand-new
3 * device gets FREE_RUNS for free (the doc's "first run free"); chargeRun spends
4 * one run when the player commits to an objective, once per run. Out of runs →
5 * the commit is refused (the paywall). Packs (Stripe) credit this balance later.
6 *
7 * Same two-adapter shape as the run store: in-memory by default (dev + tests need
8 * nothing), Supabase when configured. Keyed by an opaque device id (a server-set
9 * cookie) until accounts arrive, when a user id will supersede it.
10 *
11 * The Supabase charge goes through one SQL function (spend_run) so the
12 * ensure-grant, the idempotency check, and the decrement are atomic in a single
13 * round-trip — no read-modify-write race. The in-memory adapter mirrors that
14 * logic exactly.
15 */
16import { createClient, type SupabaseClient } from '@supabase/supabase-js'
17import { supabaseConfig } from './supabase'
18 
19/** Runs granted to a brand-new device — the free trial ("first run free").
20 * Mirrored by the spend_run SQL function; keep the two in sync. */
21export const FREE_RUNS = 1
22 
23export interface ChargeResult {
24 /** False only when the device is out of runs (the paywall). */
25 charged: boolean
26 runsRemaining: number
28 
29export interface BalanceStore {
30 /** Ensure a balance row for the device, granting the free trial on first
31 * sight; returns the current runs remaining. */
32 ensureDevice(deviceId: string): Promise<number>
33 /** Spend one run for the device, once per runId (idempotent — re-committing
34 * the same run never double-charges). charged=false means out of runs. */
35 chargeRun(deviceId: string, runId: string): Promise<ChargeResult>
36 /** Runs remaining for the device (0 if unknown). */
37 remaining(deviceId: string): Promise<number>
39 
40/** Dev default — process-local balances, durable enough for dev. Mirrors the
41 * spend_run SQL function: free grant on first sight, one charge per run. */
42export class InMemoryBalanceStore implements BalanceStore {
43 private readonly balances = new Map<string, number>()
44 // runId → the device that charged it. Idempotency is per (device, run), so a
45 // different device can't ride another's already-charged run for free.
46 private readonly chargedRuns = new Map<string, string>()
47 
48 async ensureDevice(deviceId: string): Promise<number> {
49 if (!this.balances.has(deviceId)) this.balances.set(deviceId, FREE_RUNS)
50 return this.balances.get(deviceId) ?? 0
51 }
52 
53 async chargeRun(deviceId: string, runId: string): Promise<ChargeResult> {
54 const remaining = await this.ensureDevice(deviceId)
55 if (this.chargedRuns.get(runId) === deviceId) return { charged: true, runsRemaining: remaining }
56 if (remaining <= 0) return { charged: false, runsRemaining: 0 }
57 this.balances.set(deviceId, remaining - 1)
58 this.chargedRuns.set(runId, deviceId)
59 return { charged: true, runsRemaining: remaining - 1 }
60 }
61 
62 async remaining(deviceId: string): Promise<number> {
63 return this.balances.get(deviceId) ?? 0
64 }
66 
⋯ 50 lines hidden (lines 67–116)
67/** Service-key (RLS-bypassing) balance access; the table has no public policy. */
68export class SupabaseBalanceStore implements BalanceStore {
69 constructor(private readonly client: SupabaseClient) {}
70 
71 async ensureDevice(deviceId: string): Promise<number> {
72 const { error } = await this.client
73 .from('balances')
74 .insert({ device_id: deviceId, runs_remaining: FREE_RUNS })
75 // 23505 = unique_violation: the row already exists, which is fine.
76 if (error && error.code !== '23505') throw new Error(`balance ensure failed: ${error.message}`)
77 return this.remaining(deviceId)
78 }
79 
80 async chargeRun(deviceId: string, runId: string): Promise<ChargeResult> {
81 const { data, error } = await this.client.rpc('spend_run', {
82 p_device: deviceId,
83 p_run: runId
84 })
85 if (error) throw new Error(`spend_run failed: ${error.message}`)
86 const row = (Array.isArray(data) ? data[0] : data) as { charged: boolean; runs_remaining: number }
87 return { charged: row.charged, runsRemaining: row.runs_remaining }
88 }
89 
90 async remaining(deviceId: string): Promise<number> {
91 const { data, error } = await this.client
92 .from('balances')
93 .select('runs_remaining')
94 .eq('device_id', deviceId)
95 .maybeSingle()
96 if (error) throw new Error(`balance read failed: ${error.message}`)
97 return (data?.runs_remaining as number | undefined) ?? 0
98 }
100 
101let cached: BalanceStore | null = null
102 
103/** The configured balance store (memoized). */
104export function balanceStore(): BalanceStore {
105 if (cached) return cached
106 const cfg = supabaseConfig()
107 cached = cfg
108 ? new SupabaseBalanceStore(createClient(cfg.url, cfg.secretKey, { auth: { persistSession: false } }))
109 : new InMemoryBalanceStore()
110 return cached
112 
113/** Test seam: clear the memoized store so the next balanceStore() re-reads config. */
114export function resetBalanceStore(): void {
115 cached = null

The Supabase charge does not read-modify-write. It calls one SQL function, spend_run, so the free grant, the idempotency check, and the conditional decrement happen atomically in a single round-trip.

The Supabase adapter: chargeRun is one rpc('spend_run'); ensureDevice tolerates the unique-violation.

server/utils/balance-store.ts · 116 lines
server/utils/balance-store.ts116 lines · TypeScript
⋯ 67 lines hidden (lines 1–67)
1/**
2 * The run balance — the ledger that makes a run a thing you spend. A brand-new
3 * device gets FREE_RUNS for free (the doc's "first run free"); chargeRun spends
4 * one run when the player commits to an objective, once per run. Out of runs →
5 * the commit is refused (the paywall). Packs (Stripe) credit this balance later.
6 *
7 * Same two-adapter shape as the run store: in-memory by default (dev + tests need
8 * nothing), Supabase when configured. Keyed by an opaque device id (a server-set
9 * cookie) until accounts arrive, when a user id will supersede it.
10 *
11 * The Supabase charge goes through one SQL function (spend_run) so the
12 * ensure-grant, the idempotency check, and the decrement are atomic in a single
13 * round-trip — no read-modify-write race. The in-memory adapter mirrors that
14 * logic exactly.
15 */
16import { createClient, type SupabaseClient } from '@supabase/supabase-js'
17import { supabaseConfig } from './supabase'
18 
19/** Runs granted to a brand-new device — the free trial ("first run free").
20 * Mirrored by the spend_run SQL function; keep the two in sync. */
21export const FREE_RUNS = 1
22 
23export interface ChargeResult {
24 /** False only when the device is out of runs (the paywall). */
25 charged: boolean
26 runsRemaining: number
28 
29export interface BalanceStore {
30 /** Ensure a balance row for the device, granting the free trial on first
31 * sight; returns the current runs remaining. */
32 ensureDevice(deviceId: string): Promise<number>
33 /** Spend one run for the device, once per runId (idempotent — re-committing
34 * the same run never double-charges). charged=false means out of runs. */
35 chargeRun(deviceId: string, runId: string): Promise<ChargeResult>
36 /** Runs remaining for the device (0 if unknown). */
37 remaining(deviceId: string): Promise<number>
39 
40/** Dev default — process-local balances, durable enough for dev. Mirrors the
41 * spend_run SQL function: free grant on first sight, one charge per run. */
42export class InMemoryBalanceStore implements BalanceStore {
43 private readonly balances = new Map<string, number>()
44 // runId → the device that charged it. Idempotency is per (device, run), so a
45 // different device can't ride another's already-charged run for free.
46 private readonly chargedRuns = new Map<string, string>()
47 
48 async ensureDevice(deviceId: string): Promise<number> {
49 if (!this.balances.has(deviceId)) this.balances.set(deviceId, FREE_RUNS)
50 return this.balances.get(deviceId) ?? 0
51 }
52 
53 async chargeRun(deviceId: string, runId: string): Promise<ChargeResult> {
54 const remaining = await this.ensureDevice(deviceId)
55 if (this.chargedRuns.get(runId) === deviceId) return { charged: true, runsRemaining: remaining }
56 if (remaining <= 0) return { charged: false, runsRemaining: 0 }
57 this.balances.set(deviceId, remaining - 1)
58 this.chargedRuns.set(runId, deviceId)
59 return { charged: true, runsRemaining: remaining - 1 }
60 }
61 
62 async remaining(deviceId: string): Promise<number> {
63 return this.balances.get(deviceId) ?? 0
64 }
66 
67/** Service-key (RLS-bypassing) balance access; the table has no public policy. */
68export class SupabaseBalanceStore implements BalanceStore {
69 constructor(private readonly client: SupabaseClient) {}
70 
71 async ensureDevice(deviceId: string): Promise<number> {
72 const { error } = await this.client
73 .from('balances')
74 .insert({ device_id: deviceId, runs_remaining: FREE_RUNS })
75 // 23505 = unique_violation: the row already exists, which is fine.
76 if (error && error.code !== '23505') throw new Error(`balance ensure failed: ${error.message}`)
77 return this.remaining(deviceId)
78 }
79 
80 async chargeRun(deviceId: string, runId: string): Promise<ChargeResult> {
81 const { data, error } = await this.client.rpc('spend_run', {
82 p_device: deviceId,
83 p_run: runId
84 })
85 if (error) throw new Error(`spend_run failed: ${error.message}`)
86 const row = (Array.isArray(data) ? data[0] : data) as { charged: boolean; runs_remaining: number }
87 return { charged: row.charged, runsRemaining: row.runs_remaining }
88 }
89 
90 async remaining(deviceId: string): Promise<number> {
91 const { data, error } = await this.client
92 .from('balances')
93 .select('runs_remaining')
94 .eq('device_id', deviceId)
95 .maybeSingle()
96 if (error) throw new Error(`balance read failed: ${error.message}`)
97 return (data?.runs_remaining as number | undefined) ?? 0
98 }
100 
101let cached: BalanceStore | null = null
⋯ 15 lines hidden (lines 102–116)
102 
103/** The configured balance store (memoized). */
104export function balanceStore(): BalanceStore {
105 if (cached) return cached
106 const cfg = supabaseConfig()
107 cached = cfg
108 ? new SupabaseBalanceStore(createClient(cfg.url, cfg.secretKey, { auth: { persistSession: false } }))
109 : new InMemoryBalanceStore()
110 return cached
112 
113/** Test seam: clear the memoized store so the next balanceStore() re-reads config. */
114export function resetBalanceStore(): void {
115 cached = null

The gate

The client calls this when the player commits to an objective. It spends one run for the run id and returns the new balance, or 402 when out. A run id that isn't a server-minted UUID means begin-run degraded to a local id (a store outage) — that run was never recorded, so it isn't gated; the client is allowed to play. The honest caveat: this, and a forged id, are uncharged until the AI endpoints verify the run id server-side (a tracked follow-up).

Validate (strict UUID), resolve the device, charge — 402 on out-of-runs, degrade on a non-UUID id.

server/api/run-commit.post.ts · 48 lines
server/api/run-commit.post.ts48 lines · TypeScript
⋯ 23 lines hidden (lines 1–23)
1/**
2 * POST /api/run-commit — the paywall gate. The client calls this when the player
3 * commits to an objective. It spends one run from the device's balance for this
4 * runId (once, idempotently), granting the free trial on the device's first
5 * sight. Returns { runsRemaining } when charged, or 402 when out of runs.
6 *
7 * A run id that isn't a server-minted UUID means begin-run degraded to a local id
8 * (a store outage) — that run was never recorded, so it isn't gated here; the
9 * client is allowed to play (best-effort, uncharged). NOTE: there is no
10 * server-enforced spend backstop yet — this gap (and a forged id) means uncharged
11 * play and uncharged AI spend until the AI endpoints verify the run id and the
12 * spend-cap vertebra lands. Both are tracked follow-ups, acceptable only while the
13 * alpha sits behind basic auth.
14 */
15import { deviceId } from '~/server/utils/device'
16import { balanceStore } from '~/server/utils/balance-store'
17import { isUuid } from '~/server/utils/uuid'
18 
19export default defineEventHandler(async (event) => {
20 if (getMethod(event) !== 'POST') {
21 throw createError({ statusCode: 405, statusMessage: 'Method Not Allowed' })
22 }
23 const body = await readBody(event)
24 const runId = typeof body?.runId === 'string' ? body.runId.replace(/[^a-zA-Z0-9-]/g, '').slice(0, 64) : ''
25 if (!runId) {
26 throw createError({ statusCode: 400, statusMessage: 'Bad Request: runId is required' })
27 }
28 
29 const device = deviceId(event)
30 // A degraded (non-UUID) run was never recorded server-side — don't gate it.
31 if (!isUuid(runId)) return { runsRemaining: -1, degraded: true }
32 
33 let result
34 try {
35 result = await balanceStore().chargeRun(device, runId)
36 } catch (error) {
37 console.error('Run commit failed:', error)
38 throw createError({ statusCode: 500, statusMessage: 'Internal Server Error' })
39 }
40 if (!result.charged) {
41 throw createError({
42 statusCode: 402,
43 statusMessage: 'Out of runs',
44 data: { runsRemaining: result.runsRemaining }
45 })
46 }
47 return { runsRemaining: result.runsRemaining }
48})

The client — gated commit, paywall on refusal

chooseObjective is now async and gated. It charges the run; if the device is out (402, in any of ofetch's error shapes) it raises the paywall and the run does not start. Any other failure degrades to allowing the run — a store outage must never block play. MissionSelect shows the paywall notice.

Charge at commit; 402 → paywall + don't start; other errors → degrade and start.

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

The table, and the atomic charge

One migration: the balances table (RLS on, no policy → service-key-only, like runs), two nullable columns on runs to record who spent a run and when, and the spend_run function. The function is where correctness lives: it locks the run row (for update) so two commits of the same run serialize — without that, one caller hit a spurious 402 after the other's decrement — and the conditional update ... where runs_remaining > 0 can't lose a race the way a read-then-write would. The idempotent no-op is gated on the owning device.

balances (server-only) and the two run columns.

supabase/migrations/0002_balances.sql · 63 lines
supabase/migrations/0002_balances.sql63 lines · Transact-SQL
1-- balances: the run ledger, keyed by the trial device id (a server-set cookie).
2-- A new device is granted the free trial; spend_run decrements it at commit.
3-- RLS on with no policy: service-key-only, like runs.
4create table if not exists public.balances (
5 device_id text primary key,
6 runs_remaining int not null default 0,
7 updated_at timestamptz not null default now()
8);
9 
10alter table public.balances enable row level security;
11 
12-- Tie a run to who spent it and when (set at commit). Both nullable: an
13-- uncommitted run has neither.
14alter table public.runs add column if not exists device_id text;
15alter table public.runs add column if not exists charged_at timestamptz;
⋯ 48 lines hidden (lines 16–63)
16 
17-- spend_run: grant the device's free trial on first sight, then charge ONE run
18-- for p_run — idempotently (a re-commit of the same run doesn't double-charge)
19-- and atomically (the conditional decrement can't race a read-modify-write).
20-- Returns whether it charged and the resulting balance. The free-grant constant
21-- mirrors FREE_RUNS in server/utils/balance-store.ts — keep the two in sync.
22create or replace function public.spend_run(p_device text, p_run uuid)
23returns table(charged boolean, runs_remaining int)
24language plpgsql as $$
25declare
26 v_remaining int;
27 v_charged_at timestamptz;
28 v_owner text;
29begin
30 insert into public.balances(device_id, runs_remaining)
31 values (p_device, 1)
32 on conflict (device_id) do nothing;
33 
34 -- Lock the run row so concurrent commits of the SAME run serialize here;
35 -- without the lock both pass the not-yet-charged guard and one gets a
36 -- spurious 402 after the other's decrement. Read the owner too: idempotency
37 -- is per (device, run), so only the device that charged this run gets the
38 -- no-op return — a different device falls through to its own decrement (or a
39 -- 402), never a free pass on someone else's already-charged run.
40 select r.charged_at, r.device_id into v_charged_at, v_owner
41 from public.runs r where r.id = p_run
42 for update;
43 if v_charged_at is not null and v_owner = p_device then
44 select b.runs_remaining into v_remaining from public.balances b where b.device_id = p_device;
45 return query select true, coalesce(v_remaining, 0);
46 return;
47 end if;
48 
49 -- Atomic conditional decrement: charges only if a run is available.
50 update public.balances b
51 set runs_remaining = b.runs_remaining - 1, updated_at = now()
52 where b.device_id = p_device and b.runs_remaining > 0
53 returning b.runs_remaining into v_remaining;
54 if not found then
55 select b.runs_remaining into v_remaining from public.balances b where b.device_id = p_device;
56 return query select false, coalesce(v_remaining, 0);
57 return;
58 end if;
59 
60 update public.runs set charged_at = now(), device_id = p_device where id = p_run;
61 return query select true, v_remaining;
62end;
63$$;

spend_run: free grant, FOR UPDATE serialization, per-(device,run) idempotency, atomic decrement.

supabase/migrations/0002_balances.sql · 63 lines
supabase/migrations/0002_balances.sql63 lines · Transact-SQL
⋯ 16 lines hidden (lines 1–16)
1-- balances: the run ledger, keyed by the trial device id (a server-set cookie).
2-- A new device is granted the free trial; spend_run decrements it at commit.
3-- RLS on with no policy: service-key-only, like runs.
4create table if not exists public.balances (
5 device_id text primary key,
6 runs_remaining int not null default 0,
7 updated_at timestamptz not null default now()
8);
9 
10alter table public.balances enable row level security;
11 
12-- Tie a run to who spent it and when (set at commit). Both nullable: an
13-- uncommitted run has neither.
14alter table public.runs add column if not exists device_id text;
15alter table public.runs add column if not exists charged_at timestamptz;
16 
17-- spend_run: grant the device's free trial on first sight, then charge ONE run
18-- for p_run — idempotently (a re-commit of the same run doesn't double-charge)
19-- and atomically (the conditional decrement can't race a read-modify-write).
20-- Returns whether it charged and the resulting balance. The free-grant constant
21-- mirrors FREE_RUNS in server/utils/balance-store.ts — keep the two in sync.
22create or replace function public.spend_run(p_device text, p_run uuid)
23returns table(charged boolean, runs_remaining int)
24language plpgsql as $$
25declare
26 v_remaining int;
27 v_charged_at timestamptz;
28 v_owner text;
29begin
30 insert into public.balances(device_id, runs_remaining)
31 values (p_device, 1)
32 on conflict (device_id) do nothing;
33 
34 -- Lock the run row so concurrent commits of the SAME run serialize here;
35 -- without the lock both pass the not-yet-charged guard and one gets a
36 -- spurious 402 after the other's decrement. Read the owner too: idempotency
37 -- is per (device, run), so only the device that charged this run gets the
38 -- no-op return — a different device falls through to its own decrement (or a
39 -- 402), never a free pass on someone else's already-charged run.
40 select r.charged_at, r.device_id into v_charged_at, v_owner
41 from public.runs r where r.id = p_run
42 for update;
43 if v_charged_at is not null and v_owner = p_device then
44 select b.runs_remaining into v_remaining from public.balances b where b.device_id = p_device;
45 return query select true, coalesce(v_remaining, 0);
46 return;
47 end if;
48 
49 -- Atomic conditional decrement: charges only if a run is available.
50 update public.balances b
51 set runs_remaining = b.runs_remaining - 1, updated_at = now()
52 where b.device_id = p_device and b.runs_remaining > 0
53 returning b.runs_remaining into v_remaining;
54 if not found then
55 select b.runs_remaining into v_remaining from public.balances b where b.device_id = p_device;
56 return query select false, coalesce(v_remaining, 0);
57 return;
58 end if;
59 
60 update public.runs set charged_at = now(), device_id = p_device where id = p_run;
61 return query select true, v_remaining;
62end;
63$$;