What changed, and why

When the model can't compose an objective, the server falls back to a random curated one so a run never fails to start. That floor is correct, and this change keeps it. The problem was that the fallback was silent, and in one case silence lied: when the player steered (picked an era or theme) and generation fell over, they got a curated objective that ignored their steer, with nothing to say so. In a playtest it read as "steering is broken" — the real cause was the model account hitting zero credit mid-session, which dropped every compose to curated.

The fix surfaces a quiet, non-blocking notice in exactly that case, and stays silent everywhere else. It threads one new fact — did the AI path actually produce this objective, or is it the curated floor? — from the generator, through the endpoint and the store, to a muted line in the briefing. Four small layers, below, each carrying the bit a little further.

The generator reports its provenance

generateObjective used to return a bare GameObjective, which threw away the one thing the caller now needs: whether that objective came from the model or from the curated floor. It returns a small result instead — { objective, composed } — where composed is true only when the live AI path produced it. Every curated path sets it false: the default no-AI path, and every fallback (a generation error, an out-of-bounds result, a de-dup collision).

The success return sits inside the compose loop; the curated return is the function's last line, reached by the default path or any fallback. (The local composed objective was renamed candidate so the boolean field could take the name.)

The result type, and the two return sites: composed=true only on the AI success path.

server/utils/objective-generator.ts · 278 lines
server/utils/objective-generator.ts278 lines · TypeScript
⋯ 29 lines hidden (lines 1–29)
1/**
2 * Live, AI-composed objectives. The curated pool + the GameObjective shape live
3 * in ./objectives (pure, client-safe); this module owns the model path and is
4 * therefore SERVER-ONLY — it reaches the AI gateway (and through it the model
5 * SDKs + the AsyncLocalStorage run-context, which must never enter the browser
6 * bundle). Client code imports the curated pool + the steer enums from ./objectives
7 * directly.
8 *
9 * A composition is bounded AND steerable (#71):
10 * - BOUNDED — the prompt is constrained to safely-historical objectives, and the
11 * output is independently validated (the prompt alone isn't trusted): a finite
12 * anchor year must sit at or before the shared safely-historical line, a small
13 * structured check gates the living-people / current-events framings free prose
14 * can smuggle past a year bound, and the text runs through the same moderation
15 * seam as messages. Out of bounds → reroll once, then fall back to curated. The
16 * curated pool is always the floor; a generation or validation failure never
17 * hard-fails the run start, it just yields a curated objective.
18 * - STEERED — the player biases the composition along two closed enums (era +
19 * theme); see ObjectiveSteer in ./objectives.
20 */
21import {
22 pickCurated,
23 listCuratedObjectives,
24 normalizeObjectiveTitle,
25 type GameObjective,
26 type ObjectiveSteer
27} from './objectives'
28import { SAFELY_HISTORICAL_BEFORE_YEAR } from './historical-floor'
29 
30/**
31 * What generateObjective produced: the objective plus its provenance. `composed`
32 * is true ONLY when the live AI path yielded this objective; it is false for the
33 * curated floor — both the default no-AI path and every AI fallback (a generation,
34 * bounds, or de-dup failure). The endpoint pairs `composed === false` with a
35 * requested steer to tell the player their steer was quietly dropped (#127), instead
36 * of letting a silent curated fallback read as "steering is broken".
37 */
38export interface GeneratedObjective {
39 objective: GameObjective
40 composed: boolean
⋯ 30 lines hidden (lines 42–71)
42 
43/**
44 * Returns an objective for a new run, plus whether the live AI path composed it (vs
45 * the curated floor). Curated-random by default (instant, no API call); pass
46 * useAI=true to compose one live under the given steer, with automatic fallback to
47 * curated. Never throws and never ships an out-of-bounds objective.
48 *
49 * `avoid` (#95) is the session's already-composed titles — the only anti-repeat
50 * memory the stateless server has (the client tracks and supplies them; the curated
51 * pool the server already knows). A composed result may reproduce NEITHER the
52 * curated pool NOR anything seen this session; a collision is rerolled once, then
53 * the curated FALLBACK itself avoids the seen titles, so even the floor stays fresh.
54 */
55export async function generateObjective(useAI = false, steer: ObjectiveSteer = {}, avoid: string[] = []): Promise<GeneratedObjective> {
56 // Session-seen titles, normalized — used both for the curated fallback (so it
57 // never re-serves a seen objective) and, unioned with the curated pool below,
58 // for the post-generation collision guard.
59 const seen = new Set(avoid.map(normalizeObjectiveTitle).filter(Boolean))
60 const dedupe = new Set(seen)
61 for (const o of listCuratedObjectives()) dedupe.add(normalizeObjectiveTitle(o.title))
62 
63 if (useAI) {
64 // Never ship out of bounds OR a duplicate, never hard-fail the run: compose
65 // live, and on an out-of-bounds / colliding result reroll exactly once, then
66 // fall back to the (unseen) curated floor. A transport/parse failure won't
67 // heal on a reroll, so it drops straight to curated instead of a second call.
68 for (let attempt = 1; attempt <= 2; attempt++) {
69 try {
70 const candidate = await composeObjective(steer, avoid)
71 // The de-dup check is free (a pure Set lookup), so it short-circuits
72 // ahead of the paid bounds calls: a known title never spends them.
73 const duplicate = dedupe.has(normalizeObjectiveTitle(candidate.title))
74 if (!duplicate && await objectiveWithinBounds(candidate)) return { objective: candidate, composed: true }
75 console.warn(`AI objective ${duplicate ? 'duplicates curated/seen pool' : 'out of bounds'} (attempt ${attempt} of 2); ${attempt < 2 ? 'rerolling' : 'falling back to curated'}`)
76 } catch (error) {
77 console.error('AI objective generation failed; falling back to curated pool:', error)
78 break
79 }
80 }
81 }
82 // The curated floor — reached by the default no-AI path or any AI fallback.
83 return { objective: pickCurated(seen), composed: false }
⋯ 194 lines hidden (lines 85–278)
85 
86/**
87 * Composes one fresh objective with the model under the steer. Returns the same
88 * shape as the curated pool so the rest of the game treats them identically.
89 * Throws on an empty/malformed answer (the caller owns the fallback).
90 */
91async function composeObjective(steer: ObjectiveSteer, seenTitles: string[] = []): Promise<GameObjective> {
92 // Imported lazily so the curated (default) path never pulls the model SDKs
93 // into the bundle.
94 const { completeStructured } = await import('./ai-gateway')
95 
96 const content = await completeStructured({
97 task: 'objective',
98 system: composeSystemPrompt(),
99 turns: [{ role: 'user', content: composeUserPrompt(steer, seenTitles) }],
100 schemaName: 'game_objective',
101 schema: {
102 type: 'object',
103 properties: {
104 title: { type: 'string', description: 'Punchy objective title' },
105 description: { type: 'string', description: 'A grounded brief: a few newline-separated "• " bullet lines stating the real-timeline date(s) and place this departs from in our history, what winning looks like, and where it is centered (or, if global/cross-era, said so with the span named)' },
106 era: { type: 'string', description: 'Anchoring era / setting' },
107 icon: { type: 'string', description: 'A single emoji that fits the objective' },
108 anchorYear: { type: ['integer', 'null'], description: `Signed year the objective is aimed at (negative for BC), at or before ${SAFELY_HISTORICAL_BEFORE_YEAR}, or null if it spans all history` }
109 },
110 required: ['title', 'description', 'era', 'icon', 'anchorYear'],
111 additionalProperties: false
112 }
113 })
114 
115 if (!content) throw new Error('No objective generated')
116 
117 const parsed = JSON.parse(content) as GameObjective
118 if (!parsed.title || !parsed.description) throw new Error('Invalid objective format generated')
119 return {
120 title: parsed.title,
121 description: parsed.description,
122 era: parsed.era || 'Across the ages',
123 icon: parsed.icon || '🕰️',
124 // A finite signed year anchors the chain dial; null / junk → no anchor (no-op).
125 anchorYear: typeof parsed.anchorYear === 'number' && Number.isFinite(parsed.anchorYear)
126 ? Math.round(parsed.anchorYear)
127 : undefined
128 }
130 
131/**
132 * Validates a composed objective against the safely-historical bounds (#71) —
133 * the prompt is asked to obey them, but never trusted to. Three gates: a pure year
134 * bound, the same moderation seam messages pass through, and a structured subject
135 * check for the living-people / current-events framings a year bound can't see.
136 * Never throws. The year and subject gates fail CLOSED — an inconclusive result
137 * (junk, an empty answer, an outage) reads as out of bounds, so an unvetted
138 * composition falls back to curated rather than shipping. Moderation fails OPEN,
139 * the same fail-open-but-loud seam messages pass through (a safety classifier
140 * being down must never take the game down); it rejects only on an active flag.
141 */
142async function objectiveWithinBounds(o: GameObjective): Promise<boolean> {
143 // 1. Year bound — pure, deterministic, free. A finite anchor must sit at or
144 // before the safely-historical line; a null anchor (spans-all-history) is
145 // allowed, its content still gated by the subject check below.
146 if (typeof o.anchorYear === 'number' && o.anchorYear > SAFELY_HISTORICAL_BEFORE_YEAR) return false
147 
148 const text = `${o.title}\n${o.description}`
149 // 2 + 3 are independent model/detector calls — run them together; first "out"
150 // wins. Lazy import keeps moderation (and its SDKs) off the curated path.
151 const { hardBlockCheck } = await import('./moderation')
152 const [mod, subjectInBounds] = await Promise.all([
153 hardBlockCheck(text, 'objective', 'output'),
154 checkObjectiveSubject(o)
155 ])
156 if (mod.blocked) return false
157 return subjectInBounds
159 
160/**
161 * The structured subject check (the figure-floor pattern, server/utils/
162 * lifespan-estimator.ts): a cheap classifier that gates the free-prose title +
163 * description a year bound can't reach — does this center on living people,
164 * contemporary figures, ongoing conflicts, or post-cutoff events? Fails CLOSED:
165 * an empty answer, junk, or an outage returns false, so an unvetted objective is
166 * never shipped (it falls back to the safe curated floor instead). Never throws.
167 */
168async function checkObjectiveSubject(o: GameObjective): Promise<boolean> {
169 const { completeStructured } = await import('./ai-gateway')
170 try {
171 const content = await completeStructured({
172 task: 'objective-check',
173 system: boundsCheckSystemPrompt(),
174 turns: [{
175 role: 'user',
176 content: `Title: ${o.title}\nDescription: ${o.description}\nEra: ${o.era}\nAnchor year: ${o.anchorYear ?? 'none (spans all history)'}`
177 }],
178 schemaName: 'objective_bounds',
179 schema: {
180 type: 'object',
181 properties: {
182 withinBounds: { type: 'boolean', description: 'True only when the subject is safely historical (settled past, no living people or current events)' },
183 reason: { type: 'string', description: 'One short clause on the call' }
184 },
185 required: ['withinBounds', 'reason'],
186 additionalProperties: false
187 }
188 })
189 if (!content) return false // the model blipped — fail closed to the curated floor
190 return mapBoundsWire(JSON.parse(content)) === true
191 } catch (error) {
192 console.error('Objective bounds check error:', error)
193 return false // an outage must never let an unvetted objective through
194 }
196 
197/**
198 * The bounds-check wire → a clean verdict. `null` for an unusable shape (missing /
199 * non-boolean flag), which the caller treats exactly like "out of bounds". Exported
200 * pure so the guard is directly testable, like mapLifespanWire.
201 */
202export function mapBoundsWire(wire: unknown): boolean | null {
203 const w = wire as { withinBounds?: unknown } | null
204 if (!w || typeof w.withinBounds !== 'boolean') return null
205 return w.withinBounds
207 
208/** System prompt for composition: the tone bar, the grounding bar, and the
209 * non-negotiable safety bounds. */
210function composeSystemPrompt(): string {
211 return `You are a historian and game designer inventing grand, evocative counterfactual objectives for a game where players text historical figures to bend history.
212 
213Hold every objective to three bars:
214 
215TONE — sweeping and vivid, a whole world to remake in the spirit of the examples, never a small errand.
216 
217GROUNDING (issue #82) — every brief names the real-timeline date(s) and the place it departs from in our history, so a player who is not American and does not already know the date still gets a concrete anchor. Never assume the reader's frame.
218 
219BOUNDS (safety, non-negotiable) — the objective must be safely historical:
220- Set in the settled past. Any pivotal anchor year must be at or before ${SAFELY_HISTORICAL_BEFORE_YEAR}.
221- Never centered on, and never requiring contact with, living people, contemporary public figures, ongoing conflicts, or current events. Reach for history, not the present or the recent past.`
223 
224/** Composition user turn: curated few-shot exemplars (framed as territory already
225 * taken, never a menu to copy), the session avoid-list, the steer, and the ask. */
226function composeUserPrompt(steer: ObjectiveSteer, seenTitles: string[]): string {
227 return `Objectives we love — match their TONE, SCALE, and brief SHAPE, but treat their SUBJECTS as territory already taken. Invent something genuinely new; vary away from every one in subject, era, and region. Do NOT reproduce or lightly reskin them (especially over-familiar picks like "Save the Library of Alexandria"):
228${curatedExemplars()}
229${avoidClause(seenTitles)}
230Invent ONE fresh objective, unlike anything above — a different subject, era, and region from the examples${seenTitles.length ? ' and from anything already composed this session' : ''}.${steerClause(steer)}
231 
232Give it: a punchy title; an anchoring era; a single emoji; the anchor year it is aimed at (the pivotal year where winning is decided) as a SIGNED integer — negative for BC, at or before ${SAFELY_HISTORICAL_BEFORE_YEAR} — or null if it genuinely spans all of history with no single hinge; and a GROUNDED BRIEF as the description.
233 
234The brief is a few short bullet lines, each beginning with "• " and separated by newlines (the same shape as the examples above), that together state: (1) when and where this actually happened or ended in OUR timeline, with real dates; (2) what winning looks like; (3) where on Earth it is centered — name the region or country, or, if it genuinely spans the globe or all eras, say so and name the span rather than forcing one country. Put the real date IN the brief; the real-timeline date(s) are NOT the anchor year — state the actual history, never merely restate the anchor. Keep each bullet tight.`
236 
237/** System prompt for the structured subject check. */
238function boundsCheckSystemPrompt(): string {
239 return `You are a safety check for a historical strategy game. You are given a freshly generated objective (title, description, era, anchor year). Decide whether it stays safely in the settled past.
240 
241Mark it OUT OF BOUNDS (withinBounds=false) if it centers on, or would require contacting, any of:
242- a living person or a present-day public figure,
243- an ongoing conflict or a current event,
244- anything anchored after the year ${SAFELY_HISTORICAL_BEFORE_YEAR}.
245 
246Mark it IN BOUNDS (withinBounds=true) only when its subject is historical — people and events settled in the past, before living memory. Dramatic history is fine (wars, plagues, empires, revolutions); the test is WHEN and WHO, not how grand.
247 
248When you are unsure, answer withinBounds=false. Give a short reason either way.`
250 
251/** The curated pool rendered as few-shot exemplars (title — description), read
252 * live from the pool so the bar can never drift from a hardcoded copy. */
253function curatedExemplars(): string {
254 return listCuratedObjectives()
255 .map(o => `${o.title}${o.description}`)
256 .join('\n')
258 
259/** The session's already-composed titles, listed so the model steers away from
260 * repeating them (#95). Empty when nothing's been rolled yet — the first compose
261 * carries no clause, so an unseeded prompt stays identical to before. */
262function avoidClause(seenTitles: string[]): string {
263 if (!seenTitles.length) return ''
264 return `\nYou have ALREADY composed these this session — do not repeat or echo them; pick an entirely different subject:\n${seenTitles.map(t => `${t}`).join('\n')}\n`
266 
267/** Turns the closed-enum steer into a short clause biasing the model. Absent axes
268 * simply don't appear, leaving them to the model ("no preference"). */
269function steerClause(steer: ObjectiveSteer): string {
270 const parts: string[] = []
271 if (steer.era) {
272 parts.push(steer.era === 'Across the ages'
273 ? 'Let it span the ages rather than a single era.'
274 : `Anchor it in this stretch of history: ${steer.era}.`)
275 }
276 if (steer.theme) parts.push(`Center it on the theme of ${steer.theme}.`)
277 return parts.length ? ` ${parts.join(' ')}` : ''

The endpoint combines the steer with the provenance

Only the endpoint knows both halves: whether a steer was actually requested, and whether the AI path fell back. It computes steered from the coerced steer — coerceSteer has already dropped anything off-enum to "no preference", so garbage query params can't masquerade as a steer — and returns fellBackToCurated = steered && !composed. A no-steer fallback returns false, so the common path the player never notices makes no noise.

steered is read off the coerced steer; the flag is the AND of 'steered' and 'not composed'.

server/api/objective.get.ts · 39 lines
server/api/objective.get.ts39 lines · TypeScript
⋯ 25 lines hidden (lines 1–25)
1import { generateObjective } from '../utils/objective-generator'
2import { coerceSteer } from '../utils/objectives'
3import { coerceAvoidTitles } from '../utils/validate'
4 
5/**
6 * GET /api/objective?era=…&theme=…&avoid=…&avoid=… — composes a fresh grand
7 * objective with the model, optionally steered along two closed enums (#71) and
8 * away from titles already rolled this session (#95).
9 *
10 * Objective generation MUST run server-side: it needs OPENAI_API_KEY, which only
11 * exists on the server (runtimeConfig.openaiApiKey). Calling the generator from
12 * the client would silently fall back to the curated pool every time. The curated
13 * pool itself is plain data the client already has, so this route exists purely to
14 * unlock the *live* AI path.
15 *
16 * The steer is coerced against the closed enums HERE, the single trusted gate:
17 * anything off-enum drops to "no preference" rather than reaching the prompt, so
18 * the closed-enum safety guarantee has no wire-side bypass. The avoid-list is the
19 * untrusted client boundary too — coerced to a bounded array of trimmed titles so a
20 * crafted request can't stuff the prompt (the curated half of the de-dup set is
21 * server-built, never trusted from the wire).
22 *
23 * generateObjective(true) self-heals — out-of-bounds or model-unavailable, it
24 * returns a curated objective rather than throwing — so the player is never left
25 * without one, and an out-of-bounds composition is never shipped.
26 *
27 * `fellBackToCurated` (#127) tells a half-truth's worth of honesty: it is true ONLY
28 * when the player steered (a coerced era/theme survived) AND the AI path fell back
29 * to curated — i.e. their explicit intent was silently dropped. A no-steer fallback
30 * stays quiet (false), so the common path the player never notices makes no noise.
31 */
32export default defineEventHandler(async (event) => {
33 const q = getQuery(event)
34 const steer = coerceSteer(q.era, q.theme)
35 const avoid = coerceAvoidTitles(q.avoid)
36 const steered = Boolean(steer.era || steer.theme)
37 const { objective, composed } = await generateObjective(true, steer, avoid)
38 return { success: true, objective, fellBackToCurated: steered && !composed }
39})

The store threads the flag through

fetchAIObjective now returns { objective, fellBackToCurated } instead of a bare objective-or-null. It reads the new wire field and applies one guard: report the dropped steer only when an objective actually came back. A hard failure (no objective at all) is the louder compose-error path, not this quiet notice, so its flag is forced false even if the wire somehow set it. The catch branch returns the same shape, so the UI never has to special-case a throw.

objective ? res.fellBackToCurated === true : false — the flag rides only on a present objective.

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

The briefing shows a quiet line — only when earned

MissionSelect gets a second notice ref, composeNotice, kept distinct from the existing composeError. rollFresh clears both at the top of every roll (so nothing lingers across rerolls), destructures the new flag, and sets the muted notice only when fellBackToCurated is true. In the template it renders as a muted, non-blocking line right beside the red error — never instead of it.

The muted compose-degraded line; the dedicated ref; and rollFresh setting it only on a steered fallback.

components/MissionSelect.vue · 255 lines
components/MissionSelect.vue255 lines · Vue
⋯ 98 lines hidden (lines 1–98)
1<template>
2 <!-- Mission briefing: choose (or compose) the run's grand objective, as a ledger. -->
3 <div data-testid="mission-select" class="ew-fade-in max-w-4xl mx-auto">
4 <!-- Replay (issue #89): seeded from a shared run's objective. A focused view — this
5 one objective, ready to play — not the full browse/compose ledger. -->
6 <template v-if="replay">
7 <div class="mb-7">
8 <p class="ew-label">Replay a shared objective</p>
9 <h2 class="ew-serif ew-fg text-2xl sm:text-3xl font-semibold mt-1.5 leading-tight">Take on this very objective</h2>
10 <RemixCreditLine :credit="replayCredit" class="mt-2" />
11 </div>
12 
13 <div data-testid="replay-objective" class="border-t border-b ew-line py-4 flex items-start gap-3 ew-tint"
14 :style="{ borderLeft: '2px solid var(--ew-accent)' }">
15 <span class="w-6 shrink-0 text-center text-lg leading-none select-none" aria-hidden="true">{{ replay.objective.icon }}</span>
16 <span class="min-w-0 flex-1">
17 <span class="flex items-center gap-2 flex-wrap">
18 <h3 class="ew-fg font-semibold">{{ replay.objective.title }}</h3>
19 <span v-if="replay.objective.era" class="ew-label">{{ replay.objective.era }}</span>
20 </span>
21 <!-- whitespace-pre-line: the brief is newline-separated "• " bullet lines (#82). -->
22 <span class="block ew-muted text-sm mt-0.5 leading-relaxed whitespace-pre-line">{{ replay.objective.description }}</span>
23 </span>
24 </div>
25 
26 <div class="mt-5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
27 <button type="button" data-testid="replay-exit" class="ew-tap ew-hover-fg text-xs ew-mono uppercase tracking-wide"
28 @click="exitReplay">
29 ← Choose a different objective
30 </button>
31 <button type="button" data-testid="begin-button" class="ew-btn ew-btn--primary" :disabled="beginning" @click="begin">
32 {{ beginning ? 'Charting the timeline…' : 'Play this objective' }} <span aria-hidden="true"></span>
33 </button>
34 </div>
35 </template>
36 
37 <!-- Normal briefing: choose a curated objective, or compose a fresh one. -->
38 <template v-else>
39 <div class="mb-7">
40 <p class="ew-label">Choose your crusade</p>
41 <h2 class="ew-serif ew-fg text-2xl sm:text-3xl font-semibold mt-1.5 leading-tight">What will you set right?</h2>
42 <p class="ew-muted text-sm mt-2 max-w-xl">
43 Five messages. Anyone in history. One world to remake. Pick the cause that will define your run — or have a fresh one composed.
44 </p>
45 </div>
46 
47 <!-- Objectives as borderless hairline rows (a freshly composed one leads) -->
48 <div role="radiogroup" aria-label="Choose an objective" class="border-t ew-line">
49 <button v-for="obj in options" :key="(obj === fresh ? 'fresh:' : 'curated:') + obj.title" type="button"
50 data-testid="objective-option" role="radio" :aria-checked="isSelected(obj)"
51 class="w-full text-left flex items-start gap-3 py-3 pr-2 pl-3 border-b ew-line transition ew-hover"
52 :class="isSelected(obj) ? 'ew-tint' : ''"
53 :style="{ borderLeft: `2px solid ${isSelected(obj) ? 'var(--ew-accent)' : 'transparent'}` }" @click="select(obj)">
54 <span class="ew-dot mt-1.5" :class="isSelected(obj) ? 'ew-dot--accent' : 'ew-dot--hollow'" aria-hidden="true" />
55 <span class="w-6 shrink-0 text-center text-lg leading-none select-none" aria-hidden="true">{{ obj.icon }}</span>
56 <span class="min-w-0 flex-1">
57 <span class="flex items-center gap-2 flex-wrap">
58 <h3 class="ew-fg font-semibold">{{ obj.title }}</h3>
59 <span v-if="obj.era" class="ew-label">{{ obj.era }}</span>
60 <span v-if="obj === fresh" data-testid="fresh-badge" class="ew-accent text-[10px] uppercase tracking-wider">
61 ✨ freshly composed
62 </span>
63 </span>
64 <!-- whitespace-pre-line: the brief is newline-separated "• " bullet lines (issue #82). -->
65 <span class="block ew-muted text-sm mt-0.5 leading-relaxed whitespace-pre-line">{{ obj.description }}</span>
66 </span>
67 </button>
68 </div>
69 
70 <!-- Steer a fresh composition: two closed-enum dials, set before composing.
71 "No preference" leaves an axis to the model. Closed enums only (no free
72 text), so an out-of-bounds objective can't be steered into existence. -->
73 <fieldset class="mt-6 pt-4 border-t ew-line">
74 <legend class="ew-label">Steer a fresh composition <span class="ew-faint">· optional</span></legend>
75 <div class="mt-2 flex flex-col sm:flex-row gap-3 sm:gap-4">
76 <label class="flex-1 min-w-0 text-sm">
77 <span class="ew-label block mb-1">Era</span>
78 <select v-model="steerEra" data-testid="steer-era" class="ew-select text-sm" aria-label="Steer the era">
79 <option value="">No preference</option>
80 <option v-for="era in OBJECTIVE_ERAS" :key="era" :value="era">{{ era }}</option>
81 </select>
82 </label>
83 <label class="flex-1 min-w-0 text-sm">
84 <span class="ew-label block mb-1">Theme</span>
85 <select v-model="steerTheme" data-testid="steer-theme" class="ew-select text-sm" aria-label="Steer the theme">
86 <option value="">No preference</option>
87 <option v-for="theme in OBJECTIVE_THEMES" :key="theme" :value="theme">{{ theme }}</option>
88 </select>
89 </label>
90 </div>
91 </fieldset>
92 
93 <!-- Actions: compose a fresh objective · begin the run -->
94 <div class="mt-5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
95 <div class="flex flex-col items-start gap-1">
96 <button type="button" data-testid="compose-objective" class="ew-btn text-sm" :disabled="composing" @click="rollFresh">
97 <span aria-hidden="true"></span> {{ composing ? 'Composing a fresh fate…' : 'Compose a fresh objective with AI' }}
98 </button>
99 <p v-if="activeSteer" data-testid="active-steer" class="text-xs ew-muted">Steering: {{ activeSteer }}</p>
100 <p v-if="composeError" data-testid="compose-error" class="text-xs ew-bad">{{ composeError }}</p>
101 <!-- Quiet degrade (#127): a steered compose fell back to curated. Non-blocking
102 and muted (not the ew-bad error tone) — the player has a valid objective
103 ready; this just tells the truth that the steer was dropped. -->
104 <p v-if="composeNotice" data-testid="compose-degraded" class="text-xs ew-muted">{{ composeNotice }}</p>
105 </div>
⋯ 79 lines hidden (lines 106–184)
106 
107 <button type="button" data-testid="begin-button" class="ew-btn ew-btn--primary" :disabled="!selected || beginning" @click="begin">
108 {{ beginning ? 'Charting the timeline…' : 'Begin rewriting history' }} <span aria-hidden="true"></span>
109 </button>
110 </div>
111 </template>
112 
113 <!-- Paywall: the free run is spent. The run-packs modal opens automatically on
114 the refused commit; this persistent notice lets the player reopen it if they
115 dismissed it. -->
116 <div v-if="gameStore.outOfRuns" data-testid="paywall" class="mt-4 pt-4 border-t ew-line">
117 <p class="text-sm ew-fg font-semibold">You're out of runs — your free run is spent.</p>
118 <p class="ew-muted text-xs mt-0.5">Grab a pack to keep rewriting history. One-time, never expires.</p>
119 <button type="button" data-testid="open-packs" class="ew-btn ew-btn--primary mt-3"
120 @click="gameStore.openBuyModal()">
121 See run packs <span aria-hidden="true"></span>
122 </button>
123 </div>
124 
125 <!-- Capacity: the global spend cap paused new runs (in-progress runs continue). -->
126 <p v-else-if="gameStore.atCapacity" data-testid="at-capacity" class="text-sm ew-bad mt-3">
127 The timeline is at capacity right now — please try again shortly.
128 </p>
129 
130 <!-- Compliance disclosure, shown at the start of every run (Anthropic Usage
131 Policy: AI disclosure at session start; outputs may be inaccurate; an
132 adults-only posture). -->
133 <p data-testid="ai-disclosure" class="ew-faint text-xs leading-relaxed mt-8 pt-4 border-t ew-line max-w-2xl">
134 The historical figures here are played by AI, not real people, and every reply is
135 AI-generated <span class="ew-fg">alternate history — dramatized fiction, not historical fact</span>;
136 don't rely on it as accurate. Intended for adults (18+).
137 </p>
138 </div>
139</template>
140 
141<script setup lang="ts">
142/**
143 * MissionSelect — the opening briefing. Curated objectives + a live-composed one, as
144 * borderless hairline rows; selection uses identity, not titles. Nothing commits
145 * until Begin, so a freshly composed objective can be previewed and reconsidered.
146 */
147import { ref, shallowRef, computed } from 'vue'
148import { useGameStore } from '~/stores/game'
149import type { RemixCredit } from '~/utils/run-snapshot'
150import {
151 listCuratedObjectives,
152 OBJECTIVE_ERAS,
153 OBJECTIVE_THEMES,
154 type GameObjective,
155 type ObjectiveEra,
156 type ObjectiveTheme
157} from '~/server/utils/objectives'
158 
159const gameStore = useGameStore()
160 
161// Replay mode (issue #89): set when seeded from a shared run's objective. The briefing
162// shows just that objective, ready to play; Begin commits it verbatim (no generation).
163const replay = computed(() => gameStore.replayState)
164// The "remixed from" line above the seeded objective. The title is omitted (the objective
165// is shown right below), so it reads "Remixed from <name>" / "…an anonymous run", linking
166// back to the source when still shared.
167const replayCredit = computed<RemixCredit>(() => ({
168 attribution: replay.value?.sourceAttribution ?? null,
169 objectiveTitle: '',
170 sourceToken: replay.value?.sourceToken ?? null
171}))
172 
173const curated = listCuratedObjectives()
174// shallowRef, not ref: selection is by object identity (isSelected uses ===), and the
175// curated options are plain objects. A deep ref would return a *reactive proxy* on
176// `.value`, so `selected.value === obj` (raw) would be false — the row would highlight
177// on hover but its radio dot / aria-checked would never flip. shallowRef stores the
178// value as-is, keeping identity intact.
179const fresh = shallowRef<GameObjective | null>(null)
180const selected = shallowRef<GameObjective | null>(null)
181// Titles composed this session, fed back as the avoid-list so each reroll lands
182// somewhere new (#95). Resets naturally: MissionSelect is mounted only while there's
183// no objective (v-if in play.vue), so a new run remounts it with an empty list.
184const seenTitles = ref<string[]>([])
185const composing = ref(false)
186// Hard-failure surface: no objective came back at all (red, ew-bad).
187const composeError = ref<string | null>(null)
188// Quiet-degrade surface (#127): a steered compose fell back to curated, so the
189// steer was dropped. Distinct from composeError — the player still has a valid
190// objective; this notice is muted and informational, not an error.
191const composeNotice = ref<string | null>(null)
192const beginning = ref(false)
⋯ 20 lines hidden (lines 193–212)
193 
194// Composition steer (closed enums; '' = no preference, left to the model).
195const steerEra = ref<ObjectiveEra | ''>('')
196const steerTheme = ref<ObjectiveTheme | ''>('')
197 
198const options = computed(() => (fresh.value ? [fresh.value, ...curated] : curated))
199 
200// A plain-language echo of the active steer, surfaced by the compose button.
201const activeSteer = computed(() =>
202 [steerEra.value, steerTheme.value].filter(Boolean).join(' · ') || null
204 
205function isSelected(obj: GameObjective) {
206 return selected.value === obj
208 
209function select(obj: GameObjective) {
210 selected.value = obj
212 
213async function rollFresh() {
214 composing.value = true
215 composeError.value = null
216 composeNotice.value = null
217 const { objective, fellBackToCurated } = await gameStore.fetchAIObjective({
218 era: steerEra.value || undefined,
219 theme: steerTheme.value || undefined
220 }, seenTitles.value)
221 composing.value = false
222 
223 if (objective) {
224 fresh.value = objective
225 selected.value = objective // auto-select the fresh one so Begin is one tap away
226 // Remember it so the next reroll avoids it (a curated fallback lands here too,
227 // so even a fallback won't be re-served).
228 if (!seenTitles.value.includes(objective.title)) seenTitles.value.push(objective.title)
229 // The steer was set but generation fell back to curated (#127): say so quietly,
230 // so a dropped steer doesn't read as "steering is broken".
231 if (fellBackToCurated) composeNotice.value = "Couldn't compose a fresh objective right now — here's a curated one."
232 } else {
233 composeError.value = 'The archives went quiet — pick one above, or try composing again.'
234 }
⋯ 21 lines hidden (lines 235–255)
236 
237// Leave replay mode for the full browse/compose briefing — this run becomes an original.
238function exitReplay() {
239 gameStore.clearReplayState()
240 selected.value = null
242 
243async function begin() {
244 // In replay mode the seeded objective IS the choice (no browse list rendered);
245 // otherwise it's whatever the player selected. Either way chooseObjective commits
246 // it verbatim — replay runs no generation, just reuses the captured objective.
247 const objective = replay.value?.objective ?? selected.value
248 if (!objective || beginning.value) return
249 beginning.value = true
250 // Charges one run from the balance. If out of runs, chooseObjective raises the
251 // paywall (gameStore.outOfRuns) and opens the run-packs modal; no run starts.
252 await gameStore.chooseObjective(objective)
253 beginning.value = false
255</script>

Tests pin the new behavior

The discriminating assertions live where the flag is decided. At the generator, composed is true on a clean compose and false on every fallback — out-of-bounds, transport failure, de-dup collision. At the component, the muted notice shows on a steered fallback (and the red error does not), and stays silent on a normal compose. The store gets its own pass-through and guard tests. Full unit suite green (944) and nuxt typecheck clean.

composed=true on the clean compose; composed=false on the out-of-bounds and transport fallbacks.

tests/unit/server/utils/objective-generator.spec.ts · 403 lines
tests/unit/server/utils/objective-generator.spec.ts403 lines · TypeScript
⋯ 103 lines hidden (lines 1–103)
1import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'
2import { generateObjective, mapBoundsWire } from '../../../../server/utils/objective-generator'
3import { listCuratedObjectives } from '../../../../server/utils/objectives'
4import { SAFELY_HISTORICAL_BEFORE_YEAR } from '../../../../server/utils/historical-floor'
5import { MAX_OBJECTIVE_DESC_CHARS } from '../../../../server/utils/validate'
6 
7// The AI path routes through the gateway; mock the OpenAI SDK (chat completions +
8// moderations) and pin the legacy lane, the same shape the lifespan spec uses. The
9// curated (default) path below never constructs a client, so it is unaffected.
10const createMock = vi.hoisted(() => vi.fn())
11const modMock = vi.hoisted(() => vi.fn())
12vi.mock('openai', () => ({
13 default: vi.fn(function () {
14 return {
15 chat: { completions: { create: createMock } },
16 moderations: { create: modMock }
17 }
18 })
19}))
20 
21describe('Objective Generator — curated path (no API call)', () => {
22 it('returns a well-formed objective', async () => {
23 const { objective, composed } = await generateObjective()
24 
25 // The default (no-AI) path is the curated floor — never a composition.
26 expect(composed).toBe(false)
27 expect(typeof objective.title).toBe('string')
28 expect(objective.title.length).toBeGreaterThan(0)
29 expect(typeof objective.description).toBe('string')
30 expect(objective.description.length).toBeGreaterThan(10)
31 expect(typeof objective.era).toBe('string')
32 expect(typeof objective.icon).toBe('string')
33 expect(objective.icon.length).toBeGreaterThan(0)
34 })
35 
36 it('draws from the curated pool by default (no API call)', async () => {
37 const titles = listCuratedObjectives().map(o => o.title)
38 const { objective } = await generateObjective()
39 expect(titles).toContain(objective.title)
40 })
41 
42 it('exposes a pool of several distinct objectives', () => {
43 const pool = listCuratedObjectives()
44 expect(pool.length).toBeGreaterThanOrEqual(5)
45 const titles = new Set(pool.map(o => o.title))
46 expect(titles.size).toBe(pool.length)
47 })
48 
49 it('offers variety across runs rather than a single hardcoded objective', async () => {
50 const seen = new Set<string>()
51 for (let i = 0; i < 40; i++) {
52 seen.add((await generateObjective()).objective.title)
53 }
54 expect(seen.size).toBeGreaterThan(1)
55 })
56})
57 
58/**
59 * The bounds (#71): a composition is independently validated, never trusted to the
60 * prompt. Out of bounds → reroll once → curated floor; the moderation seam runs over
61 * generated text. Curated stays the floor, so a failure never hard-fails run start.
62 */
63describe('Objective Generator — AI bounds + steer (#71)', () => {
64 const VALID = {
65 title: 'Chart the Southern Stars',
66 description: 'Map the unknown skies a full century early and redraw every voyage of the age.',
67 era: 'Age of sail',
68 icon: '🌌',
69 anchorYear: 1600
70 }
71 const FUTURE = { ...VALID, title: 'Settle the Red Planet', description: 'Plant a thriving city on Mars within the decade.', anchorYear: 2200 }
72 
73 // A queue per call kind, dispatched by the request's schema name, so a test
74 // declares "these composes, these checks" without depending on call ordering.
75 let composeQueue: unknown[]
76 let checkQueue: unknown[]
77 const asContent = (payload: unknown) => ({ choices: [{ message: { content: JSON.stringify(payload) } }] })
78 const inBounds = { withinBounds: true, reason: 'settled past' }
79 
80 beforeAll(() => {
81 process.env.OPENAI_API_KEY = 'test-key'
82 process.env.EVERWHEN_AI_LANE = 'openai'
83 })
84 afterAll(() => {
85 delete process.env.EVERWHEN_AI_LANE
86 delete process.env.EVERWHEN_MODERATION
87 })
88 
89 beforeEach(() => {
90 createMock.mockReset()
91 modMock.mockReset()
92 composeQueue = []
93 checkQueue = []
94 // Most tests isolate the year + subject logic from moderation; the dedicated
95 // moderation test flips this to 'enforce' in its own body.
96 process.env.EVERWHEN_MODERATION = 'off'
97 createMock.mockImplementation(async (req: { response_format?: { json_schema?: { name?: string } } }) => {
98 const name = req?.response_format?.json_schema?.name
99 const q = name === 'objective_bounds' ? checkQueue : composeQueue
100 return asContent(q.shift())
101 })
102 // Default: nothing flagged (only consulted when moderation is enforced).
103 modMock.mockResolvedValue({ results: [{ category_scores: {} }] })
104 })
105 
106 it('returns a composition that clears every bound', async () => {
107 composeQueue = [VALID]
108 checkQueue = [inBounds]
109 
110 const { objective: result, composed } = await generateObjective(true)
111 
112 expect(composed).toBe(true) // the AI path produced it — not a fallback
113 expect(result.title).toBe(VALID.title)
114 expect(result.anchorYear).toBe(1600)
⋯ 95 lines hidden (lines 115–209)
115 // One compose + one subject check — no reroll.
116 expect(createMock).toHaveBeenCalledTimes(2)
117 })
118 
119 it('rejects an out-of-bounds anchor year, rerolls once, and returns the valid reroll', async () => {
120 composeQueue = [FUTURE, VALID]
121 checkQueue = [inBounds]
122 
123 const { objective: result } = await generateObjective(true)
124 
125 expect(result.title).toBe(VALID.title)
126 // The year bound is pure: FUTURE is rejected before any subject-check call,
127 // so only the in-bounds reroll consults the checker.
128 const checkCalls = createMock.mock.calls.filter(
129 c => c[0]?.response_format?.json_schema?.name === 'objective_bounds'
130 )
131 expect(checkCalls).toHaveLength(1)
132 })
133 
134 it('accepts an anchor exactly at the cutoff (the gate is "> cutoff")', async () => {
135 // Derived from the constant — the cutoff is a moving target (year - 100).
136 composeQueue = [{ ...VALID, anchorYear: SAFELY_HISTORICAL_BEFORE_YEAR }]
137 checkQueue = [inBounds]
138 
139 const { objective: result } = await generateObjective(true)
140 
141 expect(result.anchorYear).toBe(SAFELY_HISTORICAL_BEFORE_YEAR)
142 })
143 
144 it('rejects an anchor one year past the cutoff, before any subject check', async () => {
145 composeQueue = [{ ...VALID, anchorYear: SAFELY_HISTORICAL_BEFORE_YEAR + 1 }, VALID]
146 checkQueue = [inBounds]
147 
148 const { objective: result } = await generateObjective(true)
149 
150 expect(result.title).toBe(VALID.title)
151 // cutoff+1 is rejected by the pure year gate, so only the reroll is checked.
152 const checkCalls = createMock.mock.calls.filter(
153 c => c[0]?.response_format?.json_schema?.name === 'objective_bounds'
154 )
155 expect(checkCalls).toHaveLength(1)
156 })
157 
158 it('accepts a BC (negative) anchor through the signed year bound', async () => {
159 composeQueue = [{ ...VALID, anchorYear: -48 }]
160 checkQueue = [inBounds]
161 
162 const { objective: result } = await generateObjective(true)
163 
164 expect(result.anchorYear).toBe(-48)
165 })
166 
167 it('still subject-checks a null-anchor (spans-all-history) composition', async () => {
168 // A null anchor skips the year gate by design, so the subject check is the
169 // only thing between a "spans all history" framing and the player.
170 composeQueue = [{ ...VALID, anchorYear: null }, VALID]
171 checkQueue = [{ withinBounds: false, reason: 'centers on a living person' }, inBounds]
172 
173 const { objective: result } = await generateObjective(true)
174 
175 expect(result.title).toBe(VALID.title)
176 // Both compositions reached the subject check — the null-anchor one included.
177 const checkCalls = createMock.mock.calls.filter(
178 c => c[0]?.response_format?.json_schema?.name === 'objective_bounds'
179 )
180 expect(checkCalls).toHaveLength(2)
181 })
182 
183 it('fails closed and rerolls when the subject check returns no content', async () => {
184 createMock.mockReset()
185 let checks = 0
186 createMock.mockImplementation(async (req: { response_format?: { json_schema?: { name?: string } } }) => {
187 if (req?.response_format?.json_schema?.name === 'objective_bounds') {
188 checks++
189 // First subject check blips (empty content) → fail closed; next is clean.
190 return checks === 1 ? { choices: [{ message: { content: '' } }] } : asContent(inBounds)
191 }
192 return asContent(VALID) // both composes are year-valid
193 })
194 
195 const { objective: result } = await generateObjective(true)
196 
197 expect(result.title).toBe(VALID.title)
198 const checkCalls = createMock.mock.calls.filter(
199 c => c[0]?.response_format?.json_schema?.name === 'objective_bounds'
200 )
201 expect(checkCalls).toHaveLength(2)
202 })
203 
204 it('rejects a composition the subject check flags, then rerolls', async () => {
205 composeQueue = [VALID, { ...VALID, title: 'A Safer Frame' }]
206 checkQueue = [{ withinBounds: false, reason: 'centers on a living person' }, inBounds]
207 
208 const { objective: result } = await generateObjective(true)
209 
210 expect(result.title).toBe('A Safer Frame')
211 })
212 
213 it('falls back to the curated floor when both attempts are out of bounds', async () => {
214 composeQueue = [FUTURE, { ...FUTURE, title: 'Still Out of Bounds' }]
215 checkQueue = []
216 
217 const { objective: result, composed } = await generateObjective(true)
218 
219 expect(composed).toBe(false) // both attempts out of bounds → the curated floor
220 const curatedTitles = listCuratedObjectives().map(o => o.title)
221 expect(curatedTitles).toContain(result.title)
222 // Exactly two compose attempts (the original + one reroll), then curated.
223 expect(createMock).toHaveBeenCalledTimes(2)
224 })
225 
226 it('falls back to curated, without a reroll, on a transport failure', async () => {
227 const quiet = vi.spyOn(console, 'error').mockImplementation(() => {})
228 createMock.mockReset()
229 createMock.mockRejectedValue(new Error('model down'))
230 
231 const { objective: result, composed } = await generateObjective(true)
232 
233 expect(composed).toBe(false) // a transport failure drops straight to curated
234 const curatedTitles = listCuratedObjectives().map(o => o.title)
⋯ 169 lines hidden (lines 235–403)
235 expect(curatedTitles).toContain(result.title)
236 // A transport error won't heal on a reroll — one attempt, then curated.
237 expect(createMock).toHaveBeenCalledTimes(1)
238 quiet.mockRestore()
239 })
240 
241 it('runs generated text through the moderation seam', async () => {
242 process.env.EVERWHEN_MODERATION = 'enforce'
243 // First composition trips the detector; the reroll is clean.
244 modMock.mockReset()
245 modMock
246 .mockResolvedValueOnce({ results: [{ category_scores: { sexual: 0.95 } }] })
247 .mockResolvedValue({ results: [{ category_scores: {} }] })
248 composeQueue = [VALID, { ...VALID, title: 'Clean Frame' }]
249 checkQueue = [inBounds, inBounds]
250 
251 const { objective: result } = await generateObjective(true)
252 
253 expect(result.title).toBe('Clean Frame')
254 // Moderation actually ran over the generated text (title + description).
255 expect(modMock).toHaveBeenCalled()
256 expect(modMock.mock.calls[0][0].input).toContain(VALID.title)
257 expect(modMock.mock.calls[0][0].input).toContain(VALID.description)
258 })
259 
260 it('threads the session avoid-list into the compose prompt (#95)', async () => {
261 composeQueue = [VALID]
262 checkQueue = [inBounds]
263 
264 await generateObjective(true, {}, ['Reach the Moon by 1900', 'Forge the First Aqueducts'])
265 
266 const composeCall = createMock.mock.calls.find(
267 c => c[0]?.response_format?.json_schema?.name === 'game_objective'
268 )!
269 const user = (composeCall[0].messages as Array<{ role: string; content: string }>)
270 .find(m => m.role === 'user')!.content
271 // The already-seen titles are listed for the model to steer away from.
272 expect(user).toContain('Reach the Moon by 1900')
273 expect(user).toContain('Forge the First Aqueducts')
274 expect(user.toLowerCase()).toContain('already composed')
275 })
276 
277 it('rejects a composition that duplicates the curated pool, then rerolls (#95)', async () => {
278 // The model reproduces a curated title verbatim (the exact bug #95 reports);
279 // the reroll is fresh. The de-dup is free, so the rejected one never reaches
280 // the (paid) subject check — only the clean reroll is checked.
281 composeQueue = [{ ...VALID, title: 'Save the Library of Alexandria' }, VALID]
282 checkQueue = [inBounds]
283 
284 const { objective: result } = await generateObjective(true)
285 
286 expect(result.title).toBe(VALID.title)
287 const checkCalls = createMock.mock.calls.filter(
288 c => c[0]?.response_format?.json_schema?.name === 'objective_bounds'
289 )
290 expect(checkCalls).toHaveLength(1)
291 })
292 
293 it('catches a duplicate that only differs in case/punctuation (#95)', async () => {
294 // Normalized comparison: a re-skin of a curated title must still collide.
295 composeQueue = [{ ...VALID, title: 'save the LIBRARY of alexandria!!!' }, VALID]
296 checkQueue = [inBounds]
297 
298 const { objective: result } = await generateObjective(true)
299 
300 expect(result.title).toBe(VALID.title)
301 })
302 
303 it('rejects a composition that repeats a title seen this session, then rerolls (#95)', async () => {
304 // The session avoid-list (not curated) catches the repeat; the reroll is fresh.
305 composeQueue = [{ ...VALID, title: 'My Earlier Roll' }, VALID]
306 checkQueue = [inBounds]
307 
308 const { objective: result } = await generateObjective(true, {}, ['My Earlier Roll'])
309 
310 expect(result.title).toBe(VALID.title)
311 })
312 
313 it('falls back to an UNSEEN curated objective when both attempts collide (#95)', async () => {
314 // Both compositions duplicate the curated pool; the curated FALLBACK must then
315 // avoid the one title we mark as already-seen, so even the floor stays fresh.
316 const seen = 'Save the Library of Alexandria'
317 composeQueue = [{ ...VALID, title: seen }, { ...VALID, title: 'Break the Black Death' }]
318 checkQueue = []
319 
320 const { objective: result, composed } = await generateObjective(true, {}, [seen])
321 
322 expect(composed).toBe(false) // both attempts collided → the curated floor
323 const curatedTitles = listCuratedObjectives().map(o => o.title)
324 expect(curatedTitles).toContain(result.title)
325 expect(result.title).not.toBe(seen)
326 // Two compose attempts, both duplicates → no subject check spent, then curated.
327 expect(createMock).toHaveBeenCalledTimes(2)
328 const checkCalls = createMock.mock.calls.filter(
329 c => c[0]?.response_format?.json_schema?.name === 'objective_bounds'
330 )
331 expect(checkCalls).toHaveLength(0)
332 })
333 
334 it('constrains and steers the compose prompt', async () => {
335 composeQueue = [VALID]
336 checkQueue = [inBounds]
337 
338 await generateObjective(true, { era: 'Antiquity', theme: 'Science & Medicine' })
339 
340 const composeCall = createMock.mock.calls.find(
341 c => c[0]?.response_format?.json_schema?.name === 'game_objective'
342 )
343 expect(composeCall).toBeDefined()
344 const messages = composeCall![0].messages as Array<{ role: string; content: string }>
345 const system = messages.find(m => m.role === 'system')!.content
346 const user = messages.find(m => m.role === 'user')!.content
347 
348 // The bounds are in the prompt (the model is asked to obey them)…
349 expect(system).toContain(String(SAFELY_HISTORICAL_BEFORE_YEAR))
350 expect(system.toLowerCase()).toContain('living people')
351 // …the steer is injected…
352 expect(user).toContain('Antiquity')
353 expect(user).toContain('Science & Medicine')
354 // …and the curated pool is shown as few-shot exemplars.
355 expect(user).toContain('Save the Library of Alexandria')
356 })
357})
358 
359describe('mapBoundsWire', () => {
360 it('reads a clean verdict', () => {
361 expect(mapBoundsWire({ withinBounds: true, reason: '' })).toBe(true)
362 expect(mapBoundsWire({ withinBounds: false, reason: 'living person' })).toBe(false)
363 })
364 
365 it('returns null for an unusable shape (treated as out of bounds)', () => {
366 expect(mapBoundsWire(null)).toBeNull()
367 expect(mapBoundsWire('blocked')).toBeNull()
368 expect(mapBoundsWire({})).toBeNull()
369 expect(mapBoundsWire({ withinBounds: 'yes' })).toBeNull()
370 })
371})
372 
373describe('Curated briefs are grounded (issue #82)', () => {
374 const pool = listCuratedObjectives()
375 
376 it('every brief states a real-timeline date and reads as bullet lines', () => {
377 for (const obj of pool) {
378 // A concrete date so a player who does not already hold one (non-US /
379 // non-Western) gets an anchor: every brief names at least one 3-4 digit
380 // year (1914, 476, 285, 221, …) or none of the issue's point is met.
381 expect(obj.description, `${obj.title}: brief needs a real date`).toMatch(/\d{3,4}/)
382 // Bulleted, multi-line — not the old single vague sentence.
383 expect(obj.description, `${obj.title}: brief should be bullet lines`).toContain('\n')
384 expect(obj.description, `${obj.title}: bullets start with "• "`).toContain('• ')
385 }
386 })
387 
388 it('every brief fits the (raised) description cap', () => {
389 for (const obj of pool) {
390 expect(obj.description.length, `${obj.title}: brief exceeds the cap`).toBeLessThanOrEqual(MAX_OBJECTIVE_DESC_CHARS)
391 }
392 })
393 
394 it('states the real abolition dates distinct from the in-game anchor (anchorYear is not the real date)', () => {
395 // Slavery is the canonical case the issue calls out: anchor 1750 is the
396 // counterfactual target, NOT when abolition actually happened. The brief must
397 // carry the real dates, proving the two are kept distinct, not conflated.
398 const slavery = pool.find(o => o.title === 'Abolish Slavery a Century Early')!
399 expect(slavery.anchorYear).toBe(1750)
400 // The real abolition dates appear in the brief, and they are not the anchor.
401 expect(slavery.description).toMatch(/1807|1833|1865|1888/)
402 })
403})

The two #127 component tests: notice shown on a steered fallback, silent on a normal compose.

tests/unit/components/MissionSelect.spec.ts · 288 lines
tests/unit/components/MissionSelect.spec.ts288 lines · TypeScript
⋯ 173 lines hidden (lines 1–173)
1import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2import { mount, flushPromises } from '@vue/test-utils'
3import { setActivePinia, createPinia } from 'pinia'
4import MissionSelect from '../../../components/MissionSelect.vue'
5import { useGameStore } from '../../../stores/game'
6import { listCuratedObjectives } from '../../../server/utils/objectives'
7 
8const FRESH = {
9 title: 'Chart the Southern Stars',
10 description: 'Map the unknown skies a century early and redraw every voyage.',
11 era: 'Age of sail',
12 icon: '🌌'
14 
15describe('MissionSelect', () => {
16 let gameStore: ReturnType<typeof useGameStore>
17 let fetchMock: ReturnType<typeof vi.fn>
18 
19 beforeEach(() => {
20 setActivePinia(createPinia())
21 gameStore = useGameStore()
22 fetchMock = vi.fn()
23 vi.stubGlobal('$fetch', fetchMock)
24 })
25 
26 afterEach(() => {
27 vi.unstubAllGlobals()
28 vi.restoreAllMocks()
29 })
30 
31 it('offers every curated objective to choose from', () => {
32 const wrapper = mount(MissionSelect)
33 const options = wrapper.findAll('[data-testid="objective-option"]')
34 expect(options.length).toBe(listCuratedObjectives().length)
35 })
36 
37 it('renders each curated brief newline-aware so bullet lines do not collapse (issue #82)', () => {
38 const wrapper = mount(MissionSelect)
39 const firstOption = wrapper.findAll('[data-testid="objective-option"]')[0]
40 const brief = firstOption.find('.whitespace-pre-line')
41 // The grounded briefs are multi-line "• " bullets; this class keeps them on
42 // separate rows at mission select (the issue's other render site).
43 expect(brief.exists()).toBe(true)
44 expect(brief.text()).toContain('• ')
45 })
46 
47 it('disables Begin until an objective is selected', async () => {
48 const wrapper = mount(MissionSelect)
49 expect(wrapper.find('[data-testid="begin-button"]').attributes('disabled')).toBeDefined()
50 
51 await wrapper.findAll('[data-testid="objective-option"]')[0].trigger('click')
52 expect(wrapper.find('[data-testid="begin-button"]').attributes('disabled')).toBeUndefined()
53 })
54 
55 it('marks the clicked objective as selected — aria-checked + the radio dot', async () => {
56 // Regression: a plain ref returns a reactive PROXY on .value, so the identity
57 // check `selected.value === obj` failed for the raw curated objects — the row
58 // tinted on hover but the radio never registered. selection must visibly stick.
59 const wrapper = mount(MissionSelect)
60 const options = wrapper.findAll('[data-testid="objective-option"]')
61 expect(options[1].attributes('aria-checked')).toBe('false')
62 
63 await options[1].trigger('click')
64 
65 expect(options[1].attributes('aria-checked')).toBe('true')
66 expect(options[0].attributes('aria-checked')).toBe('false')
67 expect(options[1].find('.ew-dot').classes()).toContain('ew-dot--accent')
68 expect(options[0].find('.ew-dot').classes()).toContain('ew-dot--hollow')
69 })
70 
71 it('commits the chosen curated objective on Begin', async () => {
72 const curated = listCuratedObjectives()
73 // begin() now fails closed: /api/run must mint, then run-commit must succeed.
74 fetchMock.mockImplementation((url: string) =>
75 url === '/api/run' ? Promise.resolve({ runId: 'r-1' }) : Promise.resolve({})
76 )
77 const wrapper = mount(MissionSelect)
78 
79 await wrapper.findAll('[data-testid="objective-option"]')[0].trigger('click')
80 await wrapper.find('[data-testid="begin-button"]').trigger('click')
81 await flushPromises() // begin() commits the run (async) before the objective lands
82 
83 expect(gameStore.currentObjective?.title).toBe(curated[0].title)
84 expect(gameStore.objectiveProgress).toBe(0)
85 })
86 
87 it('composes a fresh objective, features it, and begins with it', async () => {
88 fetchMock.mockImplementation((url: string) => {
89 if (url === '/api/objective') return Promise.resolve({ success: true, objective: FRESH })
90 if (url === '/api/run') return Promise.resolve({ runId: 'r-1' })
91 return Promise.resolve({}) // run-commit
92 })
93 const wrapper = mount(MissionSelect)
94 const before = wrapper.findAll('[data-testid="objective-option"]').length
95 
96 await wrapper.find('[data-testid="compose-objective"]').trigger('click')
97 await flushPromises()
98 
99 expect(fetchMock).toHaveBeenCalledWith('/api/objective', expect.objectContaining({
100 method: 'GET',
101 headers: expect.objectContaining({ 'x-run-id': expect.any(String) })
102 }))
103 expect(wrapper.findAll('[data-testid="objective-option"]').length).toBe(before + 1)
104 expect(wrapper.find('[data-testid="fresh-badge"]').exists()).toBe(true)
105 
106 // The fresh objective auto-selects, so Begin commits it directly.
107 await wrapper.find('[data-testid="begin-button"]').trigger('click')
108 await flushPromises() // begin() commits the run (async) before the objective lands
109 expect(gameStore.currentObjective?.title).toBe(FRESH.title)
110 })
111 
112 it('renders era + theme steer pickers, defaulting to no preference', () => {
113 const wrapper = mount(MissionSelect)
114 const era = wrapper.find('[data-testid="steer-era"]')
115 const theme = wrapper.find('[data-testid="steer-theme"]')
116 expect(era.exists()).toBe(true)
117 expect(theme.exists()).toBe(true)
118 expect((era.element as HTMLSelectElement).value).toBe('')
119 expect((theme.element as HTMLSelectElement).value).toBe('')
120 // Nothing surfaced until an axis is picked.
121 expect(wrapper.find('[data-testid="active-steer"]').exists()).toBe(false)
122 })
123 
124 it('forwards the chosen era + theme as query params when composing, and surfaces them', async () => {
125 fetchMock.mockImplementation((url: string) => {
126 if (url === '/api/objective') return Promise.resolve({ success: true, objective: FRESH })
127 if (url === '/api/run') return Promise.resolve({ runId: 'r-1' })
128 return Promise.resolve({})
129 })
130 const wrapper = mount(MissionSelect)
131 await wrapper.find('[data-testid="steer-era"]').setValue('Antiquity')
132 await wrapper.find('[data-testid="steer-theme"]').setValue('Science & Medicine')
133 
134 // The active steer is surfaced once set.
135 expect(wrapper.find('[data-testid="active-steer"]').text()).toContain('Antiquity · Science & Medicine')
136 
137 await wrapper.find('[data-testid="compose-objective"]').trigger('click')
138 await flushPromises()
139 
140 expect(fetchMock).toHaveBeenCalledWith('/api/objective', expect.objectContaining({
141 method: 'GET',
142 query: { era: 'Antiquity', theme: 'Science & Medicine' }
143 }))
144 })
145 
146 it('omits an axis left at "no preference"', async () => {
147 fetchMock.mockImplementation((url: string) => {
148 if (url === '/api/objective') return Promise.resolve({ success: true, objective: FRESH })
149 if (url === '/api/run') return Promise.resolve({ runId: 'r-1' })
150 return Promise.resolve({})
151 })
152 const wrapper = mount(MissionSelect)
153 await wrapper.find('[data-testid="steer-era"]').setValue('Medieval')
154 
155 await wrapper.find('[data-testid="compose-objective"]').trigger('click')
156 await flushPromises()
157 
158 expect(fetchMock).toHaveBeenCalledWith('/api/objective', expect.objectContaining({
159 query: { era: 'Medieval' }
160 }))
161 })
162 
163 it('shows an error and leaves the run unstarted when composing fails', async () => {
164 fetchMock.mockResolvedValue({ success: false })
165 const wrapper = mount(MissionSelect)
166 
167 await wrapper.find('[data-testid="compose-objective"]').trigger('click')
168 await flushPromises()
169 
170 expect(wrapper.find('[data-testid="compose-error"]').exists()).toBe(true)
171 expect(gameStore.currentObjective).toBeNull()
172 })
173 
174 it('shows a quiet notice (not an error) when a steered compose falls back to curated (#127)', async () => {
175 // The server returns a valid objective AND flags the dropped steer; the
176 // briefing surfaces a muted, non-blocking line — never the red compose-error.
177 fetchMock.mockImplementation((url: string) => {
178 if (url === '/api/objective') return Promise.resolve({ success: true, objective: FRESH, fellBackToCurated: true })
179 if (url === '/api/run') return Promise.resolve({ runId: 'r-1' })
180 return Promise.resolve({})
181 })
182 const wrapper = mount(MissionSelect)
183 await wrapper.find('[data-testid="steer-era"]').setValue('Antiquity')
184 
185 await wrapper.find('[data-testid="compose-objective"]').trigger('click')
186 await flushPromises()
187 
188 // A usable objective still landed and auto-selected — the notice is non-blocking.
189 expect(wrapper.find('[data-testid="fresh-badge"]').exists()).toBe(true)
190 expect(wrapper.find('[data-testid="begin-button"]').attributes('disabled')).toBeUndefined()
191 // The quiet notice shows (muted tone); the red error does NOT.
192 const notice = wrapper.find('[data-testid="compose-degraded"]')
193 expect(notice.exists()).toBe(true)
194 expect(notice.text()).toContain('curated one')
195 expect(notice.classes()).toContain('ew-muted')
196 expect(wrapper.find('[data-testid="compose-error"]').exists()).toBe(false)
197 })
198 
199 it('shows no dropped-steer notice on a normal successful compose (#127)', async () => {
200 // No flag (or false) → the common path stays silent, no noise.
201 fetchMock.mockImplementation((url: string) => {
202 if (url === '/api/objective') return Promise.resolve({ success: true, objective: FRESH })
203 if (url === '/api/run') return Promise.resolve({ runId: 'r-1' })
204 return Promise.resolve({})
205 })
206 const wrapper = mount(MissionSelect)
207 
208 await wrapper.find('[data-testid="compose-objective"]').trigger('click')
209 await flushPromises()
210 
211 expect(wrapper.find('[data-testid="fresh-badge"]').exists()).toBe(true)
212 expect(wrapper.find('[data-testid="compose-degraded"]').exists()).toBe(false)
213 })
⋯ 75 lines hidden (lines 214–288)
214 
215 it('shows the paywall and does not start the run when out of runs (402)', async () => {
216 // Begin succeeds (run minted), then the commit is refused 402 → paywall.
217 fetchMock.mockImplementation((url: string) => {
218 if (url === '/api/run') return Promise.resolve({ runId: 'r-1' })
219 if (url === '/api/run-commit') return Promise.reject({ statusCode: 402 })
220 return Promise.resolve({})
221 })
222 const wrapper = mount(MissionSelect)
223 
224 await wrapper.findAll('[data-testid="objective-option"]')[0].trigger('click')
225 await wrapper.find('[data-testid="begin-button"]').trigger('click')
226 await flushPromises()
227 
228 expect(wrapper.find('[data-testid="paywall"]').exists()).toBe(true)
229 expect(gameStore.currentObjective).toBeNull()
230 })
231 
232 it('shows the at-capacity notice and does not start when the spend cap is hit (503)', async () => {
233 fetchMock.mockImplementation((url: string) => {
234 if (url === '/api/run') return Promise.resolve({ runId: 'r-1' })
235 if (url === '/api/run-commit') return Promise.reject({ statusCode: 503, data: { atCapacity: true } })
236 return Promise.resolve({})
237 })
238 const wrapper = mount(MissionSelect)
239 
240 await wrapper.findAll('[data-testid="objective-option"]')[0].trigger('click')
241 await wrapper.find('[data-testid="begin-button"]').trigger('click')
242 await flushPromises()
243 
244 expect(wrapper.find('[data-testid="at-capacity"]').exists()).toBe(true)
245 expect(wrapper.find('[data-testid="paywall"]').exists()).toBe(false)
246 expect(gameStore.currentObjective).toBeNull()
247 })
248 
249 it('opens the run-packs modal automatically when out of runs (402)', async () => {
250 // The buy buttons now live in RunPacksModal; the refused commit opens it and
251 // loads the catalog. The paywall keeps a "See run packs" button to reopen it.
252 fetchMock.mockImplementation((url: string) => {
253 if (url === '/api/run') return Promise.resolve({ runId: 'r-1' })
254 if (url === '/api/run-commit') return Promise.reject({ statusCode: 402 })
255 if (url === '/api/packs') return Promise.resolve({ packs: [{ id: 'courier', label: 'Courier', priceCents: 500, runs: 7 }] })
256 return Promise.resolve({})
257 })
258 const wrapper = mount(MissionSelect)
259 
260 await wrapper.findAll('[data-testid="objective-option"]')[0].trigger('click')
261 await wrapper.find('[data-testid="begin-button"]').trigger('click')
262 await flushPromises()
263 
264 expect(wrapper.find('[data-testid="open-packs"]').exists()).toBe(true)
265 expect(gameStore.buyModalOpen).toBe(true)
266 expect(gameStore.packs).toEqual([{ id: 'courier', label: 'Courier', priceCents: 500, runs: 7 }])
267 })
268 
269 it('reopens the run-packs modal from the paywall button', async () => {
270 fetchMock.mockImplementation((url: string) => {
271 if (url === '/api/run') return Promise.resolve({ runId: 'r-1' })
272 if (url === '/api/run-commit') return Promise.reject({ statusCode: 402 })
273 if (url === '/api/packs') return Promise.resolve({ packs: [] })
274 return Promise.resolve({})
275 })
276 const wrapper = mount(MissionSelect)
277 await wrapper.findAll('[data-testid="objective-option"]')[0].trigger('click')
278 await wrapper.find('[data-testid="begin-button"]').trigger('click')
279 await flushPromises()
280 
281 // Dismiss, then reopen via the persistent paywall button.
282 gameStore.closeBuyModal()
283 expect(gameStore.buyModalOpen).toBe(false)
284 await wrapper.find('[data-testid="open-packs"]').trigger('click')
285 await flushPromises()
286 expect(gameStore.buyModalOpen).toBe(true)
287 })
288})