What changed, and why

The Chronicle is the game's prose telling of the altered timeline, rewritten after every turn. The final telling — the one that carries the win/loss framing — is the run's epilogue, shown on the end screen.

The bug: that refresh is fired non-blocking (so mid-game the dice reveal never waits on prose), but the end screen renders the instant the run ends. At that moment the epilogue is still being written, so chronicle still holds the previous turn's telling. The loading placeholder was gated on !chronicle && chronicleLoading, so with a stale telling present it never showed. The end screen presented the prior-turn article as the epilogue, then snapped to the real one when it landed — a misleading double-take on the run's payoff moment.

The fix is one new store flag, epiloguePending, that means specifically the epilogue is being written. The end screen shows the "final account…" wait while it's set, then cross-fades the epilogue in. Three files of logic, two of tests; no change to how the Chronicle is generated or to mid-game behavior.

epiloguePending: tracking the terminal telling

A new piece of store state. It's deliberately distinct from the existing chronicleLoading, which is true during any refresh (every turn fires one). epiloguePending is true only while the terminal telling is in flight.

The flag, beside the chronicle state it qualifies.

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

It's set where the run actually ends. After a resolved turn, sendMessage fires the (non-blocking) refresh, then — only on a terminal status — marks the epilogue pending and arranges to clear it when that refresh settles.

The terminal turn: fire the refresh, mark it pending, clear it when it settles.

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

The never-blanks guarantee still holds

A failed final refresh must fall back to the prior telling, never hang on the loading state. That guarantee is unchanged: refreshChronicle catches its own errors and keeps whatever telling was already there, returning a promise that resolves (never rejects).

On failure: keep the prior chronicle, clear loading — the panel never blanks.

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

Because that promise always resolves, the .finally from the previous section always fires and clears epiloguePending. So on a failed epilogue the end screen leaves the loading state and falls back to rendering the prior telling (see the v-else-if next) — it reveals or it falls back, but it never spins. And resetGame clears the flag too, so Play Again starts clean (stores/game.ts:1384).

The end screen: the wait, then a cross-fade

The epilogue block now keys off epiloguePending. While pending, it shows the "final account…" line; otherwise it renders the telling. The two are wrapped in a Transition so the swap is a gentle opacity cross-fade rather than an abrupt text replacement.

Loading while pending; the epilogue article otherwise; a cross-fade between them.

components/EndGameScreen.vue · 260 lines
components/EndGameScreen.vue260 lines · Vue
⋯ 34 lines hidden (lines 1–34)
1<template>
2 <div v-if="gameStore.gameStatus === 'victory' || gameStore.gameStatus === 'defeat'" ref="dialogRef" data-testid="end-game-screen"
3 role="dialog" aria-modal="true" :aria-labelledby="headingId" @keydown="onKeydown"
4 class="end-screen fixed inset-0 z-50 overflow-y-auto" :class="isVictory ? 'end--win' : 'end--loss'">
5 <div class="max-w-4xl mx-auto px-5 py-8">
6 <!-- Verdict masthead: a struck seal, the verdict, the objective + final %. The
7 win is illuminated (glowing terracotta seal, serif verdict in ink); the loss
8 is somber (a flat, cold seal and a muted verdict) — the two read apart instantly. -->
9 <div class="text-center border-b rv-line pb-6">
10 <div class="verdict-seal" :class="isVictory ? 'is-win' : 'is-loss'" aria-hidden="true">{{ isVictory ? '✦' : '⊘' }}</div>
11 <h2 v-if="isVictory" :id="headingId" ref="headingRef" tabindex="-1" data-testid="victory-message" class="rv-serif text-3xl sm:text-4xl font-bold rv-fg mt-3 outline-none">History Rewritten</h2>
12 <h2 v-else :id="headingId" ref="headingRef" tabindex="-1" data-testid="defeat-message" class="rv-serif text-3xl sm:text-4xl font-bold rv-muted mt-3 outline-none">The Timeline Held</h2>
13 <p class="text-sm mt-2.5 flex items-center justify-center gap-2 flex-wrap">
14 <span class="rv-muted inline-flex items-center gap-1.5">
15 <span aria-hidden="true">{{ gameStore.currentObjective?.icon }}</span>
16 <strong data-testid="objective-title" class="font-semibold">{{ gameStore.currentObjective?.title || 'Historical Mission' }}</strong>
17 </span>
18 <span class="rv-hair-c" aria-hidden="true">·</span>
19 <span class="rv-mono font-bold" :class="isVictory ? 'rv-ok' : 'rv-bad'">
20 <span data-testid="final-progress">{{ gameStore.objectiveProgress }}%</span> rewritten
21 </span>
22 </p>
23 
24 <p v-if="!isVictory" data-testid="defeat-explanation" class="rv-muted text-sm mt-3 max-w-prose mx-auto">
25 Your five messages are spent. What you changed remains — but it was not enough, and the objective slipped away.
26 </p>
27 
28 <div v-if="victoryEfficiency" data-testid="efficiency-display" class="mt-3 rv-mono text-sm">
29 <span class="rv-ok font-semibold">{{ victoryPhrase }}</span>
30 <span class="rv-muted"> · {{ victoryEfficiency.messagesUsed }}/5 used<span v-if="victoryEfficiency.messagesSaved > 0"> · {{ victoryEfficiency.messagesSaved }} saved</span></span>
31 <span v-if="victoryEfficiency.isEarlyVictory" class="rv-ok"> · 🌟 to spare</span>
32 </div>
33 </div>
34 
35 <!-- The Chronicle's final telling — the epilogue. While the terminal telling is
36 still being written (epiloguePending), we show the "final account…" wait, NOT
37 the prior turn's stale, pre-ending telling. When it lands it cross-fades in
38 (mirroring Chronicle.vue's rewrite reveal); a failed final refresh clears
39 pending and falls back to the prior telling, so the panel never hangs. -->
40 <div v-if="chronicle || epiloguePending" data-testid="end-chronicle" class="mt-6">
41 <span class="rv-label rv-label--rule"><span aria-hidden="true">📖</span> The Chronicle</span>
42 <Transition :name="epilogueTransition" mode="out-in">
43 <div v-if="epiloguePending" key="epilogue-loading" data-testid="end-chronicle-loading" class="rv-faint italic text-sm">
44 The chronicler sets down the final account…
45 </div>
46 <article v-else-if="chronicle" key="epilogue-body" data-testid="end-chronicle-body">
47 <h4 data-testid="end-chronicle-title" class="rv-serif text-xl font-semibold rv-fg mb-2.5 leading-tight">{{ chronicle.title }}</h4>
48 <p v-for="(para, i) in chronicle.paragraphs" :key="i" class="rv-serif rv-muted text-[15px] leading-relaxed mb-2.5 last:mb-0">{{ para }}</p>
49 </article>
50 </Transition>
51 </div>
⋯ 209 lines hidden (lines 52–260)
52 
53 <!-- The words you sent — the keepsake: your actual messages into the past -->
54 <div v-if="dispatches.length" data-testid="dispatches" class="mt-7">
55 <span class="rv-label rv-label--rule">The words you sent</span>
56 <ul class="space-y-3.5">
57 <li v-for="(d, i) in dispatches" :key="i">
58 <p class="rv-label">to {{ d.figure }}<span v-if="d.era"> · {{ d.era }}</span></p>
59 <blockquote class="rv-serif rv-fg text-base leading-relaxed border-l-2 rv-line pl-3 mt-1">"{{ d.text }}"</blockquote>
60 </li>
61 </ul>
62 </div>
63 
64 <!-- Stats — derived from the real resolved turns -->
65 <div data-testid="game-summary" class="mt-7">
66 <span class="rv-label rv-label--rule">How it played out</span>
67 <div class="flex flex-wrap gap-10">
68 <div data-testid="message-efficiency">
69 <div class="rv-mono text-3xl font-extrabold rv-fg leading-none">{{ messagesUsed }}</div>
70 <div class="rv-label mt-1">{{ messagesUsed === 1 ? 'message' : 'messages' }} sent</div>
71 </div>
72 <div v-if="gameSummary.bestRoll" data-testid="best-roll">
73 <div class="rv-mono text-3xl font-extrabold rv-ok leading-none">{{ gameSummary.bestRoll.diceRoll }}</div>
74 <div class="rv-label mt-1">{{ messagesUsed >= 2 ? 'best roll' : 'the roll that did it' }} · {{ gameSummary.bestRoll.diceOutcome }}</div>
75 </div>
76 <div v-if="gameSummary.worstRoll && messagesUsed >= 2" data-testid="worst-roll">
77 <div class="rv-mono text-3xl font-extrabold rv-bad leading-none">{{ gameSummary.worstRoll.diceRoll }}</div>
78 <div class="rv-label mt-1">worst roll · {{ gameSummary.worstRoll.diceOutcome }}</div>
79 </div>
80 </div>
81 </div>
82 
83 <!-- The history you wrote — the Spine itself, exactly as it filled in during
84 the run. Read-only here: no live "▷ now" present marker, no "the timeline
85 trembles…" empty state. Same component the player scrolled and tapped on
86 the board, so the verdict's history reads as richly as it did in play —
87 valence dots, ⚡/⚑ badges, the roll, and the Δ equation all carry over. -->
88 <div v-if="timeline.length > 0" class="mt-6">
89 <TimelineLedger readonly />
90 </div>
91 
92 <div class="mt-9 border-t rv-line pt-6 flex items-center gap-3 flex-wrap">
93 <button ref="playAgainRef" type="button" data-testid="play-again-button" class="rv-btn rv-btn--primary" @click="playAgain">
94 Try a new timeline <span aria-hidden="true"></span>
95 </button>
96 <span class="rv-faint text-xs inline-flex items-center gap-1.5">
97 <span class="rv-dot" :class="isVictory ? 'rv-dot--ok' : 'rv-dot--bad'" aria-hidden="true" /> this timeline is written.
98 </span>
99 </div>
100 </div>
101 </div>
102</template>
103 
104<script setup lang="ts">
105/**
106 * EndGameScreen — the mission debrief. A full-bleed takeover (not a modal card):
107 * verdict line, the Chronicle epilogue + big-number stat trio, and the history you
108 * wrote. All stats derive from the real resolved turns.
109 */
110import { ref, computed, watch, nextTick } from 'vue'
111import { useGameStore } from '~/stores/game'
112import { usePrefersReducedMotion } from '~/composables/usePrefersReducedMotion'
113 
114const gameStore = useGameStore()
115const reduced = usePrefersReducedMotion()
116 
117const headingId = `end-game-heading-${Math.random().toString(36).slice(2, 11)}`
118 
119// Modal focus management: it's a true takeover (aria-modal), so when it opens we
120// move focus to the VERDICT heading (tabindex=-1: announced, focusable, outside
121// the Tab cycle), trap Tab inside, and restore focus when the run ends. Focusing
122// the heading — not the bottom Play Again button — also means the screen opens at
123// the top: the verdict and the Chronicle epilogue are the first things seen, not
124// scrolled past below the fold.
125const dialogRef = ref<HTMLElement | null>(null)
126const headingRef = ref<HTMLElement | null>(null)
127const playAgainRef = ref<HTMLElement | null>(null)
128let prevFocus: HTMLElement | null = null
129 
130watch(() => gameStore.gameStatus, async (s) => {
131 if (typeof document === 'undefined') return
132 if (s === 'victory' || s === 'defeat') {
133 prevFocus = document.activeElement as HTMLElement | null
134 await nextTick()
135 if (dialogRef.value) dialogRef.value.scrollTop = 0
136 headingRef.value?.focus()
137 } else if (prevFocus) {
138 prevFocus.focus?.()
139 prevFocus = null
140 }
141})
142 
143function onKeydown(e: KeyboardEvent) {
144 if (e.key !== 'Tab' || !dialogRef.value) return
145 const focusables = dialogRef.value.querySelectorAll<HTMLElement>(
146 'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
147 )
148 if (!focusables.length) return
149 const first = focusables[0]
150 const last = focusables[focusables.length - 1]
151 const active = document.activeElement
152 if (e.shiftKey && active === first) { e.preventDefault(); last.focus() }
153 else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus() }
155 
156const isVictory = computed(() => gameStore.gameStatus === 'victory')
157const victoryEfficiency = computed(() => gameStore.getVictoryEfficiency())
158 
159// The verdict never deflates: spending every message is reframed as "Hard-won," not
160// the limp "Standard." Only the laudatory tiers keep their rating as an adjective.
161const victoryPhrase = computed(() => {
162 const rating = victoryEfficiency.value?.efficiencyRating
163 if (!rating) return 'Victory'
164 return rating === 'Standard' ? 'Hard-won victory' : `${rating} victory`
165})
166const messagesUsed = computed(() => 5 - gameStore.remainingMessages)
167const gameSummary = computed(() => gameStore.gameSummary)
168const timeline = computed(() => gameStore.timelineEvents)
169const chronicle = computed(() => gameStore.chronicle)
170// The epilogue (terminal telling) is still being written — show the wait, never the
171// prior turn's stale telling. The cross-fade mirrors Chronicle.vue and is dropped
172// under prefers-reduced-motion (the empty name disables the Transition's classes).
173const epiloguePending = computed(() => gameStore.epiloguePending)
174const epilogueTransition = computed(() => (reduced.value ? '' : 'epilogue'))
175 
176// The keepsake: the player's verbatim messages, paired with whom/when they reached.
177const dispatches = computed(() => {
178 const users = gameStore.messageHistory.filter(m => m.sender === 'user')
179 const events = gameStore.timelineEvents
180 return users.map((m, i) => ({
181 text: m.text,
182 figure: m.figureName || events[i]?.figureName || 'the past',
183 era: events[i]?.era || ''
184 }))
185})
186 
187// The page owns the FULL reset (store + figure input + composer), so Play Again
188// emits instead of resetting the store directly — the two reset paths used to
189// diverge here, leaving the previous run's figure stranded in the input.
190const emit = defineEmits<{ 'play-again': [] }>()
191const playAgain = () => {
192 emit('play-again')
194</script>
195 
196<style scoped>
197/* The epilogue reveal — the terminal telling cross-fades in over the "final account…"
198 wait, the same gentle opacity fade Chronicle.vue uses for a rewrite. Gated by the
199 transition NAME (empty under prefers-reduced-motion), so reduced-motion gets an
200 instant swap with no classes attached. */
201.epilogue-enter-active,
202.epilogue-leave-active {
203 transition: opacity var(--rv-dur-3) var(--rv-ease);
205.epilogue-enter-from,
206.epilogue-leave-to {
207 opacity: 0;
209 
210/* Win and loss share the page frame but not its mood. The win is illuminated — a warm
211 wash blooms from the top; the loss is a colder, dimmer ground that has settled back. */
212.end-screen { background: var(--rv-bg); animation: end-in var(--rv-dur-3) var(--rv-ease) both; }
213@keyframes end-in { from { opacity: 0; } to { opacity: 1; } }
214@media (prefers-reduced-motion: reduce) { .end-screen { animation: none; } }
215.end--win {
216 background:
217 radial-gradient(135% 70% at 50% -12%, color-mix(in srgb, var(--rv-accent) 13%, transparent), transparent 68%),
218 var(--rv-bg);
220.end--loss {
221 background:
222 radial-gradient(135% 70% at 50% -12%, color-mix(in srgb, var(--rv-bad) 6%, transparent), transparent 60%),
223 var(--rv-tint);
225 
226/* The struck seal — the verdict's emblem. The win seal glows like fresh sealing wax;
227 the loss seal is a cold, flat stamp. */
228.verdict-seal {
229 display: inline-grid;
230 place-items: center;
231 width: 60px;
232 height: 60px;
233 border-radius: 50%;
234 border: 2px solid;
235 font-size: 1.6rem;
236 line-height: 1;
238.verdict-seal.is-win {
239 color: var(--rv-accent);
240 border-color: var(--rv-accent);
241 box-shadow:
242 0 0 0 5px color-mix(in srgb, var(--rv-accent) 12%, transparent),
243 0 0 30px color-mix(in srgb, var(--rv-accent) 32%, transparent);
244 animation: seal-strike 0.6s var(--rv-ease-spring) both;
246.verdict-seal.is-loss {
247 color: var(--rv-faint);
248 border-color: var(--rv-line);
249 box-shadow: inset 0 0 0 4px color-mix(in srgb, var(--rv-faint) 8%, transparent);
251 
252@keyframes seal-strike {
253 0% { opacity: 0; transform: scale(.5) rotate(-12deg); }
254 60% { transform: scale(1.12) rotate(3deg); }
255 100% { opacity: 1; transform: scale(1) rotate(0); }
257@media (prefers-reduced-motion: reduce) {
258 .verdict-seal.is-win { animation: none; }
260</style>

The transition name is computed, and goes empty under prefers-reduced-motion — an empty name disables the CSS classes, so reduced-motion gets an instant, class-free swap. This mirrors Chronicle.vue's rewrite reveal exactly.

The pending flag and the motion-gated transition name.

components/EndGameScreen.vue · 260 lines
components/EndGameScreen.vue260 lines · Vue
⋯ 168 lines hidden (lines 1–168)
1<template>
2 <div v-if="gameStore.gameStatus === 'victory' || gameStore.gameStatus === 'defeat'" ref="dialogRef" data-testid="end-game-screen"
3 role="dialog" aria-modal="true" :aria-labelledby="headingId" @keydown="onKeydown"
4 class="end-screen fixed inset-0 z-50 overflow-y-auto" :class="isVictory ? 'end--win' : 'end--loss'">
5 <div class="max-w-4xl mx-auto px-5 py-8">
6 <!-- Verdict masthead: a struck seal, the verdict, the objective + final %. The
7 win is illuminated (glowing terracotta seal, serif verdict in ink); the loss
8 is somber (a flat, cold seal and a muted verdict) — the two read apart instantly. -->
9 <div class="text-center border-b rv-line pb-6">
10 <div class="verdict-seal" :class="isVictory ? 'is-win' : 'is-loss'" aria-hidden="true">{{ isVictory ? '✦' : '⊘' }}</div>
11 <h2 v-if="isVictory" :id="headingId" ref="headingRef" tabindex="-1" data-testid="victory-message" class="rv-serif text-3xl sm:text-4xl font-bold rv-fg mt-3 outline-none">History Rewritten</h2>
12 <h2 v-else :id="headingId" ref="headingRef" tabindex="-1" data-testid="defeat-message" class="rv-serif text-3xl sm:text-4xl font-bold rv-muted mt-3 outline-none">The Timeline Held</h2>
13 <p class="text-sm mt-2.5 flex items-center justify-center gap-2 flex-wrap">
14 <span class="rv-muted inline-flex items-center gap-1.5">
15 <span aria-hidden="true">{{ gameStore.currentObjective?.icon }}</span>
16 <strong data-testid="objective-title" class="font-semibold">{{ gameStore.currentObjective?.title || 'Historical Mission' }}</strong>
17 </span>
18 <span class="rv-hair-c" aria-hidden="true">·</span>
19 <span class="rv-mono font-bold" :class="isVictory ? 'rv-ok' : 'rv-bad'">
20 <span data-testid="final-progress">{{ gameStore.objectiveProgress }}%</span> rewritten
21 </span>
22 </p>
23 
24 <p v-if="!isVictory" data-testid="defeat-explanation" class="rv-muted text-sm mt-3 max-w-prose mx-auto">
25 Your five messages are spent. What you changed remains — but it was not enough, and the objective slipped away.
26 </p>
27 
28 <div v-if="victoryEfficiency" data-testid="efficiency-display" class="mt-3 rv-mono text-sm">
29 <span class="rv-ok font-semibold">{{ victoryPhrase }}</span>
30 <span class="rv-muted"> · {{ victoryEfficiency.messagesUsed }}/5 used<span v-if="victoryEfficiency.messagesSaved > 0"> · {{ victoryEfficiency.messagesSaved }} saved</span></span>
31 <span v-if="victoryEfficiency.isEarlyVictory" class="rv-ok"> · 🌟 to spare</span>
32 </div>
33 </div>
34 
35 <!-- The Chronicle's final telling — the epilogue. While the terminal telling is
36 still being written (epiloguePending), we show the "final account…" wait, NOT
37 the prior turn's stale, pre-ending telling. When it lands it cross-fades in
38 (mirroring Chronicle.vue's rewrite reveal); a failed final refresh clears
39 pending and falls back to the prior telling, so the panel never hangs. -->
40 <div v-if="chronicle || epiloguePending" data-testid="end-chronicle" class="mt-6">
41 <span class="rv-label rv-label--rule"><span aria-hidden="true">📖</span> The Chronicle</span>
42 <Transition :name="epilogueTransition" mode="out-in">
43 <div v-if="epiloguePending" key="epilogue-loading" data-testid="end-chronicle-loading" class="rv-faint italic text-sm">
44 The chronicler sets down the final account…
45 </div>
46 <article v-else-if="chronicle" key="epilogue-body" data-testid="end-chronicle-body">
47 <h4 data-testid="end-chronicle-title" class="rv-serif text-xl font-semibold rv-fg mb-2.5 leading-tight">{{ chronicle.title }}</h4>
48 <p v-for="(para, i) in chronicle.paragraphs" :key="i" class="rv-serif rv-muted text-[15px] leading-relaxed mb-2.5 last:mb-0">{{ para }}</p>
49 </article>
50 </Transition>
51 </div>
52 
53 <!-- The words you sent — the keepsake: your actual messages into the past -->
54 <div v-if="dispatches.length" data-testid="dispatches" class="mt-7">
55 <span class="rv-label rv-label--rule">The words you sent</span>
56 <ul class="space-y-3.5">
57 <li v-for="(d, i) in dispatches" :key="i">
58 <p class="rv-label">to {{ d.figure }}<span v-if="d.era"> · {{ d.era }}</span></p>
59 <blockquote class="rv-serif rv-fg text-base leading-relaxed border-l-2 rv-line pl-3 mt-1">"{{ d.text }}"</blockquote>
60 </li>
61 </ul>
62 </div>
63 
64 <!-- Stats — derived from the real resolved turns -->
65 <div data-testid="game-summary" class="mt-7">
66 <span class="rv-label rv-label--rule">How it played out</span>
67 <div class="flex flex-wrap gap-10">
68 <div data-testid="message-efficiency">
69 <div class="rv-mono text-3xl font-extrabold rv-fg leading-none">{{ messagesUsed }}</div>
70 <div class="rv-label mt-1">{{ messagesUsed === 1 ? 'message' : 'messages' }} sent</div>
71 </div>
72 <div v-if="gameSummary.bestRoll" data-testid="best-roll">
73 <div class="rv-mono text-3xl font-extrabold rv-ok leading-none">{{ gameSummary.bestRoll.diceRoll }}</div>
74 <div class="rv-label mt-1">{{ messagesUsed >= 2 ? 'best roll' : 'the roll that did it' }} · {{ gameSummary.bestRoll.diceOutcome }}</div>
75 </div>
76 <div v-if="gameSummary.worstRoll && messagesUsed >= 2" data-testid="worst-roll">
77 <div class="rv-mono text-3xl font-extrabold rv-bad leading-none">{{ gameSummary.worstRoll.diceRoll }}</div>
78 <div class="rv-label mt-1">worst roll · {{ gameSummary.worstRoll.diceOutcome }}</div>
79 </div>
80 </div>
81 </div>
82 
83 <!-- The history you wrote — the Spine itself, exactly as it filled in during
84 the run. Read-only here: no live "▷ now" present marker, no "the timeline
85 trembles…" empty state. Same component the player scrolled and tapped on
86 the board, so the verdict's history reads as richly as it did in play —
87 valence dots, ⚡/⚑ badges, the roll, and the Δ equation all carry over. -->
88 <div v-if="timeline.length > 0" class="mt-6">
89 <TimelineLedger readonly />
90 </div>
91 
92 <div class="mt-9 border-t rv-line pt-6 flex items-center gap-3 flex-wrap">
93 <button ref="playAgainRef" type="button" data-testid="play-again-button" class="rv-btn rv-btn--primary" @click="playAgain">
94 Try a new timeline <span aria-hidden="true"></span>
95 </button>
96 <span class="rv-faint text-xs inline-flex items-center gap-1.5">
97 <span class="rv-dot" :class="isVictory ? 'rv-dot--ok' : 'rv-dot--bad'" aria-hidden="true" /> this timeline is written.
98 </span>
99 </div>
100 </div>
101 </div>
102</template>
103 
104<script setup lang="ts">
105/**
106 * EndGameScreen — the mission debrief. A full-bleed takeover (not a modal card):
107 * verdict line, the Chronicle epilogue + big-number stat trio, and the history you
108 * wrote. All stats derive from the real resolved turns.
109 */
110import { ref, computed, watch, nextTick } from 'vue'
111import { useGameStore } from '~/stores/game'
112import { usePrefersReducedMotion } from '~/composables/usePrefersReducedMotion'
113 
114const gameStore = useGameStore()
115const reduced = usePrefersReducedMotion()
116 
117const headingId = `end-game-heading-${Math.random().toString(36).slice(2, 11)}`
118 
119// Modal focus management: it's a true takeover (aria-modal), so when it opens we
120// move focus to the VERDICT heading (tabindex=-1: announced, focusable, outside
121// the Tab cycle), trap Tab inside, and restore focus when the run ends. Focusing
122// the heading — not the bottom Play Again button — also means the screen opens at
123// the top: the verdict and the Chronicle epilogue are the first things seen, not
124// scrolled past below the fold.
125const dialogRef = ref<HTMLElement | null>(null)
126const headingRef = ref<HTMLElement | null>(null)
127const playAgainRef = ref<HTMLElement | null>(null)
128let prevFocus: HTMLElement | null = null
129 
130watch(() => gameStore.gameStatus, async (s) => {
131 if (typeof document === 'undefined') return
132 if (s === 'victory' || s === 'defeat') {
133 prevFocus = document.activeElement as HTMLElement | null
134 await nextTick()
135 if (dialogRef.value) dialogRef.value.scrollTop = 0
136 headingRef.value?.focus()
137 } else if (prevFocus) {
138 prevFocus.focus?.()
139 prevFocus = null
140 }
141})
142 
143function onKeydown(e: KeyboardEvent) {
144 if (e.key !== 'Tab' || !dialogRef.value) return
145 const focusables = dialogRef.value.querySelectorAll<HTMLElement>(
146 'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
147 )
148 if (!focusables.length) return
149 const first = focusables[0]
150 const last = focusables[focusables.length - 1]
151 const active = document.activeElement
152 if (e.shiftKey && active === first) { e.preventDefault(); last.focus() }
153 else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus() }
155 
156const isVictory = computed(() => gameStore.gameStatus === 'victory')
157const victoryEfficiency = computed(() => gameStore.getVictoryEfficiency())
158 
159// The verdict never deflates: spending every message is reframed as "Hard-won," not
160// the limp "Standard." Only the laudatory tiers keep their rating as an adjective.
161const victoryPhrase = computed(() => {
162 const rating = victoryEfficiency.value?.efficiencyRating
163 if (!rating) return 'Victory'
164 return rating === 'Standard' ? 'Hard-won victory' : `${rating} victory`
165})
166const messagesUsed = computed(() => 5 - gameStore.remainingMessages)
167const gameSummary = computed(() => gameStore.gameSummary)
168const timeline = computed(() => gameStore.timelineEvents)
169const chronicle = computed(() => gameStore.chronicle)
170// The epilogue (terminal telling) is still being written — show the wait, never the
171// prior turn's stale telling. The cross-fade mirrors Chronicle.vue and is dropped
172// under prefers-reduced-motion (the empty name disables the Transition's classes).
173const epiloguePending = computed(() => gameStore.epiloguePending)
174const epilogueTransition = computed(() => (reduced.value ? '' : 'epilogue'))
⋯ 86 lines hidden (lines 175–260)
175 
176// The keepsake: the player's verbatim messages, paired with whom/when they reached.
177const dispatches = computed(() => {
178 const users = gameStore.messageHistory.filter(m => m.sender === 'user')
179 const events = gameStore.timelineEvents
180 return users.map((m, i) => ({
181 text: m.text,
182 figure: m.figureName || events[i]?.figureName || 'the past',
183 era: events[i]?.era || ''
184 }))
185})
186 
187// The page owns the FULL reset (store + figure input + composer), so Play Again
188// emits instead of resetting the store directly — the two reset paths used to
189// diverge here, leaving the previous run's figure stranded in the input.
190const emit = defineEmits<{ 'play-again': [] }>()
191const playAgain = () => {
192 emit('play-again')
194</script>
195 
196<style scoped>
197/* The epilogue reveal — the terminal telling cross-fades in over the "final account…"
198 wait, the same gentle opacity fade Chronicle.vue uses for a rewrite. Gated by the
199 transition NAME (empty under prefers-reduced-motion), so reduced-motion gets an
200 instant swap with no classes attached. */
201.epilogue-enter-active,
202.epilogue-leave-active {
203 transition: opacity var(--rv-dur-3) var(--rv-ease);
205.epilogue-enter-from,
206.epilogue-leave-to {
207 opacity: 0;
209 
210/* Win and loss share the page frame but not its mood. The win is illuminated — a warm
211 wash blooms from the top; the loss is a colder, dimmer ground that has settled back. */
212.end-screen { background: var(--rv-bg); animation: end-in var(--rv-dur-3) var(--rv-ease) both; }
213@keyframes end-in { from { opacity: 0; } to { opacity: 1; } }
214@media (prefers-reduced-motion: reduce) { .end-screen { animation: none; } }
215.end--win {
216 background:
217 radial-gradient(135% 70% at 50% -12%, color-mix(in srgb, var(--rv-accent) 13%, transparent), transparent 68%),
218 var(--rv-bg);
220.end--loss {
221 background:
222 radial-gradient(135% 70% at 50% -12%, color-mix(in srgb, var(--rv-bad) 6%, transparent), transparent 60%),
223 var(--rv-tint);
225 
226/* The struck seal — the verdict's emblem. The win seal glows like fresh sealing wax;
227 the loss seal is a cold, flat stamp. */
228.verdict-seal {
229 display: inline-grid;
230 place-items: center;
231 width: 60px;
232 height: 60px;
233 border-radius: 50%;
234 border: 2px solid;
235 font-size: 1.6rem;
236 line-height: 1;
238.verdict-seal.is-win {
239 color: var(--rv-accent);
240 border-color: var(--rv-accent);
241 box-shadow:
242 0 0 0 5px color-mix(in srgb, var(--rv-accent) 12%, transparent),
243 0 0 30px color-mix(in srgb, var(--rv-accent) 32%, transparent);
244 animation: seal-strike 0.6s var(--rv-ease-spring) both;
246.verdict-seal.is-loss {
247 color: var(--rv-faint);
248 border-color: var(--rv-line);
249 box-shadow: inset 0 0 0 4px color-mix(in srgb, var(--rv-faint) 8%, transparent);
251 
252@keyframes seal-strike {
253 0% { opacity: 0; transform: scale(.5) rotate(-12deg); }
254 60% { transform: scale(1.12) rotate(3deg); }
255 100% { opacity: 1; transform: scale(1) rotate(0); }
257@media (prefers-reduced-motion: reduce) {
258 .verdict-seal.is-win { animation: none; }
260</style>

The fade itself — the same opacity/duration tokens Chronicle.vue uses.

components/EndGameScreen.vue · 260 lines
components/EndGameScreen.vue260 lines · Vue
⋯ 195 lines hidden (lines 1–195)
1<template>
2 <div v-if="gameStore.gameStatus === 'victory' || gameStore.gameStatus === 'defeat'" ref="dialogRef" data-testid="end-game-screen"
3 role="dialog" aria-modal="true" :aria-labelledby="headingId" @keydown="onKeydown"
4 class="end-screen fixed inset-0 z-50 overflow-y-auto" :class="isVictory ? 'end--win' : 'end--loss'">
5 <div class="max-w-4xl mx-auto px-5 py-8">
6 <!-- Verdict masthead: a struck seal, the verdict, the objective + final %. The
7 win is illuminated (glowing terracotta seal, serif verdict in ink); the loss
8 is somber (a flat, cold seal and a muted verdict) — the two read apart instantly. -->
9 <div class="text-center border-b rv-line pb-6">
10 <div class="verdict-seal" :class="isVictory ? 'is-win' : 'is-loss'" aria-hidden="true">{{ isVictory ? '✦' : '⊘' }}</div>
11 <h2 v-if="isVictory" :id="headingId" ref="headingRef" tabindex="-1" data-testid="victory-message" class="rv-serif text-3xl sm:text-4xl font-bold rv-fg mt-3 outline-none">History Rewritten</h2>
12 <h2 v-else :id="headingId" ref="headingRef" tabindex="-1" data-testid="defeat-message" class="rv-serif text-3xl sm:text-4xl font-bold rv-muted mt-3 outline-none">The Timeline Held</h2>
13 <p class="text-sm mt-2.5 flex items-center justify-center gap-2 flex-wrap">
14 <span class="rv-muted inline-flex items-center gap-1.5">
15 <span aria-hidden="true">{{ gameStore.currentObjective?.icon }}</span>
16 <strong data-testid="objective-title" class="font-semibold">{{ gameStore.currentObjective?.title || 'Historical Mission' }}</strong>
17 </span>
18 <span class="rv-hair-c" aria-hidden="true">·</span>
19 <span class="rv-mono font-bold" :class="isVictory ? 'rv-ok' : 'rv-bad'">
20 <span data-testid="final-progress">{{ gameStore.objectiveProgress }}%</span> rewritten
21 </span>
22 </p>
23 
24 <p v-if="!isVictory" data-testid="defeat-explanation" class="rv-muted text-sm mt-3 max-w-prose mx-auto">
25 Your five messages are spent. What you changed remains — but it was not enough, and the objective slipped away.
26 </p>
27 
28 <div v-if="victoryEfficiency" data-testid="efficiency-display" class="mt-3 rv-mono text-sm">
29 <span class="rv-ok font-semibold">{{ victoryPhrase }}</span>
30 <span class="rv-muted"> · {{ victoryEfficiency.messagesUsed }}/5 used<span v-if="victoryEfficiency.messagesSaved > 0"> · {{ victoryEfficiency.messagesSaved }} saved</span></span>
31 <span v-if="victoryEfficiency.isEarlyVictory" class="rv-ok"> · 🌟 to spare</span>
32 </div>
33 </div>
34 
35 <!-- The Chronicle's final telling — the epilogue. While the terminal telling is
36 still being written (epiloguePending), we show the "final account…" wait, NOT
37 the prior turn's stale, pre-ending telling. When it lands it cross-fades in
38 (mirroring Chronicle.vue's rewrite reveal); a failed final refresh clears
39 pending and falls back to the prior telling, so the panel never hangs. -->
40 <div v-if="chronicle || epiloguePending" data-testid="end-chronicle" class="mt-6">
41 <span class="rv-label rv-label--rule"><span aria-hidden="true">📖</span> The Chronicle</span>
42 <Transition :name="epilogueTransition" mode="out-in">
43 <div v-if="epiloguePending" key="epilogue-loading" data-testid="end-chronicle-loading" class="rv-faint italic text-sm">
44 The chronicler sets down the final account…
45 </div>
46 <article v-else-if="chronicle" key="epilogue-body" data-testid="end-chronicle-body">
47 <h4 data-testid="end-chronicle-title" class="rv-serif text-xl font-semibold rv-fg mb-2.5 leading-tight">{{ chronicle.title }}</h4>
48 <p v-for="(para, i) in chronicle.paragraphs" :key="i" class="rv-serif rv-muted text-[15px] leading-relaxed mb-2.5 last:mb-0">{{ para }}</p>
49 </article>
50 </Transition>
51 </div>
52 
53 <!-- The words you sent — the keepsake: your actual messages into the past -->
54 <div v-if="dispatches.length" data-testid="dispatches" class="mt-7">
55 <span class="rv-label rv-label--rule">The words you sent</span>
56 <ul class="space-y-3.5">
57 <li v-for="(d, i) in dispatches" :key="i">
58 <p class="rv-label">to {{ d.figure }}<span v-if="d.era"> · {{ d.era }}</span></p>
59 <blockquote class="rv-serif rv-fg text-base leading-relaxed border-l-2 rv-line pl-3 mt-1">"{{ d.text }}"</blockquote>
60 </li>
61 </ul>
62 </div>
63 
64 <!-- Stats — derived from the real resolved turns -->
65 <div data-testid="game-summary" class="mt-7">
66 <span class="rv-label rv-label--rule">How it played out</span>
67 <div class="flex flex-wrap gap-10">
68 <div data-testid="message-efficiency">
69 <div class="rv-mono text-3xl font-extrabold rv-fg leading-none">{{ messagesUsed }}</div>
70 <div class="rv-label mt-1">{{ messagesUsed === 1 ? 'message' : 'messages' }} sent</div>
71 </div>
72 <div v-if="gameSummary.bestRoll" data-testid="best-roll">
73 <div class="rv-mono text-3xl font-extrabold rv-ok leading-none">{{ gameSummary.bestRoll.diceRoll }}</div>
74 <div class="rv-label mt-1">{{ messagesUsed >= 2 ? 'best roll' : 'the roll that did it' }} · {{ gameSummary.bestRoll.diceOutcome }}</div>
75 </div>
76 <div v-if="gameSummary.worstRoll && messagesUsed >= 2" data-testid="worst-roll">
77 <div class="rv-mono text-3xl font-extrabold rv-bad leading-none">{{ gameSummary.worstRoll.diceRoll }}</div>
78 <div class="rv-label mt-1">worst roll · {{ gameSummary.worstRoll.diceOutcome }}</div>
79 </div>
80 </div>
81 </div>
82 
83 <!-- The history you wrote — the Spine itself, exactly as it filled in during
84 the run. Read-only here: no live "▷ now" present marker, no "the timeline
85 trembles…" empty state. Same component the player scrolled and tapped on
86 the board, so the verdict's history reads as richly as it did in play —
87 valence dots, ⚡/⚑ badges, the roll, and the Δ equation all carry over. -->
88 <div v-if="timeline.length > 0" class="mt-6">
89 <TimelineLedger readonly />
90 </div>
91 
92 <div class="mt-9 border-t rv-line pt-6 flex items-center gap-3 flex-wrap">
93 <button ref="playAgainRef" type="button" data-testid="play-again-button" class="rv-btn rv-btn--primary" @click="playAgain">
94 Try a new timeline <span aria-hidden="true"></span>
95 </button>
96 <span class="rv-faint text-xs inline-flex items-center gap-1.5">
97 <span class="rv-dot" :class="isVictory ? 'rv-dot--ok' : 'rv-dot--bad'" aria-hidden="true" /> this timeline is written.
98 </span>
99 </div>
100 </div>
101 </div>
102</template>
103 
104<script setup lang="ts">
105/**
106 * EndGameScreen — the mission debrief. A full-bleed takeover (not a modal card):
107 * verdict line, the Chronicle epilogue + big-number stat trio, and the history you
108 * wrote. All stats derive from the real resolved turns.
109 */
110import { ref, computed, watch, nextTick } from 'vue'
111import { useGameStore } from '~/stores/game'
112import { usePrefersReducedMotion } from '~/composables/usePrefersReducedMotion'
113 
114const gameStore = useGameStore()
115const reduced = usePrefersReducedMotion()
116 
117const headingId = `end-game-heading-${Math.random().toString(36).slice(2, 11)}`
118 
119// Modal focus management: it's a true takeover (aria-modal), so when it opens we
120// move focus to the VERDICT heading (tabindex=-1: announced, focusable, outside
121// the Tab cycle), trap Tab inside, and restore focus when the run ends. Focusing
122// the heading — not the bottom Play Again button — also means the screen opens at
123// the top: the verdict and the Chronicle epilogue are the first things seen, not
124// scrolled past below the fold.
125const dialogRef = ref<HTMLElement | null>(null)
126const headingRef = ref<HTMLElement | null>(null)
127const playAgainRef = ref<HTMLElement | null>(null)
128let prevFocus: HTMLElement | null = null
129 
130watch(() => gameStore.gameStatus, async (s) => {
131 if (typeof document === 'undefined') return
132 if (s === 'victory' || s === 'defeat') {
133 prevFocus = document.activeElement as HTMLElement | null
134 await nextTick()
135 if (dialogRef.value) dialogRef.value.scrollTop = 0
136 headingRef.value?.focus()
137 } else if (prevFocus) {
138 prevFocus.focus?.()
139 prevFocus = null
140 }
141})
142 
143function onKeydown(e: KeyboardEvent) {
144 if (e.key !== 'Tab' || !dialogRef.value) return
145 const focusables = dialogRef.value.querySelectorAll<HTMLElement>(
146 'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
147 )
148 if (!focusables.length) return
149 const first = focusables[0]
150 const last = focusables[focusables.length - 1]
151 const active = document.activeElement
152 if (e.shiftKey && active === first) { e.preventDefault(); last.focus() }
153 else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus() }
155 
156const isVictory = computed(() => gameStore.gameStatus === 'victory')
157const victoryEfficiency = computed(() => gameStore.getVictoryEfficiency())
158 
159// The verdict never deflates: spending every message is reframed as "Hard-won," not
160// the limp "Standard." Only the laudatory tiers keep their rating as an adjective.
161const victoryPhrase = computed(() => {
162 const rating = victoryEfficiency.value?.efficiencyRating
163 if (!rating) return 'Victory'
164 return rating === 'Standard' ? 'Hard-won victory' : `${rating} victory`
165})
166const messagesUsed = computed(() => 5 - gameStore.remainingMessages)
167const gameSummary = computed(() => gameStore.gameSummary)
168const timeline = computed(() => gameStore.timelineEvents)
169const chronicle = computed(() => gameStore.chronicle)
170// The epilogue (terminal telling) is still being written — show the wait, never the
171// prior turn's stale telling. The cross-fade mirrors Chronicle.vue and is dropped
172// under prefers-reduced-motion (the empty name disables the Transition's classes).
173const epiloguePending = computed(() => gameStore.epiloguePending)
174const epilogueTransition = computed(() => (reduced.value ? '' : 'epilogue'))
175 
176// The keepsake: the player's verbatim messages, paired with whom/when they reached.
177const dispatches = computed(() => {
178 const users = gameStore.messageHistory.filter(m => m.sender === 'user')
179 const events = gameStore.timelineEvents
180 return users.map((m, i) => ({
181 text: m.text,
182 figure: m.figureName || events[i]?.figureName || 'the past',
183 era: events[i]?.era || ''
184 }))
185})
186 
187// The page owns the FULL reset (store + figure input + composer), so Play Again
188// emits instead of resetting the store directly — the two reset paths used to
189// diverge here, leaving the previous run's figure stranded in the input.
190const emit = defineEmits<{ 'play-again': [] }>()
191const playAgain = () => {
192 emit('play-again')
194</script>
195 
196<style scoped>
197/* The epilogue reveal — the terminal telling cross-fades in over the "final account…"
198 wait, the same gentle opacity fade Chronicle.vue uses for a rewrite. Gated by the
199 transition NAME (empty under prefers-reduced-motion), so reduced-motion gets an
200 instant swap with no classes attached. */
201.epilogue-enter-active,
202.epilogue-leave-active {
203 transition: opacity var(--rv-dur-3) var(--rv-ease);
205.epilogue-enter-from,
206.epilogue-leave-to {
207 opacity: 0;
⋯ 52 lines hidden (lines 209–260)
209 
210/* Win and loss share the page frame but not its mood. The win is illuminated — a warm
211 wash blooms from the top; the loss is a colder, dimmer ground that has settled back. */
212.end-screen { background: var(--rv-bg); animation: end-in var(--rv-dur-3) var(--rv-ease) both; }
213@keyframes end-in { from { opacity: 0; } to { opacity: 1; } }
214@media (prefers-reduced-motion: reduce) { .end-screen { animation: none; } }
215.end--win {
216 background:
217 radial-gradient(135% 70% at 50% -12%, color-mix(in srgb, var(--rv-accent) 13%, transparent), transparent 68%),
218 var(--rv-bg);
220.end--loss {
221 background:
222 radial-gradient(135% 70% at 50% -12%, color-mix(in srgb, var(--rv-bad) 6%, transparent), transparent 60%),
223 var(--rv-tint);
225 
226/* The struck seal — the verdict's emblem. The win seal glows like fresh sealing wax;
227 the loss seal is a cold, flat stamp. */
228.verdict-seal {
229 display: inline-grid;
230 place-items: center;
231 width: 60px;
232 height: 60px;
233 border-radius: 50%;
234 border: 2px solid;
235 font-size: 1.6rem;
236 line-height: 1;
238.verdict-seal.is-win {
239 color: var(--rv-accent);
240 border-color: var(--rv-accent);
241 box-shadow:
242 0 0 0 5px color-mix(in srgb, var(--rv-accent) 12%, transparent),
243 0 0 30px color-mix(in srgb, var(--rv-accent) 32%, transparent);
244 animation: seal-strike 0.6s var(--rv-ease-spring) both;
246.verdict-seal.is-loss {
247 color: var(--rv-faint);
248 border-color: var(--rv-line);
249 box-shadow: inset 0 0 0 4px color-mix(in srgb, var(--rv-faint) 8%, transparent);
251 
252@keyframes seal-strike {
253 0% { opacity: 0; transform: scale(.5) rotate(-12deg); }
254 60% { transform: scale(1.12) rotate(3deg); }
255 100% { opacity: 1; transform: scale(1) rotate(0); }
257@media (prefers-reduced-motion: reduce) {
258 .verdict-seal.is-win { animation: none; }
260</style>

Tests that pin the behavior

Two suites. The store tests are the discriminating ones — they'd fail if the flag were never set, never cleared, or set for a non-terminal turn.

Pending during the in-flight terminal refresh; cleared on resolve (epilogue stored) and on failure (prior telling kept); not set mid-run; cleared by reset.

tests/unit/stores/game.spec.ts · 1473 lines
tests/unit/stores/game.spec.ts1473 lines · TypeScript
⋯ 470 lines hidden (lines 1–470)
1import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2import { flushPromises } from '@vue/test-utils'
3import { setActivePinia, createPinia } from 'pinia'
4import { useGameStore, REVEAL } from '../../../stores/game'
5import { DiceOutcome } from '../../../utils/dice'
6 
7/**
8 * Builds a successful /api/send-message response. Overrides are merged into data.
9 */
10function okResponse(over: Record<string, unknown> = {}) {
11 return {
12 success: true,
13 data: {
14 figure: { name: 'Nikola Tesla', era: 'New York, 1900', descriptor: 'Electrical inventor' },
15 characterResponse: { message: 'A bold notion!', action: 'Builds a resonant transmitter' },
16 diceRoll: 14,
17 diceOutcome: 'Success',
18 timeline: { headline: 'Wireless power demonstrated', detail: 'Cities begin to electrify early', era: 'New York, 1900', progressChange: 20, valence: 'positive' },
19 error: null,
20 ...over
21 }
22 }
24 
25describe('game store', () => {
26 let store: ReturnType<typeof useGameStore>
27 let fetchMock: ReturnType<typeof vi.fn>
28 
29 beforeEach(() => {
30 setActivePinia(createPinia())
31 store = useGameStore()
32 fetchMock = vi.fn()
33 vi.stubGlobal('$fetch', fetchMock)
34 // Pre-seed the run id so AI actions don't fire a begin-run (POST /api/run)
35 // fetch ahead of the call under test. The run-id block below nulls it to
36 // exercise server-side minting explicitly.
37 store.runId = 'test-run'
38 // #73 requires a grounded, deceased figure to contact. Most sendMessage tests
39 // exercise the turn pipeline, not the contact gate, so default to a resolved
40 // deceased figure with no chosen year (contactLiveness 'unknown', permissive).
41 // Gate-specific tests below override figureGrounding / contactWhen directly.
42 store.figureGrounding = {
43 name: 'Nikola Tesla', resolved: true,
44 born: { year: 1856, bce: false, signed: 1856, display: '1856' },
45 died: { year: 1943, bce: false, signed: 1943, display: '1943' }
46 } as never
47 })
48 
49 afterEach(() => {
50 vi.unstubAllGlobals()
51 vi.restoreAllMocks()
52 })
53 
54 describe('initial state', () => {
55 it('starts a fresh run', () => {
56 expect(store.remainingMessages).toBe(5)
57 expect(store.gameStatus).toBe('playing')
58 expect(store.objectiveProgress).toBe(0)
59 expect(store.messageHistory).toEqual([])
60 expect(store.timelineEvents).toEqual([])
61 expect(store.figures).toEqual([])
62 expect(store.canSendMessage).toBe(true)
63 })
64 })
65 
66 describe('progress + status resolution', () => {
67 it('clamps progress between 0 and 100', () => {
68 store.applyProgress(150)
69 expect(store.objectiveProgress).toBe(100)
70 store.applyProgress(-999)
71 expect(store.objectiveProgress).toBe(0)
72 })
73 
74 it('decrementMessages does not, by itself, end the game', () => {
75 store.decrementMessages()
76 expect(store.remainingMessages).toBe(4)
77 expect(store.gameStatus).toBe('playing')
78 })
79 
80 it('resolves victory the moment progress hits 100', () => {
81 store.objectiveProgress = 100
82 store.resolveGameStatus()
83 expect(store.gameStatus).toBe('victory')
84 })
85 
86 it('resolves defeat only once messages run out unmet', () => {
87 store.remainingMessages = 0
88 store.objectiveProgress = 50
89 store.resolveGameStatus()
90 expect(store.gameStatus).toBe('defeat')
91 })
92 
93 it('prefers victory over defeat on the final message', () => {
94 store.remainingMessages = 0
95 store.objectiveProgress = 100
96 store.resolveGameStatus()
97 expect(store.gameStatus).toBe('victory')
98 })
99 
100 it('stays playing while messages remain and the objective is unmet', () => {
101 store.remainingMessages = 3
102 store.objectiveProgress = 50
103 store.resolveGameStatus()
104 expect(store.gameStatus).toBe('playing')
105 })
106 })
107 
108 describe('figures', () => {
109 it('registers a new figure and refines it on later contact', () => {
110 store.registerFigure({ name: 'Cleopatra', era: '', descriptor: '' })
111 expect(store.figures).toHaveLength(1)
112 
113 store.registerFigure({ name: 'Cleopatra', era: 'Alexandria, 40 BC', descriptor: 'Queen of Egypt' })
114 expect(store.figures).toHaveLength(1)
115 expect(store.figures[0].era).toBe('Alexandria, 40 BC')
116 expect(store.figures[0].descriptor).toBe('Queen of Egypt')
117 })
118 })
119 
120 describe('timeline ledger', () => {
121 it('derives valence from the progress change when not given', () => {
122 store.addTimelineEvent({ figureName: 'X', era: 'e', headline: 'h', detail: 'd', diceRoll: 5, diceOutcome: DiceOutcome.FAILURE, progressChange: -5 })
123 store.addTimelineEvent({ figureName: 'Y', era: 'e', headline: 'h', detail: 'd', diceRoll: 16, diceOutcome: DiceOutcome.SUCCESS, progressChange: 12 })
124 store.addTimelineEvent({ figureName: 'Z', era: 'e', headline: 'h', detail: 'd', diceRoll: 10, diceOutcome: DiceOutcome.NEUTRAL, progressChange: 0 })
125 
126 expect(store.timelineEvents.map(e => e.valence)).toEqual(['negative', 'positive', 'neutral'])
127 })
128 })
129 
130 describe('conversation threads', () => {
131 it('keeps each figure’s thread independent', () => {
132 store.addUserMessage('To Tesla', 'Tesla')
133 store.addAIMessage('From Tesla', 'Tesla')
134 store.addUserMessage('To Cleopatra', 'Cleopatra')
135 
136 expect(store.conversationWith('Tesla')).toHaveLength(2)
137 expect(store.conversationWith('Cleopatra')).toHaveLength(1)
138 })
139 
140 it('carries the continuity verdict onto the figure turn (issue #62)', () => {
141 store.addAIMessageWithData({ text: 'It is done.', sender: 'ai', figureName: 'Caesar', continuity: 'builds' })
142 expect(store.conversationWith('Caesar')[0].continuity).toBe('builds')
143 })
144 })
145 
146 describe('sendMessage — conducted reveal cadence (animated)', () => {
147 // In a browser with motion allowed, a resolved turn releases its beats one at
148 // a time: suspense → die + reply → swing → ledger node. We stub matchMedia
149 // (motion on) + fake timers and step through. (The default test env has no
150 // matchMedia, so every OTHER test takes the instant, atomic path.)
151 beforeEach(() => {
152 vi.stubGlobal('window', { matchMedia: () => ({ matches: false }) })
153 vi.useFakeTimers()
154 })
155 afterEach(() => vi.useRealTimers())
156 
157 it('staggers the beats: suspense → reply → swing → node', async () => {
158 fetchMock.mockResolvedValue(okResponse())
159 const p = store.sendMessage('Light the world', 'Nikola Tesla')
160 
161 // Through the fetch and into the suspense hold — nothing revealed yet.
162 await vi.advanceTimersByTimeAsync(0)
163 expect(store.messageHistory.some((m) => m.sender === 'ai')).toBe(false)
164 expect(store.isLoading).toBe(true)
165 
166 // Beat 1 (after the hold): the reply lands and the die stops rolling, but
167 // the swing and the ledger node have NOT landed.
168 await vi.advanceTimersByTimeAsync(REVEAL.suspense)
169 expect(store.messageHistory.some((m) => m.sender === 'ai')).toBe(true)
170 expect(store.isLoading).toBe(false)
171 expect(store.objectiveProgress).toBe(0)
172 expect(store.timelineEvents.length).toBe(0)
173 
174 // Beat 2: the timeline shift lands; the node still hasn't.
175 await vi.advanceTimersByTimeAsync(REVEAL.swing)
176 expect(store.objectiveProgress).toBe(20)
177 expect(store.timelineEvents.length).toBe(0)
178 
179 // Beat 3: the node drops and the turn resolves.
180 await vi.advanceTimersByTimeAsync(REVEAL.node)
181 expect(store.timelineEvents.length).toBe(1)
182 expect(await p).toBe(true)
183 })
184 
185 it('aborts cleanly if the run is reset mid-reveal', async () => {
186 fetchMock.mockResolvedValue(okResponse())
187 const p = store.sendMessage('Light the world', 'Nikola Tesla')
188 await vi.advanceTimersByTimeAsync(REVEAL.suspense) // reply landed, swing pending
189 store.resetGame() // bump the epoch mid-cascade
190 await vi.advanceTimersByTimeAsync(REVEAL.swing + REVEAL.node)
191 expect(await p).toBe(false)
192 // The reset wiped state; the aborted reveal added nothing into the new run.
193 expect(store.timelineEvents.length).toBe(0)
194 expect(store.objectiveProgress).toBe(0)
195 })
196 })
197 
198 describe('sendMessage', () => {
199 it('folds a successful turn into messages, ledger, progress, and figures', async () => {
200 fetchMock.mockResolvedValue(okResponse())
201 
202 await store.sendMessage('Light the world wirelessly', 'Nikola Tesla')
203 
204 // The turn hits /api/send-message (a resolved turn also fires a
205 // non-blocking /api/chronicle refresh — see the chronicle suite below).
206 expect(fetchMock).toHaveBeenCalledWith('/api/send-message', expect.objectContaining({ method: 'POST' }))
207 expect(store.activeFigureName).toBe('Nikola Tesla')
208 
209 // user + figure turns
210 expect(store.messageHistory).toHaveLength(2)
211 const ai = store.messageHistory[1]
212 expect(ai.sender).toBe('ai')
213 expect(ai.diceRoll).toBe(14)
214 expect(ai.characterAction).toBe('Builds a resonant transmitter')
215 expect(ai.timelineImpact).toBe('Cities begin to electrify early')
216 expect(ai.figureName).toBe('Nikola Tesla')
217 
218 // ledger + progress + figure cache
219 expect(store.timelineEvents).toHaveLength(1)
220 expect(store.timelineEvents[0].headline).toBe('Wireless power demonstrated')
221 expect(store.timelineEvents[0].valence).toBe('positive')
222 expect(store.objectiveProgress).toBe(20)
223 expect(store.figures[0]).toMatchObject({ name: 'Nikola Tesla', era: 'New York, 1900' })
224 
225 expect(store.remainingMessages).toBe(4)
226 expect(store.isLoading).toBe(false)
227 })
228 
229 it('overwrites the momentum meter from the server’s advanced value (issue #62)', async () => {
230 fetchMock.mockResolvedValue(okResponse({ momentum: 3 }))
231 await store.sendMessage('Build on what he asked', 'Nikola Tesla')
232 expect(store.momentum).toBe(3)
233 })
234 
235 it('records a neutral (0%) turn instead of letting it vanish', async () => {
236 fetchMock.mockResolvedValue(okResponse({
237 timeline: { headline: 'Nothing much changes', detail: 'A pause in history', era: 'x', progressChange: 0, valence: 'neutral' }
238 }))
239 
240 await store.sendMessage('Hmm', 'Tesla')
241 
242 expect(store.timelineEvents).toHaveLength(1)
243 expect(store.timelineEvents[0].valence).toBe('neutral')
244 expect(store.objectiveProgress).toBe(0)
245 })
246 
247 it('refuses to send without a target figure', async () => {
248 await store.sendMessage('Hello?', '')
249 expect(fetchMock).not.toHaveBeenCalled()
250 expect(store.error).toBeTruthy()
251 expect(store.messageHistory).toHaveLength(0)
252 expect(store.remainingMessages).toBe(5)
253 })
254 
255 it('refunds the message and drops the dangling prompt on a network error', async () => {
256 fetchMock.mockRejectedValue(new Error('network down'))
257 
258 await store.sendMessage('A message that fails', 'Tesla')
259 
260 expect(store.error).toBeTruthy()
261 expect(store.remainingMessages).toBe(5)
262 expect(store.messageHistory).toHaveLength(0)
263 expect(store.gameStatus).toBe('playing')
264 })
265 
266 it('refunds the message when the server reports a graceful failure (success:false)', async () => {
267 fetchMock.mockResolvedValue({
268 success: false,
269 message: 'Failed to generate character response',
270 data: { userMessage: 'x', error: 'Character AI failed' }
271 })
272 
273 const resolved = await store.sendMessage('A doomed dispatch', 'Tesla')
274 
275 expect(resolved).toBe(false)
276 expect(store.error).toBe('Character AI failed')
277 expect(store.remainingMessages).toBe(5)
278 expect(store.messageHistory).toHaveLength(0)
279 expect(store.timelineEvents).toHaveLength(0)
280 })
281 
282 it('a moderation block shows a distinct notice (not an infra error) and refunds the turn', async () => {
283 fetchMock.mockResolvedValue({
284 success: false,
285 message: 'Blocked by moderation',
286 data: {
287 userMessage: 'x',
288 blocked: true,
289 moderationReason: 'This dispatch was blocked by content moderation and cannot be sent.',
290 error: 'This dispatch was blocked by content moderation and cannot be sent.'
291 }
292 })
293 
294 const resolved = await store.sendMessage('Synthesize a nerve agent at scale', 'Fritz Haber')
295 
296 expect(resolved).toBe(false)
297 // Distinct from an infra hiccup: the block banner is set, `error` is not.
298 expect(store.moderationNotice).toContain('blocked by content moderation')
299 expect(store.error).toBeNull()
300 expect(store.remainingMessages).toBe(5) // refunded, never burned
301 expect(store.messageHistory).toHaveLength(0)
302 })
303 
304 it('a graceful failure on the FINAL message does not convert to instant defeat', async () => {
305 store.remainingMessages = 1
306 store.objectiveProgress = 60
307 fetchMock.mockResolvedValue({
308 success: false,
309 message: 'Failed to generate timeline analysis',
310 data: { userMessage: 'x', diceRoll: 17, diceOutcome: 'Success', error: 'Timeline AI failed' }
311 })
312 
313 await store.sendMessage('The last word', 'Tesla')
314 
315 // The dispatch is refunded, the run lives on — an infra hiccup is not a loss.
316 expect(store.remainingMessages).toBe(1)
317 expect(store.gameStatus).toBe('playing')
318 })
319 
320 it('reports resolution truthfully: true when a turn lands, false when refunded', async () => {
321 fetchMock.mockResolvedValueOnce(okResponse())
322 expect(await store.sendMessage('First', 'Tesla')).toBe(true)
323 
324 store.lastMessageTime = null // clear the rate-limit window
325 fetchMock.mockRejectedValueOnce(new Error('down'))
326 expect(await store.sendMessage('Second', 'Tesla')).toBe(false)
327 })
328 
329 it('arms the stake only on the final message — never earlier', async () => {
330 fetchMock.mockResolvedValue(okResponse())
331 
332 await store.sendMessage('Early gambit', 'Tesla', { stake: true })
333 let body = fetchMock.mock.calls[0][1].body
334 expect(body.stake).toBe(false) // 5 messages left: the stake is refused
335 
336 store.lastMessageTime = null
337 store.remainingMessages = 1
338 await store.sendMessage('The last stand', 'Tesla', { stake: true })
339 body = fetchMock.mock.calls.find((c: unknown[]) => c[0] === '/api/send-message' && (c[1] as { body: { message: string } }).body.message === 'The last stand')![1].body
340 expect(body.stake).toBe(true)
341 })
342 
343 it('refuses the stake when the final throw can win unstaked (no dominance trap)', async () => {
344 store.remainingMessages = 1
345 store.objectiveProgress = 60 // need 40 ≤ the 50-point fuse — winnable clean
346 fetchMock.mockResolvedValue(okResponse())
347 
348 await store.sendMessage('One clean throw', 'Tesla', { stake: true })
349 
350 const body = fetchMock.mock.calls[0][1].body
351 expect(body.stake).toBe(false)
352 })
353 
354 it('records the staked badge and the turn’s grounded year on ledger AND thread', async () => {
355 store.contactWhen = 1900 // within the default grounded figure's lifetime
356 fetchMock.mockResolvedValue(okResponse({
357 staked: true,
358 craft: 'sharp',
359 naturalRoll: 12,
360 rollModifier: 1,
361 timeline: { headline: 'All in', detail: 'Everything on one throw', era: 'x', progressChange: 60, baseProgressChange: 30, valence: 'positive', anachronism: 'far-ahead' }
362 }))
363 
364 await store.sendMessage('Stake it all', 'Tesla')
365 
366 const evt = store.timelineEvents[0]
367 expect(evt.staked).toBe(true)
368 expect(evt.craft).toBe('sharp')
369 expect(evt.whenSigned).toBe(1900)
370 expect(evt.baseProgressChange).toBe(30)
371 const ai = store.messageHistory[1]
372 expect(ai.naturalRoll).toBe(12)
373 expect(ai.rollModifier).toBe(1)
374 // The thread message carries the stake too — the reveal's equation must
375 // attribute the doubling to the ⚑, not to the anachronism tier.
376 expect(ai.staked).toBe(true)
377 })
378 
379 it('does NOT prematurely defeat: a winning final message is a victory', async () => {
380 store.remainingMessages = 1
381 store.objectiveProgress = 90
382 fetchMock.mockResolvedValue(okResponse({
383 timeline: { headline: 'The last push', detail: 'It is done', era: 'x', progressChange: 20, valence: 'positive' }
384 }))
385 
386 await store.sendMessage('The decisive word', 'Tesla')
387 
388 expect(store.objectiveProgress).toBe(100)
389 expect(store.remainingMessages).toBe(0)
390 expect(store.gameStatus).toBe('victory')
391 })
392 
393 it('declares defeat when the final message falls short', async () => {
394 store.remainingMessages = 1
395 store.objectiveProgress = 10
396 fetchMock.mockResolvedValue(okResponse({
397 timeline: { headline: 'Not enough', detail: 'History resists', era: 'x', progressChange: 5, valence: 'positive' }
398 }))
399 
400 await store.sendMessage('A last try', 'Tesla')
401 
402 expect(store.objectiveProgress).toBe(15)
403 expect(store.remainingMessages).toBe(0)
404 expect(store.gameStatus).toBe('defeat')
405 })
406 })
407 
408 describe('living chronicle (refreshChronicle)', () => {
409 const telling = { title: 'The Peace That Held', paragraphs: ['In 1878, Bismarck chose trade.', 'By 1914, no war came.'] }
410 
411 function seedOneChange() {
412 store.addTimelineEvent({
413 figureName: 'Bismarck', era: '1878', headline: 'Bismarck chooses trade',
414 detail: 'Alliances soften into commerce.', diceRoll: 16, diceOutcome: DiceOutcome.SUCCESS, progressChange: 20
415 })
416 }
417 
418 it('re-narrates the timeline and stores the telling', async () => {
419 seedOneChange()
420 fetchMock.mockResolvedValue({ success: true, chronicle: telling })
421 
422 await store.refreshChronicle()
423 
424 expect(fetchMock).toHaveBeenCalledWith('/api/chronicle', expect.objectContaining({ method: 'POST' }))
425 expect(store.chronicle).toEqual(telling)
426 expect(store.chronicleLoading).toBe(false)
427 })
428 
429 it('skips (and clears) when no changes exist to chronicle', async () => {
430 store.chronicle = telling
431 await store.refreshChronicle()
432 expect(fetchMock).not.toHaveBeenCalled()
433 expect(store.chronicle).toBeNull()
434 })
435 
436 it('keeps the prior telling when a refresh fails (never blanks)', async () => {
437 seedOneChange()
438 store.chronicle = telling
439 fetchMock.mockRejectedValue(new Error('chronicler offline'))
440 
441 await store.refreshChronicle()
442 
443 expect(store.chronicle).toEqual(telling)
444 expect(store.chronicleLoading).toBe(false)
445 })
446 
447 it('fires a non-blocking refresh after a turn actually changes the world', async () => {
448 fetchMock.mockResolvedValue(okResponse())
449 
450 await store.sendMessage('Light the world wirelessly', 'Nikola Tesla')
451 // The refresh is fired but not awaited; aiHeaders is async, so let the
452 // microtasks settle before checking the chronicle fetch went out.
453 await flushPromises()
454 
455 // The refresh is fired (not awaited) once the turn added a ledger entry.
456 expect(fetchMock).toHaveBeenCalledWith('/api/chronicle', expect.objectContaining({ method: 'POST' }))
457 })
458 
459 it('does NOT refresh when a turn fails to change the world', async () => {
460 fetchMock.mockRejectedValue(new Error('network down'))
461 
462 await store.sendMessage('A message that fails', 'Tesla')
463 
464 expect(fetchMock).not.toHaveBeenCalledWith('/api/chronicle', expect.anything())
465 })
466 })
467 
468 // The end screen reads epiloguePending to show "the final account…" while the
469 // TERMINAL telling is being written — never the prior turn's stale, pre-ending
470 // telling presented as the epilogue (issue #107).
471 describe('the epilogue pending flag (terminal chronicle)', () => {
472 const epilogue = { title: 'The World Remade', paragraphs: ['And so the timeline was rewritten.'] }
473 
474 function deferred<T>() {
475 let resolve!: (v: T) => void
476 let reject!: (e: unknown) => void
477 const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
478 return { promise, resolve, reject }
479 }
480 
481 // Route the turn, the epilogue refresh, and the snapshot saves independently so
482 // the chronicle refresh can be held in flight.
483 function route(chroniclePromise: Promise<unknown>, progressChange = 20) {
484 fetchMock.mockImplementation((url: string) => {
485 if (url === '/api/chronicle') return chroniclePromise
486 if (url === '/api/send-message') return Promise.resolve(okResponse({
487 timeline: { headline: 'The last push', detail: 'It is done', era: 'x', progressChange, valence: 'positive' }
488 }))
489 return Promise.resolve({ success: true }) // /api/run-save and the rest
490 })
491 }
492 
493 it('marks the epilogue pending while the terminal telling is written, then clears it when the telling lands', async () => {
494 store.remainingMessages = 1
495 store.objectiveProgress = 90
496 const chronicle = deferred<{ success: boolean; chronicle: typeof epilogue }>()
497 route(chronicle.promise)
498 
499 await store.sendMessage('The decisive word', 'Tesla')
500 await flushPromises() // aiHeaders is async; let the non-blocking refresh fire
501 
502 expect(store.gameStatus).toBe('victory')
503 expect(store.epiloguePending).toBe(true) // the end screen shows "the final account…"
504 
505 chronicle.resolve({ success: true, chronicle: epilogue })
506 await flushPromises()
507 
508 expect(store.epiloguePending).toBe(false)
509 expect(store.chronicle).toEqual(epilogue) // the epilogue, not a stale prior telling
510 })
511 
512 it('clears the pending flag if the terminal refresh fails — falls back, never hangs on loading', async () => {
513 store.remainingMessages = 1
514 store.objectiveProgress = 90
515 const prior = { title: 'The Account So Far', paragraphs: ['the world a turn before the end'] }
516 store.chronicle = prior
517 const chronicle = deferred<never>()
518 route(chronicle.promise)
519 
520 await store.sendMessage('The decisive word', 'Tesla')
521 await flushPromises()
522 expect(store.epiloguePending).toBe(true)
523 
524 chronicle.reject(new Error('chronicler offline'))
525 await flushPromises()
526 
527 expect(store.epiloguePending).toBe(false) // not stuck on the loading state
528 expect(store.chronicle).toEqual(prior) // the prior telling is kept (never blanks)
529 })
530 
531 it('does NOT mark the epilogue pending for a non-terminal turn', async () => {
532 store.remainingMessages = 3
533 store.objectiveProgress = 10
534 route(Promise.resolve({ success: true, chronicle: epilogue }), 5) // a small, non-winning nudge
535 
536 await store.sendMessage('A mid-run move', 'Tesla')
537 await flushPromises()
538 
539 expect(store.gameStatus).toBe('playing')
540 expect(store.epiloguePending).toBe(false)
541 })
542 
543 it('resetGame clears a pending epilogue', () => {
544 store.epiloguePending = true
545 store.resetGame()
546 expect(store.epiloguePending).toBe(false)
547 })
548 })
⋯ 925 lines hidden (lines 549–1473)
549 
550 describe('staleness guards (epoch + sequencing)', () => {
551 function deferred<T>() {
552 let resolve!: (v: T) => void
553 let reject!: (e: unknown) => void
554 const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
555 return { promise, resolve, reject }
556 }
557 
558 it('a reset mid-send drops the stale response instead of haunting the new run', async () => {
559 const slow = deferred<ReturnType<typeof okResponse>>()
560 fetchMock.mockReturnValue(slow.promise)
561 
562 const inFlight = store.sendMessage('From the old world', 'Tesla')
563 store.resetGame()
564 slow.resolve(okResponse())
565 const resolved = await inFlight
566 
567 // The dead run's turn finds no purchase in the fresh one.
568 expect(resolved).toBe(false)
569 expect(store.remainingMessages).toBe(5)
570 expect(store.messageHistory).toHaveLength(0)
571 expect(store.timelineEvents).toHaveLength(0)
572 expect(store.objectiveProgress).toBe(0)
573 })
574 
575 it('a reset mid-send must not "refund" the new run on failure either', async () => {
576 const slow = deferred<never>()
577 fetchMock.mockReturnValue(slow.promise)
578 
579 const inFlight = store.sendMessage('Doomed', 'Tesla')
580 store.resetGame()
581 store.remainingMessages = 3 // the new run has its own spent counter
582 slow.reject(new Error('old run network error'))
583 await inFlight
584 
585 expect(store.remainingMessages).toBe(3) // untouched by the stale refund
586 })
587 
588 it('an earlier chronicle rewrite cannot overwrite a later one', async () => {
589 store.addTimelineEvent({ figureName: 'X', era: 'e', headline: 'h', detail: 'd', diceRoll: 16, diceOutcome: DiceOutcome.SUCCESS, progressChange: 20 })
590 
591 const first = deferred<{ success: boolean; chronicle: { title: string; paragraphs: string[] } }>()
592 const second = deferred<{ success: boolean; chronicle: { title: string; paragraphs: string[] } }>()
593 fetchMock.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
594 
595 const a = store.refreshChronicle()
596 const b = store.refreshChronicle()
597 // The LATER telling lands first; the earlier one limps in afterwards.
598 second.resolve({ success: true, chronicle: { title: 'The Later Telling', paragraphs: ['b'] } })
599 await b
600 first.resolve({ success: true, chronicle: { title: 'The Earlier Telling', paragraphs: ['a'] } })
601 await a
602 
603 expect(store.chronicle?.title).toBe('The Later Telling')
604 expect(store.chronicleLoading).toBe(false)
605 })
606 
607 it('a reset mid-chronicle keeps the old run’s telling out of the new run', async () => {
608 store.addTimelineEvent({ figureName: 'X', era: 'e', headline: 'h', detail: 'd', diceRoll: 16, diceOutcome: DiceOutcome.SUCCESS, progressChange: 20 })
609 const slow = deferred<{ success: boolean; chronicle: { title: string; paragraphs: string[] } }>()
610 fetchMock.mockReturnValue(slow.promise)
611 
612 const inFlight = store.refreshChronicle()
613 store.resetGame()
614 slow.resolve({ success: true, chronicle: { title: 'A Ghost Epilogue', paragraphs: ['boo'] } })
615 await inFlight
616 
617 expect(store.chronicle).toBeNull()
618 })
619 
620 it('out-of-order grounding keeps the latest figure’s dossier', async () => {
621 const slowA = deferred<Record<string, unknown>>()
622 const fastB = deferred<Record<string, unknown>>()
623 fetchMock.mockReturnValueOnce(slowA.promise).mockReturnValueOnce(fastB.promise)
624 
625 const a = store.groundActiveFigure('Aristotle')
626 const b = store.groundActiveFigure('Bismarck')
627 fastB.resolve({ name: 'Otto von Bismarck', resolved: true, born: { year: 1815, bce: false, signed: 1815, display: '1815' }, died: { year: 1898, bce: false, signed: 1898, display: '1898' }, living: false })
628 await b
629 slowA.resolve({ name: 'Aristotle', resolved: true, born: { year: 384, bce: true, signed: -384, display: '384 BC' }, died: { year: 322, bce: true, signed: -322, display: '322 BC' }, living: false })
630 await a
631 
632 expect(store.figureGrounding?.name).toBe('Otto von Bismarck')
633 expect(store.groundingLoading).toBe(false)
634 })
635 
636 it('clearGrounding drops the dossier instantly and a lookup still in flight finds no purchase', async () => {
637 const slow = deferred<Record<string, unknown>>()
638 fetchMock.mockReturnValue(slow.promise)
639 
640 const inFlight = store.groundActiveFigure('Napoleon')
641 store.clearGrounding() // the player typed a new name — the old dossier dies NOW
642 slow.resolve({ name: 'Napoleon', resolved: true, born: { year: 1769, bce: false, signed: 1769, display: '1769' }, died: { year: 1821, bce: false, signed: 1821, display: '1821' }, living: false })
643 await inFlight
644 
645 expect(store.figureGrounding).toBeNull()
646 expect(store.contactWhen).toBeNull()
647 expect(store.groundingLoading).toBe(false)
648 })
649 
650 it('study records the moment it was asked for, not where the slider moved mid-flight', async () => {
651 store.figureGrounding = {
652 name: 'Cleopatra', resolved: true,
653 born: { year: 69, bce: true, signed: -69, display: '69 BC' },
654 died: { year: 30, bce: true, signed: -30, display: '30 BC' }, living: false
655 } as never
656 store.setContactWhen(-40)
657 const slow = deferred<{ success: boolean; study: Record<string, unknown> }>()
658 fetchMock.mockReturnValue(slow.promise)
659 
660 const inFlight = store.studyActiveFigure()
661 store.setContactWhen(-35) // the player keeps exploring while the Archivist works
662 slow.resolve({ success: true, study: { atThisMoment: 'x', grasp: ['a'], reaching: ['b'], cannotYetKnow: 'c' } })
663 await inFlight
664 
665 // The brief is FOR -40; recording -35 would mislabel it and hide Re-study.
666 expect(store.studyWhen).toBe(-40)
667 expect(store.studyFor).toBe('Cleopatra')
668 })
669 })
670 
671 describe('victory efficiency', () => {
672 it('rates an early win and reports messages saved', () => {
673 store.gameStatus = 'victory'
674 store.remainingMessages = 4
675 const eff = store.getVictoryEfficiency()
676 expect(eff?.efficiencyRating).toBe('Legendary')
677 expect(eff?.messagesUsed).toBe(1)
678 expect(eff?.isEarlyVictory).toBe(true)
679 })
680 
681 it('returns null when not a victory', () => {
682 store.gameStatus = 'playing'
683 expect(store.getVictoryEfficiency()).toBeNull()
684 })
685 })
686 
687 describe('resetGame', () => {
688 it('returns everything to a fresh run', () => {
689 store.remainingMessages = 1
690 store.objectiveProgress = 80
691 store.momentum = 3
692 store.gameStatus = 'defeat'
693 store.messageHistory.push({ text: 'x', sender: 'user', timestamp: new Date() })
694 store.timelineEvents.push({ id: '1', figureName: 'x', era: 'e', headline: 'h', detail: 'd', diceRoll: 1, diceOutcome: DiceOutcome.FAILURE, progressChange: -1, valence: 'negative', timestamp: new Date() })
695 store.figures.push({ name: 'x', era: 'e', descriptor: 'd' })
696 store.activeFigureName = 'x'
697 store.chronicle = { title: 'A History', paragraphs: ['It happened.'] }
698 store.figureStudy = { atThisMoment: 'x', grasp: [], reaching: [], cannotYetKnow: '' }
699 store.studyFor = 'x'
700 store.studyWhen = 1500
701 store.archiveResult = { topic: 't', summary: 's', requires: [], knownSince: '' }
702 
703 store.resetGame()
704 
705 expect(store.remainingMessages).toBe(5)
706 expect(store.objectiveProgress).toBe(0)
707 expect(store.momentum).toBe(0)
708 expect(store.gameStatus).toBe('playing')
709 expect(store.messageHistory).toEqual([])
710 expect(store.timelineEvents).toEqual([])
711 expect(store.figures).toEqual([])
712 expect(store.activeFigureName).toBe('')
713 expect(store.chronicle).toBeNull()
714 expect(store.figureStudy).toBeNull()
715 expect(store.studyFor).toBe('')
716 expect(store.studyWhen).toBeNull()
717 expect(store.archiveResult).toBeNull()
718 })
719 })
720 
721 describe('objective selection', () => {
722 const objective = {
723 title: 'Save the Library of Alexandria',
724 description: 'Keep the ancient world’s knowledge intact into the modern age.',
725 era: 'Alexandria, antiquity',
726 icon: '📜'
727 }
728 
729 it('chooseObjective commits the objective and resets progress', async () => {
730 fetchMock.mockResolvedValue({ runsRemaining: 5 })
731 store.objectiveProgress = 40
732 store.momentum = 2
733 store.setError('a stale error')
734 
735 const started = await store.chooseObjective(objective)
736 
737 expect(started).toBe(true)
738 expect(store.currentObjective).toEqual(objective)
739 expect(store.objectiveProgress).toBe(0)
740 expect(store.momentum).toBe(0)
741 expect(store.error).toBeNull()
742 })
743 
744 it('fetchAIObjective returns a composed objective WITHOUT committing it', async () => {
745 fetchMock.mockResolvedValue({ success: true, objective })
746 
747 const result = await store.fetchAIObjective()
748 
749 expect(fetchMock).toHaveBeenCalledWith('/api/objective', expect.objectContaining({
750 method: 'GET',
751 headers: expect.objectContaining({ 'x-run-id': expect.any(String) })
752 }))
753 expect(result).toEqual(objective)
754 // The caller previews it, then commits via chooseObjective — not here.
755 expect(store.currentObjective).toBeNull()
756 })
757 
758 it('fetchAIObjective returns null (never throws) when composing fails', async () => {
759 fetchMock.mockRejectedValue(new Error('archives offline'))
760 expect(await store.fetchAIObjective()).toBeNull()
761 expect(store.currentObjective).toBeNull()
762 })
763 
764 it('fetchAIObjective returns null when the server reports failure', async () => {
765 fetchMock.mockResolvedValue({ success: false })
766 expect(await store.fetchAIObjective()).toBeNull()
767 })
768 })
769 
770 describe('grounded contacts (who · when · liveness)', () => {
771 const cleopatra = {
772 name: 'Cleopatra',
773 resolved: true,
774 description: 'Pharaoh of Egypt from 51 to 30 BC',
775 born: { year: 69, bce: true, signed: -69, display: '69 BC' },
776 died: { year: 30, bce: true, signed: -30, display: '30 BC' },
777 living: false
778 }
779 
780 it('grounds the active figure and defaults the when inside their lifetime', async () => {
781 fetchMock.mockResolvedValue(cleopatra)
782 
783 await store.groundActiveFigure('Cleopatra')
784 
785 expect(fetchMock).toHaveBeenCalledWith('/api/figure', expect.objectContaining({
786 params: { name: 'Cleopatra' },
787 headers: expect.objectContaining({ 'x-run-id': expect.any(String) })
788 }))
789 expect(store.figureGrounding?.resolved).toBe(true)
790 expect(store.contactWhen).toBeGreaterThanOrEqual(-69)
791 expect(store.contactWhen).toBeLessThanOrEqual(-30)
792 expect(store.contactLiveness).toBe('ok')
793 expect(store.canContact).toBe(true)
794 })
795 
796 it('flags a when before birth or after death', async () => {
797 fetchMock.mockResolvedValue(cleopatra)
798 await store.groundActiveFigure('Cleopatra')
799 
800 store.setContactWhen(-200) // 200 BC — long before she was born
801 expect(store.contactLiveness).toBe('before-birth')
802 expect(store.canContact).toBe(false)
803 
804 store.setContactWhen(1990) // long after she died
805 expect(store.contactLiveness).toBe('after-death')
806 expect(store.canContact).toBe(false)
807 
808 store.setContactWhen(-50) // 50 BC — within her life
809 expect(store.contactLiveness).toBe('ok')
810 })
811 
812 it('blocks contact to an unresolved figure — require grounding (#73)', async () => {
813 fetchMock.mockResolvedValue({ name: 'Nobody', resolved: false })
814 await store.groundActiveFigure('Nobody Atall')
815 
816 expect(store.figureGrounding?.resolved).toBe(false)
817 expect(store.contactWhen).toBeNull()
818 expect(store.contactLiveness).toBe('unresolved')
819 expect(store.canContact).toBe(false)
820 })
821 
822 it('blocks a living figure — deceased only (#72)', async () => {
823 fetchMock.mockResolvedValue({
824 name: 'Paul McCartney', resolved: true,
825 born: { year: 1942, bce: false, signed: 1942, display: '1942' },
826 living: true
827 })
828 await store.groundActiveFigure('Paul McCartney')
829 
830 // A figure with no confirmed death is never contactable, at any year.
831 store.setContactWhen(1965)
832 expect(store.contactLiveness).toBe('living')
833 expect(store.canContact).toBe(false)
834 })
835 
836 it('pins, rescrubs, and clears the contact moment (issue #32)', async () => {
837 fetchMock.mockResolvedValue(cleopatra)
838 await store.groundActiveFigure('Cleopatra')
839 
840 store.setContactMoment({ month: 6, day: 28 })
841 expect(store.contactMoment).toEqual({ month: 6, day: 28 })
842 
843 // Scrubbing the year keeps the pin — it refines whichever year is chosen.
844 store.setContactWhen(-44)
845 expect(store.contactMoment).toEqual({ month: 6, day: 28 })
846 
847 // A name change clears it with the rest of the dossier.
848 store.clearGrounding()
849 expect(store.contactMoment).toBeNull()
850 })
851 
852 it('sends the refined when string + raw moment integers (issue #32)', async () => {
853 fetchMock.mockResolvedValue(cleopatra)
854 await store.groundActiveFigure('Cleopatra')
855 store.setContactWhen(-48)
856 store.setContactMoment({ month: 3, day: 15 })
857 fetchMock.mockClear()
858 fetchMock.mockResolvedValue({ success: false, message: 'x', data: { userMessage: 'm', error: 'x' } })
859 
860 await store.sendMessage('Hold court away from the palace today.', 'Cleopatra')
861 
862 const body = (fetchMock.mock.calls[0][1] as { body: Record<string, unknown> }).body
863 expect(body.when).toBe('March 15, 48 BC')
864 expect(body.whenSigned).toBe(-48)
865 expect(body.whenMonth).toBe(3)
866 expect(body.whenDay).toBe(15)
867 })
868 
869 it('clears grounding when the figure is cleared', async () => {
870 fetchMock.mockResolvedValue(cleopatra)
871 await store.groundActiveFigure('Cleopatra')
872 await store.groundActiveFigure('')
873 expect(store.figureGrounding).toBeNull()
874 expect(store.contactWhen).toBeNull()
875 })
876 
877 it('refuses to send to someone outside their lifetime', async () => {
878 fetchMock.mockResolvedValue(cleopatra)
879 await store.groundActiveFigure('Cleopatra')
880 store.setContactWhen(-300) // before birth
881 fetchMock.mockClear()
882 
883 await store.sendMessage('Beware the Ides', 'Cleopatra')
884 
885 expect(fetchMock).not.toHaveBeenCalled() // never reached /api/send-message
886 expect(store.error).toMatch(/born yet/i)
887 expect(store.messageHistory).toHaveLength(0)
888 expect(store.remainingMessages).toBe(5)
889 })
890 
891 it('refuses to send to a living figure — deceased only (#72)', async () => {
892 fetchMock.mockResolvedValue({
893 name: 'Paul McCartney', resolved: true,
894 born: { year: 1942, bce: false, signed: 1942, display: '1942' },
895 living: true
896 })
897 await store.groundActiveFigure('Paul McCartney')
898 store.setContactWhen(1965)
899 fetchMock.mockClear()
900 
901 await store.sendMessage('Write a song about the timeline', 'Paul McCartney')
902 
903 expect(fetchMock).not.toHaveBeenCalled() // gated before the network
904 expect(store.error).toMatch(/still living/i)
905 expect(store.messageHistory).toHaveLength(0)
906 expect(store.remainingMessages).toBe(5) // no message burned
907 })
908 
909 it('refuses to send to an unresolved figure — require grounding (#73)', async () => {
910 fetchMock.mockResolvedValue({ name: 'Nobody', resolved: false })
911 await store.groundActiveFigure('Nobody Atall')
912 fetchMock.mockClear()
913 
914 await store.sendMessage('Into the void', 'Nobody Atall')
915 
916 expect(fetchMock).not.toHaveBeenCalled() // gated before the network
917 expect(store.error).toMatch(/no historical record/i)
918 expect(store.messageHistory).toHaveLength(0)
919 expect(store.remainingMessages).toBe(5) // no message burned
920 })
921 })
922 
923 describe('contactAge (so the player skips the lifetime math)', () => {
924 const cleopatra = {
925 name: 'Cleopatra', resolved: true,
926 born: { year: 69, bce: true, signed: -69, display: '69 BC' },
927 died: { year: 30, bce: true, signed: -30, display: '30 BC' },
928 living: false
929 }
930 
931 it('reports the age at the chosen year for a grounded figure', () => {
932 store.figureGrounding = cleopatra as never
933 store.setContactWhen(-50) // 50 BC
934 expect(store.contactAge).toBe(19) // -50 - (-69)
935 })
936 
937 it('is 0 in the birth year', () => {
938 store.figureGrounding = cleopatra as never
939 store.setContactWhen(-69)
940 expect(store.contactAge).toBe(0)
941 })
942 
943 it('corrects for the missing year zero across the BC→AD boundary', () => {
944 store.figureGrounding = {
945 name: 'Augustus', resolved: true,
946 born: { year: 63, bce: true, signed: -63, display: '63 BC' },
947 died: { year: 14, bce: false, signed: 14, display: '14' },
948 living: false
949 } as never
950 store.setContactWhen(14) // AD 14
951 expect(store.contactAge).toBe(76) // 14 - (-63) - 1 (no year zero)
952 })
953 
954 it('handles a modern living figure', () => {
955 store.figureGrounding = {
956 name: 'Paul McCartney', resolved: true,
957 born: { year: 1942, bce: false, signed: 1942, display: '1942' }, living: true
958 } as never
959 store.setContactWhen(1965)
960 expect(store.contactAge).toBe(23)
961 })
962 
963 it('is null when the figure is unresolved or has no birth year', () => {
964 store.figureGrounding = { name: 'Nobody', resolved: false } as never
965 store.setContactWhen(1500)
966 expect(store.contactAge).toBeNull()
967 
968 store.figureGrounding = null
969 expect(store.contactAge).toBeNull()
970 })
971 
972 it('is null before birth rather than a negative age', () => {
973 store.figureGrounding = cleopatra as never
974 store.setContactWhen(-200) // long before she was born
975 expect(store.contactAge).toBeNull()
976 })
977 })
978 
979 describe('the Archive — studyActiveFigure (prototype)', () => {
980 const cleopatra = {
981 name: 'Cleopatra', resolved: true,
982 description: 'Pharaoh of Egypt', extract: 'Cleopatra was the last active ruler of Ptolemaic Egypt.',
983 born: { year: 69, bce: true, signed: -69, display: '69 BC' },
984 died: { year: 30, bce: true, signed: -30, display: '30 BC' }, living: false
985 }
986 const study = {
987 atThisMoment: 'Queen at the height of her power.',
988 grasp: ['statecraft', 'Hellenistic science'],
989 reaching: ['securing Egypt against Rome'],
990 cannotYetKnow: 'the fall of the Roman Republic'
991 }
992 
993 it('studies a grounded figure and stores the brief', async () => {
994 store.figureGrounding = cleopatra as never
995 store.setContactWhen(-40)
996 fetchMock.mockResolvedValue({ success: true, study })
997 
998 await store.studyActiveFigure()
999 
1000 expect(fetchMock).toHaveBeenCalledWith('/api/research', expect.objectContaining({ method: 'POST' }))
1001 expect(store.figureStudy).toEqual(study)
1002 expect(store.studyFor).toBe('Cleopatra')
1003 expect(store.studyLoading).toBe(false)
1004 })
1006 it('caches per figure + year (no refetch on a second study at the same moment)', async () => {
1007 store.figureGrounding = cleopatra as never
1008 store.setContactWhen(-40)
1009 fetchMock.mockResolvedValue({ success: true, study })
1010 await store.studyActiveFigure()
1011 fetchMock.mockClear()
1013 await store.studyActiveFigure()
1014 expect(fetchMock).not.toHaveBeenCalled()
1015 })
1017 it('re-studies the figure when the contact year changes', async () => {
1018 store.figureGrounding = cleopatra as never
1019 store.setContactWhen(-40)
1020 fetchMock.mockResolvedValue({ success: true, study })
1021 await store.studyActiveFigure()
1022 expect(store.studyWhen).toBe(-40)
1023 fetchMock.mockClear()
1025 store.setContactWhen(-35) // move the slider
1026 await store.studyActiveFigure()
1028 expect(fetchMock).toHaveBeenCalledWith('/api/research', expect.objectContaining({ method: 'POST' }))
1029 expect(store.studyWhen).toBe(-35)
1030 })
1032 it('does nothing for an unresolved figure (no record to consult)', async () => {
1033 store.figureGrounding = { name: 'Nobody', resolved: false } as never
1034 await store.studyActiveFigure()
1035 expect(fetchMock).not.toHaveBeenCalled()
1036 expect(store.figureStudy).toBeNull()
1037 })
1039 it('leaves the study empty (no throw) when research fails', async () => {
1040 store.figureGrounding = cleopatra as never
1041 fetchMock.mockRejectedValue(new Error('archive offline'))
1042 await store.studyActiveFigure()
1043 expect(store.figureStudy).toBeNull()
1044 expect(store.studyLoading).toBe(false)
1045 })
1047 it('surfaces a moderation block as a distinct notice, not a study', async () => {
1048 store.figureGrounding = cleopatra as never
1049 store.setContactWhen(-40)
1050 fetchMock.mockResolvedValue({ success: false, blocked: true, error: 'That study was blocked by content moderation.' })
1052 await store.studyActiveFigure()
1054 expect(store.studyNotice).toContain('blocked by content moderation')
1055 expect(store.figureStudy).toBeNull()
1056 expect(store.studyLoading).toBe(false)
1057 })
1059 it('drops a stale study when the contact changes', async () => {
1060 store.figureStudy = study as never
1061 store.studyFor = 'Cleopatra'
1062 fetchMock.mockResolvedValue({ name: 'Tesla', resolved: false })
1064 await store.groundActiveFigure('Nikola Tesla')
1066 expect(store.figureStudy).toBeNull()
1067 expect(store.studyFor).toBe('')
1068 })
1069 })
1071 describe('the Archive — askArchive lookup (prototype, yellow)', () => {
1072 const lookup = {
1073 topic: 'Gunpowder',
1074 summary: 'An explosive mix of saltpeter, charcoal, and sulfur.',
1075 requires: ['saltpeter', 'charcoal', 'sulfur'],
1076 knownSince: 'China, ~9th century'
1079 it('looks up a topic and stores the result', async () => {
1080 fetchMock.mockResolvedValue({ success: true, lookup })
1082 await store.askArchive('gunpowder')
1084 expect(fetchMock).toHaveBeenCalledWith('/api/lookup', expect.objectContaining({ method: 'POST' }))
1085 expect(store.archiveResult).toEqual(lookup)
1086 expect(store.lookupLoading).toBe(false)
1087 })
1089 it('ignores an empty query', async () => {
1090 await store.askArchive(' ')
1091 expect(fetchMock).not.toHaveBeenCalled()
1092 })
1094 it('leaves the result empty (no throw) when the lookup fails', async () => {
1095 fetchMock.mockRejectedValue(new Error('archive offline'))
1096 await store.askArchive('gunpowder')
1097 expect(store.archiveResult).toBeNull()
1098 expect(store.lookupLoading).toBe(false)
1099 })
1100 })
1102 describe('anachronism risk-coupling (prototype)', () => {
1103 it('records the anachronism level on the resolved ledger entry', async () => {
1104 fetchMock.mockResolvedValue(okResponse({
1105 timeline: { headline: 'A leap', detail: 'Generations early', era: 'x', progressChange: 40, valence: 'positive', anachronism: 'far-ahead' }
1106 }))
1108 await store.sendMessage('Build a flying machine and aim higher', 'Leonardo da Vinci')
1110 expect(store.timelineEvents[0].anachronism).toBe('far-ahead')
1111 })
1113 it('leaves the ledger entry anachronism undefined when the server omits it', async () => {
1114 fetchMock.mockResolvedValue(okResponse())
1115 await store.sendMessage('A modest note', 'Tesla')
1116 expect(store.timelineEvents[0].anachronism).toBeUndefined()
1117 })
1118 })
1120 describe('figure suggestions (era-aware on-ramp)', () => {
1121 const objective = {
1122 title: 'Prevent the Great War',
1123 description: 'Keep 1914 from collapsing into world war.',
1124 era: 'Europe, 1914',
1125 icon: '🕊️'
1128 it('loads era-relevant suggestions for the current objective', async () => {
1129 store.currentObjective = objective
1130 fetchMock.mockResolvedValue({
1131 success: true,
1132 suggestions: [{ name: 'Wilhelm II', reason: 'Could restrain Vienna.', lifespan: '1859 – 1941' }],
1133 fallback: false
1134 })
1136 await store.loadSuggestions()
1138 expect(fetchMock).toHaveBeenCalledWith('/api/suggestions', expect.objectContaining({ method: 'POST' }))
1139 expect(store.figureSuggestions).toHaveLength(1)
1140 expect(store.figureSuggestions[0].name).toBe('Wilhelm II')
1141 expect(store.suggestionsFor).toBe('Prevent the Great War')
1142 })
1144 it('caches per objective (no refetch on a second call)', async () => {
1145 store.currentObjective = objective
1146 fetchMock.mockResolvedValue({ success: true, suggestions: [{ name: 'X', reason: 'y' }] })
1147 await store.loadSuggestions()
1148 fetchMock.mockClear()
1150 await store.loadSuggestions()
1151 expect(fetchMock).not.toHaveBeenCalled()
1152 })
1154 it('clears suggestions when there is no objective', async () => {
1155 store.currentObjective = null
1156 await store.loadSuggestions()
1157 expect(store.figureSuggestions).toEqual([])
1158 expect(fetchMock).not.toHaveBeenCalled()
1159 })
1161 it('surfaces a blocked objective instead of a silent empty list', async () => {
1162 store.currentObjective = objective
1163 fetchMock.mockResolvedValue({ success: true, suggestions: [], blocked: true, reason: 'That objective was blocked by content moderation.' })
1165 await store.loadSuggestions()
1167 expect(store.suggestionsNotice).toContain('blocked by content moderation')
1168 expect(store.figureSuggestions).toEqual([])
1169 })
1171 it('leaves suggestions empty (no throw) when the request fails — but records the ask', async () => {
1172 store.currentObjective = objective
1173 fetchMock.mockRejectedValue(new Error('suggester down'))
1174 await store.loadSuggestions()
1175 expect(store.figureSuggestions).toEqual([])
1176 // "Asked and failed" must be distinguishable from "not yet asked": the
1177 // picker shows skeletons for the latter, the honest fallback for this.
1178 expect(store.suggestionsFor).toBe('Prevent the Great War')
1179 })
1180 })
1182 describe('causal chain read (the pre-send ⏳ chip)', () => {
1183 const moonObjective = {
1184 title: 'Reach the Moon by 1900', description: 'Half a century early.',
1185 era: '19th century', icon: '🚀', anchorYear: 1900
1188 it('reads the gap to the objective anchor when no stones are laid yet', () => {
1189 store.currentObjective = moonObjective
1190 store.setContactWhen(1500) // Leonardo — 400yr from the 1900 anchor
1191 expect(store.causalChainRead?.tier).toBe('bridged')
1192 })
1194 it('a landed change becomes a foothold that bridges the next move', () => {
1195 store.currentObjective = moonObjective
1196 // Plant a stone at 1500, then reach a 1650 figure — bridged off the stone.
1197 store.addTimelineEvent({ figureName: 'Leonardo', era: 'Florence, 1500', headline: 'h', detail: 'd', diceRoll: 16, diceOutcome: DiceOutcome.SUCCESS, progressChange: 10, whenSigned: 1500 })
1198 store.setContactWhen(1650)
1199 expect(store.causalChainRead?.tier).toBe('at-hinge') // 150yr from the 1500 stone
1200 })
1202 it('no-ops (no chip) for an anchorless objective with no stones', () => {
1203 store.currentObjective = { title: 'Across the ages', description: 'x', era: 'all', icon: '🕰️' }
1204 store.setContactWhen(1500)
1205 expect(store.causalChainRead).toBeNull()
1206 })
1207 })
1209 describe('run id (cost-instrument tagging)', () => {
1210 it('mints the run id server-side (POST /api/run) on first need and reuses it', async () => {
1211 store.runId = null
1212 fetchMock.mockResolvedValue({ runId: 'server-run-1' })
1213 const h1 = await store.aiHeaders()
1214 expect(h1['x-run-id']).toBe('server-run-1')
1215 expect(store.runId).toBe('server-run-1')
1216 expect(fetchMock).toHaveBeenCalledWith('/api/run', { method: 'POST' })
1217 // Same run → reused, no second begin-run; base headers preserved.
1218 fetchMock.mockClear()
1219 const h2 = await store.aiHeaders({ 'Content-Type': 'application/json' })
1220 expect(h2['x-run-id']).toBe('server-run-1')
1221 expect(h2['Content-Type']).toBe('application/json')
1222 expect(fetchMock).not.toHaveBeenCalled()
1223 })
1225 it('rejects when begin-run fails — fail closed, no local fallback', async () => {
1226 store.runId = null
1227 fetchMock.mockRejectedValue(new Error('store down'))
1228 await expect(store.ensureRunId()).rejects.toThrow()
1229 expect(fetchMock).toHaveBeenCalledWith('/api/run', { method: 'POST' })
1230 expect(store.runId).toBeNull() // no fabricated id landed
1231 })
1233 it('resetGame clears the id so the next run begins fresh', async () => {
1234 store.runId = null
1235 fetchMock.mockResolvedValueOnce({ runId: 'run-a' })
1236 const first = await store.ensureRunId()
1237 expect(first).toBe('run-a')
1238 store.resetGame()
1239 expect(store.runId).toBeNull()
1240 fetchMock.mockResolvedValueOnce({ runId: 'run-b' })
1241 const second = await store.ensureRunId()
1242 expect(second).toBe('run-b')
1243 expect(second).not.toBe(first)
1244 })
1246 it('tags an outgoing AI call with the x-run-id header', async () => {
1247 store.runId = 'run-tag'
1248 fetchMock.mockResolvedValue({ success: false })
1249 await store.fetchAIObjective()
1250 expect(fetchMock).toHaveBeenCalledWith(
1251 '/api/objective',
1252 expect.objectContaining({
1253 method: 'GET',
1254 headers: expect.objectContaining({ 'x-run-id': 'run-tag' })
1255 })
1257 })
1259 it('collapses concurrent first-calls onto a single begin-run', async () => {
1260 store.runId = null
1261 let resolveRun: (v: { runId: string }) => void = () => {}
1262 const deferred = new Promise<{ runId: string }>((r) => { resolveRun = r })
1263 fetchMock.mockReturnValue(deferred)
1264 // Two AI calls race at run start, both before the mint resolves.
1265 const p1 = store.ensureRunId()
1266 const p2 = store.ensureRunId()
1267 resolveRun({ runId: 'shared' })
1268 const [a, b] = await Promise.all([p1, p2])
1269 expect(a).toBe('shared')
1270 expect(b).toBe('shared')
1271 expect(fetchMock).toHaveBeenCalledTimes(1) // one POST, not two
1272 })
1274 it('a resetGame mid-mint does not bleed the old run id into the new run', async () => {
1275 store.runId = null
1276 let resolveRun: (v: { runId: string }) => void = () => {}
1277 const deferred = new Promise<{ runId: string }>((r) => { resolveRun = r })
1278 fetchMock.mockReturnValueOnce(deferred)
1279 const inflight = store.ensureRunId() // old run's mint, still pending
1280 store.resetGame() // a new run begins mid-flight
1281 resolveRun({ runId: 'old-run' })
1282 expect(await inflight).toBe('old-run') // the old caller still gets its id
1283 expect(store.runId).toBeNull() // but it did NOT land in the fresh run
1284 fetchMock.mockResolvedValueOnce({ runId: 'new-run' })
1285 expect(await store.ensureRunId()).toBe('new-run') // new run mints fresh
1286 })
1288 it('begins the run then tags the AI call within one action (mint precedes the call)', async () => {
1289 store.runId = null
1290 fetchMock.mockImplementation((url: string) =>
1291 url === '/api/run' ? Promise.resolve({ runId: 'minted' }) : Promise.resolve(okResponse())
1293 await store.sendMessage('Light the world wirelessly', 'Nikola Tesla')
1294 const urls = fetchMock.mock.calls.map((c: unknown[]) => c[0])
1295 expect(urls.indexOf('/api/run')).toBeGreaterThanOrEqual(0)
1296 expect(urls.indexOf('/api/run')).toBeLessThan(urls.indexOf('/api/send-message'))
1297 expect(fetchMock).toHaveBeenCalledWith(
1298 '/api/send-message',
1299 expect.objectContaining({ headers: expect.objectContaining({ 'x-run-id': 'minted' }) })
1301 })
1302 })
1304 describe('run balance (the paywall gate)', () => {
1305 const objective = { title: 'Spare the library', description: 'The scrolls survive.', era: 'Alexandria', icon: '📜' }
1307 it('charges a run and starts when the device has balance', async () => {
1308 store.runId = 'run-x'
1309 fetchMock.mockResolvedValue({ runsRemaining: 2 })
1310 const started = await store.chooseObjective(objective)
1311 expect(started).toBe(true)
1312 expect(fetchMock).toHaveBeenCalledWith(
1313 '/api/run-commit',
1314 expect.objectContaining({ method: 'POST', body: { runId: 'run-x' } })
1316 expect(store.outOfRuns).toBe(false)
1317 expect(store.currentObjective?.title).toBe('Spare the library')
1318 expect(store.runsRemaining).toBe(2) // gauge stays live off the commit response
1319 })
1321 it('raises the paywall, opens the packs modal, and does NOT start when out of runs (402)', async () => {
1322 store.runId = 'run-y'
1323 // 402 on commit; openBuyModal then fetches the catalog.
1324 fetchMock.mockImplementation((url: string) =>
1325 url === '/api/packs' ? Promise.resolve({ packs: [] }) : Promise.reject({ statusCode: 402 })
1327 const started = await store.chooseObjective(objective)
1328 expect(started).toBe(false)
1329 expect(store.outOfRuns).toBe(true)
1330 expect(store.buyModalOpen).toBe(true) // the sales modal opens automatically
1331 expect(store.currentObjective).toBeNull() // the run did not start
1332 })
1334 it('raises the at-capacity notice (not the paywall) and does NOT start on a 503', async () => {
1335 store.runId = 'run-cap'
1336 fetchMock.mockRejectedValue({ statusCode: 503, data: { atCapacity: true } })
1337 const started = await store.chooseObjective(objective)
1338 expect(started).toBe(false)
1339 expect(store.atCapacity).toBe(true)
1340 expect(store.outOfRuns).toBe(false) // distinct from the paywall
1341 expect(store.currentObjective).toBeNull()
1342 })
1344 it('treats all three 402 error shapes as out of runs', async () => {
1345 for (const shape of [{ statusCode: 402 }, { status: 402 }, { response: { status: 402 } }]) {
1346 store.runId = 'run-shape'
1347 store.outOfRuns = false
1348 fetchMock.mockRejectedValue(shape)
1349 expect(await store.chooseObjective(objective)).toBe(false)
1350 expect(store.outOfRuns).toBe(true)
1352 })
1354 it('starts the run on a degraded (non-server) commit response', async () => {
1355 store.runId = 'run-d'
1356 fetchMock.mockResolvedValue({ runsRemaining: -1, degraded: true })
1357 const started = await store.chooseObjective(objective)
1358 expect(started).toBe(true)
1359 expect(store.outOfRuns).toBe(false)
1360 expect(store.currentObjective?.title).toBe('Spare the library')
1361 })
1363 it('does NOT start (surfaces an error) when the commit fails for a non-payment reason', async () => {
1364 store.runId = 'run-z'
1365 fetchMock.mockRejectedValue({ statusCode: 500 })
1366 const started = await store.chooseObjective(objective)
1367 expect(started).toBe(false) // fail closed — don't start a run we couldn't charge
1368 expect(store.outOfRuns).toBe(false)
1369 expect(store.currentObjective).toBeNull()
1370 expect(store.error).toBeTruthy()
1371 })
1373 it('does NOT start when begin-run fails (fail closed)', async () => {
1374 store.runId = null
1375 fetchMock.mockRejectedValue(new Error('store down')) // /api/run fails
1376 const started = await store.chooseObjective(objective)
1377 expect(started).toBe(false)
1378 expect(store.currentObjective).toBeNull()
1379 expect(store.error).toBeTruthy()
1380 })
1381 })
1383 describe('run packs (paywall purchase)', () => {
1384 it('loadPacks fetches the catalog, then caches it', async () => {
1385 fetchMock.mockResolvedValue({ packs: [{ id: 'courier', label: 'Courier', priceCents: 500, runs: 7 }] })
1386 await store.loadPacks()
1387 expect(store.packs).toHaveLength(1)
1388 expect(store.packs[0].id).toBe('courier')
1389 fetchMock.mockClear()
1390 await store.loadPacks() // cached → no second fetch
1391 expect(fetchMock).not.toHaveBeenCalled()
1392 })
1394 it('buyPack returns the Stripe checkout url', async () => {
1395 fetchMock.mockResolvedValue({ url: 'https://checkout.stripe.com/x' })
1396 expect(await store.buyPack('courier')).toBe('https://checkout.stripe.com/x')
1397 expect(fetchMock).toHaveBeenCalledWith(
1398 '/api/checkout',
1399 expect.objectContaining({ method: 'POST', body: { packId: 'courier' } })
1401 })
1403 it('buyPack returns null on failure', async () => {
1404 fetchMock.mockRejectedValue(new Error('stripe down'))
1405 expect(await store.buyPack('courier')).toBeNull()
1406 })
1408 it('does NOT cache an empty catalog — a transient empty read retries', async () => {
1409 fetchMock.mockResolvedValueOnce({ packs: [] }) // transient empty
1410 await store.loadPacks()
1411 expect(store.packs).toHaveLength(0) // not populated…
1412 fetchMock.mockResolvedValueOnce({ packs: [{ id: 'courier', label: 'Courier', priceCents: 500, runs: 7 }] })
1413 await store.loadPacks() // …and not poisoned: it refetches
1414 expect(store.packs).toHaveLength(1)
1415 })
1416 })
1418 describe('run balance gauge + sales modal + purchase return', () => {
1419 it('loadBalance populates the gauge + account fields', async () => {
1420 fetchMock.mockResolvedValue({ runsRemaining: 5, freeRuns: 1, deviceRef: 'abcd1234', email: 'a@b.co', isAnonymous: false })
1421 await store.loadBalance()
1422 expect(store.runsRemaining).toBe(5)
1423 expect(store.freeRuns).toBe(1)
1424 expect(store.deviceRef).toBe('abcd1234')
1425 expect(store.accountEmail).toBe('a@b.co')
1426 expect(store.accountAnonymous).toBe(false)
1427 })
1429 it('loadBalance marks an anonymous user (no email)', async () => {
1430 fetchMock.mockResolvedValue({ runsRemaining: 1, freeRuns: 1, deviceRef: 'xy', email: null, isAnonymous: true })
1431 await store.loadBalance()
1432 expect(store.accountEmail).toBeNull()
1433 expect(store.accountAnonymous).toBe(true)
1434 })
1436 it('loadBalance leaves the prior value on failure', async () => {
1437 store.runsRemaining = 9
1438 fetchMock.mockRejectedValue(new Error('down'))
1439 await store.loadBalance()
1440 expect(store.runsRemaining).toBe(9)
1441 })
1443 it('openBuyModal opens the modal and loads the catalog; closeBuyModal closes it', async () => {
1444 fetchMock.mockResolvedValue({ packs: [{ id: 'courier', label: 'Courier', priceCents: 500, runs: 7 }] })
1445 await store.openBuyModal()
1446 expect(store.buyModalOpen).toBe(true)
1447 expect(store.packs).toHaveLength(1)
1448 store.closeBuyModal()
1449 expect(store.buyModalOpen).toBe(false)
1450 })
1452 it('notePurchaseReturn(success) refreshes the balance and clears the paywall', async () => {
1453 store.outOfRuns = true
1454 fetchMock.mockResolvedValue({ runsRemaining: 8, freeRuns: 1, deviceRef: 'xy' })
1455 await store.notePurchaseReturn('success')
1456 expect(store.purchaseNotice).toBe('success')
1457 expect(store.outOfRuns).toBe(false)
1458 expect(store.runsRemaining).toBe(8) // re-read after the webhook credited
1459 })
1461 it('notePurchaseReturn(cancel) records the notice without refetching', async () => {
1462 await store.notePurchaseReturn('cancel')
1463 expect(store.purchaseNotice).toBe('cancel')
1464 expect(fetchMock).not.toHaveBeenCalled()
1465 })
1467 it('clearPurchaseNotice dismisses the notice', () => {
1468 store.purchaseNotice = 'success'
1469 store.clearPurchaseNotice()
1470 expect(store.purchaseNotice).toBeNull()
1471 })
1472 })

The component tests assert what the end screen renders for each store state — the three acceptance cases.

Pending → loading, not the stale article; landed → the epilogue; failed → the prior telling, never stuck on loading.

tests/unit/components/EndGameScreen.spec.ts · 247 lines
tests/unit/components/EndGameScreen.spec.ts247 lines · TypeScript
⋯ 87 lines hidden (lines 1–87)
1import { mount } from '@vue/test-utils'
2import { describe, it, expect, beforeEach } from 'vitest'
3import { setActivePinia, createPinia } from 'pinia'
4import EndGameScreen from '../../../components/EndGameScreen.vue'
5import { useGameStore } from '../../../stores/game'
6import { DiceOutcome } from '../../../utils/dice'
7import type { GameObjective } from '../../../server/utils/objectives'
8import type { Message } from '../../../stores/game'
9 
10const OBJ: GameObjective = {
11 title: 'Reach the Moon by 1900',
12 description: 'Leave Earth half a century early.',
13 era: '19th century',
14 icon: '🚀'
16 
17function resolvedTurn(diceRoll: number, diceOutcome: DiceOutcome, progressChange: number): Message {
18 return {
19 text: 'reply', sender: 'ai', figureName: 'Tesla', timestamp: new Date(),
20 diceRoll, diceOutcome, progressChange, timelineImpact: 'history shifts'
21 }
23function prompt(): Message {
24 return { text: 'prompt', sender: 'user', figureName: 'Tesla', timestamp: new Date() }
26 
27describe('EndGameScreen', () => {
28 let gameStore: ReturnType<typeof useGameStore>
29 
30 beforeEach(() => {
31 setActivePinia(createPinia())
32 gameStore = useGameStore()
33 })
34 
35 describe('Victory', () => {
36 beforeEach(() => {
37 gameStore.gameStatus = 'victory'
38 gameStore.objectiveProgress = 100
39 gameStore.currentObjective = OBJ
40 gameStore.remainingMessages = 2
41 })
42 
43 it('shows a victory headline', () => {
44 const v = mount(EndGameScreen).find('[data-testid="victory-message"]')
45 expect(v.exists()).toBe(true)
46 expect(v.text()).toContain('History Rewritten')
47 })
48 
49 it('shows the objective and final progress', () => {
50 const w = mount(EndGameScreen)
51 expect(w.find('[data-testid="objective-title"]').text()).toContain('Reach the Moon by 1900')
52 expect(w.find('[data-testid="final-progress"]').text()).toContain('100%')
53 })
54 
55 it('shows efficiency with messages saved', () => {
56 const e = mount(EndGameScreen).find('[data-testid="efficiency-display"]')
57 expect(e.exists()).toBe(true)
58 expect(e.text()).toContain('saved')
59 })
60 })
61 
62 describe('Defeat', () => {
63 beforeEach(() => {
64 gameStore.gameStatus = 'defeat'
65 gameStore.objectiveProgress = 45
66 gameStore.currentObjective = OBJ
67 gameStore.remainingMessages = 0
68 })
69 
70 it('shows a defeat headline', () => {
71 const d = mount(EndGameScreen).find('[data-testid="defeat-message"]')
72 expect(d.exists()).toBe(true)
73 expect(d.text()).toContain('Timeline Held')
74 })
75 
76 it('shows final progress below 100%', () => {
77 expect(mount(EndGameScreen).find('[data-testid="final-progress"]').text()).toContain('45%')
78 })
79 
80 it('explains the loss', () => {
81 expect(mount(EndGameScreen).find('[data-testid="defeat-explanation"]').exists()).toBe(true)
82 })
83 })
84 
85 // The epilogue is the run's payoff — the terminal telling that carries the verdict.
86 // While it's still being written we must never present the prior turn's stale,
87 // pre-ending telling as the epilogue (issue #107).
88 describe('The Chronicle epilogue — pending, reveal, and fallback', () => {
89 const stale = { title: 'Mid-run Telling', paragraphs: ['the world as it stood a turn ago'] }
90 const epilogue = { title: 'The World Remade', paragraphs: ['and so the timeline was rewritten'] }
91 
92 beforeEach(() => {
93 gameStore.gameStatus = 'victory'
94 gameStore.currentObjective = OBJ
95 })
96 
97 it('shows the "final account" wait while the epilogue is pending — not the stale prior telling', () => {
98 gameStore.chronicle = stale // a stale, pre-ending telling is still held
99 gameStore.epiloguePending = true // the terminal refresh is in flight
100 const w = mount(EndGameScreen)
101 expect(w.find('[data-testid="end-chronicle-loading"]').exists()).toBe(true)
102 // The prior-turn telling must NOT be presented as the epilogue.
103 expect(w.find('[data-testid="end-chronicle-body"]').exists()).toBe(false)
104 expect(w.text()).not.toContain('Mid-run Telling')
105 })
106 
107 it('reveals the epilogue once the terminal telling lands', () => {
108 gameStore.chronicle = epilogue
109 gameStore.epiloguePending = false
110 const w = mount(EndGameScreen)
111 expect(w.find('[data-testid="end-chronicle-loading"]').exists()).toBe(false)
112 expect(w.find('[data-testid="end-chronicle-body"]').exists()).toBe(true)
113 expect(w.find('[data-testid="end-chronicle-title"]').text()).toContain('The World Remade')
114 })
115 
116 it('falls back to the prior telling if the final refresh failed — never stuck on loading', () => {
117 // refreshChronicle keeps the prior telling on failure and clears pending.
118 gameStore.chronicle = stale
119 gameStore.epiloguePending = false
120 const w = mount(EndGameScreen)
121 expect(w.find('[data-testid="end-chronicle-loading"]').exists()).toBe(false)
122 expect(w.find('[data-testid="end-chronicle-body"]').exists()).toBe(true)
123 expect(w.find('[data-testid="end-chronicle-title"]').text()).toContain('Mid-run Telling')
124 })
125 })
⋯ 122 lines hidden (lines 126–247)
126 
127 describe('Summary stats — derived from resolved AI turns', () => {
128 beforeEach(() => {
129 gameStore.gameStatus = 'victory'
130 gameStore.remainingMessages = 3 // 2 used
131 gameStore.messageHistory = [
132 prompt(), resolvedTurn(18, DiceOutcome.SUCCESS, 25),
133 prompt(), resolvedTurn(20, DiceOutcome.CRITICAL_SUCCESS, 75)
134 ]
135 })
136 
137 it('renders the summary block', () => {
138 expect(mount(EndGameScreen).find('[data-testid="game-summary"]').exists()).toBe(true)
139 })
140 
141 it('highlights the best roll from the AI turns', () => {
142 const best = mount(EndGameScreen).find('[data-testid="best-roll"]')
143 expect(best.exists()).toBe(true)
144 expect(best.text()).toContain('20')
145 })
146 
147 it('highlights the worst roll from the AI turns', () => {
148 gameStore.messageHistory[1] = resolvedTurn(2, DiceOutcome.CRITICAL_FAILURE, -10)
149 const worst = mount(EndGameScreen).find('[data-testid="worst-roll"]')
150 expect(worst.exists()).toBe(true)
151 expect(worst.text()).toContain('2')
152 })
153 
154 it('shows the number of messages sent', () => {
155 const eff = mount(EndGameScreen).find('[data-testid="message-efficiency"]')
156 expect(eff.exists()).toBe(true)
157 expect(eff.text()).toContain('2')
158 })
159 })
160 
161 // The "history you wrote" renders through the shared Spine (TimelineLedger),
162 // not a bespoke list — so the verdict's history reads exactly like the in-game
163 // timeline (valence dots, badges, the Δ equation), per issue #84.
164 describe('The history you wrote — the shared Spine', () => {
165 beforeEach(() => {
166 gameStore.gameStatus = 'victory'
167 gameStore.addTimelineEvent({
168 figureName: 'Tesla', era: '1890s',
169 headline: 'The grid hums to life', detail: 'Alternating current spreads.',
170 diceRoll: 18, diceOutcome: DiceOutcome.SUCCESS, progressChange: 25
171 })
172 })
173 
174 it('renders via the shared TimelineLedger, not the old hand-rolled list', () => {
175 const w = mount(EndGameScreen)
176 expect(w.find('[data-testid="timeline-ledger"]').exists()).toBe(true)
177 expect(w.find('[data-testid="timeline-event"]').exists()).toBe(true)
178 // The bespoke block folded into the Spine's testid.
179 expect(w.find('[data-testid="final-timeline"]').exists()).toBe(false)
180 })
181 
182 it('renders the Spine read-only: no live "▷ now · your move" present marker', () => {
183 expect(mount(EndGameScreen).find('.spine-now').exists()).toBe(false)
184 })
185 })
186 
187 // The Chronicle epilogue fills the full debrief column like its siblings (the
188 // dispatches / stats / timeline). It used to carry an inner max-w-[62ch] measure
189 // that read as ~2/3 width next to those full-width sections (#109).
190 describe('Chronicle epilogue', () => {
191 beforeEach(() => {
192 gameStore.gameStatus = 'victory'
193 gameStore.chronicle = { title: 'The Library Stands', paragraphs: ['It did not burn.'] }
194 })
195 
196 it('renders the epilogue body', () => {
197 const body = mount(EndGameScreen).find('[data-testid="end-chronicle-body"]')
198 expect(body.exists()).toBe(true)
199 expect(body.text()).toContain('It did not burn.')
200 })
201 
202 it('fills the column — no max-w-[62ch] cap', () => {
203 const body = mount(EndGameScreen).find('[data-testid="end-chronicle-body"]')
204 expect(body.classes()).not.toContain('max-w-[62ch]')
205 })
206 })
207 
208 describe('Play again', () => {
209 beforeEach(() => { gameStore.gameStatus = 'victory' })
210 
211 it('offers a restart', () => {
212 const btn = mount(EndGameScreen).find('[data-testid="play-again-button"]')
213 expect(btn.exists()).toBe(true)
214 expect(btn.text()).toContain('new timeline')
215 })
216 
217 it('asks the page for the unified reset when clicked (it never resets the store itself)', async () => {
218 const wrapper = mount(EndGameScreen)
219 await wrapper.find('[data-testid="play-again-button"]').trigger('click')
220 
221 // The page owns the FULL reset (store + figure input + composer); the
222 // old direct store reset left the previous run's figure in the input.
223 expect(wrapper.emitted('play-again')).toHaveLength(1)
224 expect(gameStore.gameStatus).toBe('victory') // untouched by the component
225 })
226 })
227 
228 describe('Edge cases', () => {
229 it('handles a missing objective', () => {
230 gameStore.gameStatus = 'victory'
231 gameStore.currentObjective = null
232 expect(() => mount(EndGameScreen)).not.toThrow()
233 })
234 
235 it('does not render while still playing', () => {
236 gameStore.gameStatus = 'playing'
237 expect(mount(EndGameScreen).find('[data-testid="end-game-screen"]').exists()).toBe(false)
238 })
239 
240 it('exposes dialog semantics', () => {
241 gameStore.gameStatus = 'victory'
242 const screen = mount(EndGameScreen).find('[data-testid="end-game-screen"]')
243 expect(screen.attributes('role')).toBe('dialog')
244 expect(screen.attributes('aria-labelledby')).toBeTruthy()
245 })
246 })
247})