What changed, and why

A figure's suggestion blurb — the one-line "why this figure matters" under a name in the contact picker — kept describing that figure's original fate even after the player had overturned it on the timeline ledger. From playtesting: after a node where Majorian survives past 461, the panel still called him someone whose "early death after only four years meant the West lost its last chance." It quietly contradicted the timeline the player was building.

Two facts caused it. Blurbs were fetched once per objective and never refreshed. And the suggester was handed only {title, description, era} — it never saw the ledger, so a blurb had no way to know what had changed.

This PR does three things: it feeds the landed ledger to the suggester so each blurb re-narrates against the altered history; it re-runs the suggester after each change (non-blocking, the suggestion-side twin of the living Chronicle); and when a blurb changes, it plays a "history rewritten" flourish — the old wording struck through and bled away while the new writes in over it.

The suggester's brief re-narrates against the timeline

The suggester is a small tool-using agent; the only thing it acts on is the brief it's handed. The fix folds the landed changes into that brief as the history that now stands, with one instruction: where a change has overturned a figure's known fate, say what's now true of them — never re-assert the outcome the change reversed. The framing is conditional, so a turn with no changes yet produces a brief byte-identical to the tuned original.

The conditional altered-timeline section, appended only when there are changes.

server/utils/figure-suggester.ts · 337 lines
server/utils/figure-suggester.ts337 lines · TypeScript
⋯ 149 lines hidden (lines 1–149)
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'
20import { cleanArray, cleanString, MAX_ERA_CHARS, MAX_OBJECTIVE_TITLE_CHARS, MAX_TIMELINE_ENTRIES } from './validate'
21 
22export interface FigureSuggestion {
23 name: string
24 reason: string
25 lifespan?: string
27 
28/**
29 * What the model returns on the wire under the strict schema: `lifespan` is ALWAYS
30 * present (an empty string when unknown). We type the raw parse against this — not
31 * the public `FigureSuggestion` — so the schema's contract is honest at the boundary;
32 * the mapping below turns the empty-string sentinel into the optional public field.
33 * (Surfaced by the ai-contract-drift loop: schema-required vs type-optional.)
34 */
35interface FigureSuggestionWire {
36 name: string
37 reason: string
38 lifespan: string
40 
41export interface SuggestionResult {
42 suggestions: FigureSuggestion[]
43 fallback: boolean
45 
46interface ObjectiveInput {
47 title?: string
48 description?: string
49 era?: string
50 /**
51 * The changes the player has ALREADY landed on the timeline — era-stamped
52 * headlines of the running ledger (issue #131). Threaded into the brief so a
53 * reason re-narrates against the altered history instead of asserting a fate the
54 * player has since overturned. Empty on the first turn, so the prompt there stays
55 * byte-identical to the un-altered ask.
56 */
57 worldBrief?: string[]
59 
60/**
61 * Recent landed changes folded into the suggester's altered-timeline brief. A wider
62 * window than the per-figure character world-brief (bounded to one figure's own past,
63 * `slice(-5)` in send-message): the suggester re-narrates figures across ALL eras at
64 * once, so it reads a few more of the latest changes. NOT a parity with that window.
65 */
66export const MAX_LEDGER_HEADLINES = 8
67 
68/**
69 * Coerce an untrusted client ledger into bounded, era-stamped headlines for the
70 * altered-timeline brief (issue #131). Cap-first (`cleanArray`) so a crafted oversized
71 * body can't amplify CPU/allocations; then drop headline-less entries, stamp the era,
72 * and keep only the most recent few. These are the game's own prior turn headlines fed
73 * back to a model — bounded like every other untrusted field, and (like send-message
74 * reusing its ledger) not re-moderated; the objective remains the hard-blocked input.
75 */
76export function buildLedgerBrief(rawLedger: unknown): string[] {
77 return cleanArray(rawLedger, MAX_TIMELINE_ENTRIES)
78 .map((e) => {
79 const entry = e as { era?: unknown; headline?: unknown }
80 const headline = cleanString(entry?.headline, MAX_OBJECTIVE_TITLE_CHARS)
81 if (!headline) return null
82 const era = cleanString(entry?.era, MAX_ERA_CHARS)
83 return era ? `[${era}] ${headline}` : headline
84 })
85 .filter((l): l is string => l !== null)
86 .slice(-MAX_LEDGER_HEADLINES)
88 
89/** Era-agnostic safety net so the player always has somewhere to start. Every name
90 * here MUST be a historically deceased figure (the deceased-only floor, #72) — the
91 * fallback bypasses the live grounding filter, so a test pins this invariant. */
92const GENERIC_FALLBACK: FigureSuggestion[] = [
93 { name: 'Cleopatra', reason: 'A ruler whose alliances reshaped the ancient Mediterranean.' },
94 { name: 'Leonardo da Vinci', reason: 'A mind a century ahead — art, war machines, and invention.' },
95 { name: 'Genghis Khan', reason: 'Forged the largest contiguous empire in history.' },
96 { name: 'Marie Curie', reason: 'A pioneer whose science bent the modern age.' },
97 { name: 'Abraham Lincoln', reason: 'Held a fracturing nation together at its hinge point.' }
99 
100function lifespanOf(g: GroundedFigure): string | undefined {
101 if (!g.born) return undefined
102 if (g.died) return `${g.born.display}${g.died.display}`
103 return g.living ? `${g.born.display} – present` : g.born.display
105 
106const 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.
107 
108Only 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.`
109 
110const LOOKUP_TOOL = {
111 name: 'lookup_figure',
112 description: 'Verify a real historical figure exists and fetch their lifespan and role.',
113 parameters: {
114 type: 'object' as const,
115 properties: { name: { type: 'string', description: 'The figure’s name to look up' } },
116 required: ['name'],
117 additionalProperties: false
118 }
120 
121const SUGGESTIONS_SCHEMA = {
122 type: 'object',
123 properties: {
124 suggestions: {
125 type: 'array',
126 items: {
127 type: 'object',
128 properties: {
129 name: { type: 'string', description: 'The figure’s name' },
130 reason: { type: 'string', description: 'One sentence: why this figure matters for the objective' },
131 lifespan: { type: 'string', description: 'e.g. "69 BC – 30 BC"; empty string if unknown' }
132 },
133 required: ['name', 'reason', 'lifespan'],
134 additionalProperties: false
135 }
136 }
137 },
138 required: ['suggestions'],
139 additionalProperties: false
141 
142/**
143 * The research brief handed to the suggester agent. When the player has already
144 * altered the timeline, the landed changes are folded in as the history that now
145 * stands, with an instruction to keep each reason consistent with it — the fix for
146 * blurbs that kept asserting a figure's original fate after the player had overturned
147 * it (issue #131). Exported for the unit test. With no changes the brief is
148 * byte-identical to the original objective-only ask.
149 */
150export function userBrief(objective: ObjectiveInput): string {
151 const altered = objective.worldBrief?.length
152 ? `\n\nThe player has ALREADY begun rewriting history — these changes are now real, recorded events on the timeline, not predictions:\n${objective.worldBrief.map(l => `- ${l}`).join('\n')}\nTreat them as the history that now stands. Each reason must fit this altered timeline: where a change above has overturned a figure's known fate (a death undone, a defeat averted, a work never lost), say what is now true of them — never describe the original outcome the change has already reversed.`
153 : ''
154 return `Objective: "${objective.title ?? 'Alter the course of history'}".
155What winning looks like: ${objective.description ?? '—'}
156${objective.era ? `Anchoring era: ${objective.era}` : ''}${altered}
157 
158Propose 5 figures the player could message to influence this. Verify each with lookup_figure before finalizing.`
160 
161const FINAL_NUDGE = 'Now give your final answer: the 5 figures, each with a one-sentence reason it matters for this objective.'
162 
163/** Runs one lookup_figure call and shapes the evidence the agent reads. */
164async function runLookup(name: string): Promise<object> {
165 const g = name ? await groundFigure(name) : ({ name: '', resolved: false } as GroundedFigure)
166 return g.resolved
167 ? { resolved: true, name: g.name, role: g.description, lifespan: lifespanOf(g), deceased: isDeceased(g) }
⋯ 170 lines hidden (lines 168–337)
168 : { resolved: false }
170 
171function mapWire(raw: unknown): FigureSuggestion[] {
172 const parsed = raw as { suggestions?: FigureSuggestionWire[] }
173 return (parsed.suggestions ?? [])
174 .filter(s => s && s.name && s.reason)
175 .map(s => ({ name: s.name, reason: s.reason, lifespan: s.lifespan || undefined }))
176 .slice(0, 6)
178 
179/**
180 * Defense in depth for the deceased-only floor (#72): re-ground every proposed
181 * figure and keep only the confirmed-deceased ones, so a model that ignores the
182 * instruction can never surface a living person as a one-tap contact. Grounding is
183 * usually cached (the agent typically looked these up), so usually near-free — a
184 * final name that differs from a looked-up one costs a fresh bounded lookup, and a
185 * transient outage drops that pick (fail closed; the list can fall to the curated
186 * fallback). We also normalize each lifespan to the grounded record. Exported for
187 * the unit test.
188 */
189export async function keepDeceased(suggestions: FigureSuggestion[]): Promise<FigureSuggestion[]> {
190 const checked = await Promise.all(suggestions.map(async (s): Promise<FigureSuggestion | null> => {
191 const g = await groundFigure(s.name)
192 return isDeceased(g) ? { ...s, lifespan: lifespanOf(g) ?? s.lifespan } : null
193 }))
194 return checked.filter((s): s is FigureSuggestion => s !== null)
196 
197/** Max tool-call rounds in the suggester's research loop — one budget shared by both
198 * provider lanes (OpenAI + Anthropic) so a bump can't silently diverge them. */
199const MAX_AGENT_LOOKUP_ROUNDS = 3
200 
201async function runAgentOpenAI(objective: ObjectiveInput): Promise<FigureSuggestion[]> {
202 const params = routeFor('suggester').route.openai
203 const client = getOpenAIClient()
204 const tools: ChatCompletionTool[] = [{
205 type: 'function',
206 function: {
207 name: LOOKUP_TOOL.name,
208 description: LOOKUP_TOOL.description,
209 parameters: LOOKUP_TOOL.parameters
210 }
211 }]
212 const messages: ChatCompletionMessageParam[] = [
213 { role: 'system', content: SYSTEM },
214 { role: 'user', content: userBrief(objective) }
215 ]
216 
217 // Phase 1: let the agent research candidates with the grounding tool.
218 for (let round = 0; round < MAX_AGENT_LOOKUP_ROUNDS; round++) {
219 const completion = await client.chat.completions.create({
220 model: params.model,
221 messages,
222 tools,
223 tool_choice: 'auto',
224 // gpt-5.5 rejects `reasoning_effort` alongside function tools on Chat
225 // Completions, so we omit it here (the no-tool phase 2 keeps it).
226 max_completion_tokens: params.maxTokens
227 })
228 const msg = completion.choices?.[0]?.message
229 if (!msg) break
230 messages.push(msg)
231 if (!msg.tool_calls?.length) break
232 
233 for (const call of msg.tool_calls) {
234 let name = ''
235 try { name = JSON.parse(call.function.arguments || '{}').name } catch { /* bad args */ }
236 const result = await runLookup(name)
237 messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(result) })
238 }
239 }
240 
241 // Phase 2: force a clean structured list out of the research.
242 const final = await client.chat.completions.create({
243 model: params.model,
244 messages: [...messages, { role: 'user', content: FINAL_NUDGE }],
245 reasoning_effort: 'low',
246 max_completion_tokens: 1500,
247 response_format: {
248 type: 'json_schema',
249 json_schema: { name: 'figure_suggestions', strict: true, schema: SUGGESTIONS_SCHEMA }
250 }
251 })
252 
253 const content = final.choices?.[0]?.message?.content
254 if (!content) return []
255 return mapWire(JSON.parse(content))
257 
258async function runAgentAnthropic(objective: ObjectiveInput): Promise<FigureSuggestion[]> {
259 const params = routeFor('suggester').route.anthropic
260 const client = getAnthropicClient()
261 const tools: AnthropicSdk.ToolUnion[] = [{
262 name: LOOKUP_TOOL.name,
263 description: LOOKUP_TOOL.description,
264 input_schema: LOOKUP_TOOL.parameters,
265 // Strict tools: the API guarantees the input matches the schema.
266 strict: true
267 }]
268 const messages: AnthropicSdk.MessageParam[] = [
269 { role: 'user', content: userBrief(objective) }
270 ]
271 const common = {
272 model: params.model,
273 max_tokens: params.maxTokens,
274 system: SYSTEM,
275 ...(params.temperature !== undefined ? { temperature: params.temperature } : {})
276 }
277 
278 // Phase 1: the research loop — run tools until the model stops asking.
279 for (let round = 0; round < MAX_AGENT_LOOKUP_ROUNDS; round++) {
280 const response = await client.messages.create({ ...common, messages, tools })
281 messages.push({ role: 'assistant', content: response.content })
282 const toolUses = response.content.filter(
283 (b): b is AnthropicSdk.ToolUseBlock => b.type === 'tool_use'
284 )
285 if (response.stop_reason !== 'tool_use' || !toolUses.length) break
286 
287 const results: AnthropicSdk.ToolResultBlockParam[] = []
288 for (const use of toolUses) {
289 const name = typeof (use.input as { name?: unknown })?.name === 'string'
290 ? (use.input as { name: string }).name
291 : ''
292 results.push({
293 type: 'tool_result',
294 tool_use_id: use.id,
295 content: JSON.stringify(await runLookup(name))
296 })
297 }
298 messages.push({ role: 'user', content: results })
299 }
300 
301 // Phase 2: force a clean structured list out of the research.
302 const final = await client.messages.create({
303 ...common,
304 max_tokens: 1500,
305 messages: [...messages, { role: 'user', content: FINAL_NUDGE }],
306 output_config: { format: { type: 'json_schema', schema: SUGGESTIONS_SCHEMA } }
307 })
308 
309 const text = final.content.find(
310 (b): b is AnthropicSdk.TextBlock => b.type === 'text'
311 )?.text
312 if (!text) return []
313 return mapWire(JSON.parse(text))
315 
316/** Suggests era-relevant figures for an objective, with a curated fallback. */
317export async function suggestFigures(objective: ObjectiveInput): Promise<SuggestionResult> {
318 try {
319 const { lane } = routeFor('suggester')
320 const suggestions = lane === 'anthropic'
321 ? await runAgentAnthropic(objective)
322 : await runAgentOpenAI(objective)
323 // Deceased-only floor (#72): re-ground and drop anyone still living before
324 // they reach the player. If that empties the list (a fully misbehaving
325 // model), fall through to the curated, all-deceased fallback below.
326 const deceased = await keepDeceased(suggestions)
327 if (deceased.length) return { suggestions: deceased, fallback: false }
328 } catch (error) {
329 console.error('Figure suggester agent failed; using fallback:', error)
330 }
331 return { suggestions: GENERIC_FALLBACK, fallback: true }
333 
334/** Exposed for the fallback path + tests. */
335export function fallbackSuggestions(): FigureSuggestion[] {
336 return [...GENERIC_FALLBACK]

The ledger crosses the boundary, bounded once

The ledger arrives from the client, so it's untrusted input reaching a model prompt. buildLedgerBrief is the one place it's coerced: cap the array first (so a crafted oversized body can't amplify CPU before the count cap), then drop headline-less entries, stamp the era, and keep the most recent few.

Cap-first, then coerce + bound each field — the established input discipline.

server/utils/figure-suggester.ts · 337 lines
server/utils/figure-suggester.ts337 lines · TypeScript
⋯ 65 lines hidden (lines 1–65)
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'
20import { cleanArray, cleanString, MAX_ERA_CHARS, MAX_OBJECTIVE_TITLE_CHARS, MAX_TIMELINE_ENTRIES } from './validate'
21 
22export interface FigureSuggestion {
23 name: string
24 reason: string
25 lifespan?: string
27 
28/**
29 * What the model returns on the wire under the strict schema: `lifespan` is ALWAYS
30 * present (an empty string when unknown). We type the raw parse against this — not
31 * the public `FigureSuggestion` — so the schema's contract is honest at the boundary;
32 * the mapping below turns the empty-string sentinel into the optional public field.
33 * (Surfaced by the ai-contract-drift loop: schema-required vs type-optional.)
34 */
35interface FigureSuggestionWire {
36 name: string
37 reason: string
38 lifespan: string
40 
41export interface SuggestionResult {
42 suggestions: FigureSuggestion[]
43 fallback: boolean
45 
46interface ObjectiveInput {
47 title?: string
48 description?: string
49 era?: string
50 /**
51 * The changes the player has ALREADY landed on the timeline — era-stamped
52 * headlines of the running ledger (issue #131). Threaded into the brief so a
53 * reason re-narrates against the altered history instead of asserting a fate the
54 * player has since overturned. Empty on the first turn, so the prompt there stays
55 * byte-identical to the un-altered ask.
56 */
57 worldBrief?: string[]
59 
60/**
61 * Recent landed changes folded into the suggester's altered-timeline brief. A wider
62 * window than the per-figure character world-brief (bounded to one figure's own past,
63 * `slice(-5)` in send-message): the suggester re-narrates figures across ALL eras at
64 * once, so it reads a few more of the latest changes. NOT a parity with that window.
65 */
66export const MAX_LEDGER_HEADLINES = 8
67 
68/**
69 * Coerce an untrusted client ledger into bounded, era-stamped headlines for the
70 * altered-timeline brief (issue #131). Cap-first (`cleanArray`) so a crafted oversized
71 * body can't amplify CPU/allocations; then drop headline-less entries, stamp the era,
72 * and keep only the most recent few. These are the game's own prior turn headlines fed
73 * back to a model — bounded like every other untrusted field, and (like send-message
74 * reusing its ledger) not re-moderated; the objective remains the hard-blocked input.
75 */
76export function buildLedgerBrief(rawLedger: unknown): string[] {
77 return cleanArray(rawLedger, MAX_TIMELINE_ENTRIES)
78 .map((e) => {
79 const entry = e as { era?: unknown; headline?: unknown }
80 const headline = cleanString(entry?.headline, MAX_OBJECTIVE_TITLE_CHARS)
81 if (!headline) return null
82 const era = cleanString(entry?.era, MAX_ERA_CHARS)
83 return era ? `[${era}] ${headline}` : headline
84 })
85 .filter((l): l is string => l !== null)
86 .slice(-MAX_LEDGER_HEADLINES)
⋯ 250 lines hidden (lines 88–337)
88 
89/** Era-agnostic safety net so the player always has somewhere to start. Every name
90 * here MUST be a historically deceased figure (the deceased-only floor, #72) — the
91 * fallback bypasses the live grounding filter, so a test pins this invariant. */
92const GENERIC_FALLBACK: FigureSuggestion[] = [
93 { name: 'Cleopatra', reason: 'A ruler whose alliances reshaped the ancient Mediterranean.' },
94 { name: 'Leonardo da Vinci', reason: 'A mind a century ahead — art, war machines, and invention.' },
95 { name: 'Genghis Khan', reason: 'Forged the largest contiguous empire in history.' },
96 { name: 'Marie Curie', reason: 'A pioneer whose science bent the modern age.' },
97 { name: 'Abraham Lincoln', reason: 'Held a fracturing nation together at its hinge point.' }
99 
100function lifespanOf(g: GroundedFigure): string | undefined {
101 if (!g.born) return undefined
102 if (g.died) return `${g.born.display}${g.died.display}`
103 return g.living ? `${g.born.display} – present` : g.born.display
105 
106const 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.
107 
108Only 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.`
109 
110const LOOKUP_TOOL = {
111 name: 'lookup_figure',
112 description: 'Verify a real historical figure exists and fetch their lifespan and role.',
113 parameters: {
114 type: 'object' as const,
115 properties: { name: { type: 'string', description: 'The figure’s name to look up' } },
116 required: ['name'],
117 additionalProperties: false
118 }
120 
121const SUGGESTIONS_SCHEMA = {
122 type: 'object',
123 properties: {
124 suggestions: {
125 type: 'array',
126 items: {
127 type: 'object',
128 properties: {
129 name: { type: 'string', description: 'The figure’s name' },
130 reason: { type: 'string', description: 'One sentence: why this figure matters for the objective' },
131 lifespan: { type: 'string', description: 'e.g. "69 BC – 30 BC"; empty string if unknown' }
132 },
133 required: ['name', 'reason', 'lifespan'],
134 additionalProperties: false
135 }
136 }
137 },
138 required: ['suggestions'],
139 additionalProperties: false
141 
142/**
143 * The research brief handed to the suggester agent. When the player has already
144 * altered the timeline, the landed changes are folded in as the history that now
145 * stands, with an instruction to keep each reason consistent with it — the fix for
146 * blurbs that kept asserting a figure's original fate after the player had overturned
147 * it (issue #131). Exported for the unit test. With no changes the brief is
148 * byte-identical to the original objective-only ask.
149 */
150export function userBrief(objective: ObjectiveInput): string {
151 const altered = objective.worldBrief?.length
152 ? `\n\nThe player has ALREADY begun rewriting history — these changes are now real, recorded events on the timeline, not predictions:\n${objective.worldBrief.map(l => `- ${l}`).join('\n')}\nTreat them as the history that now stands. Each reason must fit this altered timeline: where a change above has overturned a figure's known fate (a death undone, a defeat averted, a work never lost), say what is now true of them — never describe the original outcome the change has already reversed.`
153 : ''
154 return `Objective: "${objective.title ?? 'Alter the course of history'}".
155What winning looks like: ${objective.description ?? '—'}
156${objective.era ? `Anchoring era: ${objective.era}` : ''}${altered}
157 
158Propose 5 figures the player could message to influence this. Verify each with lookup_figure before finalizing.`
160 
161const FINAL_NUDGE = 'Now give your final answer: the 5 figures, each with a one-sentence reason it matters for this objective.'
162 
163/** Runs one lookup_figure call and shapes the evidence the agent reads. */
164async function runLookup(name: string): Promise<object> {
165 const g = name ? await groundFigure(name) : ({ name: '', resolved: false } as GroundedFigure)
166 return g.resolved
167 ? { resolved: true, name: g.name, role: g.description, lifespan: lifespanOf(g), deceased: isDeceased(g) }
168 : { resolved: false }
170 
171function mapWire(raw: unknown): FigureSuggestion[] {
172 const parsed = raw as { suggestions?: FigureSuggestionWire[] }
173 return (parsed.suggestions ?? [])
174 .filter(s => s && s.name && s.reason)
175 .map(s => ({ name: s.name, reason: s.reason, lifespan: s.lifespan || undefined }))
176 .slice(0, 6)
178 
179/**
180 * Defense in depth for the deceased-only floor (#72): re-ground every proposed
181 * figure and keep only the confirmed-deceased ones, so a model that ignores the
182 * instruction can never surface a living person as a one-tap contact. Grounding is
183 * usually cached (the agent typically looked these up), so usually near-free — a
184 * final name that differs from a looked-up one costs a fresh bounded lookup, and a
185 * transient outage drops that pick (fail closed; the list can fall to the curated
186 * fallback). We also normalize each lifespan to the grounded record. Exported for
187 * the unit test.
188 */
189export async function keepDeceased(suggestions: FigureSuggestion[]): Promise<FigureSuggestion[]> {
190 const checked = await Promise.all(suggestions.map(async (s): Promise<FigureSuggestion | null> => {
191 const g = await groundFigure(s.name)
192 return isDeceased(g) ? { ...s, lifespan: lifespanOf(g) ?? s.lifespan } : null
193 }))
194 return checked.filter((s): s is FigureSuggestion => s !== null)
196 
197/** Max tool-call rounds in the suggester's research loop — one budget shared by both
198 * provider lanes (OpenAI + Anthropic) so a bump can't silently diverge them. */
199const MAX_AGENT_LOOKUP_ROUNDS = 3
200 
201async function runAgentOpenAI(objective: ObjectiveInput): Promise<FigureSuggestion[]> {
202 const params = routeFor('suggester').route.openai
203 const client = getOpenAIClient()
204 const tools: ChatCompletionTool[] = [{
205 type: 'function',
206 function: {
207 name: LOOKUP_TOOL.name,
208 description: LOOKUP_TOOL.description,
209 parameters: LOOKUP_TOOL.parameters
210 }
211 }]
212 const messages: ChatCompletionMessageParam[] = [
213 { role: 'system', content: SYSTEM },
214 { role: 'user', content: userBrief(objective) }
215 ]
216 
217 // Phase 1: let the agent research candidates with the grounding tool.
218 for (let round = 0; round < MAX_AGENT_LOOKUP_ROUNDS; round++) {
219 const completion = await client.chat.completions.create({
220 model: params.model,
221 messages,
222 tools,
223 tool_choice: 'auto',
224 // gpt-5.5 rejects `reasoning_effort` alongside function tools on Chat
225 // Completions, so we omit it here (the no-tool phase 2 keeps it).
226 max_completion_tokens: params.maxTokens
227 })
228 const msg = completion.choices?.[0]?.message
229 if (!msg) break
230 messages.push(msg)
231 if (!msg.tool_calls?.length) break
232 
233 for (const call of msg.tool_calls) {
234 let name = ''
235 try { name = JSON.parse(call.function.arguments || '{}').name } catch { /* bad args */ }
236 const result = await runLookup(name)
237 messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(result) })
238 }
239 }
240 
241 // Phase 2: force a clean structured list out of the research.
242 const final = await client.chat.completions.create({
243 model: params.model,
244 messages: [...messages, { role: 'user', content: FINAL_NUDGE }],
245 reasoning_effort: 'low',
246 max_completion_tokens: 1500,
247 response_format: {
248 type: 'json_schema',
249 json_schema: { name: 'figure_suggestions', strict: true, schema: SUGGESTIONS_SCHEMA }
250 }
251 })
252 
253 const content = final.choices?.[0]?.message?.content
254 if (!content) return []
255 return mapWire(JSON.parse(content))
257 
258async function runAgentAnthropic(objective: ObjectiveInput): Promise<FigureSuggestion[]> {
259 const params = routeFor('suggester').route.anthropic
260 const client = getAnthropicClient()
261 const tools: AnthropicSdk.ToolUnion[] = [{
262 name: LOOKUP_TOOL.name,
263 description: LOOKUP_TOOL.description,
264 input_schema: LOOKUP_TOOL.parameters,
265 // Strict tools: the API guarantees the input matches the schema.
266 strict: true
267 }]
268 const messages: AnthropicSdk.MessageParam[] = [
269 { role: 'user', content: userBrief(objective) }
270 ]
271 const common = {
272 model: params.model,
273 max_tokens: params.maxTokens,
274 system: SYSTEM,
275 ...(params.temperature !== undefined ? { temperature: params.temperature } : {})
276 }
277 
278 // Phase 1: the research loop — run tools until the model stops asking.
279 for (let round = 0; round < MAX_AGENT_LOOKUP_ROUNDS; round++) {
280 const response = await client.messages.create({ ...common, messages, tools })
281 messages.push({ role: 'assistant', content: response.content })
282 const toolUses = response.content.filter(
283 (b): b is AnthropicSdk.ToolUseBlock => b.type === 'tool_use'
284 )
285 if (response.stop_reason !== 'tool_use' || !toolUses.length) break
286 
287 const results: AnthropicSdk.ToolResultBlockParam[] = []
288 for (const use of toolUses) {
289 const name = typeof (use.input as { name?: unknown })?.name === 'string'
290 ? (use.input as { name: string }).name
291 : ''
292 results.push({
293 type: 'tool_result',
294 tool_use_id: use.id,
295 content: JSON.stringify(await runLookup(name))
296 })
297 }
298 messages.push({ role: 'user', content: results })
299 }
300 
301 // Phase 2: force a clean structured list out of the research.
302 const final = await client.messages.create({
303 ...common,
304 max_tokens: 1500,
305 messages: [...messages, { role: 'user', content: FINAL_NUDGE }],
306 output_config: { format: { type: 'json_schema', schema: SUGGESTIONS_SCHEMA } }
307 })
308 
309 const text = final.content.find(
310 (b): b is AnthropicSdk.TextBlock => b.type === 'text'
311 )?.text
312 if (!text) return []
313 return mapWire(JSON.parse(text))
315 
316/** Suggests era-relevant figures for an objective, with a curated fallback. */
317export async function suggestFigures(objective: ObjectiveInput): Promise<SuggestionResult> {
318 try {
319 const { lane } = routeFor('suggester')
320 const suggestions = lane === 'anthropic'
321 ? await runAgentAnthropic(objective)
322 : await runAgentOpenAI(objective)
323 // Deceased-only floor (#72): re-ground and drop anyone still living before
324 // they reach the player. If that empties the list (a fully misbehaving
325 // model), fall through to the curated, all-deceased fallback below.
326 const deceased = await keepDeceased(suggestions)
327 if (deceased.length) return { suggestions: deceased, fallback: false }
328 } catch (error) {
329 console.error('Figure suggester agent failed; using fallback:', error)
330 }
331 return { suggestions: GENERIC_FALLBACK, fallback: true }
333 
334/** Exposed for the fallback path + tests. */
335export function fallbackSuggestions(): FigureSuggestion[] {
336 return [...GENERIC_FALLBACK]

The endpoint just calls the helper and threads the result through.

server/api/suggestions.post.ts · 35 lines
server/api/suggestions.post.ts35 lines · TypeScript
⋯ 17 lines hidden (lines 1–17)
1import { suggestFigures, buildLedgerBrief } from '../utils/figure-suggester'
2import { cleanString, MAX_ERA_CHARS, MAX_OBJECTIVE_DESC_CHARS, MAX_OBJECTIVE_TITLE_CHARS } from '../utils/validate'
3 
4/**
5 * POST /api/suggestions — given the run's objective (and the changes landed so far),
6 * returns era-relevant historical figures (name + one-line reason) to seed the
7 * contact step. Runs the tool-using suggester agent server-side; always succeeds
8 * (curated fallback) so the player is never left without ideas. The landed ledger
9 * lets a blurb re-narrate against the altered timeline rather than asserting a fate
10 * the player has already overturned (issue #131).
11 */
12export default defineEventHandler(async (event) => {
13 const body = await readBody(event)
14 const objective = body?.objective ?? {}
15 // Untrusted body → the tool-using suggester agent's prompt: coerce + bound each
16 // field. Empty → undefined so the suggester's own fallbacks still apply.
17 const title = cleanString(objective.title, MAX_OBJECTIVE_TITLE_CHARS) || undefined
18 const description = cleanString(objective.description, MAX_OBJECTIVE_DESC_CHARS) || undefined
19 const era = cleanString(objective.era, MAX_ERA_CHARS) || undefined
20 
21 // The landed ledger (issue #131): era-stamped headlines of the changes the player
22 // has already made, so the suggester re-narrates each blurb against the altered
23 // timeline. Untrusted body → buildLedgerBrief caps + coerces + bounds it.
24 const worldBrief = buildLedgerBrief(body?.ledger)
25 
26 // A custom objective is untrusted text reaching a model — deterministically
27 // hard-block the unforgivable categories before the suggester acts on it.
28 // (The full Sentinel re-reviews the objective in context on every turn.)
29 const { hardBlockCheck } = await import('~/server/utils/moderation')
30 const objectiveBlock = await hardBlockCheck([title, description, era].filter(Boolean).join(' — '), 'suggestions', 'input')
31 if (objectiveBlock.blocked) return { success: true, suggestions: [], blocked: true, reason: objectiveBlock.block.reason }
32 
33 const result = await suggestFigures({ title, description, era, worldBrief })
⋯ 2 lines hidden (lines 34–35)
34 return { success: true, ...result }
35})

A non-blocking refresh, like the Chronicle

refreshSuggestions is the suggestion-side twin of refreshChronicle: it re-runs the suggester with the current ledger, fired after a landed change and never awaited, so the turn reveal waits on neither. A failed, blocked, or curated-fallback refresh keeps the prior list — a populated panel never blanks, and a model outage never regresses good era-specific picks to generic names.

Keep-prior on every degraded path; the finally settles the shared loading flag.

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

Fired next to refreshChronicle, only on a turn that actually changed the world.

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

The 'history rewritten' flourish

The animation is a self-contained component, RewriteText. Its trigger is purely the text prop changing — so callers need no diffing. A chip kept across a refresh (same :key) sees its blurb change and animates; a brand-new chip mounts fresh and just appears. The watch never fires on the initial value, and it guards on a prior value existing and reduced-motion.

Change-only trigger: stash the old text as a ghost, arm a self-clearing timer.

components/RewriteText.vue · 132 lines
components/RewriteText.vue132 lines · Vue
⋯ 43 lines hidden (lines 1–43)
1<template>
2 <span class="rw" :class="{ 'rw-active': animating }" :style="{ '--rw-dur': REWRITE_MS + 'ms' }">
3 <!-- The fallthrough class (e.g. the blurb's `ew-faint text-[11px]`) lands on the
4 root above, so both layers inherit one type style and the ghost overlay aligns
5 to the live box. Keep the root a SINGLE node (no leading sibling comment) so
6 attribute fallthrough and class reads stay unambiguous.
7 The erased history: the prior wording, struck through and bled away beneath. -->
8 <span v-if="animating" data-testid="rewrite-ghost" class="rw-ghost" aria-hidden="true">{{ ghost }}</span>
9 <!-- The rewritten history: the new wording, written in over the old. -->
10 <span class="rw-live" :class="{ 'rw-in': animating }">{{ text }}</span>
11 </span>
12</template>
13 
14<script setup lang="ts">
15/**
16 * RewriteText — text that, when it CHANGES, plays a "history rewritten" transition:
17 * the old wording is struck through and bleeds away while the new wording is written
18 * in over it, left to right (a palimpsest — old ink showing under new). First paint
19 * and reduced-motion just show the text, no flourish.
20 *
21 * Built for the figure-suggestion blurbs, which re-narrate against the altered
22 * timeline after a landed change (issue #131) — but text-agnostic and reusable. The
23 * trigger is purely the `text` prop changing, so callers need no diffing: a chip kept
24 * across a refresh (same `:key`) sees its blurb change and animates; a brand-new chip
25 * mounts fresh and just appears.
26 */
27import { ref, watch, onUnmounted } from 'vue'
28import { usePrefersReducedMotion } from '~/composables/usePrefersReducedMotion'
29 
30const props = defineProps<{ text: string }>()
31const reduced = usePrefersReducedMotion()
32 
33// The full span of the flourish. CSS reads it via --rw-dur so the keyframes and this
34// JS cleanup can never drift apart. ~900ms: expressive, a beat longer than the
35// chronicle cross-fade, fitting "history being rewritten".
36const REWRITE_MS = 900
37 
38const ghost = ref('')
39const animating = ref(false)
40let timer: ReturnType<typeof setTimeout> | null = null
41 
42// Only a real CHANGE animates: watch never fires on the initial value, and we guard
43// on a prior value existing (a blurb appearing for the first time just shows).
44// Reduced-motion swaps instantly.
45watch(() => props.text, (next, prev) => {
46 if (reduced.value || !prev || next === prev) return
47 ghost.value = prev
48 animating.value = true
49 if (timer) clearTimeout(timer)
50 timer = setTimeout(() => {
51 animating.value = false
52 ghost.value = ''
53 }, REWRITE_MS)
54})
55 
⋯ 77 lines hidden (lines 56–132)
56onUnmounted(() => { if (timer) clearTimeout(timer) })
57</script>
58 
59<style scoped>
60.rw {
61 /* Establish the containing block for the absolute ghost + sweep, without forcing
62 a display: the caller's class governs that (the blurb passes `block`). */
63 position: relative;
65 
66/* The rewritten wording rides above the fading ghost, so the new ink visibly
67 replaces the old rather than the other way round. */
68.rw-live {
69 position: relative;
70 z-index: 1;
72.rw-in {
73 animation: rw-write var(--rw-dur) var(--ew-ease) both;
75/* Written in left to right; the ink starts "wet" (accent) and dries to rest. */
76@keyframes rw-write {
77 0% { clip-path: inset(0 100% 0 0); color: var(--ew-accent); }
78 30% { color: var(--ew-accent); }
79 100% { clip-path: inset(0 0 0 0); color: inherit; }
81 
82/* The old wording, struck through (every line, via text-decoration) then bled away
83 beneath the new line — a manuscript correction: the struck passage running off the
84 page as the new ink takes its place. */
85.rw-ghost {
86 position: absolute;
87 inset: 0;
88 z-index: 0;
89 pointer-events: none;
90 color: inherit;
91 text-decoration: line-through;
92 text-decoration-color: var(--ew-accent);
93 animation: rw-erase var(--rw-dur) var(--ew-ease) forwards;
95@keyframes rw-erase {
96 0% { opacity: 1; filter: blur(0); transform: translateY(0); }
97 40% { opacity: 1; filter: blur(0); }
98 100% { opacity: 0; filter: blur(2px); transform: translateY(2px); }
100 
101/* A temporal sheen sweeping across as the line is rewritten — a wash of the one
102 accent, drawing the eye to the entry that just changed. */
103.rw-active::before {
104 content: '';
105 position: absolute;
106 inset: -1px -3px;
107 z-index: 2;
108 pointer-events: none;
109 background: linear-gradient(90deg, transparent 0%, color-mix(in srgb, var(--ew-accent) 26%, transparent) 50%, transparent 100%);
110 background-size: 55% 100%;
111 background-repeat: no-repeat;
112 animation: rw-sweep var(--rw-dur) var(--ew-ease) forwards;
114@keyframes rw-sweep {
115 0% { background-position: -60% 0; opacity: 0; }
116 20% { opacity: 1; }
117 80% { opacity: 1; }
118 100% { background-position: 170% 0; opacity: 0; }
120 
121/* The JS guard already skips the flourish under reduced motion (these layers never
122 render), so this is the CSS-level backstop the project favors everywhere: it
123 neutralizes the keyframes should a render ever apply an animating class while the OS
124 preference is set — e.g. the reactive flag briefly lagging a mid-session OS toggle. */
125@media (prefers-reduced-motion: reduce) {
126 .rw-in,
127 .rw-ghost,
128 .rw-active::before {
129 animation: none;
130 }
132</style>

The visual is a palimpsest — old ink showing under new. The old wording is struck through (every line, via text-decoration) and bled away beneath, while the new wording writes in left-to-right with the ink starting "wet" (the one accent colour) and drying to rest.

The new line writes in (clip-path); the old line strikes through and fades.

components/RewriteText.vue · 132 lines
components/RewriteText.vue132 lines · Vue
⋯ 65 lines hidden (lines 1–65)
1<template>
2 <span class="rw" :class="{ 'rw-active': animating }" :style="{ '--rw-dur': REWRITE_MS + 'ms' }">
3 <!-- The fallthrough class (e.g. the blurb's `ew-faint text-[11px]`) lands on the
4 root above, so both layers inherit one type style and the ghost overlay aligns
5 to the live box. Keep the root a SINGLE node (no leading sibling comment) so
6 attribute fallthrough and class reads stay unambiguous.
7 The erased history: the prior wording, struck through and bled away beneath. -->
8 <span v-if="animating" data-testid="rewrite-ghost" class="rw-ghost" aria-hidden="true">{{ ghost }}</span>
9 <!-- The rewritten history: the new wording, written in over the old. -->
10 <span class="rw-live" :class="{ 'rw-in': animating }">{{ text }}</span>
11 </span>
12</template>
13 
14<script setup lang="ts">
15/**
16 * RewriteText — text that, when it CHANGES, plays a "history rewritten" transition:
17 * the old wording is struck through and bleeds away while the new wording is written
18 * in over it, left to right (a palimpsest — old ink showing under new). First paint
19 * and reduced-motion just show the text, no flourish.
20 *
21 * Built for the figure-suggestion blurbs, which re-narrate against the altered
22 * timeline after a landed change (issue #131) — but text-agnostic and reusable. The
23 * trigger is purely the `text` prop changing, so callers need no diffing: a chip kept
24 * across a refresh (same `:key`) sees its blurb change and animates; a brand-new chip
25 * mounts fresh and just appears.
26 */
27import { ref, watch, onUnmounted } from 'vue'
28import { usePrefersReducedMotion } from '~/composables/usePrefersReducedMotion'
29 
30const props = defineProps<{ text: string }>()
31const reduced = usePrefersReducedMotion()
32 
33// The full span of the flourish. CSS reads it via --rw-dur so the keyframes and this
34// JS cleanup can never drift apart. ~900ms: expressive, a beat longer than the
35// chronicle cross-fade, fitting "history being rewritten".
36const REWRITE_MS = 900
37 
38const ghost = ref('')
39const animating = ref(false)
40let timer: ReturnType<typeof setTimeout> | null = null
41 
42// Only a real CHANGE animates: watch never fires on the initial value, and we guard
43// on a prior value existing (a blurb appearing for the first time just shows).
44// Reduced-motion swaps instantly.
45watch(() => props.text, (next, prev) => {
46 if (reduced.value || !prev || next === prev) return
47 ghost.value = prev
48 animating.value = true
49 if (timer) clearTimeout(timer)
50 timer = setTimeout(() => {
51 animating.value = false
52 ghost.value = ''
53 }, REWRITE_MS)
54})
55 
56onUnmounted(() => { if (timer) clearTimeout(timer) })
57</script>
58 
59<style scoped>
60.rw {
61 /* Establish the containing block for the absolute ghost + sweep, without forcing
62 a display: the caller's class governs that (the blurb passes `block`). */
63 position: relative;
65 
66/* The rewritten wording rides above the fading ghost, so the new ink visibly
67 replaces the old rather than the other way round. */
68.rw-live {
69 position: relative;
70 z-index: 1;
72.rw-in {
73 animation: rw-write var(--rw-dur) var(--ew-ease) both;
75/* Written in left to right; the ink starts "wet" (accent) and dries to rest. */
76@keyframes rw-write {
77 0% { clip-path: inset(0 100% 0 0); color: var(--ew-accent); }
78 30% { color: var(--ew-accent); }
79 100% { clip-path: inset(0 0 0 0); color: inherit; }
81 
82/* The old wording, struck through (every line, via text-decoration) then bled away
83 beneath the new line — a manuscript correction: the struck passage running off the
84 page as the new ink takes its place. */
85.rw-ghost {
86 position: absolute;
87 inset: 0;
88 z-index: 0;
89 pointer-events: none;
90 color: inherit;
91 text-decoration: line-through;
92 text-decoration-color: var(--ew-accent);
93 animation: rw-erase var(--rw-dur) var(--ew-ease) forwards;
95@keyframes rw-erase {
96 0% { opacity: 1; filter: blur(0); transform: translateY(0); }
97 40% { opacity: 1; filter: blur(0); }
98 100% { opacity: 0; filter: blur(2px); transform: translateY(2px); }
⋯ 33 lines hidden (lines 100–132)
100 
101/* A temporal sheen sweeping across as the line is rewritten — a wash of the one
102 accent, drawing the eye to the entry that just changed. */
103.rw-active::before {
104 content: '';
105 position: absolute;
106 inset: -1px -3px;
107 z-index: 2;
108 pointer-events: none;
109 background: linear-gradient(90deg, transparent 0%, color-mix(in srgb, var(--ew-accent) 26%, transparent) 50%, transparent 100%);
110 background-size: 55% 100%;
111 background-repeat: no-repeat;
112 animation: rw-sweep var(--rw-dur) var(--ew-ease) forwards;
114@keyframes rw-sweep {
115 0% { background-position: -60% 0; opacity: 0; }
116 20% { opacity: 1; }
117 80% { opacity: 1; }
118 100% { background-position: 170% 0; opacity: 0; }
120 
121/* The JS guard already skips the flourish under reduced motion (these layers never
122 render), so this is the CSS-level backstop the project favors everywhere: it
123 neutralizes the keyframes should a render ever apply an animating class while the OS
124 preference is set — e.g. the reactive flag briefly lagging a mid-session OS toggle. */
125@media (prefers-reduced-motion: reduce) {
126 .rw-in,
127 .rw-ghost,
128 .rw-active::before {
129 animation: none;
130 }
132</style>

Wiring it into the picker

One line changes in the picker: the blurb's <span> becomes a <RewriteText> carrying the same classes. The chip is keyed by name, so the same DOM node persists across a refresh and the component sees its text prop change — which is what makes only the blurbs that actually changed animate, while unchanged ones sit still.

The reason line, now a RewriteText; data-testid + classes fall through to its root.

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