What changed, and why

A run can climb to real progress and then fall back. The sharpest case: the player stakes their final message, the dice crit-fail, and the staked swing doubles them all the way down to 0%. The end screen then read 0% rewritten — as if the run had never happened. The store knew the peak each turn and threw it away.

This change keeps it. The store now tracks a high-water mark as each swing lands, and the end screen shows "peaked at X%" — but only when the run actually slipped below its peak. Two source files move, both additively: the store holds the new field, the end-screen component reads it. No existing behavior changes.

The high-water mark in the store

peakProgress is a per-run number that only ever rises. It updates in applyProgress, the single place a swing lands, right after objectiveProgress is clamped into 0..100. Because it reads the already-clamped value, the mark can never exceed 100 or fall below 0 — it inherits those bounds for free.

The field, the one update site, and both resets — the whole store change.

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

Surfacing it on the end screen

The component decides visibility with one computed. showPeak is true only when the peak sits above the final %. A victory always ends at 100%, which is also its own peak, so showPeak is false on every clean win. The stat appears only on a run that lost ground — which, in practice, is the defeat the feature exists for.

The conditional stat in the existing summary row, and the one-line guard.

components/EndGameScreen.vue · 309 lines
components/EndGameScreen.vue309 lines · Vue
⋯ 69 lines hidden (lines 1–69)
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 <RemixCreditLine v-if="remixCredit" :credit="remixCredit" class="mt-2.5" />
25 
26 <p v-if="!isVictory" data-testid="defeat-explanation" class="rv-muted text-sm mt-3 max-w-prose mx-auto">
27 Your five messages are spent. What you changed remains — but it was not enough, and the objective slipped away.
28 </p>
29 
30 <div v-if="victoryEfficiency" data-testid="efficiency-display" class="mt-3 rv-mono text-sm">
31 <span class="rv-ok font-semibold">{{ victoryPhrase }}</span>
32 <span class="rv-muted"> · {{ victoryEfficiency.messagesUsed }}/5 used<span v-if="victoryEfficiency.messagesSaved > 0"> · {{ victoryEfficiency.messagesSaved }} saved</span></span>
33 <span v-if="victoryEfficiency.isEarlyVictory" class="rv-ok"> · 🌟 to spare</span>
34 </div>
35 </div>
36 
37 <!-- The Chronicle's final telling — the epilogue. While the terminal telling is
38 still being written (epiloguePending), we show the "final account…" wait, NOT
39 the prior turn's stale, pre-ending telling. When it lands it cross-fades in
40 (mirroring Chronicle.vue's rewrite reveal); a failed final refresh clears
41 pending and falls back to the prior telling, so the panel never hangs. -->
42 <div v-if="chronicle || epiloguePending" data-testid="end-chronicle" class="mt-6">
43 <span class="rv-label rv-label--rule"><span aria-hidden="true">📖</span> The Chronicle</span>
44 <Transition :name="epilogueTransition" mode="out-in">
45 <div v-if="epiloguePending" key="epilogue-loading" data-testid="end-chronicle-loading" class="rv-faint italic text-sm">
46 The chronicler sets down the final account…
47 </div>
48 <article v-else-if="chronicle" key="epilogue-body" data-testid="end-chronicle-body">
49 <h4 data-testid="end-chronicle-title" class="rv-serif text-xl font-semibold rv-fg mb-2.5 leading-tight">{{ chronicle.title }}</h4>
50 <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>
51 </article>
52 </Transition>
53 </div>
54 
55 <!-- The words you sent — the keepsake: your actual messages into the past -->
56 <div v-if="dispatches.length" data-testid="dispatches" class="mt-7">
57 <span class="rv-label rv-label--rule">The words you sent</span>
58 <ul class="space-y-3.5">
59 <li v-for="(d, i) in dispatches" :key="i">
60 <p class="rv-label">to {{ d.figure }}<span v-if="d.era"> · {{ d.era }}</span></p>
61 <blockquote class="rv-serif rv-fg text-base leading-relaxed border-l-2 rv-line pl-3 mt-1">"{{ d.text }}"</blockquote>
62 </li>
63 </ul>
64 </div>
65 
66 <!-- Stats — derived from the real resolved turns -->
67 <div data-testid="game-summary" class="mt-7">
68 <span class="rv-label rv-label--rule">How it played out</span>
69 <div class="flex flex-wrap gap-10">
70 <!-- "Peaked at X%" — shown only when the run slipped from its high point
71 (the peak sits above the floored final %). The case it exists for is a
72 staked last dispatch that crit-fails to 0% from a real peak, where the
73 final % alone reads "as if you did nothing" (#129). A clean win never
74 shows it: its peak IS the final 100%. -->
75 <div v-if="showPeak" data-testid="peak-progress">
76 <div class="rv-mono text-3xl font-extrabold rv-accent leading-none">{{ gameStore.peakProgress }}%</div>
77 <div class="rv-label mt-1">peaked at</div>
78 </div>
79 <div data-testid="message-efficiency">
⋯ 124 lines hidden (lines 80–203)
80 <div class="rv-mono text-3xl font-extrabold rv-fg leading-none">{{ messagesUsed }}</div>
81 <div class="rv-label mt-1">{{ messagesUsed === 1 ? 'message' : 'messages' }} sent</div>
82 </div>
83 <div v-if="gameSummary.bestRoll" data-testid="best-roll">
84 <div class="rv-mono text-3xl font-extrabold rv-ok leading-none">{{ gameSummary.bestRoll.diceRoll }}</div>
85 <div class="rv-label mt-1">{{ messagesUsed >= 2 ? 'best roll' : 'the roll that did it' }} · {{ gameSummary.bestRoll.diceOutcome }}</div>
86 </div>
87 <div v-if="gameSummary.worstRoll && messagesUsed >= 2" data-testid="worst-roll">
88 <div class="rv-mono text-3xl font-extrabold rv-bad leading-none">{{ gameSummary.worstRoll.diceRoll }}</div>
89 <div class="rv-label mt-1">worst roll · {{ gameSummary.worstRoll.diceOutcome }}</div>
90 </div>
91 </div>
92 </div>
93 
94 <!-- The history you wrote — the Spine itself, exactly as it filled in during
95 the run. Read-only here: no live "▷ now" present marker, no "the timeline
96 trembles…" empty state. Same component the player scrolled and tapped on
97 the board, so the verdict's history reads as richly as it did in play —
98 valence dots, ⚡/⚑ badges, the roll, and the Δ equation all carry over. -->
99 <div v-if="timeline.length > 0" class="mt-6">
100 <TimelineLedger readonly />
101 </div>
102 
103 <!-- Share this run (issue #88) — once the snapshot is durably saved. Proud of the
104 rewrite? Post it: the public page, the unfurl, the share card. -->
105 <div v-if="canShare" class="mt-8">
106 <ShareControls :run-id="shareRunId" :share-text="shareText" />
107 </div>
108 
109 <div class="mt-9 border-t rv-line pt-6 flex items-center gap-3 flex-wrap">
110 <button ref="playAgainRef" type="button" data-testid="play-again-button" class="rv-btn rv-btn--primary" @click="playAgain">
111 Try a new timeline <span aria-hidden="true"></span>
112 </button>
113 <!-- The finished run just joined your saved-runs history, so the end screen
114 offers an obvious way to reach it: a secondary button beside the primary
115 "Try a new timeline" (the button-styled /runs link mirrors the
116 retrospective's "Back to your runs", pages/runs/[id].vue), carrying the ◷
117 glyph and "Your runs" wording from the account popover (RunsBalance.vue). -->
118 <NuxtLink to="/runs" data-testid="end-your-runs-link" class="rv-btn inline-flex items-center gap-1.5">
119 <span aria-hidden="true"></span> Your runs
120 </NuxtLink>
121 <span class="rv-faint text-xs inline-flex items-center gap-1.5">
122 <span class="rv-dot" :class="isVictory ? 'rv-dot--ok' : 'rv-dot--bad'" aria-hidden="true" /> this timeline is written.
123 </span>
124 </div>
125 </div>
126 </div>
127</template>
128 
129<script setup lang="ts">
130/**
131 * EndGameScreen — the mission debrief. A full-bleed takeover (not a modal card):
132 * verdict line, the Chronicle epilogue + big-number stat trio, and the history you
133 * wrote. All stats derive from the real resolved turns.
134 */
135import { ref, computed, watch, nextTick } from 'vue'
136import { useGameStore } from '~/stores/game'
137import { usePrefersReducedMotion } from '~/composables/usePrefersReducedMotion'
138import { buildShareText, type RemixCredit } from '~/utils/run-snapshot'
139 
140const gameStore = useGameStore()
141 
142// When this run is a replay (issue #89), the "remixed from" credit, from the live seed —
143// shown on the end screen the moment the replay resolves. The title is omitted (the
144// objective is already in the masthead above), so it reads "Remixed from <source>".
145const remixCredit = computed<RemixCredit | null>(() =>
146 gameStore.replayState
147 ? { attribution: gameStore.replayState.sourceAttribution, objectiveTitle: '', sourceToken: gameStore.replayState.sourceToken }
148 : null
150const reduced = usePrefersReducedMotion()
151 
152const headingId = `end-game-heading-${Math.random().toString(36).slice(2, 11)}`
153 
154// Modal focus management: it's a true takeover (aria-modal), so when it opens we
155// move focus to the VERDICT heading (tabindex=-1: announced, focusable, outside
156// the Tab cycle), trap Tab inside, and restore focus when the run ends. Focusing
157// the heading — not the bottom Play Again button — also means the screen opens at
158// the top: the verdict and the Chronicle epilogue are the first things seen, not
159// scrolled past below the fold.
160const dialogRef = ref<HTMLElement | null>(null)
161const headingRef = ref<HTMLElement | null>(null)
162const playAgainRef = ref<HTMLElement | null>(null)
163let prevFocus: HTMLElement | null = null
164 
165watch(() => gameStore.gameStatus, async (s) => {
166 if (typeof document === 'undefined') return
167 if (s === 'victory' || s === 'defeat') {
168 prevFocus = document.activeElement as HTMLElement | null
169 await nextTick()
170 if (dialogRef.value) dialogRef.value.scrollTop = 0
171 headingRef.value?.focus()
172 } else if (prevFocus) {
173 prevFocus.focus?.()
174 prevFocus = null
175 }
176})
177 
178function onKeydown(e: KeyboardEvent) {
179 if (e.key !== 'Tab' || !dialogRef.value) return
180 const focusables = dialogRef.value.querySelectorAll<HTMLElement>(
181 'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
182 )
183 if (!focusables.length) return
184 const first = focusables[0]
185 const last = focusables[focusables.length - 1]
186 const active = document.activeElement
187 if (e.shiftKey && active === first) { e.preventDefault(); last.focus() }
188 else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus() }
190 
191const isVictory = computed(() => gameStore.gameStatus === 'victory')
192const victoryEfficiency = computed(() => gameStore.getVictoryEfficiency())
193 
194// The verdict never deflates: spending every message is reframed as "Hard-won," not
195// the limp "Standard." Only the laudatory tiers keep their rating as an adjective.
196const victoryPhrase = computed(() => {
197 const rating = victoryEfficiency.value?.efficiencyRating
198 if (!rating) return 'Victory'
199 return rating === 'Standard' ? 'Hard-won victory' : `${rating} victory`
200})
201const messagesUsed = computed(() => 5 - gameStore.remainingMessages)
202const gameSummary = computed(() => gameStore.gameSummary)
203 
204// "Peaked at X%" surfaces only when the run lost ground from its high-water mark —
205// the peak above the final %. The case it answers is a staked last dispatch that
206// crit-fails to 0% from a real peak, where the floored final % alone would read "as
207// if the player did nothing" (#129). A clean win never shows it (peak === final 100%).
208const showPeak = computed(() => gameStore.peakProgress > gameStore.objectiveProgress)
⋯ 101 lines hidden (lines 209–309)
209 
210// Share affordance (issue #88): only for a run that's been durably saved (so there's a
211// server-side record to make public). `canShare` true ⟹ runId is a real saved uuid.
212const canShare = computed(() => gameStore.runSnapshotSaved && !!gameStore.runId)
213const shareRunId = computed(() => gameStore.runId ?? '')
214const shareText = computed(() =>
215 buildShareText(gameStore.currentObjective?.title ?? '', isVictory.value ? 'victory' : 'defeat', gameStore.objectiveProgress)
217const timeline = computed(() => gameStore.timelineEvents)
218const chronicle = computed(() => gameStore.chronicle)
219// The epilogue (terminal telling) is still being written — show the wait, never the
220// prior turn's stale telling. The cross-fade mirrors Chronicle.vue and is dropped
221// under prefers-reduced-motion (the empty name disables the Transition's classes).
222const epiloguePending = computed(() => gameStore.epiloguePending)
223const epilogueTransition = computed(() => (reduced.value ? '' : 'epilogue'))
224 
225// The keepsake: the player's verbatim messages, paired with whom/when they reached.
226const dispatches = computed(() => {
227 const users = gameStore.messageHistory.filter(m => m.sender === 'user')
228 const events = gameStore.timelineEvents
229 return users.map((m, i) => ({
230 text: m.text,
231 figure: m.figureName || events[i]?.figureName || 'the past',
232 era: events[i]?.era || ''
233 }))
234})
235 
236// The page owns the FULL reset (store + figure input + composer), so Play Again
237// emits instead of resetting the store directly — the two reset paths used to
238// diverge here, leaving the previous run's figure stranded in the input.
239const emit = defineEmits<{ 'play-again': [] }>()
240const playAgain = () => {
241 emit('play-again')
243</script>
244 
245<style scoped>
246/* The epilogue reveal — the terminal telling cross-fades in over the "final account…"
247 wait, the same gentle opacity fade Chronicle.vue uses for a rewrite. Gated by the
248 transition NAME (empty under prefers-reduced-motion), so reduced-motion gets an
249 instant swap with no classes attached. */
250.epilogue-enter-active,
251.epilogue-leave-active {
252 transition: opacity var(--rv-dur-3) var(--rv-ease);
254.epilogue-enter-from,
255.epilogue-leave-to {
256 opacity: 0;
258 
259/* Win and loss share the page frame but not its mood. The win is illuminated — a warm
260 wash blooms from the top; the loss is a colder, dimmer ground that has settled back. */
261.end-screen { background: var(--rv-bg); animation: end-in var(--rv-dur-3) var(--rv-ease) both; }
262@keyframes end-in { from { opacity: 0; } to { opacity: 1; } }
263@media (prefers-reduced-motion: reduce) { .end-screen { animation: none; } }
264.end--win {
265 background:
266 radial-gradient(135% 70% at 50% -12%, color-mix(in srgb, var(--rv-accent) 13%, transparent), transparent 68%),
267 var(--rv-bg);
269.end--loss {
270 background:
271 radial-gradient(135% 70% at 50% -12%, color-mix(in srgb, var(--rv-bad) 6%, transparent), transparent 60%),
272 var(--rv-tint);
274 
275/* The struck seal — the verdict's emblem. The win seal glows like fresh sealing wax;
276 the loss seal is a cold, flat stamp. */
277.verdict-seal {
278 display: inline-grid;
279 place-items: center;
280 width: 60px;
281 height: 60px;
282 border-radius: 50%;
283 border: 2px solid;
284 font-size: 1.6rem;
285 line-height: 1;
287.verdict-seal.is-win {
288 color: var(--rv-accent);
289 border-color: var(--rv-accent);
290 box-shadow:
291 0 0 0 5px color-mix(in srgb, var(--rv-accent) 12%, transparent),
292 0 0 30px color-mix(in srgb, var(--rv-accent) 32%, transparent);
293 animation: seal-strike 0.6s var(--rv-ease-spring) both;
295.verdict-seal.is-loss {
296 color: var(--rv-faint);
297 border-color: var(--rv-line);
298 box-shadow: inset 0 0 0 4px color-mix(in srgb, var(--rv-faint) 8%, transparent);
300 
301@keyframes seal-strike {
302 0% { opacity: 0; transform: scale(.5) rotate(-12deg); }
303 60% { transform: scale(1.12) rotate(3deg); }
304 100% { opacity: 1; transform: scale(1) rotate(0); }
306@media (prefers-reduced-motion: reduce) {
307 .verdict-seal.is-win { animation: none; }
309</style>

Tests that pin the behavior

Two discriminating pairs, not coverage padding. In the store, the peak must survive a setback: climb to 25%, crit-fail to 0%, and the mark holds at 25% — plus both resets clear it. In the component, the stat shows when the run slipped and stays hidden on a clean 100% win.

The mark holds through a setback; resetGame clears it.

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

Shown on a slipped defeat (final 0%, peak 25%); absent on a clean win.

tests/unit/components/EndGameScreen.spec.ts · 293 lines
tests/unit/components/EndGameScreen.spec.ts293 lines · TypeScript
⋯ 170 lines hidden (lines 1–170)
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 
27// NuxtLink can't resolve RouterLink under a bare @vue/test-utils mount, so stub it
28// per-mount to a plain anchor (href ← `to`): the footer "Your runs" link is the only
29// NuxtLink here, so this keeps every mount free of the resolve warning and lets the
30// link's text/href be asserted directly. (The real render is covered by the e2e.)
31const mountEnd = () =>
32 mount(EndGameScreen, { global: { stubs: { NuxtLink: { props: ['to'], template: '<a :href="to"><slot /></a>' } } } })
33 
34describe('EndGameScreen', () => {
35 let gameStore: ReturnType<typeof useGameStore>
36 
37 beforeEach(() => {
38 setActivePinia(createPinia())
39 gameStore = useGameStore()
40 })
41 
42 describe('Victory', () => {
43 beforeEach(() => {
44 gameStore.gameStatus = 'victory'
45 gameStore.objectiveProgress = 100
46 gameStore.currentObjective = OBJ
47 gameStore.remainingMessages = 2
48 })
49 
50 it('shows a victory headline', () => {
51 const v = mountEnd().find('[data-testid="victory-message"]')
52 expect(v.exists()).toBe(true)
53 expect(v.text()).toContain('History Rewritten')
54 })
55 
56 it('shows the objective and final progress', () => {
57 const w = mountEnd()
58 expect(w.find('[data-testid="objective-title"]').text()).toContain('Reach the Moon by 1900')
59 expect(w.find('[data-testid="final-progress"]').text()).toContain('100%')
60 })
61 
62 it('shows efficiency with messages saved', () => {
63 const e = mountEnd().find('[data-testid="efficiency-display"]')
64 expect(e.exists()).toBe(true)
65 expect(e.text()).toContain('saved')
66 })
67 })
68 
69 describe('Defeat', () => {
70 beforeEach(() => {
71 gameStore.gameStatus = 'defeat'
72 gameStore.objectiveProgress = 45
73 gameStore.currentObjective = OBJ
74 gameStore.remainingMessages = 0
75 })
76 
77 it('shows a defeat headline', () => {
78 const d = mountEnd().find('[data-testid="defeat-message"]')
79 expect(d.exists()).toBe(true)
80 expect(d.text()).toContain('Timeline Held')
81 })
82 
83 it('shows final progress below 100%', () => {
84 expect(mountEnd().find('[data-testid="final-progress"]').text()).toContain('45%')
85 })
86 
87 it('explains the loss', () => {
88 expect(mountEnd().find('[data-testid="defeat-explanation"]').exists()).toBe(true)
89 })
90 })
91 
92 // The epilogue is the run's payoff — the terminal telling that carries the verdict.
93 // While it's still being written we must never present the prior turn's stale,
94 // pre-ending telling as the epilogue (issue #107).
95 describe('The Chronicle epilogue — pending, reveal, and fallback', () => {
96 const stale = { title: 'Mid-run Telling', paragraphs: ['the world as it stood a turn ago'] }
97 const epilogue = { title: 'The World Remade', paragraphs: ['and so the timeline was rewritten'] }
98 
99 beforeEach(() => {
100 gameStore.gameStatus = 'victory'
101 gameStore.currentObjective = OBJ
102 })
103 
104 it('shows the "final account" wait while the epilogue is pending — not the stale prior telling', () => {
105 gameStore.chronicle = stale // a stale, pre-ending telling is still held
106 gameStore.epiloguePending = true // the terminal refresh is in flight
107 const w = mount(EndGameScreen)
108 expect(w.find('[data-testid="end-chronicle-loading"]').exists()).toBe(true)
109 // The prior-turn telling must NOT be presented as the epilogue.
110 expect(w.find('[data-testid="end-chronicle-body"]').exists()).toBe(false)
111 expect(w.text()).not.toContain('Mid-run Telling')
112 })
113 
114 it('reveals the epilogue once the terminal telling lands', () => {
115 gameStore.chronicle = epilogue
116 gameStore.epiloguePending = false
117 const w = mount(EndGameScreen)
118 expect(w.find('[data-testid="end-chronicle-loading"]').exists()).toBe(false)
119 expect(w.find('[data-testid="end-chronicle-body"]').exists()).toBe(true)
120 expect(w.find('[data-testid="end-chronicle-title"]').text()).toContain('The World Remade')
121 })
122 
123 it('falls back to the prior telling if the final refresh failed — never stuck on loading', () => {
124 // refreshChronicle keeps the prior telling on failure and clears pending.
125 gameStore.chronicle = stale
126 gameStore.epiloguePending = false
127 const w = mount(EndGameScreen)
128 expect(w.find('[data-testid="end-chronicle-loading"]').exists()).toBe(false)
129 expect(w.find('[data-testid="end-chronicle-body"]').exists()).toBe(true)
130 expect(w.find('[data-testid="end-chronicle-title"]').text()).toContain('Mid-run Telling')
131 })
132 })
133 
134 describe('Summary stats — derived from resolved AI turns', () => {
135 beforeEach(() => {
136 gameStore.gameStatus = 'victory'
137 gameStore.remainingMessages = 3 // 2 used
138 gameStore.messageHistory = [
139 prompt(), resolvedTurn(18, DiceOutcome.SUCCESS, 25),
140 prompt(), resolvedTurn(20, DiceOutcome.CRITICAL_SUCCESS, 75)
141 ]
142 })
143 
144 it('renders the summary block', () => {
145 expect(mountEnd().find('[data-testid="game-summary"]').exists()).toBe(true)
146 })
147 
148 it('highlights the best roll from the AI turns', () => {
149 const best = mountEnd().find('[data-testid="best-roll"]')
150 expect(best.exists()).toBe(true)
151 expect(best.text()).toContain('20')
152 })
153 
154 it('highlights the worst roll from the AI turns', () => {
155 gameStore.messageHistory[1] = resolvedTurn(2, DiceOutcome.CRITICAL_FAILURE, -10)
156 const worst = mountEnd().find('[data-testid="worst-roll"]')
157 expect(worst.exists()).toBe(true)
158 expect(worst.text()).toContain('2')
159 })
160 
161 it('shows the number of messages sent', () => {
162 const eff = mountEnd().find('[data-testid="message-efficiency"]')
163 expect(eff.exists()).toBe(true)
164 expect(eff.text()).toContain('2')
165 })
166 })
167 
168 // A run that built progress then lost ground — classically a staked final dispatch
169 // that crit-failed to 0% — must not read "as if the player did nothing." The summary
170 // surfaces "peaked at X%" whenever the peak sits above the floored final % (#129).
171 describe('Peaked at X% — when a run loses ground', () => {
172 it('shows the peak in the summary when the run slipped from its high point', () => {
173 gameStore.gameStatus = 'defeat'
174 gameStore.objectiveProgress = 0 // a staked last dispatch crit-failed to nothing
175 gameStore.peakProgress = 25 // but the run had really reached 25%
176 const peak = mountEnd().find('[data-testid="peak-progress"]')
177 expect(peak.exists()).toBe(true)
178 expect(peak.text()).toContain('25%')
179 })
180 
181 it('stays hidden on a clean win, where the peak is the final 100%', () => {
182 gameStore.gameStatus = 'victory'
183 gameStore.objectiveProgress = 100
184 gameStore.peakProgress = 100
185 expect(mountEnd().find('[data-testid="peak-progress"]').exists()).toBe(false)
186 })
⋯ 107 lines hidden (lines 187–293)
187 })
188 
189 // The "history you wrote" renders through the shared Spine (TimelineLedger),
190 // not a bespoke list — so the verdict's history reads exactly like the in-game
191 // timeline (valence dots, badges, the Δ equation), per issue #84.
192 describe('The history you wrote — the shared Spine', () => {
193 beforeEach(() => {
194 gameStore.gameStatus = 'victory'
195 gameStore.addTimelineEvent({
196 figureName: 'Tesla', era: '1890s',
197 headline: 'The grid hums to life', detail: 'Alternating current spreads.',
198 diceRoll: 18, diceOutcome: DiceOutcome.SUCCESS, progressChange: 25
199 })
200 })
201 
202 it('renders via the shared TimelineLedger, not the old hand-rolled list', () => {
203 const w = mountEnd()
204 expect(w.find('[data-testid="timeline-ledger"]').exists()).toBe(true)
205 expect(w.find('[data-testid="timeline-event"]').exists()).toBe(true)
206 // The bespoke block folded into the Spine's testid.
207 expect(w.find('[data-testid="final-timeline"]').exists()).toBe(false)
208 })
209 
210 it('renders the Spine read-only: no live "▷ now · your move" present marker', () => {
211 expect(mountEnd().find('.spine-now').exists()).toBe(false)
212 })
213 })
214 
215 // The Chronicle epilogue fills the full debrief column like its siblings (the
216 // dispatches / stats / timeline). It used to carry an inner max-w-[62ch] measure
217 // that read as ~2/3 width next to those full-width sections (#109).
218 describe('Chronicle epilogue', () => {
219 beforeEach(() => {
220 gameStore.gameStatus = 'victory'
221 gameStore.chronicle = { title: 'The Library Stands', paragraphs: ['It did not burn.'] }
222 })
223 
224 it('renders the epilogue body', () => {
225 const body = mount(EndGameScreen).find('[data-testid="end-chronicle-body"]')
226 expect(body.exists()).toBe(true)
227 expect(body.text()).toContain('It did not burn.')
228 })
229 
230 it('fills the column — no max-w-[62ch] cap', () => {
231 const body = mount(EndGameScreen).find('[data-testid="end-chronicle-body"]')
232 expect(body.classes()).not.toContain('max-w-[62ch]')
233 })
234 })
235 
236 describe('Play again', () => {
237 beforeEach(() => { gameStore.gameStatus = 'victory' })
238 
239 it('offers a restart', () => {
240 const btn = mountEnd().find('[data-testid="play-again-button"]')
241 expect(btn.exists()).toBe(true)
242 expect(btn.text()).toContain('new timeline')
243 })
244 
245 it('asks the page for the unified reset when clicked (it never resets the store itself)', async () => {
246 const wrapper = mountEnd()
247 await wrapper.find('[data-testid="play-again-button"]').trigger('click')
248 
249 // The page owns the FULL reset (store + figure input + composer); the
250 // old direct store reset left the previous run's figure in the input.
251 expect(wrapper.emitted('play-again')).toHaveLength(1)
252 expect(gameStore.gameStatus).toBe('victory') // untouched by the component
253 })
254 })
255 
256 // The finished run becomes part of the player's history, so the end screen
257 // surfaces the same "Your runs" affordance the retrospective side uses.
258 describe('Your runs link', () => {
259 beforeEach(() => { gameStore.gameStatus = 'victory' })
260 
261 it('offers an obvious /runs link beside Play Again', () => {
262 const link = mountEnd().find('[data-testid="end-your-runs-link"]')
263 expect(link.exists()).toBe(true)
264 expect(link.text()).toContain('Your runs')
265 expect(link.attributes('href')).toContain('/runs')
266 })
267 
268 it('shows it on a loss too', () => {
269 gameStore.gameStatus = 'defeat'
270 expect(mountEnd().find('[data-testid="end-your-runs-link"]').exists()).toBe(true)
271 })
272 })
273 
274 describe('Edge cases', () => {
275 it('handles a missing objective', () => {
276 gameStore.gameStatus = 'victory'
277 gameStore.currentObjective = null
278 expect(() => mountEnd()).not.toThrow()
279 })
280 
281 it('does not render while still playing', () => {
282 gameStore.gameStatus = 'playing'
283 expect(mountEnd().find('[data-testid="end-game-screen"]').exists()).toBe(false)
284 })
285 
286 it('exposes dialog semantics', () => {
287 gameStore.gameStatus = 'victory'
288 const screen = mountEnd().find('[data-testid="end-game-screen"]')
289 expect(screen.attributes('role')).toBe('dialog')
290 expect(screen.attributes('aria-labelledby')).toBeTruthy()
291 })
292 })
293})