What changed, and why

Two guardrails on who a player may message, added for legal, ethical, and safety reasons — the game puts AI-authored words in a real person's mouth, so the living and the not-yet-historical are off limits.

- Deceased only (#72): a figure is contactable only when we can confirm they have died. Living and undatable figures fail closed. - Require grounding (#73): you can only reach a real figure resolved against Wikipedia/Wikidata. The old free-form fallback — type any name, the AI invents a persona — is gone; an unresolved name is guided to a real match instead.

Both rules are enforced at three layers — the client store gate (UX), the authoritative server re-ground (the guarantee), and the suggester — off one predicate. This guide reads the arc end to end.

One predicate: what 'contactable' means

isDeceased is the single source of truth, shared by the server gate and the suggester. It is deliberately strict: a resolved figure with no death year is living (or too undatable to prove otherwise), so it is not deceased — fail closed.

The transient flag (for fail-closed), isDeceased, and the recent-estimated-death guard.

server/utils/figure-grounding.ts · 323 lines
server/utils/figure-grounding.ts323 lines · TypeScript
⋯ 51 lines hidden (lines 1–51)
1/**
2 * Real-world grounding for historical figures — the backbone of the "timeline RPG"
3 * direction (see docs/ROADMAP.md). Resolves a free-form name to solid facts so the
4 * game can show who someone was, validate *when* you may contact them, and feed the
5 * role-play real dates.
6 *
7 * Pipeline (the wiki APIs are open + key-less):
8 * 1. Wikipedia REST summary — the reliable resolver. It follows redirects and
9 * picks the notable article, giving a canonical title, a one-line description,
10 * a blurb, a portrait, and the Wikidata item id. (Wikidata search alone is NOT
11 * reliable: "Cleopatra" resolves to "female given name", "Genghis Khan" to an
12 * Iron Maiden song.)
13 * 2. Wikidata, looked up by that item id (title as fallback) — structured birth
14 * (P569) / death (P570), taking the best-ranked claim that actually carries a
15 * date, with sub-year precision surfaced honestly as `circa`.
16 * 3. If the record still has no birth year, a small AI estimator bridges the gap
17 * with circa dates (issue #31) — so a real, resolvable figure keeps their
18 * "when" slider even when Wikidata can't date them.
19 *
20 * Anything unresolved returns `{ resolved: false }` (with `transient` set when the
21 * lookup couldn't complete). Contact callers now BLOCK an ungrounded name (#73) —
22 * only a real, documented figure can be reached — while a transient miss is a retry.
23 *
24 * Cache honesty: only definitive answers — a real Wikidata reply (dated or not,
25 * with the estimator genuinely consulted), a genuine no-such-article miss, a
26 * disambiguation page — keep the full TTL. A transient failure anywhere on the
27 * path (a rate-limit 429, a timeout, an estimator outage) gets a short TTL, so
28 * one blip never poisons a figure for a day.
29 */
30import { estimateLifespan } from './lifespan-estimator'
31 
32/** A year on the timeline. `signed` is AD-positive / BCE-negative for comparison. */
33export interface FigureYear {
34 year: number
35 bce: boolean
36 signed: number
37 display: string
38 /** Approximate: sub-year Wikidata precision (decade/century/…) or an AI estimate. */
39 circa?: boolean
41 
42export interface GroundedFigure {
43 name: string
44 resolved: boolean
45 description?: string
46 extract?: string
47 born?: FigureYear
48 died?: FigureYear
49 living?: boolean
50 thumbnail?: string
51 wikiUrl?: string
52 /** The lookup couldn't be completed (rate-limit / timeout / outage), so an
53 * unresolved result is "we don't know yet", not "no such person". The contact
54 * gate treats this as a retry rather than a definitive miss — and never as a
55 * free-form pass (#72 / #73). */
56 transient?: boolean
58 
59/**
60 * The deceased-only contact floor (#72): a figure may be reached only once we can
61 * confirm they have died. A resolved figure with no death year is living (or not
62 * datable enough to prove otherwise) and is never contactable — fail closed. An
63 * unresolved name is not deceased here either (the free-form path is removed in #73).
64 * The canonical predicate, shared by the suggester and the send-message gate; the
65 * client store inlines the same rule (it can't import this server-only module).
66 */
67export function isDeceased(figure: GroundedFigure): boolean {
68 return figure.resolved && !!figure.died
⋯ 182 lines hidden (lines 70–251)
70 
71/**
72 * The one header for every Wikipedia / Wikidata call the server makes — grounding,
73 * the name autocomplete, and the suggester's lookups all share it (issue #58).
74 * Wikimedia's User-Agent policy wants the client identified plus a reachable CONTACT
75 * so an operator can be reached about problematic traffic; an agent without one gets
76 * throttled harder, and rate-limit blips are the dominant real-world failure for the
77 * autocomplete. A mailto is that contact — the deployed site is auth-gated and the
78 * repo private, so neither would reach a human. Exported so every caller sends the
79 * identical compliant string instead of drifting per-feature copies.
80 */
81export const WIKI_HEADERS = {
82 accept: 'application/json',
83 'User-Agent': 'Revisionist/1.0 (timeline RPG; mailto:matthew@mseeks.me)'
85 
86const FULL_TTL_MS = 24 * 60 * 60 * 1000
87const TRANSIENT_TTL_MS = 10 * 60 * 1000
88const WIKIDATA_RETRY_DELAY_MS = 400
89const cache = new Map<string, { value: GroundedFigure; expires: number }>()
90 
91/** Builds a `FigureYear`; the display string owns the "c." honesty marker. */
92export function makeFigureYear(year: number, bce: boolean, circa = false): FigureYear {
93 const base = bce ? `${year} BC` : `${year}`
94 return {
95 year,
96 bce,
97 signed: bce ? -year : year,
98 display: circa ? `c. ${base}` : base,
99 ...(circa ? { circa: true } : {})
100 }
102 
103/**
104 * Parses a Wikidata time literal (e.g. "+1856-07-10T..", "-0069-01-13T..") to a year.
105 * Precision 9 means year-level; anything below (8 decade, 7 century, 6 millennium)
106 * still carries a representative year in the literal, but only approximately —
107 * those parse as `circa` rather than being dropped or displayed as exact.
108 */
109export function parseWikidataYear(time?: string, precision?: number): FigureYear | undefined {
110 if (!time) return undefined
111 const m = /^([+-])0*(\d+)-/.exec(time)
112 if (!m) return undefined
113 const year = parseInt(m[2], 10)
114 if (!year) return undefined
115 const bce = m[1] === '-'
116 return makeFigureYear(year, bce, precision != null && precision < 9)
118 
119/** The slice of the Wikipedia REST summary we actually read. */
120interface WikiSummary {
121 type?: string
122 title?: string
123 description?: string
124 extract?: string
125 thumbnail?: { source?: string }
126 content_urls?: { desktop?: { page?: string } }
127 wikibase_item?: string
129 
130/** The slice of a Wikidata date claim we actually read. */
131interface WikidataClaim {
132 rank?: string
133 mainsnak?: {
134 snaktype?: string
135 datavalue?: { value?: { time?: string; precision?: number } }
136 }
138 
139/** The slice of the Wikidata `wbgetentities` response we actually read. */
140interface WikidataEntities {
141 entities?: Record<string, { claims?: Record<string, WikidataClaim[]> }>
143 
144/**
145 * The best date a claim list actually carries. Wikidata routinely stacks several
146 * birth/death claims (scholarly guesses, deprecated values, "unknown value"
147 * placeholders) and the community-preferred one is often NOT first — Moses, Jesus
148 * and Confucius all keep theirs at index 2. So: only claims whose snak carries a
149 * real time (somevalue/novalue snaks don't), never deprecated, preferred rank first.
150 */
151export function pickClaimYear(claims?: WikidataClaim[]): FigureYear | undefined {
152 const usable = (claims ?? []).filter(c =>
153 c.rank !== 'deprecated' &&
154 c.mainsnak?.snaktype === 'value' &&
155 c.mainsnak.datavalue?.value?.time
156 )
157 const best = usable.find(c => c.rank === 'preferred') ?? usable[0]
158 const value = best?.mainsnak?.datavalue?.value
159 return parseWikidataYear(value?.time, value?.precision)
161 
162/**
163 * A fetch that tells a definitive miss from a transient one: 404 means the thing
164 * genuinely isn't there (cacheable at full TTL), anything else — 429, 5xx, timeout,
165 * network error — is weather to retry soon, not remember for a day.
166 */
167interface FetchOutcome<T> {
168 data: T | null
169 transient: boolean
171 
172async function fetchJson<T>(url: string): Promise<FetchOutcome<T>> {
173 try {
174 const res = await fetch(url, { headers: WIKI_HEADERS, signal: AbortSignal.timeout(6000) })
175 if (!res.ok) return { data: null, transient: res.status !== 404 }
176 return { data: (await res.json()) as T, transient: false }
177 } catch {
178 return { data: null, transient: true }
179 }
181 
182interface WikidataDates {
183 born?: FigureYear
184 died?: FigureYear
185 /** True when we never got a real answer (so the result must not cache long). */
186 transient: boolean
188 
189/**
190 * Birth/death from Wikidata — by item id when the summary supplied one (immune to
191 * title-matching quirks), by sitelink title otherwise. Rate-limit blips are the
192 * dominant way dates go missing in practice (a 429 body carries no `entities`), so
193 * a no-entities response gets one spaced retry before being declared transient.
194 */
195async function wikidataDates(qid: string | undefined, title: string): Promise<WikidataDates> {
196 const subject = qid
197 ? `ids=${encodeURIComponent(qid)}`
198 : `sites=enwiki&titles=${encodeURIComponent(title)}&normalize=1`
199 const url = `https://www.wikidata.org/w/api.php?action=wbgetentities&${subject}&props=claims&languages=en&format=json&origin=*`
200 let outcome = await fetchJson<WikidataEntities>(url)
201 if (!outcome.data?.entities) {
202 await new Promise(resolve => setTimeout(resolve, WIKIDATA_RETRY_DELAY_MS))
203 outcome = await fetchJson<WikidataEntities>(url)
204 }
205 const entities = outcome.data?.entities
206 if (!entities) return { transient: true }
207 const id = Object.keys(entities)[0]
208 const claims = id ? entities[id]?.claims : undefined
209 return { born: pickClaimYear(claims?.P569), died: pickClaimYear(claims?.P570), transient: false }
211 
212/**
213 * Resolves a name to grounded facts (cached). Never throws; returns
214 * `{ resolved: false }` when the figure can't be confidently identified.
215 */
216export async function groundFigure(rawName: string): Promise<GroundedFigure> {
217 const name = (rawName || '').trim()
218 if (!name) return { name: '', resolved: false }
219 
220 const key = name.toLowerCase()
221 const hit = cache.get(key)
222 if (hit && hit.expires > Date.now()) return hit.value
223 
224 const { value, ttlMs } = await resolve(name)
225 cache.set(key, { value, expires: Date.now() + ttlMs })
226 return value
228 
229/**
230 * Keep a URL only if it's a plain http(s) link — drop `javascript:` / `data:` /
231 * relative values. These come from a remote Wikipedia response and end up bound to
232 * `:href` / `:src` in the figure dossier; Vue escapes the value but NOT the scheme,
233 * so a spoofed `javascript:` URL would execute on click. (Surfaced by the
234 * unsafe-render loop.) Exported for the figure-search route, which binds Wikipedia
235 * thumbnails the same way.
236 */
237export function safeUrl(url?: string): string | undefined {
238 if (!url) return undefined
239 try {
240 const { protocol } = new URL(url)
241 return protocol === 'https:' || protocol === 'http:' ? url : undefined
242 } catch {
243 return undefined
244 }
246 
247interface Resolution {
248 value: GroundedFigure
249 ttlMs: number
251 
252/** A purely AI-estimated death is only trusted as "historical" (and the figure
253 * contactable, #72) when it predates this year — a recent estimated death is the
254 * least trustworthy signal and could mark a living person dead. Documented deaths
255 * are exempt (they're facts). */
256const ESTIMATED_DEATH_HISTORICAL_BEFORE = new Date().getFullYear() - 100
⋯ 31 lines hidden (lines 257–287)
257 
258async function resolve(name: string): Promise<Resolution> {
259 const summary = await fetchJson<WikiSummary>(
260 `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(name)}`
261 )
262 
263 // No article, or an ambiguous disambiguation page → can't pin a person.
264 const article = summary.data
265 if (!article || article.type === 'disambiguation' || !article.title) {
266 return {
267 value: { name, resolved: false, transient: summary.transient },
268 ttlMs: summary.transient ? TRANSIENT_TTL_MS : FULL_TTL_MS
269 }
270 }
271 
272 const title: string = article.title
273 
274 const dates = await wikidataDates(article.wikibase_item, title)
275 let born = dates.born
276 let died = dates.died
277 let bridgeTransient = false
278 
279 // The AI bridge (issue #31): the record knows WHO they are but not WHEN — either
280 // Wikidata genuinely has no usable birth claim, or the lookup is down right now.
281 // Estimated years arrive marked circa, so the dossier and prompts stay honest.
282 // A recorded death is law: the estimate only fills what the record lacks, and an
283 // estimate that contradicts the record (born after the documented death) is
284 // dropped whole rather than shipped as an incoherent lifespan.
285 if (!born) {
286 const bridge = await estimateLifespan(title, article.description, article.extract, died?.display)
287 bridgeTransient = bridge.transient
288 if (bridge.estimate) {
289 const estBorn = makeFigureYear(bridge.estimate.born.year, bridge.estimate.born.bce, true)
290 if (!died || estBorn.signed <= died.signed) {
291 born = estBorn
292 // A purely AI-ESTIMATED death is trusted as "deceased" (the floor in
293 // #72) only when it's clearly historical. An LLM guess of a RECENT
294 // death is exactly what could mark a living person contactable, so we
295 // don't promote it — the figure stays `living` and is blocked. A
296 // DOCUMENTED death (already in `died`) is a fact and is kept as-is.
297 const estDied = bridge.estimate.died
298 ? makeFigureYear(bridge.estimate.died.year, bridge.estimate.died.bce, true)
299 : undefined
300 died = died ?? (estDied && estDied.signed <= ESTIMATED_DEATH_HISTORICAL_BEFORE ? estDied : undefined)
301 }
⋯ 22 lines hidden (lines 302–323)
302 }
303 }
304 
305 return {
306 value: {
307 name: title,
308 resolved: true,
309 description: article.description || undefined,
310 extract: article.extract || undefined,
311 born,
312 died,
313 living: !!born && !died,
314 thumbnail: safeUrl(article.thumbnail?.source),
315 wikiUrl: safeUrl(article.content_urls?.desktop?.page),
316 transient: dates.transient || bridgeTransient
317 },
318 // Anything short of a definitive answer keeps the short TTL: a transient
319 // Wikidata miss (even when the estimator papered over it — the next lookup
320 // should get a shot at the real dates) and an estimator outage alike.
321 ttlMs: dates.transient || bridgeTransient ? TRANSIENT_TTL_MS : FULL_TTL_MS
322 }

The client gate (UX)

contactLiveness folds both rules into one getter, in order: an ungrounded name is 'unresolved' (#73); a resolved figure with no death is 'living' (#72); a resolved, deceased figure is then gated to its lifetime. canContact admits only the contactable states, so the Send button disables itself.

The whole rule in one place; 'unknown' survives only for the narrow resolved-deceased edge.

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

The send is stopped before the network, with an honest, specific reason — still living, couldn't be dated, no record found (or, on a network blip, try again).

Each blocked state gets its own message; nothing is sent.

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

The authoritative server gate (the guarantee)

The client gate is convenience; a direct POST or a send racing the grounding debounce would skip it. So the turn endpoint re-grounds the name itself — the client payload is untrusted — and blocks. It is cache-backed in the warm path (the same lookup the dossier just did), one bounded call on a cold cache.

resolved-not-deceased → block; unresolved-definitive → block (#73); transient → retry.

server/api/send-message.post.ts · 330 lines
server/api/send-message.post.ts330 lines · TypeScript
⋯ 136 lines hidden (lines 1–136)
1import { MAX_MESSAGE_CHARS } from '~/utils/game-config'
2import { toContactMoment, formatContactMoment } from '~/utils/contact-moment'
3import {
4 cleanArray,
5 cleanString,
6 clampInt,
7 MAX_ERA_CHARS,
8 MAX_FIGURE_NAME_CHARS,
9 MAX_GROUNDING_CHARS,
10 MAX_HISTORY_ENTRIES,
11 MAX_LEDGER_SWING,
12 MAX_OBJECTIVE_DESC_CHARS,
13 MAX_OBJECTIVE_TITLE_CHARS,
14 MAX_TIMELINE_ENTRIES
15} from '~/server/utils/validate'
16 
17export default defineEventHandler(async (event) => {
18 // Only allow POST requests
19 if (getMethod(event) !== 'POST') {
20 throw createError({
21 statusCode: 405,
22 statusMessage: 'Method Not Allowed'
23 })
24 }
25 
26 const body = await readBody(event)
27 
28 // The client UI bounds these, but a direct POST is untrusted and every field
29 // below feeds a gpt-5.5 prompt — so validate the boundary here, not just in the
30 // store. (Surfaced by the input-validation-gap loop.)
31 if (!body || typeof body.message !== 'string' || !body.message.trim()) {
32 throw createError({
33 statusCode: 400,
34 statusMessage: 'Bad Request: message parameter is required'
35 })
36 }
37 if (body.message.trim().length > MAX_MESSAGE_CHARS) {
38 throw createError({
39 statusCode: 400,
40 statusMessage: `Bad Request: message exceeds ${MAX_MESSAGE_CHARS} characters`
41 })
42 }
43 if (typeof body.figureName !== 'string' || !body.figureName.trim()) {
44 throw createError({
45 statusCode: 400,
46 statusMessage: 'Bad Request: figureName parameter is required'
47 })
48 }
49 
50 const { when = null, figureContext = null, objective = null } = body
51 const message = body.message.trim()
52 // A name is interpolated into every prompt (and the Wikipedia lookup upstream),
53 // so it gets a hard cap like every other prompt-feeding string.
54 const figure = cleanString(body.figureName, MAX_FIGURE_NAME_CHARS)
55 // The grounded contact year (signed: AD positive / BC negative) — anchors the
56 // causal-distance read and the character's world-brief. Optional + untrusted.
57 const figureYear = typeof body.whenSigned === 'number' && Number.isFinite(body.whenSigned)
58 ? clampInt(body.whenSigned, 12000)
59 : undefined
60 // The pinned moment (issue #32) arrives as untrusted integers and is
61 // re-validated here; the display string the prompts see is DERIVED, never
62 // the client's. Sub-year detail is prompt flavor only — every mechanic
63 // below (world-brief, liveness, the wager) still computes on figureYear.
64 const figureMoment = figureYear !== undefined ? toContactMoment(body.whenMonth, body.whenDay) : null
65 const whenLabel = figureYear !== undefined ? formatContactMoment(figureYear, figureMoment) : undefined
66 // The last-stand wager: the client offers it only on the final dispatch; the
67 // doubling is applied after amplification, and the response echoes it.
68 const staked = body.stake === true
69 
70 // Normalize the objective into the context the Timeline Engine needs (coerced +
71 // bounded — a non-string or megabyte field would otherwise reach the prompt raw).
72 const objectiveContext = {
73 title: cleanString(objective?.title, MAX_OBJECTIVE_TITLE_CHARS) || 'Alter the course of history',
74 description: cleanString(objective?.description, MAX_OBJECTIVE_DESC_CHARS) || 'Bend the chain of events toward a better world.',
75 era: cleanString(objective?.era, MAX_ERA_CHARS)
76 }
77 // The objective's far anchor year (signed: AD+/BC−) — the foothold the
78 // causal-chain dial decays toward. Optional + untrusted; absent → no-op.
79 const objectiveAnchorYear = typeof objective?.anchorYear === 'number' && Number.isFinite(objective.anchorYear)
80 ? clampInt(objective.anchorYear, 12000)
81 : undefined
82 
83 // Sanitize the client-supplied ledger + thread: cap the counts and coerce each
84 // field, so a crafted body can't crash a `.map`, forge an unbounded prior
85 // history, or amplify token cost.
86 const timeline = cleanArray(body.timeline, MAX_TIMELINE_ENTRIES).map((raw) => {
87 const e = (raw ?? {}) as Record<string, unknown>
88 return {
89 era: cleanString(e.era, MAX_ERA_CHARS),
90 headline: cleanString(e.headline, MAX_OBJECTIVE_TITLE_CHARS),
91 detail: cleanString(e.detail, MAX_OBJECTIVE_DESC_CHARS),
92 progressChange: clampInt(e.progressChange, MAX_LEDGER_SWING),
93 whenSigned: typeof e.whenSigned === 'number' && Number.isFinite(e.whenSigned)
94 ? clampInt(e.whenSigned, 12000)
95 : undefined
96 }
97 })
98 const conversationHistory = cleanArray(body.conversationHistory, MAX_HISTORY_ENTRIES)
99 .map((raw) => (raw ?? {}) as Record<string, unknown>)
100 .filter((m) => (m.sender === 'user' || m.sender === 'ai') && typeof m.text === 'string')
101 .map((m) => ({ sender: m.sender as 'user' | 'ai', text: cleanString(m.text, MAX_MESSAGE_CHARS) }))
102 
103 try {
104 // Paywall enforcement: the run must be charged and owned by this device,
105 // or a forged / unpaid run id could draw free generation. Fails open if
106 // the balance store is unreachable (see run-guard) so an outage never
107 // blocks a legit in-progress run.
108 const { runIsActive } = await import('~/server/utils/run-guard')
109 if (!(await runIsActive(event))) {
110 return {
111 success: false as const,
112 message: 'Run not active',
113 data: { userMessage: message, error: 'This run is no longer active. Start a new run to continue.' }
114 }
115 }
116 
117 const { callCharacterAI, callTimelineAI, callJudgeAI } = await import('~/server/utils/openai')
118 const { rollD20, applyCraftModifier } = await import('~/utils/dice')
119 const { CRAFT_MODIFIER } = await import('~/utils/craft')
120 const { hardBlockCheck, moderateAction } = await import('~/server/utils/moderation')
121 
122 // A blocked turn never burns a message: success:false + data.blocked lets
123 // the store show a distinct, honest "blocked" banner (not an infra retry).
124 const blockedResponse = (reason: string) => ({
125 success: false as const,
126 message: 'Blocked by moderation',
127 data: { userMessage: message, blocked: true as const, moderationReason: reason, error: reason }
128 })
129 
130 // Moderation, stage 1 — a deterministic hard block on the raw dispatch
131 // BEFORE we spend a generation token. The unforgivable categories (sexual
132 // content, anything involving minors, self-harm facilitation) need no
133 // context; the contextual Sentinel runs post-generation over the exchange.
134 const inputBlock = await hardBlockCheck(message, 'turn', 'input')
135 if (inputBlock.blocked) return blockedResponse(inputBlock.block.reason)
136 
137 // Contact gating, enforced authoritatively here (#72 deceased-only + #73
138 // require-grounding): the client payload is untrusted, and a direct POST (or
139 // a send racing the grounding debounce) could otherwise reach a living or
140 // ungrounded figure. Re-ground the name ourselves — cache-backed in the warm
141 // path (the same in-process cache /api/figure just filled), one bounded
142 // external lookup on a cold cache — and:
143 // • resolved + not deceased → block (living, or undatable: fail closed)
144 // • unresolved + transient → retry (a lookup we couldn't complete)
145 // • unresolved + definitive → block (#73: no free-form; reach a real figure)
146 // This gate enforces existence + liveness (the safety floor); a deceased
147 // figure's lifetime window is a client-side UX nicety (anachronism is
148 // mechanically allowed via the wager), so it is deliberately not re-checked here.
149 const { groundFigure, isDeceased } = await import('~/server/utils/figure-grounding')
150 const grounded = await groundFigure(figure)
151 if (grounded.resolved && !isDeceased(grounded)) {
152 return blockedResponse('This figure is still living and cannot be contacted — Revisionist only reaches figures from history.')
153 }
154 if (!grounded.resolved) {
155 return grounded.transient
156 ? {
157 success: false as const,
158 message: 'Could not verify the figure',
159 data: { userMessage: message, error: "Couldn't reach the record to verify who this is — please try again in a moment." }
160 }
161 : blockedResponse('No historical record found for this name — reach for a real, documented figure.')
162 }
163 
164 // The client supplies figureContext (it grounded the figure via /api/figure),
165 // so treat it as untrusted: coerce + bound each field before it becomes a
⋯ 165 lines hidden (lines 166–330)
166 // "fact" in the character system prompt.
167 const grounding = {
168 when: whenLabel ?? (cleanString(when, MAX_ERA_CHARS) || undefined),
169 // The figure should take a pinned moment with period-appropriate
170 // granularity rather than false precision (the prompt line is
171 // conditional so year-only turns stay byte-identical).
172 momentRefined: figureMoment !== null,
173 description: cleanString(figureContext?.description, MAX_GROUNDING_CHARS) || undefined,
174 lifespan: cleanString(figureContext?.lifespan, MAX_ERA_CHARS) || undefined
175 }
176 
177 // Step 1: The Judge grades the dispatch's craft — the one place player skill
178 // directly tilts fate. A Judge outage grades 'sound' (modifier 0): an infra
179 // hiccup must never tilt the die either way.
180 const judged = await callJudgeAI({
181 objective: objectiveContext,
182 figureName: figure,
183 when: grounding.when,
184 momentRefined: figureMoment !== null,
185 userMessage: message
186 })
187 const craft = judged.success && judged.judge ? judged.judge.craft : 'sound'
188 const craftReason = judged.success && judged.judge ? judged.judge.reason : ''
189 const rollModifier = CRAFT_MODIFIER[craft]
190 
191 // Step 2: Roll the dice of fate, tilted by craft (clamped to the die's faces).
192 const natural = rollD20()
193 const diceResult = applyCraftModifier(natural.roll, rollModifier)
194 
195 // Step 3: The chosen figure replies and acts, in character, blind to the goal.
196 // Grounding (when + real facts) anchors the role-play; the world-brief hands
197 // them the altered history their own moment would already know (changes at or
198 // before their year), so a turn-4 figure doesn't role-play the unaltered world.
199 // Headlines only — `detail` narrates downstream ripples (often stamped with
200 // later eras) and would hand the figure the future outright.
201 const worldBrief = figureYear === undefined
202 ? []
203 : timeline
204 .filter(e => typeof e.whenSigned === 'number' && e.whenSigned <= figureYear && e.headline)
205 .slice(-5)
206 .map(e => `${e.era ? `[${e.era}] ` : ''}${e.headline}`)
207 const character = await callCharacterAI(figure, message, conversationHistory, diceResult.outcome, grounding, worldBrief)
208 if (!character.success || !character.characterResponse) {
209 // A model-side safety refusal is a block, not an infra hiccup.
210 if (character.refused) return blockedResponse(character.error || 'This dispatch was declined by content moderation and cannot be sent.')
211 return {
212 success: false,
213 message: 'Failed to generate character response',
214 data: {
215 userMessage: message,
216 error: character.error || 'Character AI failed'
217 }
218 }
219 }
220 
221 const reply = character.characterResponse
222 
223 // Enforce the message-length constraint on the figure's reply.
224 if (reply.message && reply.message.length > MAX_MESSAGE_CHARS) {
225 console.warn(`Figure reply exceeded ${MAX_MESSAGE_CHARS} characters (${reply.message.length}); truncating.`)
226 reply.message = reply.message.substring(0, MAX_MESSAGE_CHARS - 3) + '...'
227 }
228 
229 // Step 4: The Timeline Engine judges how the action ripples toward the objective.
230 const timelineResult = await callTimelineAI({
231 objective: objectiveContext,
232 figureName: figure,
233 era: reply.era || objectiveContext.era,
234 userMessage: message,
235 characterMessage: reply.message,
236 characterAction: reply.action,
237 diceRoll: diceResult.roll,
238 diceOutcome: diceResult.outcome,
239 timeline,
240 figureYear,
241 figureMoment: figureMoment ? whenLabel : undefined,
242 objectiveAnchorYear
243 })
244 
245 if (!timelineResult.success || !timelineResult.ripple) {
246 if (timelineResult.refused) return blockedResponse(timelineResult.error || 'This dispatch was declined by content moderation and cannot be sent.')
247 return {
248 success: false,
249 message: 'Failed to generate timeline analysis',
250 data: {
251 userMessage: message,
252 diceRoll: diceResult.roll,
253 diceOutcome: diceResult.outcome,
254 characterResponse: { message: reply.message, action: reply.action },
255 error: timelineResult.error || 'Timeline AI failed'
256 }
257 }
258 }
259 
260 const ripple = timelineResult.ripple
261 
262 // Moderation, stage 2 — the contextual Sentinel reviews the WHOLE exchange
263 // (full thread + this dispatch + the figure's reply and the timeline's
264 // narration) against the verbatim Usage Policy, and the detector hard-blocks
265 // the outputs. A block here suppresses the reveal: the generated text never
266 // ships, and the turn is refunded with an honest banner.
267 const moderation = await moderateAction({
268 surface: 'turn',
269 objective: { title: objectiveContext.title, description: objectiveContext.description },
270 figureName: figure,
271 era: reply.era || objectiveContext.era,
272 conversation: conversationHistory.map(m => ({ role: m.sender === 'user' ? 'user' : 'assistant', text: m.text })),
273 userText: message,
274 outputs: [reply.message, reply.action, ripple.headline, ripple.detail].filter((t): t is string => !!t)
275 })
276 if (moderation.blocked) return blockedResponse(moderation.block.reason)
277 
278 // The last stand: a staked dispatch doubles its resolved swing, both ways,
279 // and may escape the per-turn fuse up to the full meter. The valence badge
280 // is re-asserted AFTER the doubling — the sign-guard inside callTimelineAI
281 // ran on the pre-stake value, and a doubled swing must wear its own color.
282 const { applyStake } = await import('~/utils/swing-bands')
283 const finalProgressChange = staked ? applyStake(ripple.progressChange) : ripple.progressChange
284 const finalValence = staked && Math.abs(finalProgressChange) > 3
285 ? (finalProgressChange > 0 ? 'positive' as const : 'negative' as const)
286 : ripple.valence
287 
288 return {
289 success: true,
290 message: 'Timeline updated',
291 data: {
292 userMessage: message,
293 figure: {
294 name: figure,
295 era: reply.era,
296 descriptor: reply.persona
297 },
298 // The effective (craft-tilted) roll drives the bands and the UI; the
299 // natural roll + modifier ride along so the reveal can show the tilt.
300 diceRoll: diceResult.roll,
301 diceOutcome: diceResult.outcome,
302 naturalRoll: natural.roll,
303 rollModifier,
304 craft,
305 craftReason,
306 staked,
307 characterResponse: { message: reply.message, action: reply.action },
308 timeline: {
309 headline: ripple.headline,
310 detail: ripple.detail,
311 era: reply.era || objectiveContext.era,
312 progressChange: finalProgressChange,
313 baseProgressChange: ripple.baseProgressChange,
314 valence: finalValence,
315 anachronism: ripple.anachronism,
316 causalChain: ripple.causalChain
317 },
318 error: null
319 }
320 }
321 } catch (error) {
322 // Log the detail server-side; the wire gets a clean line (internal error
323 // text — stack hints, key issues, SDK messages — is not for the client).
324 console.error('API endpoint error:', error)
325 throw createError({
326 statusCode: 500,
327 statusMessage: 'Internal Server Error'
328 })
329 }
330})

The same re-ground gate on the research surface.

server/api/research.post.ts · 89 lines
server/api/research.post.ts89 lines · TypeScript
⋯ 50 lines hidden (lines 1–50)
1import { cleanString, MAX_ERA_CHARS, MAX_GROUNDING_CHARS } from '~/server/utils/validate'
2 
3// The Wikipedia extract is longer than the other grounded fields; give it room.
4const MAX_EXTRACT_CHARS = 1500
5 
6/**
7 * POST /api/research — the Archivist (prototype). Given a figure (and the year the
8 * player means to reach them, plus the grounded facts the client already holds), it
9 * returns a green-level, OBJECTIVE-BLIND brief: who they are at that moment, what
10 * they grasp, what they're reaching for, what they cannot yet know. In-game research
11 * so the player needn't break out to a search engine.
12 *
13 * Like the other AI routes, the untrusted body is coerced + bounded before it feeds
14 * the prompt. A model failure self-heals to { success: false } — the caller simply
15 * shows nothing.
16 */
17export default defineEventHandler(async (event) => {
18 if (getMethod(event) !== 'POST') {
19 throw createError({ statusCode: 405, statusMessage: 'Method Not Allowed' })
20 }
21 
22 const body = await readBody(event)
23 if (!body || typeof body.figureName !== 'string' || !body.figureName.trim()) {
24 throw createError({ statusCode: 400, statusMessage: 'Bad Request: figureName is required' })
25 }
26 
27 const figureName = body.figureName.trim().slice(0, MAX_ERA_CHARS)
28 const when = cleanString(body.when, MAX_ERA_CHARS) || undefined
29 const description = cleanString(body.description, MAX_GROUNDING_CHARS) || undefined
30 const extract = cleanString(body.extract, MAX_EXTRACT_CHARS) || undefined
31 
32 try {
33 // Paywall enforcement: the Archivist study runs during a charged run, so a
34 // forged/unpaid run id must not draw free generation here either.
35 const { runIsActive } = await import('~/server/utils/run-guard')
36 if (!(await runIsActive(event))) {
37 return { success: false, error: 'Run not active' }
38 }
39 
40 const { callArchivistAI } = await import('~/server/utils/openai')
41 const { hardBlockCheck, moderateAction } = await import('~/server/utils/moderation')
42 
43 // The Archivist takes untrusted figure text and returns a model brief to the
44 // player — the same untrusted-input → model → player path the turn and the
45 // Archive lookup gate, so it gets the same two stages: hard-block the input
46 // up front, then the Sentinel reviews the generated brief before it ships.
47 const subject = [figureName, description, extract].filter(Boolean).join(' — ')
48 const inputBlock = await hardBlockCheck(subject, 'research', 'input')
49 if (inputBlock.blocked) return { success: false, blocked: true, error: inputBlock.block.reason }
50 
51 // Contact gating (#72 deceased-only + #73 require-grounding): the Archivist
52 // generates a model brief ABOUT a real person, so it gets the same target
53 // gate as a contact. Re-ground and refuse a living/undatable figure; block an
54 // ungrounded name (#73: no free-form), retrying only a transient lookup.
55 // Single-sourced via isDeceased.
56 const { groundFigure, isDeceased } = await import('~/server/utils/figure-grounding')
57 const grounded = await groundFigure(figureName)
58 if (grounded.resolved && !isDeceased(grounded)) {
59 return { success: false, blocked: true, error: 'This figure is still living and cannot be studied — Revisionist only reaches figures from history.' }
60 }
61 if (!grounded.resolved) {
62 return grounded.transient
63 ? { success: false, error: "Couldn't reach the record to verify who this is — please try again in a moment." }
64 : { success: false, blocked: true, error: 'No historical record found for this name — Revisionist only studies real, documented figures.' }
65 }
66 
⋯ 23 lines hidden (lines 67–89)
67 const result = await callArchivistAI({ figureName, when, description, extract })
68 
69 if (!result.success || !result.study) {
70 return { success: false, error: result.error || 'Archivist failed' }
71 }
72 
73 const s = result.study
74 const moderation = await moderateAction({
75 surface: 'research',
76 figureName,
77 era: when,
78 userText: subject,
79 outputs: [s.atThisMoment, ...(s.grasp ?? []), ...(s.reaching ?? []), s.cannotYetKnow].filter((t): t is string => !!t)
80 })
81 if (moderation.blocked) return { success: false, blocked: true, error: moderation.block.reason }
82 
83 return { success: true, study: result.study }
84 } catch (error) {
85 console.error('Research endpoint error:', error)
86 // Log the detail server-side; the wire gets a clean line.
87 throw createError({ statusCode: 500, statusMessage: 'Internal Server Error' })
88 }
89})

The suggester never offers someone you can't reach

Suggestions are one-tap contacts, so they get the rule too: a deceased-only instruction in the prompt, and — defense in depth against a model that ignores it — a re-ground filter that drops anyone not confirmed deceased before they reach the player. The curated fallback is all-deceased (pinned by a test).

Prompt rule, the keepDeceased filter, and where suggestFigures applies it.

server/utils/figure-suggester.ts · 284 lines
server/utils/figure-suggester.ts284 lines · TypeScript
⋯ 67 lines hidden (lines 1–67)
1/**
2 * Figure suggester — a small tool-using agent (grounding slice 2, see docs/ROADMAP.md).
3 *
4 * Given the run's objective, it proposes real historical figures the player could
5 * message, each with a one-line reason. It's built as a mini-agent: the model may
6 * call `lookup_figure` (which reuses the Wikidata/Wikipedia grounding) to verify a
7 * candidate exists and learn their era/role, so suggestions stay accurate rather
8 * than hallucinated. If the model is unavailable or misbehaves, a curated fallback
9 * guarantees the player is never left without ideas.
10 *
11 * Runs on the lane the routing table picks (ai-routing.ts): the agent loop is
12 * implemented per provider — the tool-call wire shapes differ — but both share
13 * the same system prompt, tool contract, and final structured-list schema.
14 */
15import type { ChatCompletionMessageParam, ChatCompletionTool } from 'openai/resources/chat/completions'
16import type AnthropicSdk from '@anthropic-ai/sdk'
17import { getOpenAIClient, getAnthropicClient } from './ai-gateway'
18import { routeFor } from './ai-routing'
19import { groundFigure, isDeceased, type GroundedFigure } from './figure-grounding'
20 
21export interface FigureSuggestion {
22 name: string
23 reason: string
24 lifespan?: string
26 
27/**
28 * What the model returns on the wire under the strict schema: `lifespan` is ALWAYS
29 * present (an empty string when unknown). We type the raw parse against this — not
30 * the public `FigureSuggestion` — so the schema's contract is honest at the boundary;
31 * the mapping below turns the empty-string sentinel into the optional public field.
32 * (Surfaced by the ai-contract-drift loop: schema-required vs type-optional.)
33 */
34interface FigureSuggestionWire {
35 name: string
36 reason: string
37 lifespan: string
39 
40export interface SuggestionResult {
41 suggestions: FigureSuggestion[]
42 fallback: boolean
44 
45interface ObjectiveInput {
46 title?: string
47 description?: string
48 era?: string
50 
51/** Era-agnostic safety net so the player always has somewhere to start. Every name
52 * here MUST be a historically deceased figure (the deceased-only floor, #72) — the
53 * fallback bypasses the live grounding filter, so a test pins this invariant. */
54const GENERIC_FALLBACK: FigureSuggestion[] = [
55 { name: 'Cleopatra', reason: 'A ruler whose alliances reshaped the ancient Mediterranean.' },
56 { name: 'Leonardo da Vinci', reason: 'A mind a century ahead — art, war machines, and invention.' },
57 { name: 'Genghis Khan', reason: 'Forged the largest contiguous empire in history.' },
58 { name: 'Marie Curie', reason: 'A pioneer whose science bent the modern age.' },
59 { name: 'Abraham Lincoln', reason: 'Held a fracturing nation together at its hinge point.' }
61 
62function lifespanOf(g: GroundedFigure): string | undefined {
63 if (!g.born) return undefined
64 if (g.died) return `${g.born.display}${g.died.display}`
65 return g.living ? `${g.born.display} – present` : g.born.display
67 
68const SYSTEM = `You are a historian helping a player of a timeline game decide whom to contact. Given their grand objective, propose real historical figures whose decisions could plausibly bend events toward it — spanning the relevant era(s) and the dynamics actually at play. Favor figures with real leverage over the situation, and include a few the player might not think of.
69 
70Only suggest figures who have already died — never anyone still living. Call lookup_figure to confirm a person is real, learn their lifespan and role, and check they are deceased; use it to keep every suggestion accurate and era-appropriate. Don't suggest anyone you can't ground, and drop anyone the lookup shows is still living.`
⋯ 60 lines hidden (lines 71–130)
71 
72const LOOKUP_TOOL = {
73 name: 'lookup_figure',
74 description: 'Verify a real historical figure exists and fetch their lifespan and role.',
75 parameters: {
76 type: 'object' as const,
77 properties: { name: { type: 'string', description: 'The figure’s name to look up' } },
78 required: ['name'],
79 additionalProperties: false
80 }
82 
83const SUGGESTIONS_SCHEMA = {
84 type: 'object',
85 properties: {
86 suggestions: {
87 type: 'array',
88 items: {
89 type: 'object',
90 properties: {
91 name: { type: 'string', description: 'The figure’s name' },
92 reason: { type: 'string', description: 'One sentence: why this figure matters for the objective' },
93 lifespan: { type: 'string', description: 'e.g. "69 BC – 30 BC"; empty string if unknown' }
94 },
95 required: ['name', 'reason', 'lifespan'],
96 additionalProperties: false
97 }
98 }
99 },
100 required: ['suggestions'],
101 additionalProperties: false
103 
104function userBrief(objective: ObjectiveInput): string {
105 return `Objective: "${objective.title ?? 'Alter the course of history'}".
106What winning looks like: ${objective.description ?? '—'}
107${objective.era ? `Anchoring era: ${objective.era}` : ''}
108 
109Propose 5 figures the player could message to influence this. Verify each with lookup_figure before finalizing.`
111 
112const FINAL_NUDGE = 'Now give your final answer: the 5 figures, each with a one-sentence reason it matters for this objective.'
113 
114/** Runs one lookup_figure call and shapes the evidence the agent reads. */
115async function runLookup(name: string): Promise<object> {
116 const g = name ? await groundFigure(name) : ({ name: '', resolved: false } as GroundedFigure)
117 return g.resolved
118 ? { resolved: true, name: g.name, role: g.description, lifespan: lifespanOf(g), deceased: isDeceased(g) }
119 : { resolved: false }
121 
122function mapWire(raw: unknown): FigureSuggestion[] {
123 const parsed = raw as { suggestions?: FigureSuggestionWire[] }
124 return (parsed.suggestions ?? [])
125 .filter(s => s && s.name && s.reason)
126 .map(s => ({ name: s.name, reason: s.reason, lifespan: s.lifespan || undefined }))
127 .slice(0, 6)
129 
130/**
131 * Defense in depth for the deceased-only floor (#72): re-ground every proposed
132 * figure and keep only the confirmed-deceased ones, so a model that ignores the
133 * instruction can never surface a living person as a one-tap contact. Grounding is
134 * usually cached (the agent typically looked these up), so usually near-free — a
135 * final name that differs from a looked-up one costs a fresh bounded lookup, and a
136 * transient outage drops that pick (fail closed; the list can fall to the curated
137 * fallback). We also normalize each lifespan to the grounded record. Exported for
138 * the unit test.
139 */
140export async function keepDeceased(suggestions: FigureSuggestion[]): Promise<FigureSuggestion[]> {
141 const checked = await Promise.all(suggestions.map(async (s): Promise<FigureSuggestion | null> => {
142 const g = await groundFigure(s.name)
143 return isDeceased(g) ? { ...s, lifespan: lifespanOf(g) ?? s.lifespan } : null
144 }))
145 return checked.filter((s): s is FigureSuggestion => s !== null)
⋯ 123 lines hidden (lines 147–269)
147 
148async function runAgentOpenAI(objective: ObjectiveInput): Promise<FigureSuggestion[]> {
149 const params = routeFor('suggester').route.openai
150 const client = getOpenAIClient()
151 const tools: ChatCompletionTool[] = [{
152 type: 'function',
153 function: {
154 name: LOOKUP_TOOL.name,
155 description: LOOKUP_TOOL.description,
156 parameters: LOOKUP_TOOL.parameters
157 }
158 }]
159 const messages: ChatCompletionMessageParam[] = [
160 { role: 'system', content: SYSTEM },
161 { role: 'user', content: userBrief(objective) }
162 ]
163 
164 // Phase 1: let the agent research candidates with the grounding tool.
165 for (let round = 0; round < 3; round++) {
166 const completion = await client.chat.completions.create({
167 model: params.model,
168 messages,
169 tools,
170 tool_choice: 'auto',
171 // gpt-5.5 rejects `reasoning_effort` alongside function tools on Chat
172 // Completions, so we omit it here (the no-tool phase 2 keeps it).
173 max_completion_tokens: params.maxTokens
174 })
175 const msg = completion.choices?.[0]?.message
176 if (!msg) break
177 messages.push(msg)
178 if (!msg.tool_calls?.length) break
179 
180 for (const call of msg.tool_calls) {
181 let name = ''
182 try { name = JSON.parse(call.function.arguments || '{}').name } catch { /* bad args */ }
183 const result = await runLookup(name)
184 messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(result) })
185 }
186 }
187 
188 // Phase 2: force a clean structured list out of the research.
189 const final = await client.chat.completions.create({
190 model: params.model,
191 messages: [...messages, { role: 'user', content: FINAL_NUDGE }],
192 reasoning_effort: 'low',
193 max_completion_tokens: 1500,
194 response_format: {
195 type: 'json_schema',
196 json_schema: { name: 'figure_suggestions', strict: true, schema: SUGGESTIONS_SCHEMA }
197 }
198 })
199 
200 const content = final.choices?.[0]?.message?.content
201 if (!content) return []
202 return mapWire(JSON.parse(content))
204 
205async function runAgentAnthropic(objective: ObjectiveInput): Promise<FigureSuggestion[]> {
206 const params = routeFor('suggester').route.anthropic
207 const client = getAnthropicClient()
208 const tools: AnthropicSdk.ToolUnion[] = [{
209 name: LOOKUP_TOOL.name,
210 description: LOOKUP_TOOL.description,
211 input_schema: LOOKUP_TOOL.parameters,
212 // Strict tools: the API guarantees the input matches the schema.
213 strict: true
214 }]
215 const messages: AnthropicSdk.MessageParam[] = [
216 { role: 'user', content: userBrief(objective) }
217 ]
218 const common = {
219 model: params.model,
220 max_tokens: params.maxTokens,
221 system: SYSTEM,
222 ...(params.temperature !== undefined ? { temperature: params.temperature } : {})
223 }
224 
225 // Phase 1: the research loop — run tools until the model stops asking.
226 for (let round = 0; round < 3; round++) {
227 const response = await client.messages.create({ ...common, messages, tools })
228 messages.push({ role: 'assistant', content: response.content })
229 const toolUses = response.content.filter(
230 (b): b is AnthropicSdk.ToolUseBlock => b.type === 'tool_use'
231 )
232 if (response.stop_reason !== 'tool_use' || !toolUses.length) break
233 
234 const results: AnthropicSdk.ToolResultBlockParam[] = []
235 for (const use of toolUses) {
236 const name = typeof (use.input as { name?: unknown })?.name === 'string'
237 ? (use.input as { name: string }).name
238 : ''
239 results.push({
240 type: 'tool_result',
241 tool_use_id: use.id,
242 content: JSON.stringify(await runLookup(name))
243 })
244 }
245 messages.push({ role: 'user', content: results })
246 }
247 
248 // Phase 2: force a clean structured list out of the research.
249 const final = await client.messages.create({
250 ...common,
251 max_tokens: 1500,
252 messages: [...messages, { role: 'user', content: FINAL_NUDGE }],
253 output_config: { format: { type: 'json_schema', schema: SUGGESTIONS_SCHEMA } }
254 })
255 
256 const text = final.content.find(
257 (b): b is AnthropicSdk.TextBlock => b.type === 'text'
258 )?.text
259 if (!text) return []
260 return mapWire(JSON.parse(text))
262 
263/** Suggests era-relevant figures for an objective, with a curated fallback. */
264export async function suggestFigures(objective: ObjectiveInput): Promise<SuggestionResult> {
265 try {
266 const { lane } = routeFor('suggester')
267 const suggestions = lane === 'anthropic'
268 ? await runAgentAnthropic(objective)
269 : await runAgentOpenAI(objective)
270 // Deceased-only floor (#72): re-ground and drop anyone still living before
271 // they reach the player. If that empties the list (a fully misbehaving
272 // model), fall through to the curated, all-deceased fallback below.
273 const deceased = await keepDeceased(suggestions)
274 if (deceased.length) return { suggestions: deceased, fallback: false }
275 } catch (error) {
⋯ 9 lines hidden (lines 276–284)
276 console.error('Figure suggester agent failed; using fallback:', error)
277 }
278 return { suggestions: GENERIC_FALLBACK, fallback: true }
280 
281/** Exposed for the fallback path + tests. */
282export function fallbackSuggestions(): FigureSuggestion[] {
283 return [...GENERIC_FALLBACK]

The dossier tells the truth

The picker stops offering what it can't deliver. A resolved-but-living (or undatable) figure shows an honest block in place of the when-slider; an unresolved name shows a guide to a real match, not a "send into the unknown".

The contactBlocked computed + reason, the block notice, and the unresolved guide.

components/FigurePicker.vue · 536 lines
components/FigurePicker.vue536 lines · Vue
⋯ 63 lines hidden (lines 1–63)
1<template>
2 <div data-testid="figure-picker" class="space-y-3">
3 <div ref="searchBoxRef" class="relative" @focusout="onSearchFocusOut">
4 <label for="figure-input" class="rv-label block mb-1">Who in history will you reach?</label>
5 <input id="figure-input" ref="searchInputRef" v-model="figure" data-testid="figure-input" type="text"
6 class="rv-field rv-mono text-sm" placeholder="Anyone, any era — Cleopatra, Tesla, Genghis Khan…"
7 :disabled="disabled"
8 role="combobox" aria-autocomplete="list" :aria-expanded="listboxOpen ? 'true' : 'false'"
9 :aria-controls="listboxOpen ? 'figure-search-listbox' : undefined"
10 :aria-activedescendant="activeIndex >= 0 ? `figure-search-option-${activeIndex}` : undefined"
11 autocomplete="off" @keydown="onSearchKeydown" />
12 <span class="sr-only" role="status">{{ searchStatus }}</span>
13 <!-- Name autocomplete: Wikipedia title search, the description line telling
14 the pharaoh from the 1963 film. Picking one fills the input; grounding
15 fires exactly as if it had been typed. -->
16 <ul v-if="listboxOpen" id="figure-search-listbox" data-testid="figure-search-listbox" role="listbox"
17 aria-label="Matching names from the record"
18 class="search-pop absolute left-0 right-0 top-full mt-1 z-20 border rv-line rounded-sm rv-bg max-h-64 overflow-y-auto">
19 <li v-for="(r, i) in searchResults" :id="`figure-search-option-${i}`" :key="r.name" role="option"
20 :aria-selected="i === activeIndex ? 'true' : 'false'" data-testid="figure-search-option"
21 class="flex items-center gap-2 px-2.5 py-1.5 cursor-pointer text-sm"
22 :class="i === activeIndex ? 'rv-tint' : 'rv-hover'"
23 @mousedown.prevent @click="choose(r.name)" @mousemove="activeIndex = i">
24 <img v-if="r.thumbnail" :src="r.thumbnail" alt="" class="h-7 w-7 shrink-0 rounded object-cover" aria-hidden="true" />
25 <span v-else class="h-7 w-7 shrink-0 rounded rv-tint" aria-hidden="true" />
26 <span class="min-w-0">
27 <span class="rv-fg font-medium block truncate">{{ r.name }}</span>
28 <span v-if="r.description" class="rv-faint text-[11px] block truncate">{{ r.description }}</span>
29 </span>
30 </li>
31 </ul>
32 <!-- The search couldn't reach Wikipedia after its retries: a visible, retryable
33 hint so a rate-limit blip degrades honestly instead of an empty box that
34 reads as "no matches" (issue #58). The sr-only role=status span above is the
35 sole live region — this surface is NOT one, to avoid a double announcement. -->
36 <div v-else-if="searchOpen && searchUnavailable" data-testid="figure-search-unavailable"
37 class="search-pop absolute left-0 right-0 top-full mt-1 z-20 border rv-line rounded-sm rv-bg px-2.5 py-2 text-sm rv-muted flex items-center justify-between gap-2">
38 <span>Couldn't reach the record — a brief network hiccup.</span>
39 <button type="button" data-testid="figure-search-retry"
40 class="rv-accent text-[11px] font-medium hover:underline shrink-0"
41 @mousedown.prevent @click="retrySearch">Retry</button>
42 </div>
43 </div>
44 
45 <!-- Grounding: who they were, and when you reach them -->
46 <div v-if="groundingLoading" data-testid="grounding-loading" class="flex items-center gap-2 text-xs rv-faint">
47 <span class="rv-spinner" aria-hidden="true" />
48 Consulting the records…
49 </div>
50 
51 <div v-else-if="grounding?.resolved" data-testid="figure-dossier" class="rv-card p-3">
52 <div class="flex gap-3">
53 <img v-if="grounding.thumbnail" :src="grounding.thumbnail" :alt="grounding.name"
54 class="h-12 w-12 shrink-0 rounded object-cover" />
55 <div class="min-w-0 flex-1">
56 <div class="flex flex-wrap items-center gap-x-2 gap-y-0.5">
57 <span data-testid="dossier-name" class="rv-fg font-semibold text-sm">{{ grounding.name }}</span>
58 <span v-if="lifespan" data-testid="dossier-lifespan" class="rv-mono text-[11px] rv-faint">{{ lifespan }}</span>
59 <a v-if="grounding.wikiUrl" :href="grounding.wikiUrl" target="_blank" rel="noopener noreferrer"
60 class="rv-accent text-[11px]">Wikipedia ↗</a>
61 </div>
62 <p v-if="grounding.description" class="rv-muted text-xs mt-0.5">{{ grounding.description }}</p>
63 
64 <!-- Deceased-only floor (#72): a living (or undatable) figure can't be
65 reached. Show an honest block in place of the when-control; the send is
66 gated by canContact, and the server re-checks authoritatively. -->
67 <p v-if="contactBlocked" data-testid="contact-blocked" role="alert"
68 class="mt-2.5 border-t rv-line pt-2.5 text-[11px] border-l-2 border-red-500/70 bg-red-500/5 pl-2 py-1 text-red-300">
69 <span class="font-semibold uppercase tracking-wide">⚠ blocked</span> {{ contactBlockReason }}
70 </p>
71 
72 <!-- When do you reach them? A promoted, lifetime-bounded playhead: the
73 chosen year + live age lead, big and in the accent, so the moment you're
74 writing into is the loudest thing in the dossier (not a buried micro-row). -->
75 <div v-else-if="hasLifespan" data-testid="when-control" class="mt-2.5 border-t rv-line pt-2.5">
⋯ 84 lines hidden (lines 76–159)
76 <div class="flex items-baseline justify-between gap-2">
77 <span id="when-label" class="rv-label">Reach them in</span>
78 <span data-testid="when-display" class="rv-mono rv-accent text-lg font-bold leading-none">
79 {{ whenDisplay }}<span v-if="contactAge != null" data-testid="when-age" class="rv-faint text-xs font-normal"> · age {{ contactAge }}</span>
80 </span>
81 </div>
82 <input type="range" data-testid="when-slider" class="w-full mt-2" :style="{ accentColor: 'var(--rv-accent)' }"
83 :min="range.min" :max="range.max" :value="contactWhen ?? range.min" :disabled="disabled"
84 aria-labelledby="when-label" :aria-valuetext="whenValueText" @input="onWhenInput" />
85 <div class="flex items-center justify-between text-[10px] rv-faint rv-mono">
86 <span>{{ grounding.born?.display }} · born</span>
87 <span>{{ grounding.died?.display ? grounding.died.display + ' · died' : 'present' }}</span>
88 </div>
89 
90 <!-- Pin the moment (issue #32): an optional month/day refinement of the
91 chosen year. Display + prompt flavor only — the year stays the value
92 every mechanic computes with, so the wager, world-brief, and liveness
93 gates are untouched by whatever is pinned here. -->
94 <div class="mt-1.5">
95 <button v-if="!momentOpen" type="button" data-testid="pin-moment"
96 class="rv-accent text-[11px] font-medium hover:underline disabled:opacity-50"
97 :disabled="disabled" @click="momentOpen = true">
98 <span aria-hidden="true">📍</span> {{ contactMoment ? 'adjust the moment' : 'pin the moment' }}
99 </button>
100 <div v-else data-testid="moment-picker">
101 <div class="flex flex-wrap gap-1" role="group" aria-label="Month of the contact">
102 <button v-for="(m, i) in MONTH_NAMES" :key="m" type="button" data-testid="month-chip"
103 class="rv-press border rv-line rounded-sm px-1.5 py-0.5 text-[10px]"
104 :class="contactMoment?.month === i + 1 ? 'rv-tint rv-fg' : 'rv-muted rv-hover'"
105 :aria-pressed="contactMoment?.month === i + 1 ? 'true' : 'false'"
106 :disabled="disabled" @click="pickMonth(i + 1)">{{ m.slice(0, 3) }}</button>
107 </div>
108 <div class="flex items-center gap-1.5 mt-1">
109 <template v-if="contactMoment">
110 <label for="moment-day" class="rv-label">day</label>
111 <input id="moment-day" data-testid="moment-day" type="number" inputmode="numeric"
112 class="rv-field rv-mono text-xs w-16 px-1.5 py-0.5" min="1" :max="maxDay"
113 :value="contactMoment.day ?? ''" :disabled="disabled" placeholder="—"
114 @input="onDayInput" />
115 </template>
116 <button type="button" data-testid="unpin-moment"
117 class="rv-faint text-[11px] hover:underline ml-auto" :disabled="disabled"
118 @click="unpinMoment">{{ contactMoment ? 'unpin' : 'close' }}</button>
119 </div>
120 </div>
121 </div>
122 </div>
123 
124 <!-- Even the AI bridge couldn't date them: say so honestly instead of a
125 mysteriously absent control. The copy claims only the lookup outcome
126 (it may be a transient outage, not a dateless record). No manual year
127 here — the contact year prices the anachronism wager, so it stays
128 grounded or unset. -->
129 <p v-else data-testid="when-unknown" class="mt-2.5 border-t rv-line pt-2.5 text-[11px] italic rv-faint">
130 No birth year could be found for them — your message will find them in their own time.
131 </p>
132 
133 <!-- The Archive: study who you're reaching, at the chosen moment -->
134 <div v-if="!contactBlocked" data-testid="archive" class="mt-2 border-t rv-line pt-2">
135 <button v-if="!studyShown" type="button" data-testid="study-button"
136 class="rv-accent text-[11px] font-medium hover:underline disabled:opacity-50"
137 :disabled="studyLoading || disabled" @click="studyThem">
138 <span aria-hidden="true">🔎</span> {{ studyLoading ? 'The Archivist consults the record…' : studyLabel }}
139 </button>
140 
141 <div v-else data-testid="figure-study" class="space-y-1 text-[11px] leading-snug rv-muted">
142 <p class="italic rv-fg">{{ figureStudy?.atThisMoment }}</p>
143 <p v-if="figureStudy?.grasp.length"><span class="rv-faint">grasps</span> {{ figureStudy?.grasp.join(' · ') }}</p>
144 <p v-if="figureStudy?.reaching.length"><span class="rv-faint">reaching</span> {{ figureStudy?.reaching.join(' · ') }}</p>
145 <p v-if="figureStudy?.cannotYetKnow" class="rv-warn"><span class="rv-faint">beyond them</span> {{ figureStudy?.cannotYetKnow }}</p>
146 <button v-if="yearMoved" type="button" data-testid="restudy-button"
147 class="rv-accent font-medium hover:underline disabled:opacity-50" :disabled="studyLoading || disabled" @click="studyThem">
148 <span aria-hidden="true">🔎</span> {{ studyLoading ? 'Consulting…' : restudyLabel }}
149 </button>
150 </div>
151 
152 <p v-if="studyNotice" data-testid="study-blocked" role="alert" aria-live="assertive"
153 class="mt-1 text-[11px] border-l-2 border-red-500/70 bg-red-500/5 pl-2 py-1 text-red-300">
154 <span class="font-semibold uppercase tracking-wide">⚠ blocked</span> {{ studyNotice }}
155 </p>
156 </div>
157 </div>
158 </div>
159 </div>
160 
161 <div v-else-if="grounding && !grounding.resolved" data-testid="figure-unresolved" class="text-xs italic rv-faint">
162 <template v-if="grounding.transient">Couldn't reach the record for "{{ activeName }}" — try again in a moment.</template>
163 <template v-else>No record found for "{{ activeName }}" — reach for a real historical figure (pick a match as you type, or refine the name).</template>
164 </div>
⋯ 66 lines hidden (lines 165–230)
165 
166 <!-- Figures already contacted this run -->
167 <div v-if="contacted.length" class="flex flex-wrap items-center gap-1.5">
168 <span class="rv-label">contacts</span>
169 <button v-for="f in contacted" :key="f.name" type="button" data-testid="contact-chip"
170 class="rv-press text-xs px-2 py-0.5 border rv-line rounded-sm inline-flex items-center gap-1.5" :class="chipClass(f.name)" @click="select(f.name)">
171 <span class="rv-dot" :class="f.name === figure ? 'rv-dot--accent' : 'rv-dot--hollow'" aria-hidden="true" />{{ f.name }}
172 </button>
173 </div>
174 
175 <!-- Era-relevant figures for this objective (the on-ramp); you can still type anyone. -->
176 <div class="space-y-1.5">
177 <span class="rv-label block" aria-live="polite">{{ suggestionsLabel }}</span>
178 <p v-if="suggestionsNotice" data-testid="suggestions-blocked" role="alert" aria-live="assertive"
179 class="text-[11px] border-l-2 border-red-500/70 bg-red-500/5 pl-2 py-1 text-red-300">
180 <span class="font-semibold uppercase tracking-wide">⚠ blocked</span> {{ suggestionsNotice }}
181 </p>
182 <!-- While the era-aware agent works: quiet skeleton chips — never clickable
183 generic defaults masquerading as objective-relevant picks. The famous-
184 names fallback appears only AFTER the agent has genuinely come up empty,
185 labeled honestly as what it is. -->
186 <div v-if="suggestionsPending" data-testid="suggestion-skeletons" class="flex flex-col gap-1" aria-hidden="true">
187 <div v-for="i in 3" :key="i" class="border rv-line rounded-sm px-2.5 py-2">
188 <span class="skeleton-line w-32" />
189 <span class="skeleton-line w-48 mt-1.5" />
190 </div>
191 </div>
192 <div v-else class="flex flex-col gap-1">
193 <button v-for="s in displaySuggestions" :key="s.name" type="button" data-testid="suggestion-chip"
194 class="rv-press border rv-line rounded-sm px-2.5 py-1.5 text-left" :class="suggestionClass(s.name)" @click="select(s.name)">
195 <span class="text-sm rv-fg font-medium">{{ s.name }}</span>
196 <span v-if="s.lifespan" class="ml-1.5 rv-mono text-[10px] rv-faint">{{ s.lifespan }}</span>
197 <span v-if="s.reason" data-testid="suggestion-reason" class="block text-[11px] leading-snug rv-faint">{{ s.reason }}</span>
198 </button>
199 </div>
200 </div>
201 </div>
202</template>
203 
204<script setup lang="ts">
205/**
206 * FigurePicker — choose whom (free-form name, suggestion, or prior contact) and, once
207 * grounded, when to reach them (a lifetime-bounded slider with a live age). The
208 * Archive's "study them" brief discloses who they are at that moment. Re-skin only —
209 * every store binding (grounding, contactWhen, study, suggestions) is unchanged.
210 */
211import { computed, watch, onMounted, onUnmounted } from 'vue'
212import { useGameStore } from '~/stores/game'
213import { formatContactMoment, MONTH_NAMES, MONTH_MAX_DAY } from '~/utils/contact-moment'
214import type { FigureSuggestion } from '~/server/utils/figure-suggester'
215 
216const figure = defineModel<string>({ default: '' })
217defineProps<{ disabled?: boolean }>()
218 
219const gameStore = useGameStore()
220const contacted = computed(() => gameStore.figures)
221const figureSuggestions = computed(() => gameStore.figureSuggestions)
222const suggestionsLoading = computed(() => gameStore.suggestionsLoading)
223const suggestionsNotice = computed(() => gameStore.suggestionsNotice)
224const grounding = computed(() => gameStore.figureGrounding)
225const groundingLoading = computed(() => gameStore.groundingLoading)
226const contactWhen = computed(() => gameStore.contactWhen)
227const contactAge = computed(() => gameStore.contactAge)
228const activeName = computed(() => figure.value.trim())
229 
230const hasLifespan = computed(() => !!(grounding.value?.resolved && grounding.value.born))
231// Deceased-only floor (#72): a resolved figure with no confirmed death is living
232// (or undatable) and can't be reached — show a block instead of the when-control.
233const contactBlocked = computed(() => !!(grounding.value?.resolved && !grounding.value.died))
234const contactBlockReason = computed(() => {
235 const g = grounding.value
236 if (!contactBlocked.value || !g) return ''
237 return g.born
238 ? `${g.name} is still living — Revisionist only reaches figures from history.`
239 : `${g.name} couldn't be dated — Revisionist can only reach figures it can place in history.`
240})
241const lifespan = computed(() => {
⋯ 295 lines hidden (lines 242–536)
242 const g = grounding.value
243 if (!g?.born) return ''
244 if (g.died) return `${g.born.display}${g.died.display}`
245 return g.living ? `${g.born.display} – present` : g.born.display
246})
247const range = computed(() => ({
248 min: grounding.value?.born?.signed ?? 0,
249 max: grounding.value?.died?.signed ?? new Date().getFullYear()
250}))
251const contactMoment = computed(() => gameStore.contactMoment)
252const whenDisplay = computed(() => (contactWhen.value != null ? formatContactMoment(contactWhen.value, contactMoment.value) : ''))
253// What AT announces for the slider: the human year + live age (e.g. "44 BC · age 25"),
254// not the raw signed integer the value attribute carries.
255const whenValueText = computed(() =>
256 contactWhen.value == null ? '' : whenDisplay.value + (contactAge.value != null ? ` · age ${contactAge.value}` : '')
258 
259function onWhenInput(e: Event) {
260 gameStore.setContactWhen(Number((e.target as HTMLInputElement).value))
262 
263// --- Pin the moment (issue #32): month chips + optional day, year-canonical ---
264const momentOpen = ref(false)
265const maxDay = computed(() => (contactMoment.value ? MONTH_MAX_DAY[contactMoment.value.month - 1] : 31))
266function pickMonth(month: number) {
267 const current = gameStore.contactMoment
268 if (current?.month === month) return
269 // Switching months keeps the day where it stays valid, clamps where it doesn't.
270 const day = current?.day ? Math.min(current.day, MONTH_MAX_DAY[month - 1]) : undefined
271 gameStore.setContactMoment(day ? { month, day } : { month })
273function onDayInput(e: Event) {
274 const current = gameStore.contactMoment
275 if (!current) return
276 const raw = Number((e.target as HTMLInputElement).value)
277 if (!Number.isInteger(raw) || raw < 1) {
278 gameStore.setContactMoment({ month: current.month })
279 return
280 }
281 gameStore.setContactMoment({ month: current.month, day: Math.min(raw, MONTH_MAX_DAY[current.month - 1]) })
283function unpinMoment() {
284 gameStore.setContactMoment(null)
285 momentOpen.value = false
287 
288// The Archive: study the grounded figure in-game, at the chosen moment.
289const figureStudy = computed(() => gameStore.figureStudy)
290const studyLoading = computed(() => gameStore.studyLoading)
291const studyNotice = computed(() => gameStore.studyNotice)
292const studyShown = computed(() => !!figureStudy.value && gameStore.studyFor === grounding.value?.name)
293const yearMoved = computed(() => studyShown.value && gameStore.studyWhen !== contactWhen.value)
294const studyLabel = computed(() => (contactWhen.value != null ? `Study them in ${whenDisplay.value}` : 'Study them'))
295const restudyLabel = computed(() => (contactWhen.value != null ? `Re-study in ${whenDisplay.value}` : 'Re-study'))
296function studyThem() { gameStore.studyActiveFigure() }
297 
298const LOCAL_DEFAULT: FigureSuggestion[] = [
299 { name: 'Cleopatra', reason: '' },
300 { name: 'Nikola Tesla', reason: '' },
301 { name: 'Genghis Khan', reason: '' },
302 { name: 'Marie Curie', reason: '' },
303 { name: 'Leonardo da Vinci', reason: '' }
305const displaySuggestions = computed(() => (figureSuggestions.value.length ? figureSuggestions.value : LOCAL_DEFAULT))
306// Pending = in flight, OR simply not yet asked for this objective — the latter
307// covers the first painted frame (loadSuggestions fires onMounted, after render),
308// which would otherwise flash the clickable famous-names fallback for one frame.
309// The store records suggestionsFor even on failure, so a genuine miss still
310// settles into the honest fallback rather than skeletons forever.
311const suggestionsPending = computed(() =>
312 !figureSuggestions.value.length && (
313 suggestionsLoading.value ||
314 (!!gameStore.currentObjective && gameStore.suggestionsFor !== gameStore.currentObjective.title)
315 )
317const suggestionsLabel = computed(() =>
318 suggestionsPending.value
319 ? 'finding figures who matter here…'
320 : figureSuggestions.value.length
321 ? 'figures who might matter here'
322 : 'some famous names to start with'
324 
325function select(name: string) {
326 choose(name)
328 
329// --- Name autocomplete (a combobox over Wikipedia title search) ---
330interface SearchResult { name: string; description?: string; thumbnail?: string }
331const searchBoxRef = ref<HTMLElement | null>(null)
332const searchInputRef = ref<HTMLInputElement | null>(null)
333const searchResults = ref<SearchResult[]>([])
334const searchOpen = ref(false)
335// Sustained weather (rate-limit blips) finally gave up with nothing to show: a
336// visible, retryable hint takes the popup so an outage degrades honestly instead
337// of an empty box that reads as "no matches" (issue #58).
338const searchUnavailable = ref(false)
339const activeIndex = ref(-1)
340// The listbox is the result popup; the unavailable hint is a separate, non-listbox
341// surface. aria-expanded / aria-controls track only this.
342const listboxOpen = computed(() => searchOpen.value && searchResults.value.length > 0)
343let searchDebounce: ReturnType<typeof setTimeout> | null = null
344let searchSeq = 0
345// Backoff for transient (rate-limit) misses: spaced retries before giving up, so a
346// throttled prefix like "Leonardo" gets time to clear rather than vanishing.
347const SEARCH_RETRY_DELAYS_MS = [800, 1600]
348// The name most recently CHOSEN (dropdown option, suggestion chip, contact chip):
349// the model change it causes must not reopen the dropdown over the fresh dossier.
350let lastChosen = ''
351 
352/** Closing also INVALIDATES any response still in flight: bumping the seq is what
353 * keeps a slow fetch from reopening a stale dropdown over a chosen figure's
354 * dossier, an emptied input, or a disabled one mid-send. It also cancels a pending
355 * backoff retry — otherwise a timer armed before the dismissal would later fire and
356 * pop the dropdown (or the unavailable hint) back up unprompted. */
357function closeSearch() {
358 if (searchDebounce) clearTimeout(searchDebounce)
359 searchDebounce = null
360 searchSeq++
361 searchResults.value = []
362 searchOpen.value = false
363 searchUnavailable.value = false
364 activeIndex.value = -1
366 
367/** Deliberate selection from any surface: fill the input and stand down. */
368function choose(name: string) {
369 lastChosen = name
370 figure.value = name
371 closeSearch()
373 
374/** Fire the actual lookup for the CURRENT seq; results land only if still current. */
375async function runSearch(q: string, seq: number, attempt = 0) {
376 try {
377 const res = await $fetch('/api/figure-search', { params: { q } }) as { results?: SearchResult[]; transient?: boolean }
378 if (seq !== searchSeq) return // closed, or a newer keystroke took over
379 const results = res?.results ?? []
380 // Weather, not absence: an empty answer the server marks transient must not
381 // read as "no matches" — keep whatever is showing and retry with backoff.
382 // (A new keystroke clears the timer; the seq guard drops a stale landing.)
383 if (!results.length && res?.transient) {
384 const delay = SEARCH_RETRY_DELAYS_MS[attempt]
385 if (delay != null) {
386 searchDebounce = setTimeout(() => { void runSearch(q, seq, attempt + 1) }, delay)
387 } else if (!searchResults.value.length) {
388 // Out of retries with nothing to fall back on: say so plainly and
389 // offer a retry, rather than an empty box that looks like "no matches".
390 searchUnavailable.value = true
391 searchOpen.value = true
392 activeIndex.value = -1
393 }
394 return
395 }
396 searchUnavailable.value = false
397 searchResults.value = results
398 searchOpen.value = results.length > 0
399 activeIndex.value = -1
400 } catch {
401 if (seq === searchSeq) closeSearch()
402 }
404 
405/** Manual retry from the "unavailable" hint — refire the search for the current text.
406 * Returns focus to the input first: clearing the hint unmounts the Retry button, and
407 * a keyboard user activating it would otherwise have focus fall to <body>. */
408function retrySearch() {
409 const q = (figure.value || '').trim()
410 if (q.length < 2) return
411 searchInputRef.value?.focus()
412 searchUnavailable.value = false
413 void runSearch(q, ++searchSeq)
415 
416watch(figure, (name) => {
417 if (searchDebounce) clearTimeout(searchDebounce)
418 const q = (name || '').trim()
419 if (q.length < 2 || q === lastChosen) {
420 closeSearch()
421 // One-shot suppression: it exists to swallow the single model echo a
422 // choice causes. A later deliberate retype of the same name searches again.
423 if (q === lastChosen) lastChosen = ''
424 return
425 }
426 lastChosen = ''
427 searchUnavailable.value = false // a fresh keystroke retires any prior outage hint
428 const seq = ++searchSeq
429 searchDebounce = setTimeout(() => { void runSearch(q, seq) }, 250)
430})
431 
432function onSearchKeydown(e: KeyboardEvent) {
433 if (e.isComposing) return // IME candidate navigation owns these keys
434 // Escape always dismisses: it must clear a populated listbox OR the unavailable
435 // hint, and — even when nothing is on screen yet — cancel a pending backoff retry
436 // and invalidate any in-flight fetch (closeSearch clears the timer and bumps the
437 // seq). Handled before the guard below, which only covers a populated listbox.
438 if (e.key === 'Escape') {
439 closeSearch()
440 return
441 }
442 if (!searchOpen.value || !searchResults.value.length) {
443 // APG editable-combobox: Down Arrow on a closed combobox reopens the popup
444 // (the only recovery after Escape that doesn't require editing the text).
445 if (e.key === 'ArrowDown') {
446 const q = (figure.value || '').trim()
447 if (q.length >= 2) {
448 e.preventDefault()
449 void runSearch(q, ++searchSeq)
450 }
451 }
452 return
453 }
454 if (e.key === 'ArrowDown') {
455 e.preventDefault()
456 activeIndex.value = (activeIndex.value + 1) % searchResults.value.length
457 } else if (e.key === 'ArrowUp') {
458 e.preventDefault()
459 activeIndex.value = activeIndex.value <= 0 ? searchResults.value.length - 1 : activeIndex.value - 1
460 } else if (e.key === 'Enter') {
461 if (activeIndex.value >= 0) {
462 e.preventDefault()
463 choose(searchResults.value[activeIndex.value].name)
464 }
465 }
466 // Escape is handled up top (it must also dismiss the unavailable hint, where
467 // searchResults is empty and this block is skipped).
469 
470/** Close when focus genuinely leaves the combobox (not when it moves within it). */
471function onSearchFocusOut(e: FocusEvent) {
472 const next = e.relatedTarget as Node | null
473 if (next && searchBoxRef.value?.contains(next)) return
474 closeSearch()
476 
477/** Spoken result count — aria-expanded alone is not reliably announced while typing. */
478const searchStatus = computed(() =>
479 searchUnavailable.value
480 ? 'The record is unreachable right now — press the down arrow or Retry to search again.'
481 : listboxOpen.value
482 ? `${searchResults.value.length} ${searchResults.value.length === 1 ? 'match' : 'matches'} — up and down arrows to review, Enter to choose`
483 : ''
485 
486function chipClass(name: string) {
487 return name === figure.value ? 'rv-tint rv-fg' : 'rv-muted rv-hover'
489function suggestionClass(name: string) {
490 return name === figure.value ? 'rv-tint' : 'rv-hover'
492 
493// Ground the chosen figure, debounced so typing doesn't spam the lookup. The
494// PREVIOUS dossier is cleared synchronously the instant the name changes: a send
495// fired during the debounce/fetch window must go out clean (free-form), never
496// wearing the old figure's facts, year, or liveness gate.
497let debounce: ReturnType<typeof setTimeout> | null = null
498watch(figure, (name) => {
499 if (debounce) clearTimeout(debounce)
500 gameStore.clearGrounding()
501 const trimmed = (name || '').trim()
502 if (!trimmed) return
503 debounce = setTimeout(() => gameStore.groundActiveFigure(trimmed), 350)
504})
505onUnmounted(() => {
506 if (debounce) clearTimeout(debounce)
507 if (searchDebounce) clearTimeout(searchDebounce)
508})
509 
510// Load era-relevant suggestions for the current objective when the board appears.
511onMounted(() => gameStore.loadSuggestions())
512</script>
513 
514<style scoped>
515/* Quiet placeholder bars while the suggester works — letterpress tint, a gentle
516 pulse, stilled under reduced motion. Never a clickable fake. */
517.skeleton-line {
518 display: block;
519 height: 10px;
520 border-radius: 2px;
521 background: var(--rv-tint);
522 animation: skeleton-pulse 1.6s var(--rv-ease) infinite;
524@keyframes skeleton-pulse {
525 0%, 100% { opacity: .55; }
526 50% { opacity: 1; }
528@media (prefers-reduced-motion: reduce) {
529 .skeleton-line { animation: none; }
531 
532/* The autocomplete pop floats over the dossier on warm paper, not chrome. */
533.search-pop {
534 box-shadow: 0 6px 18px color-mix(in srgb, var(--rv-fg) 10%, transparent);
536</style>