What changed, and why

A resolved turn applied all its state in one synchronous block — the AI reply, the ledger node, the progress swing — and flipped loading off. Every component watches its own slice and animates on change, so they all fired in the same tick: the die landing, the reply, the %-swing, and the spine node moved at once, with no suspense and no path for the eye.

The fix doesn't touch any component. It sequences the writes in the store, which sequences the existing animations for free — into one conducted beat.

The pacing, and when it applies

Three small constants and a guard. canAnimateReveal() is the seam: only a real browser with motion allowed staggers the reveal. SSR and the test env have no matchMedia, so they collapse to the instant, atomic apply — which is why the store's contract (all state present the moment sendMessage resolves) is unchanged and every other test is unaffected.

REVEAL timings + the matchMedia gate that keeps SSR/tests instant.

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

The conducted sequence

Inside sendMessage, after the response: a beat of suspense (the die keeps casting), then the reply lands and loading is flipped here (mid-sequence) so the die lands as its own beat; then the swing, timed to coincide with the reply's own staged "ripple" line; then the ledger node. Each await is epoch-guarded, so a reset mid-reveal returns without writing into the new run.

suspense → die+reply → swing → node, each gated by animate and the epoch.

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

Tests

The cadence is asserted deterministically with fake timers + a stubbed matchMedia: step the clock and check each beat lands in order (suspense → reply → swing → node), and that a reset mid-reveal aborts without leaking state into the new run. The instant path stays covered by every existing sendMessage test.

Advance timers beat-by-beat; assert the ordering and the mid-reveal abort.

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