The loop earned its keep

PR #39 shipped the Sentinel and, alongside it, a read-only moderation-audit loop — MHE's reflective guardrail, "are our guardrails still working?". On its first run the loop did exactly its job: it flagged an untrusted-input → model → player path the rollout had missed. This PR closes it (plus a minor silent-block the loop also raised), and re-runs the loop to confirm the fix.

Two gaps. One real: /api/research (the Archivist brief) returned a model brief to the player with no moderation, while every other model surface had it — a crafted POST could use the Archivist as an unmoderated model proxy. One minor: /api/suggestions blocked a custom objective silently.

The research gate

The fix gives /api/research the same two stages as the turn loop and the Archive lookup: a deterministic hardBlockCheck on the untrusted subject before the model runs, then moderateAction (detector + Sentinel) over the generated brief before it ships. A block returns { blocked, error } — the shape the store already knows how to surface.

Input hard-block up front, Sentinel review of the brief after — the brief never ships if either trips.

server/api/research.post.ts · 66 lines
server/api/research.post.ts66 lines · TypeScript
⋯ 31 lines hidden (lines 1–31)
1import { cleanString, MAX_ERA_CHARS, MAX_GROUNDING_CHARS } from '~/server/utils/validate'
2 
3// The Wikipedia extract is longer than the other grounded fields; give it room.
4const MAX_EXTRACT_CHARS = 1500
5 
6/**
7 * POST /api/research — the Archivist (prototype). Given a figure (and the year the
8 * player means to reach them, plus the grounded facts the client already holds), it
9 * returns a green-level, OBJECTIVE-BLIND brief: who they are at that moment, what
10 * they grasp, what they're reaching for, what they cannot yet know. In-game research
11 * so the player needn't break out to a search engine.
12 *
13 * Like the other AI routes, the untrusted body is coerced + bounded before it feeds
14 * the prompt. A model failure self-heals to { success: false } — the caller simply
15 * shows nothing.
16 */
17export default defineEventHandler(async (event) => {
18 if (getMethod(event) !== 'POST') {
19 throw createError({ statusCode: 405, statusMessage: 'Method Not Allowed' })
20 }
21 
22 const body = await readBody(event)
23 if (!body || typeof body.figureName !== 'string' || !body.figureName.trim()) {
24 throw createError({ statusCode: 400, statusMessage: 'Bad Request: figureName is required' })
25 }
26 
27 const figureName = body.figureName.trim().slice(0, MAX_ERA_CHARS)
28 const when = cleanString(body.when, MAX_ERA_CHARS) || undefined
29 const description = cleanString(body.description, MAX_GROUNDING_CHARS) || undefined
30 const extract = cleanString(body.extract, MAX_EXTRACT_CHARS) || undefined
31 
32 try {
33 const { callArchivistAI } = await import('~/server/utils/openai')
34 const { hardBlockCheck, moderateAction } = await import('~/server/utils/moderation')
35 
36 // The Archivist takes untrusted figure text and returns a model brief to the
37 // player — the same untrusted-input → model → player path the turn and the
38 // Archive lookup gate, so it gets the same two stages: hard-block the input
39 // up front, then the Sentinel reviews the generated brief before it ships.
40 const subject = [figureName, description, extract].filter(Boolean).join(' — ')
41 const inputBlock = await hardBlockCheck(subject, 'research', 'input')
42 if (inputBlock.blocked) return { success: false, blocked: true, error: inputBlock.block.reason }
43 
44 const result = await callArchivistAI({ figureName, when, description, extract })
45 
46 if (!result.success || !result.study) {
47 return { success: false, error: result.error || 'Archivist failed' }
48 }
49 
50 const s = result.study
51 const moderation = await moderateAction({
52 surface: 'research',
53 figureName,
54 era: when,
55 userText: subject,
56 outputs: [s.atThisMoment, ...(s.grasp ?? []), ...(s.reaching ?? []), s.cannotYetKnow].filter((t): t is string => !!t)
57 })
58 if (moderation.blocked) return { success: false, blocked: true, error: moderation.block.reason }
59 
60 return { success: true, study: result.study }
⋯ 6 lines hidden (lines 61–66)
61 } catch (error) {
62 console.error('Research endpoint error:', error)
63 // Log the detail server-side; the wire gets a clean line.
64 throw createError({ statusCode: 500, statusMessage: 'Internal Server Error' })
65 }
66})

Surfacing the block (no more silent failures)

Two new store notices, studyNotice and suggestionsNotice, mirror the existing moderationNotice / archiveNotice — one per surface that takes untrusted input into a model. The study notice is cleared on the same paths that clear a stale study (re-study, re-ground, reset); the suggestions notice replaces the old silent empty-list. The suggestions block previously masqueraded as success: true with no ideas; now it says why.

studyActiveFigure surfaces a blocked brief as a notice, not a study; loadSuggestions surfaces a blocked objective instead of an empty list.

stores/game.ts · 973 lines
stores/game.ts973 lines · TypeScript
⋯ 451 lines hidden (lines 1–451)
1import { defineStore } from 'pinia'
2import type { GameObjective } from '~/server/utils/objective-generator'
3import type { GroundedFigure } from '~/server/utils/figure-grounding'
4import { formatContactMoment, type ContactMoment } from '~/utils/contact-moment'
5import type { FigureSuggestion } from '~/server/utils/figure-suggester'
6import type { ChronicleEntry } from '~/server/utils/openai'
7import type { FigureStudy, ArchiveLookup } from '~/server/utils/prompt-builder'
8import type { Anachronism } from '~/server/utils/anachronism'
9import type { Craft } from '~/utils/craft'
10import type { DiceOutcome } from '~/utils/dice'
11import { generateGameSummary, rateEfficiency, type GameSummary } from '~/utils/game-summary'
12import { TOTAL_MESSAGES, MAX_PROGRESS_SWING } from '~/utils/game-config'
13 
14export type Valence = 'positive' | 'negative' | 'neutral'
15 
16/**
17 * A historical figure the player has reached out to. Figures are freeform — the
18 * player can write to anyone, in any era. The AI infers each figure's era and a
19 * short descriptor on first contact, which we cache here for the UI.
20 */
21export interface HistoricalFigure {
22 name: string
23 era: string
24 descriptor: string
26 
27/**
28 * Timeline Ledger entry — a single recorded change to history.
29 *
30 * The ledger is the heart of the game: every resolved turn appends one entry
31 * describing how the world bent, so the player literally watches the timeline
32 * rewrite itself as they play.
33 */
34export interface TimelineEvent {
35 id: string
36 figureName: string
37 era: string
38 headline: string
39 detail: string
40 diceRoll: number
41 diceOutcome: DiceOutcome
42 progressChange: number
43 /** The engine's swing BEFORE the anachronism amplifier — shown so the player
44 * can see exactly what their wager did ("+8 ⚡ far-ahead → +11%"). */
45 baseProgressChange?: number
46 valence: Valence
47 /** How anachronistic the player's nudge was — it widened this swing. */
48 anachronism?: Anachronism
49 /** The Judge's grade of the dispatch that caused this change. */
50 craft?: Craft
51 /** Signed year of the intervention, when the contact was grounded. */
52 whenSigned?: number
53 /** True when this was the run's staked last stand (doubled swing). */
54 staked?: boolean
55 timestamp: Date
57 
58/**
59 * Message Interface — one line in the conversation with a figure.
60 * Dice / outcome / action / impact / progress are recorded on the AI (figure)
61 * turn, since that is the resolved result of the roll.
62 */
63export interface Message {
64 text: string
65 sender: 'user' | 'ai' | 'system'
66 timestamp: Date
67 figureName?: string
68 /** The effective (craft-tilted) roll — what the bands judged. */
69 diceRoll?: number
70 diceOutcome?: DiceOutcome
71 /** The die as it actually landed, before the craft modifier. */
72 naturalRoll?: number
73 /** The Judge's tilt on the roll (±2..0), and the grade + reason behind it. */
74 rollModifier?: number
75 craft?: Craft
76 craftReason?: string
77 characterAction?: string
78 timelineImpact?: string
79 progressChange?: number
80 /** The pre-amplifier swing + the wager level, for the reveal's equation. */
81 baseProgressChange?: number
82 anachronism?: Anachronism
83 /** True when this turn was the staked last stand. */
84 staked?: boolean
86 
87export type GameStatus = 'playing' | 'victory' | 'defeat'
88 
89/**
90 * Shape returned by `POST /api/send-message` (see `server/api/send-message.post.ts`).
91 * Discriminated by `success`: when `true`, all fields downstream of the dice roll are
92 * guaranteed; when `false`, only `userMessage` + `error` are guaranteed (and the
93 * Character-AI-failure path doesn't even surface a dice roll). The discriminant lets
94 * the consumer destructure without optional chaining or null gymnastics.
95 */
96type SendMessageApiResponse =
97 | {
98 success: true
99 message: string
100 data: {
101 userMessage: string
102 figure: { name: string; era: string; descriptor: string }
103 diceRoll: number
104 diceOutcome: DiceOutcome
105 // Judge-of-craft fields (optional so older mocks/fixtures stay valid;
106 // the live server always sends them).
107 naturalRoll?: number
108 rollModifier?: number
109 craft?: Craft
110 craftReason?: string
111 staked?: boolean
112 characterResponse: { message: string; action: string }
113 timeline: {
114 headline: string
115 detail: string
116 era: string
117 progressChange: number
118 baseProgressChange?: number
119 valence: Valence
120 anachronism?: Anachronism
121 }
122 error: null
123 }
124 }
125 | {
126 success: false
127 message: string
128 data: {
129 userMessage: string
130 diceRoll?: number
131 diceOutcome?: DiceOutcome
132 characterResponse?: { message: string; action: string }
133 error: string
134 /** A content-moderation block (not an infra failure): the dispatch was
135 * refused by the detector, the Sentinel, or the model itself. */
136 blocked?: boolean
137 moderationReason?: string
138 }
139 }
140 
141const RATE_LIMIT_MS = 1000 // message cooldown window, in ms
142const MAX_PROGRESS = 100 // objective progress is clamped to 0..MAX_PROGRESS
143 
144function valenceOf(progressChange: number): Valence {
145 if (progressChange > 0) return 'positive'
146 if (progressChange < 0) return 'negative'
147 return 'neutral'
149 
150/** Formats a signed timeline year (AD positive, BCE negative) for display. */
151export function formatContactYear(signed: number): string {
152 return signed < 0 ? `${-signed} BC` : `${signed}`
154 
155/** Human lifespan line for a grounded figure, e.g. "69 BC – 30 BC" or "1942 – present". */
156function lifespanText(g: GroundedFigure): string | undefined {
157 if (!g.born) return undefined
158 if (g.died) return `${g.born.display}${g.died.display}`
159 return g.living ? `${g.born.display} – present` : g.born.display
161 
162export type ContactLiveness = 'ok' | 'before-birth' | 'after-death' | 'unknown'
163 
164/**
165 * Game Store — core state for a session of freeform timeline editing.
166 */
167export const useGameStore = defineStore('game', {
168 state: () => ({
169 remainingMessages: TOTAL_MESSAGES,
170 messageHistory: [] as Message[],
171 gameStatus: 'playing' as GameStatus,
172 isLoading: false,
173 error: null as string | null,
174 lastMessageTime: null as number | null,
175 isRateLimited: false,
176 // Staleness plumbing, not run state. runEpoch increments on every reset so an
177 // async result from a dead run can never write into a fresh one; the seq
178 // counters order overlapping requests of the same kind so only the latest
179 // lands (an earlier chronicle rewrite must not overwrite a later one).
180 // Deliberately NOT restored by resetGame — they must survive it to work.
181 runEpoch: 0,
182 chronicleSeq: 0,
183 groundingSeq: 0,
184 currentObjective: null as GameObjective | null,
185 objectiveProgress: 0,
186 timelineEvents: [] as TimelineEvent[],
187 figures: [] as HistoricalFigure[],
188 activeFigureName: '' as string,
189 // Grounding for the active contact: real facts + the chosen year to reach them.
190 figureGrounding: null as GroundedFigure | null,
191 groundingLoading: false,
192 contactWhen: null as number | null,
193 /** Optional sub-year refinement of contactWhen — display/prompt flavor
194 * only; every mechanic keeps doing arithmetic on the YEAR (issue #32). */
195 contactMoment: null as ContactMoment | null,
196 // Era-relevant figure suggestions for the current objective (the on-ramp).
197 figureSuggestions: [] as FigureSuggestion[],
198 suggestionsLoading: false,
199 suggestionsFor: '' as string,
200 // The living Chronicle (Layer 3): a prose telling of the altered timeline,
201 // rewritten each turn. Non-blocking — see refreshChronicle.
202 chronicle: null as ChronicleEntry | null,
203 chronicleLoading: false,
204 // The Archive (prototype): an objective-blind brief on the active figure, so
205 // the player can research who they're reaching without leaving the game. The
206 // brief is moment-specific, so it's cached against the figure AND the year.
207 figureStudy: null as FigureStudy | null,
208 studyLoading: false,
209 studyFor: '' as string,
210 studyWhen: null as number | null,
211 // Archive topic lookup (the "yellow" layer): concrete domain facts on demand.
212 archiveResult: null as ArchiveLookup | null,
213 lookupLoading: false,
214 // A content-moderation block — distinct from `error` (an infra hiccup) so
215 // the UI shows an honest, visibly different banner. One per surface that
216 // takes untrusted input into a model: the dispatch/turn, the Archive lookup,
217 // the Archivist study, and the figure-suggestions step.
218 moderationNotice: null as string | null,
219 archiveNotice: null as string | null,
220 studyNotice: null as string | null,
221 suggestionsNotice: null as string | null
222 }),
223 
224 getters: {
225 /**
226 * Can the player send right now? (messages left, still playing, not
227 * mid-request, not rate-limited)
228 */
229 canSendMessage(): boolean {
230 return this.remainingMessages > 0 &&
231 this.gameStatus === 'playing' &&
232 !this.isLoading &&
233 !this.isRateLimited
234 },
235 
236 /**
237 * The conversation thread with a single figure (their turns + the
238 * player's turns addressed to them). Each figure keeps a coherent,
239 * independent thread.
240 */
241 conversationWith(): (name: string) => Message[] {
242 return (name: string) => this.messageHistory.filter(
243 m => m.figureName === name && m.sender !== 'system'
244 )
245 },
246 
247 /** Total progress, net of setbacks, the player has clawed back. */
248 gameSummary(): GameSummary {
249 return generateGameSummary(this)
250 },
251 
252 /**
253 * Whether the chosen `when` falls within the active figure's lifetime.
254 * 'unknown' (permissive) whenever we lack solid dates — we never block a
255 * figure we couldn't ground, only one we know you can't reach yet/anymore.
256 */
257 contactLiveness(): ContactLiveness {
258 const g = this.figureGrounding
259 if (!g || !g.resolved || !g.born || this.contactWhen == null) return 'unknown'
260 if (this.contactWhen < g.born.signed) return 'before-birth'
261 const latest = g.died ? g.died.signed : new Date().getFullYear()
262 if (this.contactWhen > latest) return 'after-death'
263 return 'ok'
264 },
265 
266 /** Can the chosen contact + when actually be reached? */
267 canContact(): boolean {
268 return this.contactLiveness === 'ok' || this.contactLiveness === 'unknown'
269 },
270 
271 /**
272 * The last stand is offered ONLY when the final dispatch can no longer win
273 * inside the normal per-turn fuse — from any nearer position an unstaked
274 * throw can still land it, and offering the (strictly win-probability-
275 * increasing) doubling there would be a dominance trap, not a decision.
276 */
277 canStake(): boolean {
278 return this.remainingMessages === 1 &&
279 this.gameStatus === 'playing' &&
280 (MAX_PROGRESS - this.objectiveProgress) > MAX_PROGRESS_SWING
281 },
282 
283 /**
284 * The active figure's age at the chosen `when` — so the player never has to
285 * do lifetime math in their head. Null when we lack a birth year or a chosen
286 * year (unresolved / free-form contact). Corrects for the missing year zero
287 * when a life spans the BC→AD boundary (1 BC → AD 1 is one year, not two).
288 */
289 contactAge(): number | null {
290 const g = this.figureGrounding
291 if (!g?.resolved || !g.born || this.contactWhen == null) return null
292 const bornSigned = g.born.signed
293 const whenSigned = this.contactWhen
294 if (whenSigned < bornSigned) return null // before birth — no meaningful age
295 const crossedZero = bornSigned < 0 && whenSigned > 0 ? 1 : 0
296 return whenSigned - bornSigned - crossedZero
297 }
298 },
299 
300 actions: {
301 // ---------- message helpers ----------
302 addUserMessage(text: string, figureName?: string) {
303 if (this.gameStatus !== 'playing') return
304 this.messageHistory.push({
305 text,
306 sender: 'user',
307 timestamp: new Date(),
308 figureName
309 })
310 },
311 
312 addAIMessage(text: string, figureName?: string) {
313 this.messageHistory.push({
314 text,
315 sender: 'ai',
316 timestamp: new Date(),
317 figureName
318 })
319 },
320 
321 addAIMessageWithData(messageData: Partial<Message> & { text: string, sender: 'ai' }) {
322 this.messageHistory.push({
323 text: messageData.text,
324 sender: messageData.sender,
325 timestamp: messageData.timestamp || new Date(),
326 figureName: messageData.figureName,
327 diceRoll: messageData.diceRoll,
328 diceOutcome: messageData.diceOutcome,
329 naturalRoll: messageData.naturalRoll,
330 rollModifier: messageData.rollModifier,
331 craft: messageData.craft,
332 craftReason: messageData.craftReason,
333 characterAction: messageData.characterAction,
334 timelineImpact: messageData.timelineImpact,
335 progressChange: messageData.progressChange,
336 baseProgressChange: messageData.baseProgressChange,
337 anachronism: messageData.anachronism,
338 staked: messageData.staked
339 })
340 },
341 
342 // ---------- figures ----------
343 registerFigure(figure: HistoricalFigure) {
344 const existing = this.figures.find(f => f.name === figure.name)
345 if (existing) {
346 if (figure.era) existing.era = figure.era
347 if (figure.descriptor) existing.descriptor = figure.descriptor
348 } else {
349 this.figures.push({ ...figure })
350 }
351 },
352 
353 setActiveFigure(name: string) {
354 this.activeFigureName = name
355 },
356 
357 /**
358 * Synchronously clears the dossier the moment the contact NAME changes, so
359 * a send racing the grounding debounce/fetch goes out clean (free-form)
360 * instead of wearing the PREVIOUS figure's facts, year, and liveness gate —
361 * and so the wrong year can never be written into the ledger's whenSigned.
362 */
363 clearGrounding() {
364 this.groundingSeq++ // invalidate any lookup still in flight
365 this.figureGrounding = null
366 this.contactWhen = null
367 this.contactMoment = null
368 this.groundingLoading = false
369 this.figureStudy = null
370 this.studyFor = ''
371 this.studyWhen = null
372 this.studyNotice = null
373 },
374 
375 /**
376 * Resolves the active figure against the grounding service and defaults the
377 * "when" to a point inside any known lifetime. Never throws: an unresolved
378 * figure simply clears grounding, leaving the contact free-form.
379 */
380 async groundActiveFigure(name: string): Promise<void> {
381 const target = (name || '').trim()
382 // A new contact makes any prior Archive study stale.
383 this.figureStudy = null
384 this.studyFor = ''
385 this.studyWhen = null
386 this.studyNotice = null
387 if (!target) {
388 this.groundingSeq++ // invalidate any lookup still in flight
389 this.figureGrounding = null
390 this.contactWhen = null
391 this.contactMoment = null
392 this.groundingLoading = false
393 return
394 }
395 // Out-of-order guard: only the LATEST lookup may land. Without it, a slow
396 // response for the previous name attaches the wrong dossier (and a wrong
397 // figureContext on a fast send) to whatever the player typed next.
398 const epoch = this.runEpoch
399 const seq = ++this.groundingSeq
400 this.groundingLoading = true
401 try {
402 const grounded = await $fetch('/api/figure', { params: { name: target } }) as GroundedFigure
403 if (epoch !== this.runEpoch || seq !== this.groundingSeq) return
404 this.figureGrounding = grounded
405 this.contactMoment = null
406 if (grounded?.resolved && grounded.born) {
407 const latest = grounded.died ? grounded.died.signed : new Date().getFullYear()
408 this.contactWhen = Math.round((grounded.born.signed + latest) / 2)
409 } else {
410 this.contactWhen = null
411 }
412 } catch (error) {
413 if (epoch !== this.runEpoch || seq !== this.groundingSeq) return
414 console.error('Figure grounding failed:', error)
415 this.figureGrounding = null
416 this.contactWhen = null
417 this.contactMoment = null
418 } finally {
419 if (epoch === this.runEpoch && seq === this.groundingSeq) this.groundingLoading = false
420 }
421 },
422 
423 setContactWhen(year: number | null) {
424 // Scrubbing the year keeps any pinned month/day — the pin refines
425 // whichever year is chosen, it doesn't belong to one.
426 this.contactWhen = year
427 },
428 
429 setContactMoment(moment: ContactMoment | null) {
430 this.contactMoment = moment
431 },
432 
433 /**
434 * Studies the active (grounded) figure via the Archivist — an objective-blind
435 * brief on who they are AT THE CHOSEN MOMENT, so the player can research in the
436 * game instead of a browser tab. The brief is moment-specific, so it's cached
437 * against the figure AND the year: move the contact slider and the player can
438 * re-study them at the new moment. Graceful on failure (leaves the prior brief).
439 * Only resolved figures can be studied — an unresolved name has no record.
440 */
441 async studyActiveFigure(): Promise<void> {
442 const g = this.figureGrounding
443 if (!g?.resolved) {
444 this.figureStudy = null
445 return
446 }
447 // Cache hit only when both the figure AND the studied year still match.
448 if (this.studyFor === g.name && this.studyWhen === this.contactWhen && this.figureStudy) return
449 
450 // Capture the moment being studied NOW: the brief describes this figure at
451 // this year. If the slider moves mid-flight, recording the live value would
452 // mislabel the brief and silently suppress the Re-study button.
453 const epoch = this.runEpoch
454 const studiedName = g.name
455 const studiedWhen = this.contactWhen
456 this.studyLoading = true
457 this.studyNotice = null
458 try {
459 const res = await $fetch('/api/research', {
460 method: 'POST',
461 headers: { 'Content-Type': 'application/json' },
462 body: {
463 figureName: studiedName,
464 when: studiedWhen != null ? formatContactYear(studiedWhen) : undefined,
465 description: g.description,
466 extract: g.extract
467 }
468 }) as { success?: boolean; study?: FigureStudy; blocked?: boolean; error?: string }
469 if (epoch !== this.runEpoch) return
470 // If the contact changed mid-flight, the stale-study clear in
471 // groundActiveFigure already ran — don't resurrect the old brief.
472 if (res?.blocked && this.figureGrounding?.name === studiedName) {
473 this.studyNotice = res.error || 'That study was blocked by content moderation.'
474 } else if (res?.success && res.study && this.figureGrounding?.name === studiedName) {
475 this.figureStudy = res.study
476 this.studyFor = studiedName
477 this.studyWhen = studiedWhen
478 }
⋯ 57 lines hidden (lines 479–535)
479 } catch (error) {
480 if (epoch !== this.runEpoch) return
481 console.error('Failed to study the figure:', error)
482 } finally {
483 if (epoch === this.runEpoch) this.studyLoading = false
484 }
485 },
486 
487 /**
488 * Asks the Archive about a freeform topic (the "yellow" layer) — concrete
489 * domain facts: what it is, what it takes, and when it first became known
490 * (the player's anachronism read). Graceful on failure. Topic-scoped, so it
491 * persists across figure changes within a run.
492 */
493 async askArchive(query: string): Promise<void> {
494 const q = (query || '').trim()
495 if (!q) return
496 const epoch = this.runEpoch
497 this.lookupLoading = true
498 this.archiveNotice = null
499 try {
500 const res = await $fetch('/api/lookup', {
501 method: 'POST',
502 headers: { 'Content-Type': 'application/json' },
503 body: { query: q }
504 }) as { success?: boolean; lookup?: ArchiveLookup; blocked?: boolean; error?: string }
505 if (epoch !== this.runEpoch) return
506 if (res?.blocked) {
507 // The Archive is the sharpest elicitation vector — a block here is
508 // honest and visible, not a silent empty result.
509 this.archiveNotice = res.error || 'That lookup was blocked by content moderation.'
510 } else if (res?.success && res.lookup) {
511 this.archiveResult = res.lookup
512 }
513 } catch (error) {
514 if (epoch !== this.runEpoch) return
515 console.error('Archive lookup failed:', error)
516 } finally {
517 if (epoch === this.runEpoch) this.lookupLoading = false
518 }
519 },
520 
521 /**
522 * Loads era-relevant figure suggestions for the current objective (the
523 * educational on-ramp). Cached per objective; on any failure it leaves the
524 * list empty so the UI falls back to its generic starters.
525 */
526 async loadSuggestions(): Promise<void> {
527 const objective = this.currentObjective
528 if (!objective) {
529 this.figureSuggestions = []
530 this.suggestionsFor = ''
531 return
532 }
533 if (this.suggestionsFor === objective.title && this.figureSuggestions.length) return
534 
535 const epoch = this.runEpoch
536 this.suggestionsLoading = true
537 this.suggestionsNotice = null
538 try {
539 const res = await $fetch('/api/suggestions', {
540 method: 'POST',
541 headers: { 'Content-Type': 'application/json' },
542 body: {
543 objective: {
544 title: objective.title,
545 description: objective.description,
546 era: objective.era
547 }
548 }
549 }) as { success?: boolean; suggestions?: FigureSuggestion[]; blocked?: boolean; reason?: string }
550 if (epoch !== this.runEpoch) return
551 // A blocked objective must not masquerade as an empty result — surface it.
552 if (res?.blocked) this.suggestionsNotice = res.reason || 'That objective was blocked by content moderation.'
553 this.figureSuggestions = res?.suggestions ?? []
554 this.suggestionsFor = objective.title
⋯ 419 lines hidden (lines 555–973)
555 } catch (error) {
556 if (epoch !== this.runEpoch) return
557 console.error('Failed to load figure suggestions:', error)
558 this.figureSuggestions = []
559 // Record that this objective WAS asked, even though it failed — the
560 // picker uses suggestionsFor to tell "not yet asked" (skeletons)
561 // from "asked and came up empty" (the honest famous-names fallback).
562 this.suggestionsFor = objective.title
563 } finally {
564 if (epoch === this.runEpoch) this.suggestionsLoading = false
565 }
566 },
567 
568 // ---------- timeline ledger ----------
569 addTimelineEvent(
570 evt: Omit<TimelineEvent, 'id' | 'timestamp' | 'valence'> & { valence?: Valence; timestamp?: Date }
571 ) {
572 this.timelineEvents.push({
573 id: `evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
574 figureName: evt.figureName,
575 era: evt.era,
576 headline: evt.headline,
577 detail: evt.detail,
578 diceRoll: evt.diceRoll,
579 diceOutcome: evt.diceOutcome,
580 progressChange: evt.progressChange,
581 baseProgressChange: evt.baseProgressChange,
582 valence: evt.valence ?? valenceOf(evt.progressChange),
583 anachronism: evt.anachronism,
584 craft: evt.craft,
585 whenSigned: evt.whenSigned,
586 staked: evt.staked,
587 timestamp: evt.timestamp ?? new Date()
588 })
589 },
590 
591 // ---------- the living chronicle (Layer 3) ----------
592 /**
593 * Re-narrates the Chronicle from the freshly-bent timeline. Fired
594 * non-blocking after a resolved turn (and at game end): the dice/progress
595 * reveal never waits on prose. True to the game's name, the WHOLE account is
596 * rewritten each turn — a later change can re-frame how earlier events read.
597 * Graceful: a failed refresh keeps the prior telling, so the panel never
598 * blanks; an empty timeline clears it (nothing to chronicle yet).
599 */
600 async refreshChronicle(): Promise<void> {
601 if (!this.timelineEvents.length) {
602 this.chronicle = null
603 return
604 }
605 // Sequencing: rewrites overlap (each turn fires one, never awaited), so
606 // only the LATEST issued refresh may land — an earlier telling arriving
607 // late must not regress the account (or worse, the epilogue). The epoch
608 // guard keeps a dead run's telling out of a fresh run entirely.
609 const epoch = this.runEpoch
610 const seq = ++this.chronicleSeq
611 this.chronicleLoading = true
612 try {
613 const res = await $fetch('/api/chronicle', {
614 method: 'POST',
615 headers: { 'Content-Type': 'application/json' },
616 body: {
617 objective: this.currentObjective,
618 status: this.gameStatus,
619 progress: this.objectiveProgress,
620 timeline: this.timelineEvents.map(e => ({
621 era: e.era,
622 figureName: e.figureName,
623 headline: e.headline,
624 detail: e.detail,
625 progressChange: e.progressChange
626 }))
627 }
628 }) as { success?: boolean; chronicle?: ChronicleEntry }
629 if (epoch !== this.runEpoch || seq !== this.chronicleSeq) return
630 if (res?.success && res.chronicle) this.chronicle = res.chronicle
631 } catch (error) {
632 if (epoch !== this.runEpoch || seq !== this.chronicleSeq) return
633 console.error('Failed to refresh the chronicle:', error)
634 // Keep the prior chronicle — a failed refresh never blanks the panel.
635 } finally {
636 if (epoch === this.runEpoch && seq === this.chronicleSeq) this.chronicleLoading = false
637 }
638 },
639 
640 // ---------- simple setters ----------
641 setLoading(loading: boolean) { this.isLoading = loading },
642 setError(error: string | null) { this.error = error },
643 clearModerationNotice() { this.moderationNotice = null },
644 clearArchiveNotice() { this.archiveNotice = null },
645 
646 // ---------- rate limiting ----------
647 checkRateLimit(): boolean {
648 const now = Date.now()
649 if (this.lastMessageTime && (now - this.lastMessageTime) < RATE_LIMIT_MS) return false
650 return true
651 },
652 
653 setRateLimit() {
654 this.isRateLimited = true
655 const remainingTime = this.lastMessageTime
656 ? Math.max(0, RATE_LIMIT_MS - (Date.now() - this.lastMessageTime))
657 : 0
658 setTimeout(() => { this.isRateLimited = false }, remainingTime || RATE_LIMIT_MS)
659 },
660 
661 // ---------- counter & status ----------
662 decrementMessages() {
663 if (this.remainingMessages > 0) this.remainingMessages--
664 },
665 
666 /**
667 * Rolls back an unresolved turn: refunds the spent message and drops the
668 * dangling user entry, so the counter and history can't drift out of sync.
669 * Used for BOTH failure shapes — a thrown request and a graceful HTTP-200
670 * `success:false` — an infra hiccup must never burn one of the five
671 * dispatches (or, on the last one, convert into an instant unearned defeat).
672 */
673 refundUnresolvedTurn() {
674 if (this.remainingMessages < TOTAL_MESSAGES) this.remainingMessages++
675 for (let i = this.messageHistory.length - 1; i >= 0; i--) {
676 if (this.messageHistory[i].sender === 'user') {
677 this.messageHistory.splice(i, 1)
678 break
679 }
680 }
681 },
682 
683 applyProgress(progressChange: number) {
684 this.objectiveProgress = Math.max(0, Math.min(MAX_PROGRESS, this.objectiveProgress + progressChange))
685 },
686 
687 /**
688 * Resolves win/lose AFTER a turn's progress has been applied.
689 *
690 * Victory the instant the objective hits 100%; defeat only once the
691 * final message is spent without reaching it. This fixes the old
692 * premature-defeat bug, where status flipped on the last decrement
693 * BEFORE that turn's progress had a chance to land.
694 */
695 resolveGameStatus() {
696 if (this.objectiveProgress >= MAX_PROGRESS) {
697 this.gameStatus = 'victory'
698 } else if (this.remainingMessages <= 0) {
699 this.gameStatus = 'defeat'
700 }
701 },
702 
703 // ---------- the turn ----------
704 /**
705 * Sends a 160-char message to a chosen figure and folds the result back
706 * into the world: the figure replies + acts, the dice decide the swing,
707 * and the Timeline Engine records how history bent.
708 *
709 * Returns `true` only when the turn actually RESOLVED (a ledger entry
710 * landed). A blocked or failed send returns `false` so the composer can
711 * keep the player's crafted words instead of wiping them.
712 *
713 * `opts.stake` arms the last stand — honored only when `canStake` holds
714 * (final message, win out of normal reach; the server doubles the resolved
715 * swing, both ways, past the usual cap).
716 */
717 async sendMessage(text: string, figureName?: string, opts?: { stake?: boolean }): Promise<boolean> {
718 const target = (figureName ?? this.activeFigureName ?? '').trim()
719 const stake = opts?.stake === true && this.canStake
720 
721 if (!this.canSendMessage) return false
722 if (!target) {
723 this.setError('Choose who in history to send your message to first.')
724 return false
725 }
726 if (!this.checkRateLimit()) {
727 this.setRateLimit()
728 this.setError('The timeline needs a moment — wait before sending again.')
729 return false
730 }
731 if (!this.canContact) {
732 const who = this.figureGrounding?.name || target
733 this.setError(
734 this.contactLiveness === 'before-birth'
735 ? `${who} isn't born yet in that year — choose a later moment to reach them.`
736 : `${who} has died by that year — choose an earlier moment to reach them.`
737 )
738 return false
739 }
740 
741 this.setError(null)
742 this.moderationNotice = null
743 this.setActiveFigure(target)
744 this.addUserMessage(text, target)
745 this.decrementMessages()
746 this.setLoading(true)
747 this.lastMessageTime = Date.now()
748 
749 // If the run is reset while this request is in flight, every write below
750 // would land in a world that no longer exists — the epoch guard drops the
751 // response (no fold-in, no refund: the new run's counter is not ours).
752 const epoch = this.runEpoch
753 const ledgerBefore = this.timelineEvents.length
754 // Capture the contact year NOW: the slider can move while the request is
755 // in flight, and the ledger must record the year this turn was SENT to.
756 const sentWhen = this.contactWhen
757 const sentMoment = this.contactMoment
758 let resolved = false
759 
760 try {
761 const response = await $fetch<SendMessageApiResponse>('/api/send-message', {
762 method: 'POST',
763 headers: { 'Content-Type': 'application/json' },
764 body: {
765 message: text,
766 figureName: target,
767 when: sentWhen != null ? formatContactMoment(sentWhen, sentMoment) : undefined,
768 whenSigned: sentWhen ?? undefined,
769 // The pinned moment travels as validated integers; the server
770 // re-derives the display string rather than trusting ours.
771 whenMonth: sentWhen != null ? sentMoment?.month : undefined,
772 whenDay: sentWhen != null ? sentMoment?.day : undefined,
773 stake,
774 figureContext: this.figureGrounding?.resolved
775 ? {
776 description: this.figureGrounding.description,
777 lifespan: lifespanText(this.figureGrounding)
778 }
779 : undefined,
780 objective: this.currentObjective,
781 timeline: this.timelineEvents.map(e => ({
782 era: e.era,
783 figureName: e.figureName,
784 headline: e.headline,
785 detail: e.detail,
786 progressChange: e.progressChange,
787 whenSigned: e.whenSigned
788 })),
789 conversationHistory: this.conversationWith(target)
790 }
791 })
792 
793 if (epoch !== this.runEpoch) return false
794 
795 if (response?.success && response.data?.characterResponse) {
796 const { figure, characterResponse, diceRoll, diceOutcome, timeline } = response.data
797 
798 if (figure?.name) {
799 this.registerFigure({
800 name: figure.name,
801 era: figure.era || '',
802 descriptor: figure.descriptor || ''
803 })
804 }
805 
806 this.addAIMessageWithData({
807 text: characterResponse.message,
808 sender: 'ai',
809 figureName: target,
810 timestamp: new Date(),
811 diceRoll,
812 diceOutcome,
813 naturalRoll: response.data.naturalRoll ?? diceRoll,
814 rollModifier: response.data.rollModifier ?? 0,
815 craft: response.data.craft,
816 craftReason: response.data.craftReason,
817 characterAction: characterResponse.action,
818 timelineImpact: timeline?.detail,
819 progressChange: timeline?.progressChange,
820 baseProgressChange: timeline?.baseProgressChange,
821 anachronism: timeline?.anachronism,
822 staked: response.data.staked
823 })
824 
825 if (timeline) {
826 this.addTimelineEvent({
827 figureName: target,
828 era: timeline.era || figure?.era || (this.currentObjective?.era ?? ''),
829 headline: timeline.headline,
830 detail: timeline.detail,
831 diceRoll,
832 diceOutcome,
833 progressChange: timeline.progressChange ?? 0,
834 baseProgressChange: timeline.baseProgressChange,
835 valence: timeline.valence,
836 anachronism: timeline.anachronism,
837 craft: response.data.craft,
838 whenSigned: sentWhen ?? undefined,
839 staked: response.data.staked
840 })
841 // Use !== undefined so a genuine neutral (0%) turn still
842 // registers instead of silently vanishing.
843 if (timeline.progressChange !== undefined) {
844 this.applyProgress(timeline.progressChange)
845 }
846 resolved = true
847 }
848 } else {
849 // A graceful failure (HTTP 200, success:false): an AI layer gave
850 // out mid-turn. No ripple landed, so the dispatch is refunded —
851 // exactly like the thrown path below.
852 // Narrow to the failure variant (its data carries `blocked`).
853 const failed = response && !response.success ? response.data : undefined
854 if (failed?.blocked) {
855 // A content-moderation block, not an infra hiccup — show an
856 // honest, distinct banner (not "try again"). The composer
857 // keeps the player's words (sendMessage returns false).
858 this.moderationNotice = failed.moderationReason || 'That dispatch was blocked by content moderation and cannot be sent.'
859 } else {
860 this.setError(failed?.error || 'History did not answer. Try again.')
861 }
862 this.refundUnresolvedTurn()
863 }
864 } catch (error) {
865 if (epoch !== this.runEpoch) return false
866 console.error('Error sending message:', error)
867 this.setError('The timeline resisted your message. Please try again.')
868 this.refundUnresolvedTurn()
869 } finally {
870 if (epoch === this.runEpoch) {
871 this.setLoading(false)
872 this.resolveGameStatus()
873 }
874 }
875 
876 // The living chronicle re-narrates the world from the new timeline — but
877 // only when this turn actually added a change, and never awaited: the turn
878 // reveal must not wait on prose. By here the status is resolved, so the
879 // final turn's chronicle carries the victory/defeat framing (the epilogue).
880 if (resolved && this.timelineEvents.length > ledgerBefore) {
881 void this.refreshChronicle()
882 }
883 return resolved
884 },
885 
886 /**
887 * Commits a chosen objective and starts the run from a clean slate of
888 * progress. Used by the mission-select screen for both curated picks and
889 * freshly composed ones.
890 */
891 chooseObjective(objective: GameObjective) {
892 this.currentObjective = objective
893 this.objectiveProgress = 0
894 this.error = null
895 },
896 
897 /**
898 * Asks the server to compose a fresh objective with the model, WITHOUT
899 * committing it — the caller previews it, then commits via chooseObjective.
900 * Generation lives server-side because it needs the OpenAI key (the client
901 * has no access to it). Returns null rather than throwing if composing
902 * fails, so the UI can quietly fall back to the curated pool.
903 */
904 async fetchAIObjective(): Promise<GameObjective | null> {
905 try {
906 const res = await $fetch('/api/objective', { method: 'GET' }) as {
907 success?: boolean
908 objective?: GameObjective
909 }
910 return res?.success && res.objective ? res.objective : null
911 } catch (error) {
912 console.error('Failed to compose a fresh objective:', error)
913 return null
914 }
915 },
916 
917 getVictoryEfficiency() {
918 if (this.gameStatus === 'victory') {
919 const messagesSaved = this.remainingMessages
920 const messagesUsed = TOTAL_MESSAGES - this.remainingMessages
921 const efficiencyPercentage = Math.round((messagesSaved / TOTAL_MESSAGES) * 100)
922 
923 const efficiencyRating = rateEfficiency(messagesSaved)
924 
925 return {
926 messagesSaved,
927 messagesUsed,
928 efficiencyPercentage,
929 efficiencyRating,
930 isEarlyVictory: messagesSaved > 0
931 }
932 }
933 return null
934 },
935 
936 resetGame() {
937 // Tear the epoch first: anything still in flight belongs to the old run
938 // and must find no purchase here. (The seq counters deliberately survive.)
939 this.runEpoch++
940 this.remainingMessages = TOTAL_MESSAGES
941 this.messageHistory = []
942 this.gameStatus = 'playing'
943 this.isLoading = false
944 this.error = null
945 this.lastMessageTime = null
946 this.isRateLimited = false
947 this.currentObjective = null
948 this.objectiveProgress = 0
949 this.timelineEvents = []
950 this.figures = []
951 this.activeFigureName = ''
952 this.figureGrounding = null
953 this.groundingLoading = false
954 this.contactWhen = null
955 this.contactMoment = null
956 this.figureSuggestions = []
957 this.suggestionsLoading = false
958 this.suggestionsFor = ''
959 this.chronicle = null
960 this.chronicleLoading = false
961 this.figureStudy = null
962 this.studyLoading = false
963 this.studyFor = ''
964 this.studyWhen = null
965 this.archiveResult = null
966 this.lookupLoading = false
967 this.moderationNotice = null
968 this.archiveNotice = null
969 this.studyNotice = null
970 this.suggestionsNotice = null
971 }
972 }
973})

Two distinct red block banners, consistent with the dispatch + Archive banners.

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

Verification

410 unit tests (two new: a blocked study surfaces a notice not a brief; a blocked objective surfaces a notice not an empty list), clean typecheck, 12/12 Playwright e2e, PR CI green. The fix targets the exact path the moderation-audit loop flagged — the same loop, run on demand (npm run loop:moderation-audit), is the standing check that it stays closed. That loop's first run is what surfaced this gap; closing the loop on its own signal is the whole point of a reflective guardrail.

The discriminating tests — a moderation block is a distinct notice, never silently dropped.

tests/unit/stores/game.spec.ts · 976 lines
tests/unit/stores/game.spec.ts976 lines · TypeScript
⋯ 841 lines hidden (lines 1–841)
1import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2import { setActivePinia, createPinia } from 'pinia'
3import { useGameStore } from '../../../stores/game'
4import { DiceOutcome } from '../../../utils/dice'
5 
6/**
7 * Builds a successful /api/send-message response. Overrides are merged into data.
8 */
9function okResponse(over: Record<string, any> = {}) {
10 return {
11 success: true,
12 data: {
13 figure: { name: 'Nikola Tesla', era: 'New York, 1900', descriptor: 'Electrical inventor' },
14 characterResponse: { message: 'A bold notion!', action: 'Builds a resonant transmitter' },
15 diceRoll: 14,
16 diceOutcome: 'Success',
17 timeline: { headline: 'Wireless power demonstrated', detail: 'Cities begin to electrify early', era: 'New York, 1900', progressChange: 20, valence: 'positive' },
18 error: null,
19 ...over
20 }
21 }
23 
24describe('game store', () => {
25 let store: ReturnType<typeof useGameStore>
26 let fetchMock: ReturnType<typeof vi.fn>
27 
28 beforeEach(() => {
29 setActivePinia(createPinia())
30 store = useGameStore()
31 fetchMock = vi.fn()
32 vi.stubGlobal('$fetch', fetchMock)
33 })
34 
35 afterEach(() => {
36 vi.unstubAllGlobals()
37 vi.restoreAllMocks()
38 })
39 
40 describe('initial state', () => {
41 it('starts a fresh run', () => {
42 expect(store.remainingMessages).toBe(5)
43 expect(store.gameStatus).toBe('playing')
44 expect(store.objectiveProgress).toBe(0)
45 expect(store.messageHistory).toEqual([])
46 expect(store.timelineEvents).toEqual([])
47 expect(store.figures).toEqual([])
48 expect(store.canSendMessage).toBe(true)
49 })
50 })
51 
52 describe('progress + status resolution', () => {
53 it('clamps progress between 0 and 100', () => {
54 store.applyProgress(150)
55 expect(store.objectiveProgress).toBe(100)
56 store.applyProgress(-999)
57 expect(store.objectiveProgress).toBe(0)
58 })
59 
60 it('decrementMessages does not, by itself, end the game', () => {
61 store.decrementMessages()
62 expect(store.remainingMessages).toBe(4)
63 expect(store.gameStatus).toBe('playing')
64 })
65 
66 it('resolves victory the moment progress hits 100', () => {
67 store.objectiveProgress = 100
68 store.resolveGameStatus()
69 expect(store.gameStatus).toBe('victory')
70 })
71 
72 it('resolves defeat only once messages run out unmet', () => {
73 store.remainingMessages = 0
74 store.objectiveProgress = 50
75 store.resolveGameStatus()
76 expect(store.gameStatus).toBe('defeat')
77 })
78 
79 it('prefers victory over defeat on the final message', () => {
80 store.remainingMessages = 0
81 store.objectiveProgress = 100
82 store.resolveGameStatus()
83 expect(store.gameStatus).toBe('victory')
84 })
85 
86 it('stays playing while messages remain and the objective is unmet', () => {
87 store.remainingMessages = 3
88 store.objectiveProgress = 50
89 store.resolveGameStatus()
90 expect(store.gameStatus).toBe('playing')
91 })
92 })
93 
94 describe('figures', () => {
95 it('registers a new figure and refines it on later contact', () => {
96 store.registerFigure({ name: 'Cleopatra', era: '', descriptor: '' })
97 expect(store.figures).toHaveLength(1)
98 
99 store.registerFigure({ name: 'Cleopatra', era: 'Alexandria, 40 BC', descriptor: 'Queen of Egypt' })
100 expect(store.figures).toHaveLength(1)
101 expect(store.figures[0].era).toBe('Alexandria, 40 BC')
102 expect(store.figures[0].descriptor).toBe('Queen of Egypt')
103 })
104 })
105 
106 describe('timeline ledger', () => {
107 it('derives valence from the progress change when not given', () => {
108 store.addTimelineEvent({ figureName: 'X', era: 'e', headline: 'h', detail: 'd', diceRoll: 5, diceOutcome: DiceOutcome.FAILURE, progressChange: -5 })
109 store.addTimelineEvent({ figureName: 'Y', era: 'e', headline: 'h', detail: 'd', diceRoll: 16, diceOutcome: DiceOutcome.SUCCESS, progressChange: 12 })
110 store.addTimelineEvent({ figureName: 'Z', era: 'e', headline: 'h', detail: 'd', diceRoll: 10, diceOutcome: DiceOutcome.NEUTRAL, progressChange: 0 })
111 
112 expect(store.timelineEvents.map(e => e.valence)).toEqual(['negative', 'positive', 'neutral'])
113 })
114 })
115 
116 describe('conversation threads', () => {
117 it('keeps each figure’s thread independent', () => {
118 store.addUserMessage('To Tesla', 'Tesla')
119 store.addAIMessage('From Tesla', 'Tesla')
120 store.addUserMessage('To Cleopatra', 'Cleopatra')
121 
122 expect(store.conversationWith('Tesla')).toHaveLength(2)
123 expect(store.conversationWith('Cleopatra')).toHaveLength(1)
124 })
125 })
126 
127 describe('sendMessage', () => {
128 it('folds a successful turn into messages, ledger, progress, and figures', async () => {
129 fetchMock.mockResolvedValue(okResponse())
130 
131 await store.sendMessage('Light the world wirelessly', 'Nikola Tesla')
132 
133 // The turn hits /api/send-message (a resolved turn also fires a
134 // non-blocking /api/chronicle refresh — see the chronicle suite below).
135 expect(fetchMock).toHaveBeenCalledWith('/api/send-message', expect.objectContaining({ method: 'POST' }))
136 expect(store.activeFigureName).toBe('Nikola Tesla')
137 
138 // user + figure turns
139 expect(store.messageHistory).toHaveLength(2)
140 const ai = store.messageHistory[1]
141 expect(ai.sender).toBe('ai')
142 expect(ai.diceRoll).toBe(14)
143 expect(ai.characterAction).toBe('Builds a resonant transmitter')
144 expect(ai.timelineImpact).toBe('Cities begin to electrify early')
145 expect(ai.figureName).toBe('Nikola Tesla')
146 
147 // ledger + progress + figure cache
148 expect(store.timelineEvents).toHaveLength(1)
149 expect(store.timelineEvents[0].headline).toBe('Wireless power demonstrated')
150 expect(store.timelineEvents[0].valence).toBe('positive')
151 expect(store.objectiveProgress).toBe(20)
152 expect(store.figures[0]).toMatchObject({ name: 'Nikola Tesla', era: 'New York, 1900' })
153 
154 expect(store.remainingMessages).toBe(4)
155 expect(store.isLoading).toBe(false)
156 })
157 
158 it('records a neutral (0%) turn instead of letting it vanish', async () => {
159 fetchMock.mockResolvedValue(okResponse({
160 timeline: { headline: 'Nothing much changes', detail: 'A pause in history', era: 'x', progressChange: 0, valence: 'neutral' }
161 }))
162 
163 await store.sendMessage('Hmm', 'Tesla')
164 
165 expect(store.timelineEvents).toHaveLength(1)
166 expect(store.timelineEvents[0].valence).toBe('neutral')
167 expect(store.objectiveProgress).toBe(0)
168 })
169 
170 it('refuses to send without a target figure', async () => {
171 await store.sendMessage('Hello?', '')
172 expect(fetchMock).not.toHaveBeenCalled()
173 expect(store.error).toBeTruthy()
174 expect(store.messageHistory).toHaveLength(0)
175 expect(store.remainingMessages).toBe(5)
176 })
177 
178 it('refunds the message and drops the dangling prompt on a network error', async () => {
179 fetchMock.mockRejectedValue(new Error('network down'))
180 
181 await store.sendMessage('A message that fails', 'Tesla')
182 
183 expect(store.error).toBeTruthy()
184 expect(store.remainingMessages).toBe(5)
185 expect(store.messageHistory).toHaveLength(0)
186 expect(store.gameStatus).toBe('playing')
187 })
188 
189 it('refunds the message when the server reports a graceful failure (success:false)', async () => {
190 fetchMock.mockResolvedValue({
191 success: false,
192 message: 'Failed to generate character response',
193 data: { userMessage: 'x', error: 'Character AI failed' }
194 })
195 
196 const resolved = await store.sendMessage('A doomed dispatch', 'Tesla')
197 
198 expect(resolved).toBe(false)
199 expect(store.error).toBe('Character AI failed')
200 expect(store.remainingMessages).toBe(5)
201 expect(store.messageHistory).toHaveLength(0)
202 expect(store.timelineEvents).toHaveLength(0)
203 })
204 
205 it('a moderation block shows a distinct notice (not an infra error) and refunds the turn', async () => {
206 fetchMock.mockResolvedValue({
207 success: false,
208 message: 'Blocked by moderation',
209 data: {
210 userMessage: 'x',
211 blocked: true,
212 moderationReason: 'This dispatch was blocked by content moderation and cannot be sent.',
213 error: 'This dispatch was blocked by content moderation and cannot be sent.'
214 }
215 })
216 
217 const resolved = await store.sendMessage('Synthesize a nerve agent at scale', 'Fritz Haber')
218 
219 expect(resolved).toBe(false)
220 // Distinct from an infra hiccup: the block banner is set, `error` is not.
221 expect(store.moderationNotice).toContain('blocked by content moderation')
222 expect(store.error).toBeNull()
223 expect(store.remainingMessages).toBe(5) // refunded, never burned
224 expect(store.messageHistory).toHaveLength(0)
225 })
226 
227 it('a graceful failure on the FINAL message does not convert to instant defeat', async () => {
228 store.remainingMessages = 1
229 store.objectiveProgress = 60
230 fetchMock.mockResolvedValue({
231 success: false,
232 message: 'Failed to generate timeline analysis',
233 data: { userMessage: 'x', diceRoll: 17, diceOutcome: 'Success', error: 'Timeline AI failed' }
234 })
235 
236 await store.sendMessage('The last word', 'Tesla')
237 
238 // The dispatch is refunded, the run lives on — an infra hiccup is not a loss.
239 expect(store.remainingMessages).toBe(1)
240 expect(store.gameStatus).toBe('playing')
241 })
242 
243 it('reports resolution truthfully: true when a turn lands, false when refunded', async () => {
244 fetchMock.mockResolvedValueOnce(okResponse())
245 expect(await store.sendMessage('First', 'Tesla')).toBe(true)
246 
247 store.lastMessageTime = null // clear the rate-limit window
248 fetchMock.mockRejectedValueOnce(new Error('down'))
249 expect(await store.sendMessage('Second', 'Tesla')).toBe(false)
250 })
251 
252 it('arms the stake only on the final message — never earlier', async () => {
253 fetchMock.mockResolvedValue(okResponse())
254 
255 await store.sendMessage('Early gambit', 'Tesla', { stake: true })
256 let body = fetchMock.mock.calls[0][1].body
257 expect(body.stake).toBe(false) // 5 messages left: the stake is refused
258 
259 store.lastMessageTime = null
260 store.remainingMessages = 1
261 await store.sendMessage('The last stand', 'Tesla', { stake: true })
262 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
263 expect(body.stake).toBe(true)
264 })
265 
266 it('refuses the stake when the final throw can win unstaked (no dominance trap)', async () => {
267 store.remainingMessages = 1
268 store.objectiveProgress = 60 // need 40 ≤ the 50-point fuse — winnable clean
269 fetchMock.mockResolvedValue(okResponse())
270 
271 await store.sendMessage('One clean throw', 'Tesla', { stake: true })
272 
273 const body = fetchMock.mock.calls[0][1].body
274 expect(body.stake).toBe(false)
275 })
276 
277 it('records the staked badge and the turn’s grounded year on ledger AND thread', async () => {
278 store.contactWhen = -48
279 fetchMock.mockResolvedValue(okResponse({
280 staked: true,
281 craft: 'sharp',
282 naturalRoll: 12,
283 rollModifier: 1,
284 timeline: { headline: 'All in', detail: 'Everything on one throw', era: 'x', progressChange: 60, baseProgressChange: 30, valence: 'positive', anachronism: 'far-ahead' }
285 }))
286 
287 await store.sendMessage('Stake it all', 'Tesla')
288 
289 const evt = store.timelineEvents[0]
290 expect(evt.staked).toBe(true)
291 expect(evt.craft).toBe('sharp')
292 expect(evt.whenSigned).toBe(-48)
293 expect(evt.baseProgressChange).toBe(30)
294 const ai = store.messageHistory[1]
295 expect(ai.naturalRoll).toBe(12)
296 expect(ai.rollModifier).toBe(1)
297 // The thread message carries the stake too — the reveal's equation must
298 // attribute the doubling to the ⚑, not to the anachronism tier.
299 expect(ai.staked).toBe(true)
300 })
301 
302 it('does NOT prematurely defeat: a winning final message is a victory', async () => {
303 store.remainingMessages = 1
304 store.objectiveProgress = 90
305 fetchMock.mockResolvedValue(okResponse({
306 timeline: { headline: 'The last push', detail: 'It is done', era: 'x', progressChange: 20, valence: 'positive' }
307 }))
308 
309 await store.sendMessage('The decisive word', 'Tesla')
310 
311 expect(store.objectiveProgress).toBe(100)
312 expect(store.remainingMessages).toBe(0)
313 expect(store.gameStatus).toBe('victory')
314 })
315 
316 it('declares defeat when the final message falls short', async () => {
317 store.remainingMessages = 1
318 store.objectiveProgress = 10
319 fetchMock.mockResolvedValue(okResponse({
320 timeline: { headline: 'Not enough', detail: 'History resists', era: 'x', progressChange: 5, valence: 'positive' }
321 }))
322 
323 await store.sendMessage('A last try', 'Tesla')
324 
325 expect(store.objectiveProgress).toBe(15)
326 expect(store.remainingMessages).toBe(0)
327 expect(store.gameStatus).toBe('defeat')
328 })
329 })
330 
331 describe('living chronicle (refreshChronicle)', () => {
332 const telling = { title: 'The Peace That Held', paragraphs: ['In 1878, Bismarck chose trade.', 'By 1914, no war came.'] }
333 
334 function seedOneChange() {
335 store.addTimelineEvent({
336 figureName: 'Bismarck', era: '1878', headline: 'Bismarck chooses trade',
337 detail: 'Alliances soften into commerce.', diceRoll: 16, diceOutcome: DiceOutcome.SUCCESS, progressChange: 20
338 })
339 }
340 
341 it('re-narrates the timeline and stores the telling', async () => {
342 seedOneChange()
343 fetchMock.mockResolvedValue({ success: true, chronicle: telling })
344 
345 await store.refreshChronicle()
346 
347 expect(fetchMock).toHaveBeenCalledWith('/api/chronicle', expect.objectContaining({ method: 'POST' }))
348 expect(store.chronicle).toEqual(telling)
349 expect(store.chronicleLoading).toBe(false)
350 })
351 
352 it('skips (and clears) when no changes exist to chronicle', async () => {
353 store.chronicle = telling
354 await store.refreshChronicle()
355 expect(fetchMock).not.toHaveBeenCalled()
356 expect(store.chronicle).toBeNull()
357 })
358 
359 it('keeps the prior telling when a refresh fails (never blanks)', async () => {
360 seedOneChange()
361 store.chronicle = telling
362 fetchMock.mockRejectedValue(new Error('chronicler offline'))
363 
364 await store.refreshChronicle()
365 
366 expect(store.chronicle).toEqual(telling)
367 expect(store.chronicleLoading).toBe(false)
368 })
369 
370 it('fires a non-blocking refresh after a turn actually changes the world', async () => {
371 fetchMock.mockResolvedValue(okResponse())
372 
373 await store.sendMessage('Light the world wirelessly', 'Nikola Tesla')
374 
375 // The refresh is fired (not awaited) once the turn added a ledger entry.
376 expect(fetchMock).toHaveBeenCalledWith('/api/chronicle', expect.objectContaining({ method: 'POST' }))
377 })
378 
379 it('does NOT refresh when a turn fails to change the world', async () => {
380 fetchMock.mockRejectedValue(new Error('network down'))
381 
382 await store.sendMessage('A message that fails', 'Tesla')
383 
384 expect(fetchMock).not.toHaveBeenCalledWith('/api/chronicle', expect.anything())
385 })
386 })
387 
388 describe('staleness guards (epoch + sequencing)', () => {
389 function deferred<T>() {
390 let resolve!: (v: T) => void
391 let reject!: (e: unknown) => void
392 const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
393 return { promise, resolve, reject }
394 }
395 
396 it('a reset mid-send drops the stale response instead of haunting the new run', async () => {
397 const slow = deferred<ReturnType<typeof okResponse>>()
398 fetchMock.mockReturnValue(slow.promise)
399 
400 const inFlight = store.sendMessage('From the old world', 'Tesla')
401 store.resetGame()
402 slow.resolve(okResponse())
403 const resolved = await inFlight
404 
405 // The dead run's turn finds no purchase in the fresh one.
406 expect(resolved).toBe(false)
407 expect(store.remainingMessages).toBe(5)
408 expect(store.messageHistory).toHaveLength(0)
409 expect(store.timelineEvents).toHaveLength(0)
410 expect(store.objectiveProgress).toBe(0)
411 })
412 
413 it('a reset mid-send must not "refund" the new run on failure either', async () => {
414 const slow = deferred<never>()
415 fetchMock.mockReturnValue(slow.promise)
416 
417 const inFlight = store.sendMessage('Doomed', 'Tesla')
418 store.resetGame()
419 store.remainingMessages = 3 // the new run has its own spent counter
420 slow.reject(new Error('old run network error'))
421 await inFlight
422 
423 expect(store.remainingMessages).toBe(3) // untouched by the stale refund
424 })
425 
426 it('an earlier chronicle rewrite cannot overwrite a later one', async () => {
427 store.addTimelineEvent({ figureName: 'X', era: 'e', headline: 'h', detail: 'd', diceRoll: 16, diceOutcome: DiceOutcome.SUCCESS, progressChange: 20 })
428 
429 const first = deferred<{ success: boolean; chronicle: { title: string; paragraphs: string[] } }>()
430 const second = deferred<{ success: boolean; chronicle: { title: string; paragraphs: string[] } }>()
431 fetchMock.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
432 
433 const a = store.refreshChronicle()
434 const b = store.refreshChronicle()
435 // The LATER telling lands first; the earlier one limps in afterwards.
436 second.resolve({ success: true, chronicle: { title: 'The Later Telling', paragraphs: ['b'] } })
437 await b
438 first.resolve({ success: true, chronicle: { title: 'The Earlier Telling', paragraphs: ['a'] } })
439 await a
440 
441 expect(store.chronicle?.title).toBe('The Later Telling')
442 expect(store.chronicleLoading).toBe(false)
443 })
444 
445 it('a reset mid-chronicle keeps the old run’s telling out of the new run', async () => {
446 store.addTimelineEvent({ figureName: 'X', era: 'e', headline: 'h', detail: 'd', diceRoll: 16, diceOutcome: DiceOutcome.SUCCESS, progressChange: 20 })
447 const slow = deferred<{ success: boolean; chronicle: { title: string; paragraphs: string[] } }>()
448 fetchMock.mockReturnValue(slow.promise)
449 
450 const inFlight = store.refreshChronicle()
451 store.resetGame()
452 slow.resolve({ success: true, chronicle: { title: 'A Ghost Epilogue', paragraphs: ['boo'] } })
453 await inFlight
454 
455 expect(store.chronicle).toBeNull()
456 })
457 
458 it('out-of-order grounding keeps the latest figure’s dossier', async () => {
459 const slowA = deferred<Record<string, unknown>>()
460 const fastB = deferred<Record<string, unknown>>()
461 fetchMock.mockReturnValueOnce(slowA.promise).mockReturnValueOnce(fastB.promise)
462 
463 const a = store.groundActiveFigure('Aristotle')
464 const b = store.groundActiveFigure('Bismarck')
465 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 })
466 await b
467 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 })
468 await a
469 
470 expect(store.figureGrounding?.name).toBe('Otto von Bismarck')
471 expect(store.groundingLoading).toBe(false)
472 })
473 
474 it('clearGrounding drops the dossier instantly and a lookup still in flight finds no purchase', async () => {
475 const slow = deferred<Record<string, unknown>>()
476 fetchMock.mockReturnValue(slow.promise)
477 
478 const inFlight = store.groundActiveFigure('Napoleon')
479 store.clearGrounding() // the player typed a new name — the old dossier dies NOW
480 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 })
481 await inFlight
482 
483 expect(store.figureGrounding).toBeNull()
484 expect(store.contactWhen).toBeNull()
485 expect(store.groundingLoading).toBe(false)
486 })
487 
488 it('study records the moment it was asked for, not where the slider moved mid-flight', async () => {
489 store.figureGrounding = {
490 name: 'Cleopatra', resolved: true,
491 born: { year: 69, bce: true, signed: -69, display: '69 BC' },
492 died: { year: 30, bce: true, signed: -30, display: '30 BC' }, living: false
493 } as never
494 store.setContactWhen(-40)
495 const slow = deferred<{ success: boolean; study: Record<string, unknown> }>()
496 fetchMock.mockReturnValue(slow.promise)
497 
498 const inFlight = store.studyActiveFigure()
499 store.setContactWhen(-35) // the player keeps exploring while the Archivist works
500 slow.resolve({ success: true, study: { atThisMoment: 'x', grasp: ['a'], reaching: ['b'], cannotYetKnow: 'c' } })
501 await inFlight
502 
503 // The brief is FOR -40; recording -35 would mislabel it and hide Re-study.
504 expect(store.studyWhen).toBe(-40)
505 expect(store.studyFor).toBe('Cleopatra')
506 })
507 })
508 
509 describe('victory efficiency', () => {
510 it('rates an early win and reports messages saved', () => {
511 store.gameStatus = 'victory'
512 store.remainingMessages = 4
513 const eff = store.getVictoryEfficiency()
514 expect(eff?.efficiencyRating).toBe('Legendary')
515 expect(eff?.messagesUsed).toBe(1)
516 expect(eff?.isEarlyVictory).toBe(true)
517 })
518 
519 it('returns null when not a victory', () => {
520 store.gameStatus = 'playing'
521 expect(store.getVictoryEfficiency()).toBeNull()
522 })
523 })
524 
525 describe('resetGame', () => {
526 it('returns everything to a fresh run', () => {
527 store.remainingMessages = 1
528 store.objectiveProgress = 80
529 store.gameStatus = 'defeat'
530 store.messageHistory.push({ text: 'x', sender: 'user', timestamp: new Date() })
531 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() })
532 store.figures.push({ name: 'x', era: 'e', descriptor: 'd' })
533 store.activeFigureName = 'x'
534 store.chronicle = { title: 'A History', paragraphs: ['It happened.'] }
535 store.figureStudy = { atThisMoment: 'x', grasp: [], reaching: [], cannotYetKnow: '' }
536 store.studyFor = 'x'
537 store.studyWhen = 1500
538 store.archiveResult = { topic: 't', summary: 's', requires: [], knownSince: '' }
539 
540 store.resetGame()
541 
542 expect(store.remainingMessages).toBe(5)
543 expect(store.objectiveProgress).toBe(0)
544 expect(store.gameStatus).toBe('playing')
545 expect(store.messageHistory).toEqual([])
546 expect(store.timelineEvents).toEqual([])
547 expect(store.figures).toEqual([])
548 expect(store.activeFigureName).toBe('')
549 expect(store.chronicle).toBeNull()
550 expect(store.figureStudy).toBeNull()
551 expect(store.studyFor).toBe('')
552 expect(store.studyWhen).toBeNull()
553 expect(store.archiveResult).toBeNull()
554 })
555 })
556 
557 describe('objective selection', () => {
558 const objective = {
559 title: 'Save the Library of Alexandria',
560 description: 'Keep the ancient world’s knowledge intact into the modern age.',
561 era: 'Alexandria, antiquity',
562 icon: '📜'
563 }
564 
565 it('chooseObjective commits the objective and resets progress', () => {
566 store.objectiveProgress = 40
567 store.setError('a stale error')
568 
569 store.chooseObjective(objective)
570 
571 expect(store.currentObjective).toEqual(objective)
572 expect(store.objectiveProgress).toBe(0)
573 expect(store.error).toBeNull()
574 })
575 
576 it('fetchAIObjective returns a composed objective WITHOUT committing it', async () => {
577 fetchMock.mockResolvedValue({ success: true, objective })
578 
579 const result = await store.fetchAIObjective()
580 
581 expect(fetchMock).toHaveBeenCalledWith('/api/objective', { method: 'GET' })
582 expect(result).toEqual(objective)
583 // The caller previews it, then commits via chooseObjective — not here.
584 expect(store.currentObjective).toBeNull()
585 })
586 
587 it('fetchAIObjective returns null (never throws) when composing fails', async () => {
588 fetchMock.mockRejectedValue(new Error('archives offline'))
589 expect(await store.fetchAIObjective()).toBeNull()
590 expect(store.currentObjective).toBeNull()
591 })
592 
593 it('fetchAIObjective returns null when the server reports failure', async () => {
594 fetchMock.mockResolvedValue({ success: false })
595 expect(await store.fetchAIObjective()).toBeNull()
596 })
597 })
598 
599 describe('grounded contacts (who · when · liveness)', () => {
600 const cleopatra = {
601 name: 'Cleopatra',
602 resolved: true,
603 description: 'Pharaoh of Egypt from 51 to 30 BC',
604 born: { year: 69, bce: true, signed: -69, display: '69 BC' },
605 died: { year: 30, bce: true, signed: -30, display: '30 BC' },
606 living: false
607 }
608 
609 it('grounds the active figure and defaults the when inside their lifetime', async () => {
610 fetchMock.mockResolvedValue(cleopatra)
611 
612 await store.groundActiveFigure('Cleopatra')
613 
614 expect(fetchMock).toHaveBeenCalledWith('/api/figure', { params: { name: 'Cleopatra' } })
615 expect(store.figureGrounding?.resolved).toBe(true)
616 expect(store.contactWhen).toBeGreaterThanOrEqual(-69)
617 expect(store.contactWhen).toBeLessThanOrEqual(-30)
618 expect(store.contactLiveness).toBe('ok')
619 expect(store.canContact).toBe(true)
620 })
621 
622 it('flags a when before birth or after death', async () => {
623 fetchMock.mockResolvedValue(cleopatra)
624 await store.groundActiveFigure('Cleopatra')
625 
626 store.setContactWhen(-200) // 200 BC — long before she was born
627 expect(store.contactLiveness).toBe('before-birth')
628 expect(store.canContact).toBe(false)
629 
630 store.setContactWhen(1990) // long after she died
631 expect(store.contactLiveness).toBe('after-death')
632 expect(store.canContact).toBe(false)
633 
634 store.setContactWhen(-50) // 50 BC — within her life
635 expect(store.contactLiveness).toBe('ok')
636 })
637 
638 it('stays permissive when a figure cannot be grounded', async () => {
639 fetchMock.mockResolvedValue({ name: 'Nobody', resolved: false })
640 await store.groundActiveFigure('Nobody Atall')
641 
642 expect(store.figureGrounding?.resolved).toBe(false)
643 expect(store.contactWhen).toBeNull()
644 expect(store.contactLiveness).toBe('unknown')
645 expect(store.canContact).toBe(true)
646 })
647 
648 it('treats a living figure as reachable up to the present', async () => {
649 fetchMock.mockResolvedValue({
650 name: 'Paul McCartney', resolved: true,
651 born: { year: 1942, bce: false, signed: 1942, display: '1942' },
652 living: true
653 })
654 await store.groundActiveFigure('Paul McCartney')
655 
656 store.setContactWhen(1965)
657 expect(store.contactLiveness).toBe('ok')
658 store.setContactWhen(1900) // before birth
659 expect(store.contactLiveness).toBe('before-birth')
660 })
661 
662 it('pins, rescrubs, and clears the contact moment (issue #32)', async () => {
663 fetchMock.mockResolvedValue(cleopatra)
664 await store.groundActiveFigure('Cleopatra')
665 
666 store.setContactMoment({ month: 6, day: 28 })
667 expect(store.contactMoment).toEqual({ month: 6, day: 28 })
668 
669 // Scrubbing the year keeps the pin — it refines whichever year is chosen.
670 store.setContactWhen(-44)
671 expect(store.contactMoment).toEqual({ month: 6, day: 28 })
672 
673 // A name change clears it with the rest of the dossier.
674 store.clearGrounding()
675 expect(store.contactMoment).toBeNull()
676 })
677 
678 it('sends the refined when string + raw moment integers (issue #32)', async () => {
679 fetchMock.mockResolvedValue(cleopatra)
680 await store.groundActiveFigure('Cleopatra')
681 store.setContactWhen(-48)
682 store.setContactMoment({ month: 3, day: 15 })
683 fetchMock.mockClear()
684 fetchMock.mockResolvedValue({ success: false, message: 'x', data: { userMessage: 'm', error: 'x' } })
685 
686 await store.sendMessage('Hold court away from the palace today.', 'Cleopatra')
687 
688 const body = (fetchMock.mock.calls[0][1] as { body: Record<string, unknown> }).body
689 expect(body.when).toBe('March 15, 48 BC')
690 expect(body.whenSigned).toBe(-48)
691 expect(body.whenMonth).toBe(3)
692 expect(body.whenDay).toBe(15)
693 })
694 
695 it('clears grounding when the figure is cleared', async () => {
696 fetchMock.mockResolvedValue(cleopatra)
697 await store.groundActiveFigure('Cleopatra')
698 await store.groundActiveFigure('')
699 expect(store.figureGrounding).toBeNull()
700 expect(store.contactWhen).toBeNull()
701 })
702 
703 it('refuses to send to someone outside their lifetime', async () => {
704 fetchMock.mockResolvedValue(cleopatra)
705 await store.groundActiveFigure('Cleopatra')
706 store.setContactWhen(-300) // before birth
707 fetchMock.mockClear()
708 
709 await store.sendMessage('Beware the Ides', 'Cleopatra')
710 
711 expect(fetchMock).not.toHaveBeenCalled() // never reached /api/send-message
712 expect(store.error).toMatch(/born yet/i)
713 expect(store.messageHistory).toHaveLength(0)
714 expect(store.remainingMessages).toBe(5)
715 })
716 })
717 
718 describe('contactAge (so the player skips the lifetime math)', () => {
719 const cleopatra = {
720 name: 'Cleopatra', resolved: true,
721 born: { year: 69, bce: true, signed: -69, display: '69 BC' },
722 died: { year: 30, bce: true, signed: -30, display: '30 BC' },
723 living: false
724 }
725 
726 it('reports the age at the chosen year for a grounded figure', () => {
727 store.figureGrounding = cleopatra as never
728 store.setContactWhen(-50) // 50 BC
729 expect(store.contactAge).toBe(19) // -50 - (-69)
730 })
731 
732 it('is 0 in the birth year', () => {
733 store.figureGrounding = cleopatra as never
734 store.setContactWhen(-69)
735 expect(store.contactAge).toBe(0)
736 })
737 
738 it('corrects for the missing year zero across the BC→AD boundary', () => {
739 store.figureGrounding = {
740 name: 'Augustus', resolved: true,
741 born: { year: 63, bce: true, signed: -63, display: '63 BC' },
742 died: { year: 14, bce: false, signed: 14, display: '14' },
743 living: false
744 } as never
745 store.setContactWhen(14) // AD 14
746 expect(store.contactAge).toBe(76) // 14 - (-63) - 1 (no year zero)
747 })
748 
749 it('handles a modern living figure', () => {
750 store.figureGrounding = {
751 name: 'Paul McCartney', resolved: true,
752 born: { year: 1942, bce: false, signed: 1942, display: '1942' }, living: true
753 } as never
754 store.setContactWhen(1965)
755 expect(store.contactAge).toBe(23)
756 })
757 
758 it('is null when the figure is unresolved or has no birth year', () => {
759 store.figureGrounding = { name: 'Nobody', resolved: false } as never
760 store.setContactWhen(1500)
761 expect(store.contactAge).toBeNull()
762 
763 store.figureGrounding = null
764 expect(store.contactAge).toBeNull()
765 })
766 
767 it('is null before birth rather than a negative age', () => {
768 store.figureGrounding = cleopatra as never
769 store.setContactWhen(-200) // long before she was born
770 expect(store.contactAge).toBeNull()
771 })
772 })
773 
774 describe('the Archive — studyActiveFigure (prototype)', () => {
775 const cleopatra = {
776 name: 'Cleopatra', resolved: true,
777 description: 'Pharaoh of Egypt', extract: 'Cleopatra was the last active ruler of Ptolemaic Egypt.',
778 born: { year: 69, bce: true, signed: -69, display: '69 BC' },
779 died: { year: 30, bce: true, signed: -30, display: '30 BC' }, living: false
780 }
781 const study = {
782 atThisMoment: 'Queen at the height of her power.',
783 grasp: ['statecraft', 'Hellenistic science'],
784 reaching: ['securing Egypt against Rome'],
785 cannotYetKnow: 'the fall of the Roman Republic'
786 }
787 
788 it('studies a grounded figure and stores the brief', async () => {
789 store.figureGrounding = cleopatra as never
790 store.setContactWhen(-40)
791 fetchMock.mockResolvedValue({ success: true, study })
792 
793 await store.studyActiveFigure()
794 
795 expect(fetchMock).toHaveBeenCalledWith('/api/research', expect.objectContaining({ method: 'POST' }))
796 expect(store.figureStudy).toEqual(study)
797 expect(store.studyFor).toBe('Cleopatra')
798 expect(store.studyLoading).toBe(false)
799 })
800 
801 it('caches per figure + year (no refetch on a second study at the same moment)', async () => {
802 store.figureGrounding = cleopatra as never
803 store.setContactWhen(-40)
804 fetchMock.mockResolvedValue({ success: true, study })
805 await store.studyActiveFigure()
806 fetchMock.mockClear()
807 
808 await store.studyActiveFigure()
809 expect(fetchMock).not.toHaveBeenCalled()
810 })
811 
812 it('re-studies the figure when the contact year changes', async () => {
813 store.figureGrounding = cleopatra as never
814 store.setContactWhen(-40)
815 fetchMock.mockResolvedValue({ success: true, study })
816 await store.studyActiveFigure()
817 expect(store.studyWhen).toBe(-40)
818 fetchMock.mockClear()
819 
820 store.setContactWhen(-35) // move the slider
821 await store.studyActiveFigure()
822 
823 expect(fetchMock).toHaveBeenCalledWith('/api/research', expect.objectContaining({ method: 'POST' }))
824 expect(store.studyWhen).toBe(-35)
825 })
826 
827 it('does nothing for an unresolved figure (no record to consult)', async () => {
828 store.figureGrounding = { name: 'Nobody', resolved: false } as never
829 await store.studyActiveFigure()
830 expect(fetchMock).not.toHaveBeenCalled()
831 expect(store.figureStudy).toBeNull()
832 })
833 
834 it('leaves the study empty (no throw) when research fails', async () => {
835 store.figureGrounding = cleopatra as never
836 fetchMock.mockRejectedValue(new Error('archive offline'))
837 await store.studyActiveFigure()
838 expect(store.figureStudy).toBeNull()
839 expect(store.studyLoading).toBe(false)
840 })
841 
842 it('surfaces a moderation block as a distinct notice, not a study', async () => {
843 store.figureGrounding = cleopatra as never
844 store.setContactWhen(-40)
845 fetchMock.mockResolvedValue({ success: false, blocked: true, error: 'That study was blocked by content moderation.' })
846 
847 await store.studyActiveFigure()
848 
849 expect(store.studyNotice).toContain('blocked by content moderation')
850 expect(store.figureStudy).toBeNull()
851 expect(store.studyLoading).toBe(false)
852 })
853 
⋯ 123 lines hidden (lines 854–976)
854 it('drops a stale study when the contact changes', async () => {
855 store.figureStudy = study as never
856 store.studyFor = 'Cleopatra'
857 fetchMock.mockResolvedValue({ name: 'Tesla', resolved: false })
858 
859 await store.groundActiveFigure('Nikola Tesla')
860 
861 expect(store.figureStudy).toBeNull()
862 expect(store.studyFor).toBe('')
863 })
864 })
865 
866 describe('the Archive — askArchive lookup (prototype, yellow)', () => {
867 const lookup = {
868 topic: 'Gunpowder',
869 summary: 'An explosive mix of saltpeter, charcoal, and sulfur.',
870 requires: ['saltpeter', 'charcoal', 'sulfur'],
871 knownSince: 'China, ~9th century'
872 }
873 
874 it('looks up a topic and stores the result', async () => {
875 fetchMock.mockResolvedValue({ success: true, lookup })
876 
877 await store.askArchive('gunpowder')
878 
879 expect(fetchMock).toHaveBeenCalledWith('/api/lookup', expect.objectContaining({ method: 'POST' }))
880 expect(store.archiveResult).toEqual(lookup)
881 expect(store.lookupLoading).toBe(false)
882 })
883 
884 it('ignores an empty query', async () => {
885 await store.askArchive(' ')
886 expect(fetchMock).not.toHaveBeenCalled()
887 })
888 
889 it('leaves the result empty (no throw) when the lookup fails', async () => {
890 fetchMock.mockRejectedValue(new Error('archive offline'))
891 await store.askArchive('gunpowder')
892 expect(store.archiveResult).toBeNull()
893 expect(store.lookupLoading).toBe(false)
894 })
895 })
896 
897 describe('anachronism risk-coupling (prototype)', () => {
898 it('records the anachronism level on the resolved ledger entry', async () => {
899 fetchMock.mockResolvedValue(okResponse({
900 timeline: { headline: 'A leap', detail: 'Generations early', era: 'x', progressChange: 40, valence: 'positive', anachronism: 'far-ahead' }
901 }))
902 
903 await store.sendMessage('Build a flying machine and aim higher', 'Leonardo da Vinci')
904 
905 expect(store.timelineEvents[0].anachronism).toBe('far-ahead')
906 })
907 
908 it('leaves the ledger entry anachronism undefined when the server omits it', async () => {
909 fetchMock.mockResolvedValue(okResponse())
910 await store.sendMessage('A modest note', 'Tesla')
911 expect(store.timelineEvents[0].anachronism).toBeUndefined()
912 })
913 })
914 
915 describe('figure suggestions (era-aware on-ramp)', () => {
916 const objective = {
917 title: 'Prevent the Great War',
918 description: 'Keep 1914 from collapsing into world war.',
919 era: 'Europe, 1914',
920 icon: '🕊️'
921 }
922 
923 it('loads era-relevant suggestions for the current objective', async () => {
924 store.currentObjective = objective
925 fetchMock.mockResolvedValue({
926 success: true,
927 suggestions: [{ name: 'Wilhelm II', reason: 'Could restrain Vienna.', lifespan: '1859 – 1941' }],
928 fallback: false
929 })
930 
931 await store.loadSuggestions()
932 
933 expect(fetchMock).toHaveBeenCalledWith('/api/suggestions', expect.objectContaining({ method: 'POST' }))
934 expect(store.figureSuggestions).toHaveLength(1)
935 expect(store.figureSuggestions[0].name).toBe('Wilhelm II')
936 expect(store.suggestionsFor).toBe('Prevent the Great War')
937 })
938 
939 it('caches per objective (no refetch on a second call)', async () => {
940 store.currentObjective = objective
941 fetchMock.mockResolvedValue({ success: true, suggestions: [{ name: 'X', reason: 'y' }] })
942 await store.loadSuggestions()
943 fetchMock.mockClear()
944 
945 await store.loadSuggestions()
946 expect(fetchMock).not.toHaveBeenCalled()
947 })
948 
949 it('clears suggestions when there is no objective', async () => {
950 store.currentObjective = null
951 await store.loadSuggestions()
952 expect(store.figureSuggestions).toEqual([])
953 expect(fetchMock).not.toHaveBeenCalled()
954 })
955 
956 it('surfaces a blocked objective instead of a silent empty list', async () => {
957 store.currentObjective = objective
958 fetchMock.mockResolvedValue({ success: true, suggestions: [], blocked: true, reason: 'That objective was blocked by content moderation.' })
959 
960 await store.loadSuggestions()
961 
962 expect(store.suggestionsNotice).toContain('blocked by content moderation')
963 expect(store.figureSuggestions).toEqual([])
964 })
965 
966 it('leaves suggestions empty (no throw) when the request fails — but records the ask', async () => {
967 store.currentObjective = objective
968 fetchMock.mockRejectedValue(new Error('suggester down'))
969 await store.loadSuggestions()
970 expect(store.figureSuggestions).toEqual([])
971 // "Asked and failed" must be distinguishable from "not yet asked": the
972 // picker shows skeletons for the latter, the honest fallback for this.
973 expect(store.suggestionsFor).toBe('Prevent the Great War')
974 })
975 })
976})