What changed, and why

"Compose a fresh objective with AI" used to be one model call with no rails: an open prompt invented an objective and the only check was that title and description were non-empty. Nothing stopped it anchoring on living people, contemporary figures, or current events — the same legal and safety risk the figure floor (#72) exists to remove — and the player had no say in what came back.

This change makes the composition bounded and steerable, while keeping the curated pool as both the instant default and the safe floor beneath it.

- Bounds (server). The prompt is constrained to safely-historical objectives, and the output is then independently validated — the prompt is asked to obey the bounds, never trusted to. A year bound, a structured subject check, and the message moderation seam each get a say. Out of bounds → reroll once → curated fallback. - Steer (client). Two closed-enum dials, era and theme, bias the composition. Closed enums only — no free text — so there is no client path that smuggles an unsafe objective past the floor.

Read the shared cutoff first; everything else leans on it.

One safely-historical line, shared with the figure floor (#72)

The figure floor (#72) already drew a "safely historical" line — about a century back — for trusting an estimated death. This PR reuses that exact line for the objective bounds, and to keep the two from drifting it lifts the value into one shared constant. The duplicated-constant loop flags re-typed rules like this, so there is exactly one source.

The single source of the cutoff.

server/utils/historical-floor.ts · 11 lines
server/utils/historical-floor.ts11 lines · TypeScript
1/**
2 * The single safely-historical line — the cutoff #72 draws and #71 reuses.
3 *
4 * A purely AI-estimated death is trusted as historical (figure-grounding), and an
5 * AI-composed objective may anchor (objective-generator), only at or before this
6 * year: about a century back, the same defensible "out of living memory" boundary
7 * that keeps the living, the recently dead, and current events out of scope. Kept
8 * in one place so the figure floor and the objective bounds can never drift apart
9 * (the duplicated-constant loop flags re-typed copies of a rule exactly like this).
10 */
11export const SAFELY_HISTORICAL_BEFORE_YEAR = new Date().getFullYear() - 100

The figure floor now sources the value here instead of redefining it.

server/utils/figure-grounding.ts · 325 lines
server/utils/figure-grounding.ts325 lines · TypeScript
⋯ 254 lines hidden (lines 1–254)
1/**
2 * Real-world grounding for historical figures — the backbone of the "timeline RPG"
3 * direction (see docs/ROADMAP.md). Resolves a free-form name to solid facts so the
4 * game can show who someone was, validate *when* you may contact them, and feed the
5 * role-play real dates.
6 *
7 * Pipeline (the wiki APIs are open + key-less):
8 * 1. Wikipedia REST summary — the reliable resolver. It follows redirects and
9 * picks the notable article, giving a canonical title, a one-line description,
10 * a blurb, a portrait, and the Wikidata item id. (Wikidata search alone is NOT
11 * reliable: "Cleopatra" resolves to "female given name", "Genghis Khan" to an
12 * Iron Maiden song.)
13 * 2. Wikidata, looked up by that item id (title as fallback) — structured birth
14 * (P569) / death (P570), taking the best-ranked claim that actually carries a
15 * date, with sub-year precision surfaced honestly as `circa`.
16 * 3. If the record still has no birth year, a small AI estimator bridges the gap
17 * with circa dates (issue #31) — so a real, resolvable figure keeps their
18 * "when" slider even when Wikidata can't date them.
19 *
20 * Anything unresolved returns `{ resolved: false }` (with `transient` set when the
21 * lookup couldn't complete). Contact callers now BLOCK an ungrounded name (#73) —
22 * only a real, documented figure can be reached — while a transient miss is a retry.
23 *
24 * Cache honesty: only definitive answers — a real Wikidata reply (dated or not,
25 * with the estimator genuinely consulted), a genuine no-such-article miss, a
26 * disambiguation page — keep the full TTL. A transient failure anywhere on the
27 * path (a rate-limit 429, a timeout, an estimator outage) gets a short TTL, so
28 * one blip never poisons a figure for a day.
29 */
30import { estimateLifespan } from './lifespan-estimator'
31import { SAFELY_HISTORICAL_BEFORE_YEAR } from './historical-floor'
32 
33/** A year on the timeline. `signed` is AD-positive / BCE-negative for comparison. */
34export interface FigureYear {
35 year: number
36 bce: boolean
37 signed: number
38 display: string
39 /** Approximate: sub-year Wikidata precision (decade/century/…) or an AI estimate. */
40 circa?: boolean
42 
43export interface GroundedFigure {
44 name: string
45 resolved: boolean
46 description?: string
47 extract?: string
48 born?: FigureYear
49 died?: FigureYear
50 living?: boolean
51 thumbnail?: string
52 wikiUrl?: string
53 /** The lookup couldn't be completed (rate-limit / timeout / outage), so an
54 * unresolved result is "we don't know yet", not "no such person". The contact
55 * gate treats this as a retry rather than a definitive miss — and never as a
56 * free-form pass (#72 / #73). */
57 transient?: boolean
59 
60/**
61 * The deceased-only contact floor (#72): a figure may be reached only once we can
62 * confirm they have died. A resolved figure with no death year is living (or not
63 * datable enough to prove otherwise) and is never contactable — fail closed. An
64 * unresolved name is not deceased here either (the free-form path is removed in #73).
65 * The canonical predicate, shared by the suggester and the send-message gate; the
66 * client store inlines the same rule (it can't import this server-only module).
67 */
68export function isDeceased(figure: GroundedFigure): boolean {
69 return figure.resolved && !!figure.died
71 
72/**
73 * The one header for every Wikipedia / Wikidata call the server makes — grounding,
74 * the name autocomplete, and the suggester's lookups all share it (issue #58).
75 * Wikimedia's User-Agent policy wants the client identified plus a reachable CONTACT
76 * so an operator can be reached about problematic traffic; an agent without one gets
77 * throttled harder, and rate-limit blips are the dominant real-world failure for the
78 * autocomplete. A mailto is that contact — the deployed site is auth-gated and the
79 * repo private, so neither would reach a human. Exported so every caller sends the
80 * identical compliant string instead of drifting per-feature copies.
81 */
82export const WIKI_HEADERS = {
83 accept: 'application/json',
84 'User-Agent': 'Revisionist/1.0 (timeline RPG; mailto:matthew@mseeks.me)'
86 
87const FULL_TTL_MS = 24 * 60 * 60 * 1000
88const TRANSIENT_TTL_MS = 10 * 60 * 1000
89const WIKIDATA_RETRY_DELAY_MS = 400
90const cache = new Map<string, { value: GroundedFigure; expires: number }>()
91 
92/** Builds a `FigureYear`; the display string owns the "c." honesty marker. */
93export function makeFigureYear(year: number, bce: boolean, circa = false): FigureYear {
94 const base = bce ? `${year} BC` : `${year}`
95 return {
96 year,
97 bce,
98 signed: bce ? -year : year,
99 display: circa ? `c. ${base}` : base,
100 ...(circa ? { circa: true } : {})
101 }
103 
104/**
105 * Parses a Wikidata time literal (e.g. "+1856-07-10T..", "-0069-01-13T..") to a year.
106 * Precision 9 means year-level; anything below (8 decade, 7 century, 6 millennium)
107 * still carries a representative year in the literal, but only approximately —
108 * those parse as `circa` rather than being dropped or displayed as exact.
109 */
110export function parseWikidataYear(time?: string, precision?: number): FigureYear | undefined {
111 if (!time) return undefined
112 const m = /^([+-])0*(\d+)-/.exec(time)
113 if (!m) return undefined
114 const year = parseInt(m[2], 10)
115 if (!year) return undefined
116 const bce = m[1] === '-'
117 return makeFigureYear(year, bce, precision != null && precision < 9)
119 
120/** The slice of the Wikipedia REST summary we actually read. */
121interface WikiSummary {
122 type?: string
123 title?: string
124 description?: string
125 extract?: string
126 thumbnail?: { source?: string }
127 content_urls?: { desktop?: { page?: string } }
128 wikibase_item?: string
130 
131/** The slice of a Wikidata date claim we actually read. */
132interface WikidataClaim {
133 rank?: string
134 mainsnak?: {
135 snaktype?: string
136 datavalue?: { value?: { time?: string; precision?: number } }
137 }
139 
140/** The slice of the Wikidata `wbgetentities` response we actually read. */
141interface WikidataEntities {
142 entities?: Record<string, { claims?: Record<string, WikidataClaim[]> }>
144 
145/**
146 * The best date a claim list actually carries. Wikidata routinely stacks several
147 * birth/death claims (scholarly guesses, deprecated values, "unknown value"
148 * placeholders) and the community-preferred one is often NOT first — Moses, Jesus
149 * and Confucius all keep theirs at index 2. So: only claims whose snak carries a
150 * real time (somevalue/novalue snaks don't), never deprecated, preferred rank first.
151 */
152export function pickClaimYear(claims?: WikidataClaim[]): FigureYear | undefined {
153 const usable = (claims ?? []).filter(c =>
154 c.rank !== 'deprecated' &&
155 c.mainsnak?.snaktype === 'value' &&
156 c.mainsnak.datavalue?.value?.time
157 )
158 const best = usable.find(c => c.rank === 'preferred') ?? usable[0]
159 const value = best?.mainsnak?.datavalue?.value
160 return parseWikidataYear(value?.time, value?.precision)
162 
163/**
164 * A fetch that tells a definitive miss from a transient one: 404 means the thing
165 * genuinely isn't there (cacheable at full TTL), anything else — 429, 5xx, timeout,
166 * network error — is weather to retry soon, not remember for a day.
167 */
168interface FetchOutcome<T> {
169 data: T | null
170 transient: boolean
172 
173async function fetchJson<T>(url: string): Promise<FetchOutcome<T>> {
174 try {
175 const res = await fetch(url, { headers: WIKI_HEADERS, signal: AbortSignal.timeout(6000) })
176 if (!res.ok) return { data: null, transient: res.status !== 404 }
177 return { data: (await res.json()) as T, transient: false }
178 } catch {
179 return { data: null, transient: true }
180 }
182 
183interface WikidataDates {
184 born?: FigureYear
185 died?: FigureYear
186 /** True when we never got a real answer (so the result must not cache long). */
187 transient: boolean
189 
190/**
191 * Birth/death from Wikidata — by item id when the summary supplied one (immune to
192 * title-matching quirks), by sitelink title otherwise. Rate-limit blips are the
193 * dominant way dates go missing in practice (a 429 body carries no `entities`), so
194 * a no-entities response gets one spaced retry before being declared transient.
195 */
196async function wikidataDates(qid: string | undefined, title: string): Promise<WikidataDates> {
197 const subject = qid
198 ? `ids=${encodeURIComponent(qid)}`
199 : `sites=enwiki&titles=${encodeURIComponent(title)}&normalize=1`
200 const url = `https://www.wikidata.org/w/api.php?action=wbgetentities&${subject}&props=claims&languages=en&format=json&origin=*`
201 let outcome = await fetchJson<WikidataEntities>(url)
202 if (!outcome.data?.entities) {
203 await new Promise(resolve => setTimeout(resolve, WIKIDATA_RETRY_DELAY_MS))
204 outcome = await fetchJson<WikidataEntities>(url)
205 }
206 const entities = outcome.data?.entities
207 if (!entities) return { transient: true }
208 const id = Object.keys(entities)[0]
209 const claims = id ? entities[id]?.claims : undefined
210 return { born: pickClaimYear(claims?.P569), died: pickClaimYear(claims?.P570), transient: false }
212 
213/**
214 * Resolves a name to grounded facts (cached). Never throws; returns
215 * `{ resolved: false }` when the figure can't be confidently identified.
216 */
217export async function groundFigure(rawName: string): Promise<GroundedFigure> {
218 const name = (rawName || '').trim()
219 if (!name) return { name: '', resolved: false }
220 
221 const key = name.toLowerCase()
222 const hit = cache.get(key)
223 if (hit && hit.expires > Date.now()) return hit.value
224 
225 const { value, ttlMs } = await resolve(name)
226 cache.set(key, { value, expires: Date.now() + ttlMs })
227 return value
229 
230/**
231 * Keep a URL only if it's a plain http(s) link — drop `javascript:` / `data:` /
232 * relative values. These come from a remote Wikipedia response and end up bound to
233 * `:href` / `:src` in the figure dossier; Vue escapes the value but NOT the scheme,
234 * so a spoofed `javascript:` URL would execute on click. (Surfaced by the
235 * unsafe-render loop.) Exported for the figure-search route, which binds Wikipedia
236 * thumbnails the same way.
237 */
238export function safeUrl(url?: string): string | undefined {
239 if (!url) return undefined
240 try {
241 const { protocol } = new URL(url)
242 return protocol === 'https:' || protocol === 'http:' ? url : undefined
243 } catch {
244 return undefined
245 }
247 
248interface Resolution {
249 value: GroundedFigure
250 ttlMs: number
252 
253/** A purely AI-estimated death is only trusted as "historical" (and the figure
254 * contactable, #72) when it predates this year — a recent estimated death is the
255 * least trustworthy signal and could mark a living person dead. Documented deaths
256 * are exempt (they're facts). The same safely-historical line the objective bounds
257 * reuse (#71), sourced once from historical-floor so the two never drift. */
258const ESTIMATED_DEATH_HISTORICAL_BEFORE = SAFELY_HISTORICAL_BEFORE_YEAR
⋯ 67 lines hidden (lines 259–325)
259 
260async function resolve(name: string): Promise<Resolution> {
261 const summary = await fetchJson<WikiSummary>(
262 `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(name)}`
263 )
264 
265 // No article, or an ambiguous disambiguation page → can't pin a person.
266 const article = summary.data
267 if (!article || article.type === 'disambiguation' || !article.title) {
268 return {
269 value: { name, resolved: false, transient: summary.transient },
270 ttlMs: summary.transient ? TRANSIENT_TTL_MS : FULL_TTL_MS
271 }
272 }
273 
274 const title: string = article.title
275 
276 const dates = await wikidataDates(article.wikibase_item, title)
277 let born = dates.born
278 let died = dates.died
279 let bridgeTransient = false
280 
281 // The AI bridge (issue #31): the record knows WHO they are but not WHEN — either
282 // Wikidata genuinely has no usable birth claim, or the lookup is down right now.
283 // Estimated years arrive marked circa, so the dossier and prompts stay honest.
284 // A recorded death is law: the estimate only fills what the record lacks, and an
285 // estimate that contradicts the record (born after the documented death) is
286 // dropped whole rather than shipped as an incoherent lifespan.
287 if (!born) {
288 const bridge = await estimateLifespan(title, article.description, article.extract, died?.display)
289 bridgeTransient = bridge.transient
290 if (bridge.estimate) {
291 const estBorn = makeFigureYear(bridge.estimate.born.year, bridge.estimate.born.bce, true)
292 if (!died || estBorn.signed <= died.signed) {
293 born = estBorn
294 // A purely AI-ESTIMATED death is trusted as "deceased" (the floor in
295 // #72) only when it's clearly historical. An LLM guess of a RECENT
296 // death is exactly what could mark a living person contactable, so we
297 // don't promote it — the figure stays `living` and is blocked. A
298 // DOCUMENTED death (already in `died`) is a fact and is kept as-is.
299 const estDied = bridge.estimate.died
300 ? makeFigureYear(bridge.estimate.died.year, bridge.estimate.died.bce, true)
301 : undefined
302 died = died ?? (estDied && estDied.signed <= ESTIMATED_DEATH_HISTORICAL_BEFORE ? estDied : undefined)
303 }
304 }
305 }
306 
307 return {
308 value: {
309 name: title,
310 resolved: true,
311 description: article.description || undefined,
312 extract: article.extract || undefined,
313 born,
314 died,
315 living: !!born && !died,
316 thumbnail: safeUrl(article.thumbnail?.source),
317 wikiUrl: safeUrl(article.content_urls?.desktop?.page),
318 transient: dates.transient || bridgeTransient
319 },
320 // Anything short of a definitive answer keeps the short TTL: a transient
321 // Wikidata miss (even when the estimator papered over it — the next lookup
322 // should get a shot at the real dates) and an estimator outage alike.
323 ttlMs: dates.transient || bridgeTransient ? TRANSIENT_TTL_MS : FULL_TTL_MS
324 }

Bounding the composition: three gates, never trusting the prompt

objectiveWithinBounds is the gate a composition must clear before it can reach the player. The year bound is pure and free: a finite anchor must sit at or before the cutoff (a null anchor — "spans all history" — skips the year gate but is still subject-checked below). Then two independent calls run together: moderation over the generated text, and the structured subject check.

The three gates; first 'out' wins.

server/utils/objective-generator.ts · 232 lines
server/utils/objective-generator.ts232 lines · TypeScript
⋯ 109 lines hidden (lines 1–109)
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 type GameObjective,
25 type ObjectiveSteer
26} from './objectives'
27import { SAFELY_HISTORICAL_BEFORE_YEAR } from './historical-floor'
28 
29/**
30 * Returns an objective for a new run. Curated-random by default (instant, no API
31 * call); pass useAI=true to compose one live under the given steer, with automatic
32 * fallback to curated. Never throws and never ships an out-of-bounds objective.
33 */
34export async function generateObjective(useAI = false, steer: ObjectiveSteer = {}): Promise<GameObjective> {
35 if (useAI) {
36 // Never ship out of bounds, never hard-fail the run: compose live, and on an
37 // out-of-bounds result reroll exactly once, then fall back to the curated
38 // floor. A transport/parse failure won't heal on a reroll, so it drops
39 // straight to curated instead of spending a second call.
40 for (let attempt = 1; attempt <= 2; attempt++) {
41 try {
42 const composed = await composeObjective(steer)
43 if (await objectiveWithinBounds(composed)) return composed
44 console.warn(`AI objective out of bounds (attempt ${attempt} of 2); ${attempt < 2 ? 'rerolling' : 'falling back to curated'}`)
45 } catch (error) {
46 console.error('AI objective generation failed; falling back to curated pool:', error)
47 break
48 }
49 }
50 }
51 return pickCurated()
53 
54/**
55 * Composes one fresh objective with the model under the steer. Returns the same
56 * shape as the curated pool so the rest of the game treats them identically.
57 * Throws on an empty/malformed answer (the caller owns the fallback).
58 */
59async function composeObjective(steer: ObjectiveSteer): Promise<GameObjective> {
60 // Imported lazily so the curated (default) path never pulls the model SDKs
61 // into the bundle.
62 const { completeStructured } = await import('./ai-gateway')
63 
64 const content = await completeStructured({
65 task: 'objective',
66 system: composeSystemPrompt(),
67 turns: [{ role: 'user', content: composeUserPrompt(steer) }],
68 schemaName: 'game_objective',
69 schema: {
70 type: 'object',
71 properties: {
72 title: { type: 'string', description: 'Punchy objective title' },
73 description: { type: 'string', description: 'One or two sentences on what winning looks like' },
74 era: { type: 'string', description: 'Anchoring era / setting' },
75 icon: { type: 'string', description: 'A single emoji that fits the objective' },
76 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` }
77 },
78 required: ['title', 'description', 'era', 'icon', 'anchorYear'],
79 additionalProperties: false
80 }
81 })
82 
83 if (!content) throw new Error('No objective generated')
84 
85 const parsed = JSON.parse(content) as GameObjective
86 if (!parsed.title || !parsed.description) throw new Error('Invalid objective format generated')
87 return {
88 title: parsed.title,
89 description: parsed.description,
90 era: parsed.era || 'Across the ages',
91 icon: parsed.icon || '🕰️',
92 // A finite signed year anchors the chain dial; null / junk → no anchor (no-op).
93 anchorYear: typeof parsed.anchorYear === 'number' && Number.isFinite(parsed.anchorYear)
94 ? Math.round(parsed.anchorYear)
95 : undefined
96 }
98 
99/**
100 * Validates a composed objective against the safely-historical bounds (#71) —
101 * the prompt is asked to obey them, but never trusted to. Three gates: a pure year
102 * bound, the same moderation seam messages pass through, and a structured subject
103 * check for the living-people / current-events framings a year bound can't see.
104 * Never throws. The year and subject gates fail CLOSED — an inconclusive result
105 * (junk, an empty answer, an outage) reads as out of bounds, so an unvetted
106 * composition falls back to curated rather than shipping. Moderation fails OPEN,
107 * the same fail-open-but-loud seam messages pass through (a safety classifier
108 * being down must never take the game down); it rejects only on an active flag.
109 */
110async function objectiveWithinBounds(o: GameObjective): Promise<boolean> {
111 // 1. Year bound — pure, deterministic, free. A finite anchor must sit at or
112 // before the safely-historical line; a null anchor (spans-all-history) is
113 // allowed, its content still gated by the subject check below.
114 if (typeof o.anchorYear === 'number' && o.anchorYear > SAFELY_HISTORICAL_BEFORE_YEAR) return false
115 
116 const text = `${o.title}\n${o.description}`
117 // 2 + 3 are independent model/detector calls — run them together; first "out"
118 // wins. Lazy import keeps moderation (and its SDKs) off the curated path.
119 const { hardBlockCheck } = await import('./moderation')
120 const [mod, subjectInBounds] = await Promise.all([
121 hardBlockCheck(text, 'objective', 'output'),
122 checkObjectiveSubject(o)
123 ])
124 if (mod.blocked) return false
125 return subjectInBounds
⋯ 106 lines hidden (lines 127–232)
127 
128/**
129 * The structured subject check (the figure-floor pattern, server/utils/
130 * lifespan-estimator.ts): a cheap classifier that gates the free-prose title +
131 * description a year bound can't reach — does this center on living people,
132 * contemporary figures, ongoing conflicts, or post-cutoff events? Fails CLOSED:
133 * an empty answer, junk, or an outage returns false, so an unvetted objective is
134 * never shipped (it falls back to the safe curated floor instead). Never throws.
135 */
136async function checkObjectiveSubject(o: GameObjective): Promise<boolean> {
137 const { completeStructured } = await import('./ai-gateway')
138 try {
139 const content = await completeStructured({
140 task: 'objective-check',
141 system: boundsCheckSystemPrompt(),
142 turns: [{
143 role: 'user',
144 content: `Title: ${o.title}\nDescription: ${o.description}\nEra: ${o.era}\nAnchor year: ${o.anchorYear ?? 'none (spans all history)'}`
145 }],
146 schemaName: 'objective_bounds',
147 schema: {
148 type: 'object',
149 properties: {
150 withinBounds: { type: 'boolean', description: 'True only when the subject is safely historical (settled past, no living people or current events)' },
151 reason: { type: 'string', description: 'One short clause on the call' }
152 },
153 required: ['withinBounds', 'reason'],
154 additionalProperties: false
155 }
156 })
157 if (!content) return false // the model blipped — fail closed to the curated floor
158 return mapBoundsWire(JSON.parse(content)) === true
159 } catch (error) {
160 console.error('Objective bounds check error:', error)
161 return false // an outage must never let an unvetted objective through
162 }
164 
165/**
166 * The bounds-check wire → a clean verdict. `null` for an unusable shape (missing /
167 * non-boolean flag), which the caller treats exactly like "out of bounds". Exported
168 * pure so the guard is directly testable, like mapLifespanWire.
169 */
170export function mapBoundsWire(wire: unknown): boolean | null {
171 const w = wire as { withinBounds?: unknown } | null
172 if (!w || typeof w.withinBounds !== 'boolean') return null
173 return w.withinBounds
175 
176/** System prompt for composition: the tone bar + the non-negotiable safety bounds. */
177function composeSystemPrompt(): string {
178 return `You are a historian and game designer inventing grand, evocative counterfactual objectives for a game where players text historical figures to bend history.
179 
180Hold every objective to two bars:
181 
182TONE — sweeping and vivid, a whole world to remake in the spirit of the examples, never a small errand.
183 
184BOUNDS (safety, non-negotiable) — the objective must be safely historical:
185- Set in the settled past. Any pivotal anchor year must be at or before ${SAFELY_HISTORICAL_BEFORE_YEAR}.
186- 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.`
188 
189/** Composition user turn: curated few-shot exemplars, the steer, and the ask. */
190function composeUserPrompt(steer: ObjectiveSteer): string {
191 return `Objectives we love (match this tone and scale; invent something NEW and distinct from every one):
192${curatedExemplars()}
193 
194Invent ONE fresh objective, distinct from all of the above.${steerClause(steer)}
195 
196Give it: a punchy title; a vivid one-or-two-sentence description of what winning looks like; an anchoring era; a single emoji; and 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.`
198 
199/** System prompt for the structured subject check. */
200function boundsCheckSystemPrompt(): string {
201 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.
202 
203Mark it OUT OF BOUNDS (withinBounds=false) if it centers on, or would require contacting, any of:
204- a living person or a present-day public figure,
205- an ongoing conflict or a current event,
206- anything anchored after the year ${SAFELY_HISTORICAL_BEFORE_YEAR}.
207 
208Mark 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.
209 
210When you are unsure, answer withinBounds=false. Give a short reason either way.`
212 
213/** The curated pool rendered as few-shot exemplars (title — description), read
214 * live from the pool so the bar can never drift from a hardcoded copy. */
215function curatedExemplars(): string {
216 return listCuratedObjectives()
217 .map(o => `${o.title}${o.description}`)
218 .join('\n')
220 
221/** Turns the closed-enum steer into a short clause biasing the model. Absent axes
222 * simply don't appear, leaving them to the model ("no preference"). */
223function steerClause(steer: ObjectiveSteer): string {
224 const parts: string[] = []
225 if (steer.era) {
226 parts.push(steer.era === 'Across the ages'
227 ? 'Let it span the ages rather than a single era.'
228 : `Anchor it in this stretch of history: ${steer.era}.`)
229 }
230 if (steer.theme) parts.push(`Center it on the theme of ${steer.theme}.`)
231 return parts.length ? ` ${parts.join(' ')}` : ''

The structured subject check (a year bound can't read prose)

A year is a number; "rally the world behind a living head of state" is prose. The subject check is the classifier that reads the free text — does this center on living people, contemporary figures, ongoing conflicts, or post-cutoff events? It mirrors the figure-floor pattern (lifespan-estimator.ts): a small structured call, validated by a pure mapper, and it fails closed — an empty answer, junk, or an outage all return false, so an unvetted objective is never shipped.

checkObjectiveSubject (fail-closed) and the pure mapBoundsWire it leans on.

server/utils/objective-generator.ts · 232 lines
server/utils/objective-generator.ts232 lines · TypeScript
⋯ 135 lines hidden (lines 1–135)
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 type GameObjective,
25 type ObjectiveSteer
26} from './objectives'
27import { SAFELY_HISTORICAL_BEFORE_YEAR } from './historical-floor'
28 
29/**
30 * Returns an objective for a new run. Curated-random by default (instant, no API
31 * call); pass useAI=true to compose one live under the given steer, with automatic
32 * fallback to curated. Never throws and never ships an out-of-bounds objective.
33 */
34export async function generateObjective(useAI = false, steer: ObjectiveSteer = {}): Promise<GameObjective> {
35 if (useAI) {
36 // Never ship out of bounds, never hard-fail the run: compose live, and on an
37 // out-of-bounds result reroll exactly once, then fall back to the curated
38 // floor. A transport/parse failure won't heal on a reroll, so it drops
39 // straight to curated instead of spending a second call.
40 for (let attempt = 1; attempt <= 2; attempt++) {
41 try {
42 const composed = await composeObjective(steer)
43 if (await objectiveWithinBounds(composed)) return composed
44 console.warn(`AI objective out of bounds (attempt ${attempt} of 2); ${attempt < 2 ? 'rerolling' : 'falling back to curated'}`)
45 } catch (error) {
46 console.error('AI objective generation failed; falling back to curated pool:', error)
47 break
48 }
49 }
50 }
51 return pickCurated()
53 
54/**
55 * Composes one fresh objective with the model under the steer. Returns the same
56 * shape as the curated pool so the rest of the game treats them identically.
57 * Throws on an empty/malformed answer (the caller owns the fallback).
58 */
59async function composeObjective(steer: ObjectiveSteer): Promise<GameObjective> {
60 // Imported lazily so the curated (default) path never pulls the model SDKs
61 // into the bundle.
62 const { completeStructured } = await import('./ai-gateway')
63 
64 const content = await completeStructured({
65 task: 'objective',
66 system: composeSystemPrompt(),
67 turns: [{ role: 'user', content: composeUserPrompt(steer) }],
68 schemaName: 'game_objective',
69 schema: {
70 type: 'object',
71 properties: {
72 title: { type: 'string', description: 'Punchy objective title' },
73 description: { type: 'string', description: 'One or two sentences on what winning looks like' },
74 era: { type: 'string', description: 'Anchoring era / setting' },
75 icon: { type: 'string', description: 'A single emoji that fits the objective' },
76 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` }
77 },
78 required: ['title', 'description', 'era', 'icon', 'anchorYear'],
79 additionalProperties: false
80 }
81 })
82 
83 if (!content) throw new Error('No objective generated')
84 
85 const parsed = JSON.parse(content) as GameObjective
86 if (!parsed.title || !parsed.description) throw new Error('Invalid objective format generated')
87 return {
88 title: parsed.title,
89 description: parsed.description,
90 era: parsed.era || 'Across the ages',
91 icon: parsed.icon || '🕰️',
92 // A finite signed year anchors the chain dial; null / junk → no anchor (no-op).
93 anchorYear: typeof parsed.anchorYear === 'number' && Number.isFinite(parsed.anchorYear)
94 ? Math.round(parsed.anchorYear)
95 : undefined
96 }
98 
99/**
100 * Validates a composed objective against the safely-historical bounds (#71) —
101 * the prompt is asked to obey them, but never trusted to. Three gates: a pure year
102 * bound, the same moderation seam messages pass through, and a structured subject
103 * check for the living-people / current-events framings a year bound can't see.
104 * Never throws. The year and subject gates fail CLOSED — an inconclusive result
105 * (junk, an empty answer, an outage) reads as out of bounds, so an unvetted
106 * composition falls back to curated rather than shipping. Moderation fails OPEN,
107 * the same fail-open-but-loud seam messages pass through (a safety classifier
108 * being down must never take the game down); it rejects only on an active flag.
109 */
110async function objectiveWithinBounds(o: GameObjective): Promise<boolean> {
111 // 1. Year bound — pure, deterministic, free. A finite anchor must sit at or
112 // before the safely-historical line; a null anchor (spans-all-history) is
113 // allowed, its content still gated by the subject check below.
114 if (typeof o.anchorYear === 'number' && o.anchorYear > SAFELY_HISTORICAL_BEFORE_YEAR) return false
115 
116 const text = `${o.title}\n${o.description}`
117 // 2 + 3 are independent model/detector calls — run them together; first "out"
118 // wins. Lazy import keeps moderation (and its SDKs) off the curated path.
119 const { hardBlockCheck } = await import('./moderation')
120 const [mod, subjectInBounds] = await Promise.all([
121 hardBlockCheck(text, 'objective', 'output'),
122 checkObjectiveSubject(o)
123 ])
124 if (mod.blocked) return false
125 return subjectInBounds
127 
128/**
129 * The structured subject check (the figure-floor pattern, server/utils/
130 * lifespan-estimator.ts): a cheap classifier that gates the free-prose title +
131 * description a year bound can't reach — does this center on living people,
132 * contemporary figures, ongoing conflicts, or post-cutoff events? Fails CLOSED:
133 * an empty answer, junk, or an outage returns false, so an unvetted objective is
134 * never shipped (it falls back to the safe curated floor instead). Never throws.
135 */
136async function checkObjectiveSubject(o: GameObjective): Promise<boolean> {
137 const { completeStructured } = await import('./ai-gateway')
138 try {
139 const content = await completeStructured({
140 task: 'objective-check',
141 system: boundsCheckSystemPrompt(),
142 turns: [{
143 role: 'user',
144 content: `Title: ${o.title}\nDescription: ${o.description}\nEra: ${o.era}\nAnchor year: ${o.anchorYear ?? 'none (spans all history)'}`
145 }],
146 schemaName: 'objective_bounds',
147 schema: {
148 type: 'object',
149 properties: {
150 withinBounds: { type: 'boolean', description: 'True only when the subject is safely historical (settled past, no living people or current events)' },
151 reason: { type: 'string', description: 'One short clause on the call' }
152 },
153 required: ['withinBounds', 'reason'],
154 additionalProperties: false
155 }
156 })
157 if (!content) return false // the model blipped — fail closed to the curated floor
158 return mapBoundsWire(JSON.parse(content)) === true
159 } catch (error) {
160 console.error('Objective bounds check error:', error)
161 return false // an outage must never let an unvetted objective through
162 }
164 
165/**
166 * The bounds-check wire → a clean verdict. `null` for an unusable shape (missing /
167 * non-boolean flag), which the caller treats exactly like "out of bounds". Exported
168 * pure so the guard is directly testable, like mapLifespanWire.
169 */
170export function mapBoundsWire(wire: unknown): boolean | null {
171 const w = wire as { withinBounds?: unknown } | null
172 if (!w || typeof w.withinBounds !== 'boolean') return null
173 return w.withinBounds
175 
⋯ 57 lines hidden (lines 176–232)
176/** System prompt for composition: the tone bar + the non-negotiable safety bounds. */
177function composeSystemPrompt(): string {
178 return `You are a historian and game designer inventing grand, evocative counterfactual objectives for a game where players text historical figures to bend history.
179 
180Hold every objective to two bars:
181 
182TONE — sweeping and vivid, a whole world to remake in the spirit of the examples, never a small errand.
183 
184BOUNDS (safety, non-negotiable) — the objective must be safely historical:
185- Set in the settled past. Any pivotal anchor year must be at or before ${SAFELY_HISTORICAL_BEFORE_YEAR}.
186- 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.`
188 
189/** Composition user turn: curated few-shot exemplars, the steer, and the ask. */
190function composeUserPrompt(steer: ObjectiveSteer): string {
191 return `Objectives we love (match this tone and scale; invent something NEW and distinct from every one):
192${curatedExemplars()}
193 
194Invent ONE fresh objective, distinct from all of the above.${steerClause(steer)}
195 
196Give it: a punchy title; a vivid one-or-two-sentence description of what winning looks like; an anchoring era; a single emoji; and 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.`
198 
199/** System prompt for the structured subject check. */
200function boundsCheckSystemPrompt(): string {
201 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.
202 
203Mark it OUT OF BOUNDS (withinBounds=false) if it centers on, or would require contacting, any of:
204- a living person or a present-day public figure,
205- an ongoing conflict or a current event,
206- anything anchored after the year ${SAFELY_HISTORICAL_BEFORE_YEAR}.
207 
208Mark 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.
209 
210When you are unsure, answer withinBounds=false. Give a short reason either way.`
212 
213/** The curated pool rendered as few-shot exemplars (title — description), read
214 * live from the pool so the bar can never drift from a hardcoded copy. */
215function curatedExemplars(): string {
216 return listCuratedObjectives()
217 .map(o => `${o.title}${o.description}`)
218 .join('\n')
220 
221/** Turns the closed-enum steer into a short clause biasing the model. Absent axes
222 * simply don't appear, leaving them to the model ("no preference"). */
223function steerClause(steer: ObjectiveSteer): string {
224 const parts: string[] = []
225 if (steer.era) {
226 parts.push(steer.era === 'Across the ages'
227 ? 'Let it span the ages rather than a single era.'
228 : `Anchor it in this stretch of history: ${steer.era}.`)
229 }
230 if (steer.theme) parts.push(`Center it on the theme of ${steer.theme}.`)
231 return parts.length ? ` ${parts.join(' ')}` : ''

The check runs on its own cheap, temp-0 route so its cost and telemetry stay separate from the composition call. It is at most two calls per composition.

The new 'objective-check' task: Haiku, temp 0, off the critical path.

server/utils/ai-routing.ts · 178 lines
server/utils/ai-routing.ts178 lines · TypeScript
⋯ 27 lines hidden (lines 1–27)
1/**
2 * The per-task model routing table — the provider seam the go-to-market plan
3 * calls for (dispatch 1's bake-off lives behind this file). Every AI task names
4 * its lane and its full parameter payload here, because valid parameter sets
5 * differ by model family:
6 *
7 * - Haiku 4.5 rejects `effort` and has no adaptive thinking — omit both.
8 * - Sonnet 4.6 defaults `effort` to HIGH — always set it explicitly (this game
9 * wants reflexes, not deliberation). `temperature` is accepted.
10 * - Opus 4.8 rejects `temperature`/`top_p`/`top_k` — steer tone via prompts.
11 * Thinking is adaptive-only; omitting `thinking` runs without thinking.
12 *
13 * Lane defaults were decided by the two-lane bake-off plus the prompt-tuning
14 * campaign (evals/bakeoff.eval.ts, evals/prompt-tune.eval.ts; snapshots +
15 * verdict in evals/results/) — quality-first, with cost a close second. All
16 * ten tasks run Anthropic: the prose tellings initially lost the blind
17 * pairwise 19-0 under the shared prompt, and the Claude-voice rewrite
18 * (buildChroniclePromptClaude) won them back on Sonnet. The OpenAI lane stays
19 * fully alive behind REVISIONIST_AI_LANE so a forced reverse migration is a
20 * config change, not a rewrite (the GTM's platform-risk counter).
21 */
22 
23export type AiTask =
24 | 'judge'
25 | 'character'
26 | 'timeline'
27 | 'chronicler'
28 | 'epilogue'
29 | 'archivist-study'
30 | 'archive-lookup'
31 | 'objective'
32 | 'objective-check'
33 | 'suggester'
34 | 'lifespan'
35 | 'sentinel'
⋯ 98 lines hidden (lines 36–133)
36 
37export type AiLane = 'anthropic' | 'openai'
38 
39export interface AnthropicParams {
40 model: string
41 maxTokens: number
42 /** Omit on Opus-tier models — the API rejects sampling params there. */
43 temperature?: number
44 /** Omit on Haiku 4.5 — the API rejects the effort parameter there.
45 * 'xhigh' exists on Opus 4.7+ and Fable 5 only. */
46 effort?: 'low' | 'medium' | 'high' | 'xhigh'
47 /** 'adaptive' turns thinking on (billed as output); omitted = no thinking. */
48 thinking?: 'adaptive'
50 
51export interface OpenAiParams {
52 model: string
53 /** gpt-5.5 bills hidden reasoning inside this cap — keep generous headroom. */
54 maxTokens: number
55 reasoningEffort?: 'low' | 'medium' | 'high'
57 
58export interface TaskRoute {
59 lane: AiLane
60 anthropic: AnthropicParams
61 openai: OpenAiParams
63 
64/** The OpenAI lane's flagship — one place, like the old exported MODEL constant. */
65const GPT = 'gpt-5.5'
66const OPENAI_DEFAULTS: OpenAiParams = { model: GPT, maxTokens: 1200, reasoningEffort: 'low' }
67 
68/**
69 * With thinking off, Anthropic's max_tokens is TRUE output budget — no hidden
70 * reasoning billed inside it — so caps are sized to real output shapes instead
71 * of the old padded 1200.
72 */
73export const AI_ROUTES: Record<AiTask, TaskRoute> = {
74 // A rubric classifier in the critical path of every dispatch (it runs before
75 // the die). Haiku for speed; the calibration eval is its gate.
76 judge: {
77 lane: 'anthropic',
78 anthropic: { model: 'claude-haiku-4-5', maxTokens: 300, temperature: 0 },
79 openai: OPENAI_DEFAULTS
80 },
81 // The player converses with this output every turn — prose is product.
82 character: {
83 lane: 'anthropic',
84 anthropic: { model: 'claude-sonnet-4-6', maxTokens: 700, temperature: 0.9, effort: 'low' },
85 openai: OPENAI_DEFAULTS
86 },
87 // The rules-critical judgment layer; code clamps swings into the rolled band,
88 // the model places within band and rates anachronism.
89 timeline: {
90 lane: 'anthropic',
91 anthropic: { model: 'claude-sonnet-4-6', maxTokens: 600, temperature: 0.3, effort: 'medium' },
92 openai: OPENAI_DEFAULTS
93 },
94 // Re-narrates the whole timeline each turn. The original bake-off lost this
95 // slot 9-0 under the shared prompt; the Claude-voice rewrite (V2's
96 // transmission-chain bar, buildChroniclePromptClaude) won it back 8-5 in
97 // blind pairwise on Sonnet at ~1/4 the prior cost-per-telling — and the
98 // incumbent lane's quota outage made the flip availability-driven too.
99 // Higher-N confirmation pending (evals/results/2026-06-11-verdict.md).
100 chronicler: {
101 lane: 'anthropic',
102 anthropic: { model: 'claude-sonnet-4-6', maxTokens: 2000, temperature: 1, effort: 'high' },
103 openai: OPENAI_DEFAULTS
104 },
105 // The final telling — the share artifact and the thing people pay for. The
106 // Claude-voice V1 prompt on SONNET beat the incumbent 16-3 across two
107 // dual-graded rounds (7-3, then 9-0 at N=12) — decisively confirmed, and
108 // cheaper than the Opus/Fable candidates it outscored. Sonnet over Opus is
109 // what the data said, twice; Fable won too but at 4x Sonnet's price for no
110 // measured gain over it.
111 epilogue: {
112 lane: 'anthropic',
113 anthropic: { model: 'claude-sonnet-4-6', maxTokens: 2000, temperature: 1, effort: 'high' },
114 openai: OPENAI_DEFAULTS
115 },
116 // Feeds the player's wager calibration — accuracy matters.
117 'archivist-study': {
118 lane: 'anthropic',
119 anthropic: { model: 'claude-sonnet-4-6', maxTokens: 800, temperature: 0.5, effort: 'low' },
120 openai: OPENAI_DEFAULTS
121 },
122 // Factual micro-answers with a known-since stamp.
123 'archive-lookup': {
124 lane: 'anthropic',
125 anthropic: { model: 'claude-haiku-4-5', maxTokens: 500, temperature: 0.3 },
126 openai: OPENAI_DEFAULTS
127 },
128 // 0-1 calls per run; the objective steers every layer's valence.
129 objective: {
130 lane: 'anthropic',
131 anthropic: { model: 'claude-sonnet-4-6', maxTokens: 600, temperature: 1, effort: 'low' },
132 openai: OPENAI_DEFAULTS
133 },
134 // Gates a freshly composed objective on the safely-historical bounds (#71): a
135 // cheap, temp-0 classifier (the figure-floor pattern) that catches the
136 // living-people / current-events framings a year bound alone can't. Haiku,
137 // off the critical generation path — at most twice per composition.
138 'objective-check': {
139 lane: 'anthropic',
140 anthropic: { model: 'claude-haiku-4-5', maxTokens: 200, temperature: 0 },
141 openai: OPENAI_DEFAULTS
142 },
⋯ 36 lines hidden (lines 143–178)
143 // Tool-using agent verified against free grounding, curated floor beneath it.
144 // maxTokens here is per ROUND of the agent loop.
145 suggester: {
146 lane: 'anthropic',
147 anthropic: { model: 'claude-haiku-4-5', maxTokens: 1500, temperature: 0.7 },
148 openai: { model: GPT, maxTokens: 2500 }
149 },
150 // A dates classifier with hard validation guards behind it (issue #31 bridge).
151 lifespan: {
152 lane: 'anthropic',
153 anthropic: { model: 'claude-haiku-4-5', maxTokens: 300, temperature: 0 },
154 openai: OPENAI_DEFAULTS
155 },
156 // The safety Sentinel — classifies a whole exchange against the verbatim
157 // Usage Policy (server/utils/usage-policy.ts). Haiku at temp 0 for a
158 // consistent, cheap classifier, exactly the tier Anthropic's content-
159 // moderation guide recommends. Runs once per user action, never on the
160 // critical generation path's tokens.
161 sentinel: {
162 lane: 'anthropic',
163 anthropic: { model: 'claude-haiku-4-5', maxTokens: 400, temperature: 0 },
164 openai: OPENAI_DEFAULTS
165 }
167 
168/**
169 * Resolves a task to its lane + parameters. REVISIONIST_AI_LANE overrides the
170 * whole table ('openai' | 'anthropic') — the emergency lever, and how the eval
171 * matrix runs identical fixtures down each lane.
172 */
173export function routeFor(task: AiTask): { lane: AiLane; route: TaskRoute } {
174 const route = AI_ROUTES[task]
175 const override = process.env.REVISIONIST_AI_LANE
176 const lane = override === 'openai' || override === 'anthropic' ? override : route.lane
177 return { lane, route }

Reroll once, then the curated floor

generateObjective is the orchestrator. With useAI, it composes, validates, and on an out-of-bounds result rerolls exactly once before falling back to curated. A transport or parse failure won't heal on a reroll, so it drops straight to curated instead of spending a second call. Curated stays the floor: no path here ever hard-fails the start of a run.

Compose → validate → reroll-once → curated. The whole control flow.

server/utils/objective-generator.ts · 232 lines
server/utils/objective-generator.ts232 lines · TypeScript
⋯ 33 lines hidden (lines 1–33)
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 type GameObjective,
25 type ObjectiveSteer
26} from './objectives'
27import { SAFELY_HISTORICAL_BEFORE_YEAR } from './historical-floor'
28 
29/**
30 * Returns an objective for a new run. Curated-random by default (instant, no API
31 * call); pass useAI=true to compose one live under the given steer, with automatic
32 * fallback to curated. Never throws and never ships an out-of-bounds objective.
33 */
34export async function generateObjective(useAI = false, steer: ObjectiveSteer = {}): Promise<GameObjective> {
35 if (useAI) {
36 // Never ship out of bounds, never hard-fail the run: compose live, and on an
37 // out-of-bounds result reroll exactly once, then fall back to the curated
38 // floor. A transport/parse failure won't heal on a reroll, so it drops
39 // straight to curated instead of spending a second call.
40 for (let attempt = 1; attempt <= 2; attempt++) {
41 try {
42 const composed = await composeObjective(steer)
43 if (await objectiveWithinBounds(composed)) return composed
44 console.warn(`AI objective out of bounds (attempt ${attempt} of 2); ${attempt < 2 ? 'rerolling' : 'falling back to curated'}`)
45 } catch (error) {
46 console.error('AI objective generation failed; falling back to curated pool:', error)
47 break
48 }
49 }
50 }
51 return pickCurated()
⋯ 180 lines hidden (lines 53–232)
53 
54/**
55 * Composes one fresh objective with the model under the steer. Returns the same
56 * shape as the curated pool so the rest of the game treats them identically.
57 * Throws on an empty/malformed answer (the caller owns the fallback).
58 */
59async function composeObjective(steer: ObjectiveSteer): Promise<GameObjective> {
60 // Imported lazily so the curated (default) path never pulls the model SDKs
61 // into the bundle.
62 const { completeStructured } = await import('./ai-gateway')
63 
64 const content = await completeStructured({
65 task: 'objective',
66 system: composeSystemPrompt(),
67 turns: [{ role: 'user', content: composeUserPrompt(steer) }],
68 schemaName: 'game_objective',
69 schema: {
70 type: 'object',
71 properties: {
72 title: { type: 'string', description: 'Punchy objective title' },
73 description: { type: 'string', description: 'One or two sentences on what winning looks like' },
74 era: { type: 'string', description: 'Anchoring era / setting' },
75 icon: { type: 'string', description: 'A single emoji that fits the objective' },
76 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` }
77 },
78 required: ['title', 'description', 'era', 'icon', 'anchorYear'],
79 additionalProperties: false
80 }
81 })
82 
83 if (!content) throw new Error('No objective generated')
84 
85 const parsed = JSON.parse(content) as GameObjective
86 if (!parsed.title || !parsed.description) throw new Error('Invalid objective format generated')
87 return {
88 title: parsed.title,
89 description: parsed.description,
90 era: parsed.era || 'Across the ages',
91 icon: parsed.icon || '🕰️',
92 // A finite signed year anchors the chain dial; null / junk → no anchor (no-op).
93 anchorYear: typeof parsed.anchorYear === 'number' && Number.isFinite(parsed.anchorYear)
94 ? Math.round(parsed.anchorYear)
95 : undefined
96 }
98 
99/**
100 * Validates a composed objective against the safely-historical bounds (#71) —
101 * the prompt is asked to obey them, but never trusted to. Three gates: a pure year
102 * bound, the same moderation seam messages pass through, and a structured subject
103 * check for the living-people / current-events framings a year bound can't see.
104 * Never throws. The year and subject gates fail CLOSED — an inconclusive result
105 * (junk, an empty answer, an outage) reads as out of bounds, so an unvetted
106 * composition falls back to curated rather than shipping. Moderation fails OPEN,
107 * the same fail-open-but-loud seam messages pass through (a safety classifier
108 * being down must never take the game down); it rejects only on an active flag.
109 */
110async function objectiveWithinBounds(o: GameObjective): Promise<boolean> {
111 // 1. Year bound — pure, deterministic, free. A finite anchor must sit at or
112 // before the safely-historical line; a null anchor (spans-all-history) is
113 // allowed, its content still gated by the subject check below.
114 if (typeof o.anchorYear === 'number' && o.anchorYear > SAFELY_HISTORICAL_BEFORE_YEAR) return false
115 
116 const text = `${o.title}\n${o.description}`
117 // 2 + 3 are independent model/detector calls — run them together; first "out"
118 // wins. Lazy import keeps moderation (and its SDKs) off the curated path.
119 const { hardBlockCheck } = await import('./moderation')
120 const [mod, subjectInBounds] = await Promise.all([
121 hardBlockCheck(text, 'objective', 'output'),
122 checkObjectiveSubject(o)
123 ])
124 if (mod.blocked) return false
125 return subjectInBounds
127 
128/**
129 * The structured subject check (the figure-floor pattern, server/utils/
130 * lifespan-estimator.ts): a cheap classifier that gates the free-prose title +
131 * description a year bound can't reach — does this center on living people,
132 * contemporary figures, ongoing conflicts, or post-cutoff events? Fails CLOSED:
133 * an empty answer, junk, or an outage returns false, so an unvetted objective is
134 * never shipped (it falls back to the safe curated floor instead). Never throws.
135 */
136async function checkObjectiveSubject(o: GameObjective): Promise<boolean> {
137 const { completeStructured } = await import('./ai-gateway')
138 try {
139 const content = await completeStructured({
140 task: 'objective-check',
141 system: boundsCheckSystemPrompt(),
142 turns: [{
143 role: 'user',
144 content: `Title: ${o.title}\nDescription: ${o.description}\nEra: ${o.era}\nAnchor year: ${o.anchorYear ?? 'none (spans all history)'}`
145 }],
146 schemaName: 'objective_bounds',
147 schema: {
148 type: 'object',
149 properties: {
150 withinBounds: { type: 'boolean', description: 'True only when the subject is safely historical (settled past, no living people or current events)' },
151 reason: { type: 'string', description: 'One short clause on the call' }
152 },
153 required: ['withinBounds', 'reason'],
154 additionalProperties: false
155 }
156 })
157 if (!content) return false // the model blipped — fail closed to the curated floor
158 return mapBoundsWire(JSON.parse(content)) === true
159 } catch (error) {
160 console.error('Objective bounds check error:', error)
161 return false // an outage must never let an unvetted objective through
162 }
164 
165/**
166 * The bounds-check wire → a clean verdict. `null` for an unusable shape (missing /
167 * non-boolean flag), which the caller treats exactly like "out of bounds". Exported
168 * pure so the guard is directly testable, like mapLifespanWire.
169 */
170export function mapBoundsWire(wire: unknown): boolean | null {
171 const w = wire as { withinBounds?: unknown } | null
172 if (!w || typeof w.withinBounds !== 'boolean') return null
173 return w.withinBounds
175 
176/** System prompt for composition: the tone bar + the non-negotiable safety bounds. */
177function composeSystemPrompt(): string {
178 return `You are a historian and game designer inventing grand, evocative counterfactual objectives for a game where players text historical figures to bend history.
179 
180Hold every objective to two bars:
181 
182TONE — sweeping and vivid, a whole world to remake in the spirit of the examples, never a small errand.
183 
184BOUNDS (safety, non-negotiable) — the objective must be safely historical:
185- Set in the settled past. Any pivotal anchor year must be at or before ${SAFELY_HISTORICAL_BEFORE_YEAR}.
186- 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.`
188 
189/** Composition user turn: curated few-shot exemplars, the steer, and the ask. */
190function composeUserPrompt(steer: ObjectiveSteer): string {
191 return `Objectives we love (match this tone and scale; invent something NEW and distinct from every one):
192${curatedExemplars()}
193 
194Invent ONE fresh objective, distinct from all of the above.${steerClause(steer)}
195 
196Give it: a punchy title; a vivid one-or-two-sentence description of what winning looks like; an anchoring era; a single emoji; and 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.`
198 
199/** System prompt for the structured subject check. */
200function boundsCheckSystemPrompt(): string {
201 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.
202 
203Mark it OUT OF BOUNDS (withinBounds=false) if it centers on, or would require contacting, any of:
204- a living person or a present-day public figure,
205- an ongoing conflict or a current event,
206- anything anchored after the year ${SAFELY_HISTORICAL_BEFORE_YEAR}.
207 
208Mark 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.
209 
210When you are unsure, answer withinBounds=false. Give a short reason either way.`
212 
213/** The curated pool rendered as few-shot exemplars (title — description), read
214 * live from the pool so the bar can never drift from a hardcoded copy. */
215function curatedExemplars(): string {
216 return listCuratedObjectives()
217 .map(o => `${o.title}${o.description}`)
218 .join('\n')
220 
221/** Turns the closed-enum steer into a short clause biasing the model. Absent axes
222 * simply don't appear, leaving them to the model ("no preference"). */
223function steerClause(steer: ObjectiveSteer): string {
224 const parts: string[] = []
225 if (steer.era) {
226 parts.push(steer.era === 'Across the ages'
227 ? 'Let it span the ages rather than a single era.'
228 : `Anchor it in this stretch of history: ${steer.era}.`)
229 }
230 if (steer.theme) parts.push(`Center it on the theme of ${steer.theme}.`)
231 return parts.length ? ` ${parts.join(' ')}` : ''

Steering: closed enums, one trusted gate

The player biases the composition along two closed enums — an era bucket and a theme. The labels double as the wire values and the prompt phrasing, so there is no value/label map to drift, and every era bucket sits in the past (there is deliberately no "present day" option to pick).

The taxonomies, the ObjectiveSteer type, and coerceSteer — the one trusted gate.

server/utils/objectives.ts · 165 lines
server/utils/objectives.ts165 lines · TypeScript
⋯ 124 lines hidden (lines 1–124)
1/**
2 * The curated objective pool — pure, client-safe data. Split out of
3 * objective-generator.ts so the client (MissionSelect) can import the curated
4 * list without dragging the AI gateway (and its model SDKs + the server-only
5 * AsyncLocalStorage run-context) into the browser bundle. The live AI-generation
6 * path stays in objective-generator.ts, which is server-only.
7 */
8 
9/**
10 * A grand counterfactual objective — the frame for a whole run. Kept deliberately
11 * lean: a title, what winning looks like, an anchoring era for flavor, and an icon.
12 */
13export interface GameObjective {
14 title: string
15 description: string
16 era: string
17 icon: string
18 /**
19 * Signed year (AD positive, BC negative) the objective is aimed at — the far
20 * anchor the causal-chain dial decays toward (utils/causal-chain.ts). Omitted
21 * for objectives that genuinely span all of history, where there is no single
22 * hinge to chain toward and the dial simply no-ops.
23 */
24 anchorYear?: number
26 
27/**
28 * Curated pool of diverse, opinionated objectives. Selecting at random gives an
29 * instant, reliable, offline-friendly start, and each one points the player at a
30 * different stretch of history — reinforcing that you can reach for anyone, anywhen.
31 */
32const CURATED_OBJECTIVES: GameObjective[] = [
33 {
34 title: 'Prevent the Great War',
35 description: "Keep the tangle of alliances in 1914 from collapsing into world war. Defuse the crises, cool the hotheads, or remove the spark before Europe marches.",
36 era: 'Europe, 1914',
37 icon: '🕊️',
38 anchorYear: 1914
39 },
40 {
41 title: 'Advance Medicine by 300 Years',
42 description: "Plant germ theory, vaccination, and antiseptics centuries early so that plague, fever, and infection lose their ancient grip on humankind.",
43 era: 'Across the ages',
44 icon: '⚕️'
45 },
46 {
47 title: 'Save the Library of Alexandria',
48 description: "Ensure the ancient world's greatest store of knowledge survives the fires and the centuries, intact, into the modern age.",
49 era: 'Alexandria, antiquity',
50 icon: '📜',
51 anchorYear: -48
52 },
53 {
54 title: 'Establish Global Democracy',
55 description: "Nudge empires, kings, and conquerors toward representative rule, so that by the modern day the world has learned to govern itself.",
56 era: 'Across the ages',
57 icon: '🗳️'
58 },
59 {
60 title: 'Reach the Moon by 1900',
61 description: "Accelerate rocketry, mathematics, and sheer ambition so that humanity slips the bonds of Earth half a century ahead of schedule.",
62 era: '19th century',
63 icon: '🚀',
64 anchorYear: 1900
65 },
66 {
67 title: 'Avert the Fall of Rome',
68 description: "Shore up the Empire against the pressures that broke it — and keep the lights of Rome from going dark across the West.",
69 era: 'Rome, late antiquity',
70 icon: '🏛️',
71 anchorYear: 476
72 },
73 {
74 title: 'Abolish Slavery a Century Early',
75 description: "Turn hearts, faiths, and laws against human bondage generations ahead of history, sparing untold millions.",
76 era: '18th century',
77 icon: '⛓️',
78 anchorYear: 1750
79 },
80 {
81 title: 'Spark an Early Industrial East',
82 description: "Light the steam-and-steel revolution in the East centuries before the West, and rewrite the whole global order.",
83 era: 'Asia, 2nd millennium',
84 icon: '⚙️',
85 anchorYear: 1400
86 },
87 {
88 title: 'Break the Black Death',
89 description: "Sever the chain of the plague before it crosses into Europe and erases a third of its people.",
90 era: 'Eurasia, 14th century',
91 icon: '🐀',
92 anchorYear: 1347
93 },
94 {
95 title: 'Unite Rivals Without War',
96 description: "Forge unity among warring kingdoms through diplomacy and marriage rather than conquest, sparing generations of bloodshed.",
97 era: 'Antiquity',
98 icon: '🤝'
99 }
101 
102export function pickCurated(): GameObjective {
103 return CURATED_OBJECTIVES[Math.floor(Math.random() * CURATED_OBJECTIVES.length)]
105 
106/** Exposed so the UI can offer the player a hand-picked objective if desired. */
107export function listCuratedObjectives(): GameObjective[] {
108 return [...CURATED_OBJECTIVES]
110 
111/**
112 * Steering for a fresh composition (#71). The player biases the model along two
113 * CLOSED enums — an era bucket and a theme — set before composing; "no preference"
114 * on an axis is simply its absence, leaving that axis to the model. Closed enums
115 * only, never free text: text the player typed would have to be committed verbatim
116 * to mean anything, and that would bypass the server's safety bounds + moderation
117 * (the hole #72's safely-historical floor must not have). So the player shapes
118 * INTENT; the generator owns the artifact.
119 *
120 * The labels double as the wire values and the prompt phrasing — one string per
121 * option, no value/label map to drift. Every era bucket sits at or before the
122 * safely-historical line (historical-floor.ts); there is deliberately no "present
123 * day" bucket to pick.
124 */
125export const OBJECTIVE_ERAS = [
126 'Antiquity',
127 'Classical',
128 'Medieval',
129 'Renaissance',
130 'Early Modern',
131 'Age of Revolutions / Industrial',
132 'Across the ages'
133] as const
134export type ObjectiveEra = typeof OBJECTIVE_ERAS[number]
135 
136export const OBJECTIVE_THEMES = [
137 'Science & Medicine',
138 'War & Peace',
139 'Power & Politics',
140 'Exploration',
141 'Culture & Ideas',
142 'Faith & Philosophy'
143] as const
144export type ObjectiveTheme = typeof OBJECTIVE_THEMES[number]
145 
146/** A composition steer — each axis present only when the player picked it. */
147export interface ObjectiveSteer {
148 era?: ObjectiveEra
149 theme?: ObjectiveTheme
151 
152/**
153 * The one gate from raw input to a trusted steer, shared by the API route (which
154 * sees query strings) and any caller. Anything not in the closed enum — a typo, an
155 * injected string, a stale value — drops to "no preference" rather than reaching
156 * the prompt, so the closed-enum guarantee has no client- or wire-side bypass.
157 */
158export function coerceSteer(era?: unknown, theme?: unknown): ObjectiveSteer {
159 const eras: readonly string[] = OBJECTIVE_ERAS
160 const themes: readonly string[] = OBJECTIVE_THEMES
161 return {
162 ...(typeof era === 'string' && eras.includes(era) ? { era: era as ObjectiveEra } : {}),
163 ...(typeof theme === 'string' && themes.includes(theme) ? { theme: theme as ObjectiveTheme } : {})
164 }

The steer reaches the prompt as a short clause (absent axes simply don't appear), and rides the wire as query params the route re-validates with the same coerceSteer.

The steer injected into the compose prompt; the cutoff is in the ask too.

server/utils/objective-generator.ts · 232 lines
server/utils/objective-generator.ts232 lines · TypeScript
⋯ 193 lines hidden (lines 1–193)
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 type GameObjective,
25 type ObjectiveSteer
26} from './objectives'
27import { SAFELY_HISTORICAL_BEFORE_YEAR } from './historical-floor'
28 
29/**
30 * Returns an objective for a new run. Curated-random by default (instant, no API
31 * call); pass useAI=true to compose one live under the given steer, with automatic
32 * fallback to curated. Never throws and never ships an out-of-bounds objective.
33 */
34export async function generateObjective(useAI = false, steer: ObjectiveSteer = {}): Promise<GameObjective> {
35 if (useAI) {
36 // Never ship out of bounds, never hard-fail the run: compose live, and on an
37 // out-of-bounds result reroll exactly once, then fall back to the curated
38 // floor. A transport/parse failure won't heal on a reroll, so it drops
39 // straight to curated instead of spending a second call.
40 for (let attempt = 1; attempt <= 2; attempt++) {
41 try {
42 const composed = await composeObjective(steer)
43 if (await objectiveWithinBounds(composed)) return composed
44 console.warn(`AI objective out of bounds (attempt ${attempt} of 2); ${attempt < 2 ? 'rerolling' : 'falling back to curated'}`)
45 } catch (error) {
46 console.error('AI objective generation failed; falling back to curated pool:', error)
47 break
48 }
49 }
50 }
51 return pickCurated()
53 
54/**
55 * Composes one fresh objective with the model under the steer. Returns the same
56 * shape as the curated pool so the rest of the game treats them identically.
57 * Throws on an empty/malformed answer (the caller owns the fallback).
58 */
59async function composeObjective(steer: ObjectiveSteer): Promise<GameObjective> {
60 // Imported lazily so the curated (default) path never pulls the model SDKs
61 // into the bundle.
62 const { completeStructured } = await import('./ai-gateway')
63 
64 const content = await completeStructured({
65 task: 'objective',
66 system: composeSystemPrompt(),
67 turns: [{ role: 'user', content: composeUserPrompt(steer) }],
68 schemaName: 'game_objective',
69 schema: {
70 type: 'object',
71 properties: {
72 title: { type: 'string', description: 'Punchy objective title' },
73 description: { type: 'string', description: 'One or two sentences on what winning looks like' },
74 era: { type: 'string', description: 'Anchoring era / setting' },
75 icon: { type: 'string', description: 'A single emoji that fits the objective' },
76 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` }
77 },
78 required: ['title', 'description', 'era', 'icon', 'anchorYear'],
79 additionalProperties: false
80 }
81 })
82 
83 if (!content) throw new Error('No objective generated')
84 
85 const parsed = JSON.parse(content) as GameObjective
86 if (!parsed.title || !parsed.description) throw new Error('Invalid objective format generated')
87 return {
88 title: parsed.title,
89 description: parsed.description,
90 era: parsed.era || 'Across the ages',
91 icon: parsed.icon || '🕰️',
92 // A finite signed year anchors the chain dial; null / junk → no anchor (no-op).
93 anchorYear: typeof parsed.anchorYear === 'number' && Number.isFinite(parsed.anchorYear)
94 ? Math.round(parsed.anchorYear)
95 : undefined
96 }
98 
99/**
100 * Validates a composed objective against the safely-historical bounds (#71) —
101 * the prompt is asked to obey them, but never trusted to. Three gates: a pure year
102 * bound, the same moderation seam messages pass through, and a structured subject
103 * check for the living-people / current-events framings a year bound can't see.
104 * Never throws. The year and subject gates fail CLOSED — an inconclusive result
105 * (junk, an empty answer, an outage) reads as out of bounds, so an unvetted
106 * composition falls back to curated rather than shipping. Moderation fails OPEN,
107 * the same fail-open-but-loud seam messages pass through (a safety classifier
108 * being down must never take the game down); it rejects only on an active flag.
109 */
110async function objectiveWithinBounds(o: GameObjective): Promise<boolean> {
111 // 1. Year bound — pure, deterministic, free. A finite anchor must sit at or
112 // before the safely-historical line; a null anchor (spans-all-history) is
113 // allowed, its content still gated by the subject check below.
114 if (typeof o.anchorYear === 'number' && o.anchorYear > SAFELY_HISTORICAL_BEFORE_YEAR) return false
115 
116 const text = `${o.title}\n${o.description}`
117 // 2 + 3 are independent model/detector calls — run them together; first "out"
118 // wins. Lazy import keeps moderation (and its SDKs) off the curated path.
119 const { hardBlockCheck } = await import('./moderation')
120 const [mod, subjectInBounds] = await Promise.all([
121 hardBlockCheck(text, 'objective', 'output'),
122 checkObjectiveSubject(o)
123 ])
124 if (mod.blocked) return false
125 return subjectInBounds
127 
128/**
129 * The structured subject check (the figure-floor pattern, server/utils/
130 * lifespan-estimator.ts): a cheap classifier that gates the free-prose title +
131 * description a year bound can't reach — does this center on living people,
132 * contemporary figures, ongoing conflicts, or post-cutoff events? Fails CLOSED:
133 * an empty answer, junk, or an outage returns false, so an unvetted objective is
134 * never shipped (it falls back to the safe curated floor instead). Never throws.
135 */
136async function checkObjectiveSubject(o: GameObjective): Promise<boolean> {
137 const { completeStructured } = await import('./ai-gateway')
138 try {
139 const content = await completeStructured({
140 task: 'objective-check',
141 system: boundsCheckSystemPrompt(),
142 turns: [{
143 role: 'user',
144 content: `Title: ${o.title}\nDescription: ${o.description}\nEra: ${o.era}\nAnchor year: ${o.anchorYear ?? 'none (spans all history)'}`
145 }],
146 schemaName: 'objective_bounds',
147 schema: {
148 type: 'object',
149 properties: {
150 withinBounds: { type: 'boolean', description: 'True only when the subject is safely historical (settled past, no living people or current events)' },
151 reason: { type: 'string', description: 'One short clause on the call' }
152 },
153 required: ['withinBounds', 'reason'],
154 additionalProperties: false
155 }
156 })
157 if (!content) return false // the model blipped — fail closed to the curated floor
158 return mapBoundsWire(JSON.parse(content)) === true
159 } catch (error) {
160 console.error('Objective bounds check error:', error)
161 return false // an outage must never let an unvetted objective through
162 }
164 
165/**
166 * The bounds-check wire → a clean verdict. `null` for an unusable shape (missing /
167 * non-boolean flag), which the caller treats exactly like "out of bounds". Exported
168 * pure so the guard is directly testable, like mapLifespanWire.
169 */
170export function mapBoundsWire(wire: unknown): boolean | null {
171 const w = wire as { withinBounds?: unknown } | null
172 if (!w || typeof w.withinBounds !== 'boolean') return null
173 return w.withinBounds
175 
176/** System prompt for composition: the tone bar + the non-negotiable safety bounds. */
177function composeSystemPrompt(): string {
178 return `You are a historian and game designer inventing grand, evocative counterfactual objectives for a game where players text historical figures to bend history.
179 
180Hold every objective to two bars:
181 
182TONE — sweeping and vivid, a whole world to remake in the spirit of the examples, never a small errand.
183 
184BOUNDS (safety, non-negotiable) — the objective must be safely historical:
185- Set in the settled past. Any pivotal anchor year must be at or before ${SAFELY_HISTORICAL_BEFORE_YEAR}.
186- 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.`
188 
189/** Composition user turn: curated few-shot exemplars, the steer, and the ask. */
190function composeUserPrompt(steer: ObjectiveSteer): string {
191 return `Objectives we love (match this tone and scale; invent something NEW and distinct from every one):
192${curatedExemplars()}
193 
194Invent ONE fresh objective, distinct from all of the above.${steerClause(steer)}
195 
196Give it: a punchy title; a vivid one-or-two-sentence description of what winning looks like; an anchoring era; a single emoji; and 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.`
⋯ 24 lines hidden (lines 197–220)
198 
199/** System prompt for the structured subject check. */
200function boundsCheckSystemPrompt(): string {
201 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.
202 
203Mark it OUT OF BOUNDS (withinBounds=false) if it centers on, or would require contacting, any of:
204- a living person or a present-day public figure,
205- an ongoing conflict or a current event,
206- anything anchored after the year ${SAFELY_HISTORICAL_BEFORE_YEAR}.
207 
208Mark 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.
209 
210When you are unsure, answer withinBounds=false. Give a short reason either way.`
212 
213/** The curated pool rendered as few-shot exemplars (title — description), read
214 * live from the pool so the bar can never drift from a hardcoded copy. */
215function curatedExemplars(): string {
216 return listCuratedObjectives()
217 .map(o => `${o.title}${o.description}`)
218 .join('\n')
220 
221/** Turns the closed-enum steer into a short clause biasing the model. Absent axes
222 * simply don't appear, leaving them to the model ("no preference"). */
223function steerClause(steer: ObjectiveSteer): string {
224 const parts: string[] = []
225 if (steer.era) {
226 parts.push(steer.era === 'Across the ages'
227 ? 'Let it span the ages rather than a single era.'
228 : `Anchor it in this stretch of history: ${steer.era}.`)
229 }
230 if (steer.theme) parts.push(`Center it on the theme of ${steer.theme}.`)
231 return parts.length ? ` ${parts.join(' ')}` : ''

The route coerces era/theme before composing.

server/api/objective.get.ts · 27 lines
server/api/objective.get.ts27 lines · TypeScript
⋯ 19 lines hidden (lines 1–19)
1import { generateObjective } from '../utils/objective-generator'
2import { coerceSteer } from '../utils/objectives'
3 
4/**
5 * GET /api/objective?era=…&theme=… — composes a fresh grand objective with the
6 * model, optionally steered along two closed enums (#71).
7 *
8 * Objective generation MUST run server-side: it needs OPENAI_API_KEY, which only
9 * exists on the server (runtimeConfig.openaiApiKey). Calling the generator from
10 * the client would silently fall back to the curated pool every time. The curated
11 * pool itself is plain data the client already has, so this route exists purely to
12 * unlock the *live* AI path.
13 *
14 * The steer is coerced against the closed enums HERE, the single trusted gate:
15 * anything off-enum drops to "no preference" rather than reaching the prompt, so
16 * the closed-enum safety guarantee has no wire-side bypass.
17 *
18 * generateObjective(true) self-heals — out-of-bounds or model-unavailable, it
19 * returns a curated objective rather than throwing — so the player is never left
20 * without one, and an out-of-bounds composition is never shipped.
21 */
22export default defineEventHandler(async (event) => {
23 const q = getQuery(event)
24 const steer = coerceSteer(q.era, q.theme)
25 const objective = await generateObjective(true, steer)
26 return { success: true, objective }
27})

The store forwards the steer as query params; absent axes are omitted.

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

The UI: two dials, the active steer surfaced

The mission-select screen gains a small "Steer a fresh composition" section: two native selects defaulting to "No preference", styled with the same hairline form language as the rest of the app. The active steer is echoed under the compose button. Nothing else about the preview/commit/reroll flow changes — compose still rerolls, and the fresh objective commits only on Begin.

The two closed-enum dials and the surfaced active steer.

components/MissionSelect.vue · 175 lines
components/MissionSelect.vue175 lines · Vue
⋯ 33 lines hidden (lines 1–33)
1<template>
2 <!-- Mission briefing: choose (or compose) the run's grand objective, as a ledger. -->
3 <div data-testid="mission-select" class="rv-fade-in max-w-4xl mx-auto">
4 <div class="mb-7">
5 <p class="rv-label">Choose your crusade</p>
6 <h2 class="rv-serif rv-fg text-2xl sm:text-3xl font-semibold mt-1.5 leading-tight">What will you set right?</h2>
7 <p class="rv-muted text-sm mt-2 max-w-xl">
8 Five messages. Anyone in history. One world to remake. Pick the cause that will define your run — or have a fresh one composed.
9 </p>
10 </div>
11 
12 <!-- Objectives as borderless hairline rows (a freshly composed one leads) -->
13 <div role="radiogroup" aria-label="Choose an objective" class="border-t rv-line">
14 <button v-for="obj in options" :key="(obj === fresh ? 'fresh:' : 'curated:') + obj.title" type="button"
15 data-testid="objective-option" role="radio" :aria-checked="isSelected(obj)"
16 class="w-full text-left flex items-start gap-3 py-3 pr-2 pl-3 border-b rv-line transition rv-hover"
17 :class="isSelected(obj) ? 'rv-tint' : ''"
18 :style="{ borderLeft: `2px solid ${isSelected(obj) ? 'var(--rv-accent)' : 'transparent'}` }" @click="select(obj)">
19 <span class="rv-dot mt-1.5" :class="isSelected(obj) ? 'rv-dot--accent' : 'rv-dot--hollow'" aria-hidden="true" />
20 <span class="w-6 shrink-0 text-center text-lg leading-none select-none" aria-hidden="true">{{ obj.icon }}</span>
21 <span class="min-w-0 flex-1">
22 <span class="flex items-center gap-2 flex-wrap">
23 <h3 class="rv-fg font-semibold">{{ obj.title }}</h3>
24 <span v-if="obj.era" class="rv-label">{{ obj.era }}</span>
25 <span v-if="obj === fresh" data-testid="fresh-badge" class="rv-accent text-[10px] uppercase tracking-wider">
26 ✨ freshly composed
27 </span>
28 </span>
29 <span class="block rv-muted text-sm mt-0.5 leading-relaxed">{{ obj.description }}</span>
30 </span>
31 </button>
32 </div>
33 
34 <!-- Steer a fresh composition: two closed-enum dials, set before composing.
35 "No preference" leaves an axis to the model. Closed enums only (no free
36 text), so an out-of-bounds objective can't be steered into existence. -->
37 <fieldset class="mt-6 pt-4 border-t rv-line">
38 <legend class="rv-label">Steer a fresh composition <span class="rv-faint">· optional</span></legend>
39 <div class="mt-2 flex flex-col sm:flex-row gap-3 sm:gap-4">
40 <label class="flex-1 min-w-0 text-sm">
41 <span class="rv-label block mb-1">Era</span>
42 <select v-model="steerEra" data-testid="steer-era" class="rv-select text-sm" aria-label="Steer the era">
43 <option value="">No preference</option>
44 <option v-for="era in OBJECTIVE_ERAS" :key="era" :value="era">{{ era }}</option>
45 </select>
46 </label>
⋯ 13 lines hidden (lines 47–59)
47 <label class="flex-1 min-w-0 text-sm">
48 <span class="rv-label block mb-1">Theme</span>
49 <select v-model="steerTheme" data-testid="steer-theme" class="rv-select text-sm" aria-label="Steer the theme">
50 <option value="">No preference</option>
51 <option v-for="theme in OBJECTIVE_THEMES" :key="theme" :value="theme">{{ theme }}</option>
52 </select>
53 </label>
54 </div>
55 </fieldset>
56 
57 <!-- Actions: compose a fresh objective · begin the run -->
58 <div class="mt-5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
59 <div class="flex flex-col items-start gap-1">
60 <button type="button" data-testid="compose-objective" class="rv-btn text-sm" :disabled="composing" @click="rollFresh">
61 <span aria-hidden="true"></span> {{ composing ? 'Composing a fresh fate…' : 'Compose a fresh objective with AI' }}
62 </button>
63 <p v-if="activeSteer" data-testid="active-steer" class="text-xs rv-muted">Steering: {{ activeSteer }}</p>
⋯ 112 lines hidden (lines 64–175)
64 <p v-if="composeError" data-testid="compose-error" class="text-xs rv-bad">{{ composeError }}</p>
65 </div>
66 
67 <button type="button" data-testid="begin-button" class="rv-btn rv-btn--primary" :disabled="!selected || beginning" @click="begin">
68 {{ beginning ? 'Charting the timeline…' : 'Begin rewriting history' }} <span aria-hidden="true"></span>
69 </button>
70 </div>
71 
72 <!-- Paywall: the free run is spent. The run-packs modal opens automatically on
73 the refused commit; this persistent notice lets the player reopen it if they
74 dismissed it. -->
75 <div v-if="gameStore.outOfRuns" data-testid="paywall" class="mt-4 pt-4 border-t rv-line">
76 <p class="text-sm rv-fg font-semibold">You're out of runs — your free run is spent.</p>
77 <p class="rv-muted text-xs mt-0.5">Grab a pack to keep rewriting history. One-time, never expires.</p>
78 <button type="button" data-testid="open-packs" class="rv-btn rv-btn--primary mt-3"
79 @click="gameStore.openBuyModal()">
80 See run packs <span aria-hidden="true"></span>
81 </button>
82 </div>
83 
84 <!-- Capacity: the global spend cap paused new runs (in-progress runs continue). -->
85 <p v-else-if="gameStore.atCapacity" data-testid="at-capacity" class="text-sm rv-bad mt-3">
86 The timeline is at capacity right now — please try again shortly.
87 </p>
88 
89 <!-- Compliance disclosure, shown at the start of every run (Anthropic Usage
90 Policy: AI disclosure at session start; outputs may be inaccurate; an
91 adults-only posture). -->
92 <p data-testid="ai-disclosure" class="rv-faint text-xs leading-relaxed mt-8 pt-4 border-t rv-line max-w-2xl">
93 The historical figures here are played by AI, not real people, and every reply is
94 AI-generated <span class="rv-fg">alternate history — dramatized fiction, not historical fact</span>;
95 don't rely on it as accurate. Intended for adults (18+).
96 </p>
97 </div>
98</template>
99 
100<script setup lang="ts">
101/**
102 * MissionSelect — the opening briefing. Curated objectives + a live-composed one, as
103 * borderless hairline rows; selection uses identity, not titles. Nothing commits
104 * until Begin, so a freshly composed objective can be previewed and reconsidered.
105 */
106import { ref, shallowRef, computed } from 'vue'
107import { useGameStore } from '~/stores/game'
108import {
109 listCuratedObjectives,
110 OBJECTIVE_ERAS,
111 OBJECTIVE_THEMES,
112 type GameObjective,
113 type ObjectiveEra,
114 type ObjectiveTheme
115} from '~/server/utils/objectives'
116 
117const gameStore = useGameStore()
118 
119const curated = listCuratedObjectives()
120// shallowRef, not ref: selection is by object identity (isSelected uses ===), and the
121// curated options are plain objects. A deep ref would return a *reactive proxy* on
122// `.value`, so `selected.value === obj` (raw) would be false — the row would highlight
123// on hover but its radio dot / aria-checked would never flip. shallowRef stores the
124// value as-is, keeping identity intact.
125const fresh = shallowRef<GameObjective | null>(null)
126const selected = shallowRef<GameObjective | null>(null)
127const composing = ref(false)
128const composeError = ref<string | null>(null)
129const beginning = ref(false)
130 
131// Composition steer (closed enums; '' = no preference, left to the model).
132const steerEra = ref<ObjectiveEra | ''>('')
133const steerTheme = ref<ObjectiveTheme | ''>('')
134 
135const options = computed(() => (fresh.value ? [fresh.value, ...curated] : curated))
136 
137// A plain-language echo of the active steer, surfaced by the compose button.
138const activeSteer = computed(() =>
139 [steerEra.value, steerTheme.value].filter(Boolean).join(' · ') || null
141 
142function isSelected(obj: GameObjective) {
143 return selected.value === obj
145 
146function select(obj: GameObjective) {
147 selected.value = obj
149 
150async function rollFresh() {
151 composing.value = true
152 composeError.value = null
153 const objective = await gameStore.fetchAIObjective({
154 era: steerEra.value || undefined,
155 theme: steerTheme.value || undefined
156 })
157 composing.value = false
158 
159 if (objective) {
160 fresh.value = objective
161 selected.value = objective // auto-select the fresh one so Begin is one tap away
162 } else {
163 composeError.value = 'The archives went quiet — pick one above, or try composing again.'
164 }
166 
167async function begin() {
168 if (!selected.value || beginning.value) return
169 beginning.value = true
170 // Charges one run from the balance. If out of runs, chooseObjective raises the
171 // paywall (gameStore.outOfRuns) and opens the run-packs modal; no run starts.
172 await gameStore.chooseObjective(selected.value)
173 beginning.value = false
175</script>

The script: typed steer refs, the active-steer line, and the steer passed on compose.

components/MissionSelect.vue · 175 lines
components/MissionSelect.vue175 lines · Vue
⋯ 129 lines hidden (lines 1–129)
1<template>
2 <!-- Mission briefing: choose (or compose) the run's grand objective, as a ledger. -->
3 <div data-testid="mission-select" class="rv-fade-in max-w-4xl mx-auto">
4 <div class="mb-7">
5 <p class="rv-label">Choose your crusade</p>
6 <h2 class="rv-serif rv-fg text-2xl sm:text-3xl font-semibold mt-1.5 leading-tight">What will you set right?</h2>
7 <p class="rv-muted text-sm mt-2 max-w-xl">
8 Five messages. Anyone in history. One world to remake. Pick the cause that will define your run — or have a fresh one composed.
9 </p>
10 </div>
11 
12 <!-- Objectives as borderless hairline rows (a freshly composed one leads) -->
13 <div role="radiogroup" aria-label="Choose an objective" class="border-t rv-line">
14 <button v-for="obj in options" :key="(obj === fresh ? 'fresh:' : 'curated:') + obj.title" type="button"
15 data-testid="objective-option" role="radio" :aria-checked="isSelected(obj)"
16 class="w-full text-left flex items-start gap-3 py-3 pr-2 pl-3 border-b rv-line transition rv-hover"
17 :class="isSelected(obj) ? 'rv-tint' : ''"
18 :style="{ borderLeft: `2px solid ${isSelected(obj) ? 'var(--rv-accent)' : 'transparent'}` }" @click="select(obj)">
19 <span class="rv-dot mt-1.5" :class="isSelected(obj) ? 'rv-dot--accent' : 'rv-dot--hollow'" aria-hidden="true" />
20 <span class="w-6 shrink-0 text-center text-lg leading-none select-none" aria-hidden="true">{{ obj.icon }}</span>
21 <span class="min-w-0 flex-1">
22 <span class="flex items-center gap-2 flex-wrap">
23 <h3 class="rv-fg font-semibold">{{ obj.title }}</h3>
24 <span v-if="obj.era" class="rv-label">{{ obj.era }}</span>
25 <span v-if="obj === fresh" data-testid="fresh-badge" class="rv-accent text-[10px] uppercase tracking-wider">
26 ✨ freshly composed
27 </span>
28 </span>
29 <span class="block rv-muted text-sm mt-0.5 leading-relaxed">{{ obj.description }}</span>
30 </span>
31 </button>
32 </div>
33 
34 <!-- Steer a fresh composition: two closed-enum dials, set before composing.
35 "No preference" leaves an axis to the model. Closed enums only (no free
36 text), so an out-of-bounds objective can't be steered into existence. -->
37 <fieldset class="mt-6 pt-4 border-t rv-line">
38 <legend class="rv-label">Steer a fresh composition <span class="rv-faint">· optional</span></legend>
39 <div class="mt-2 flex flex-col sm:flex-row gap-3 sm:gap-4">
40 <label class="flex-1 min-w-0 text-sm">
41 <span class="rv-label block mb-1">Era</span>
42 <select v-model="steerEra" data-testid="steer-era" class="rv-select text-sm" aria-label="Steer the era">
43 <option value="">No preference</option>
44 <option v-for="era in OBJECTIVE_ERAS" :key="era" :value="era">{{ era }}</option>
45 </select>
46 </label>
47 <label class="flex-1 min-w-0 text-sm">
48 <span class="rv-label block mb-1">Theme</span>
49 <select v-model="steerTheme" data-testid="steer-theme" class="rv-select text-sm" aria-label="Steer the theme">
50 <option value="">No preference</option>
51 <option v-for="theme in OBJECTIVE_THEMES" :key="theme" :value="theme">{{ theme }}</option>
52 </select>
53 </label>
54 </div>
55 </fieldset>
56 
57 <!-- Actions: compose a fresh objective · begin the run -->
58 <div class="mt-5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
59 <div class="flex flex-col items-start gap-1">
60 <button type="button" data-testid="compose-objective" class="rv-btn text-sm" :disabled="composing" @click="rollFresh">
61 <span aria-hidden="true"></span> {{ composing ? 'Composing a fresh fate…' : 'Compose a fresh objective with AI' }}
62 </button>
63 <p v-if="activeSteer" data-testid="active-steer" class="text-xs rv-muted">Steering: {{ activeSteer }}</p>
64 <p v-if="composeError" data-testid="compose-error" class="text-xs rv-bad">{{ composeError }}</p>
65 </div>
66 
67 <button type="button" data-testid="begin-button" class="rv-btn rv-btn--primary" :disabled="!selected || beginning" @click="begin">
68 {{ beginning ? 'Charting the timeline…' : 'Begin rewriting history' }} <span aria-hidden="true"></span>
69 </button>
70 </div>
71 
72 <!-- Paywall: the free run is spent. The run-packs modal opens automatically on
73 the refused commit; this persistent notice lets the player reopen it if they
74 dismissed it. -->
75 <div v-if="gameStore.outOfRuns" data-testid="paywall" class="mt-4 pt-4 border-t rv-line">
76 <p class="text-sm rv-fg font-semibold">You're out of runs — your free run is spent.</p>
77 <p class="rv-muted text-xs mt-0.5">Grab a pack to keep rewriting history. One-time, never expires.</p>
78 <button type="button" data-testid="open-packs" class="rv-btn rv-btn--primary mt-3"
79 @click="gameStore.openBuyModal()">
80 See run packs <span aria-hidden="true"></span>
81 </button>
82 </div>
83 
84 <!-- Capacity: the global spend cap paused new runs (in-progress runs continue). -->
85 <p v-else-if="gameStore.atCapacity" data-testid="at-capacity" class="text-sm rv-bad mt-3">
86 The timeline is at capacity right now — please try again shortly.
87 </p>
88 
89 <!-- Compliance disclosure, shown at the start of every run (Anthropic Usage
90 Policy: AI disclosure at session start; outputs may be inaccurate; an
91 adults-only posture). -->
92 <p data-testid="ai-disclosure" class="rv-faint text-xs leading-relaxed mt-8 pt-4 border-t rv-line max-w-2xl">
93 The historical figures here are played by AI, not real people, and every reply is
94 AI-generated <span class="rv-fg">alternate history — dramatized fiction, not historical fact</span>;
95 don't rely on it as accurate. Intended for adults (18+).
96 </p>
97 </div>
98</template>
99 
100<script setup lang="ts">
101/**
102 * MissionSelect — the opening briefing. Curated objectives + a live-composed one, as
103 * borderless hairline rows; selection uses identity, not titles. Nothing commits
104 * until Begin, so a freshly composed objective can be previewed and reconsidered.
105 */
106import { ref, shallowRef, computed } from 'vue'
107import { useGameStore } from '~/stores/game'
108import {
109 listCuratedObjectives,
110 OBJECTIVE_ERAS,
111 OBJECTIVE_THEMES,
112 type GameObjective,
113 type ObjectiveEra,
114 type ObjectiveTheme
115} from '~/server/utils/objectives'
116 
117const gameStore = useGameStore()
118 
119const curated = listCuratedObjectives()
120// shallowRef, not ref: selection is by object identity (isSelected uses ===), and the
121// curated options are plain objects. A deep ref would return a *reactive proxy* on
122// `.value`, so `selected.value === obj` (raw) would be false — the row would highlight
123// on hover but its radio dot / aria-checked would never flip. shallowRef stores the
124// value as-is, keeping identity intact.
125const fresh = shallowRef<GameObjective | null>(null)
126const selected = shallowRef<GameObjective | null>(null)
127const composing = ref(false)
128const composeError = ref<string | null>(null)
129const beginning = ref(false)
130 
131// Composition steer (closed enums; '' = no preference, left to the model).
132const steerEra = ref<ObjectiveEra | ''>('')
133const steerTheme = ref<ObjectiveTheme | ''>('')
134 
135const options = computed(() => (fresh.value ? [fresh.value, ...curated] : curated))
136 
137// A plain-language echo of the active steer, surfaced by the compose button.
138const activeSteer = computed(() =>
139 [steerEra.value, steerTheme.value].filter(Boolean).join(' · ') || null
141 
142function isSelected(obj: GameObjective) {
143 return selected.value === obj
145 
146function select(obj: GameObjective) {
147 selected.value = obj
149 
150async function rollFresh() {
151 composing.value = true
152 composeError.value = null
153 const objective = await gameStore.fetchAIObjective({
154 era: steerEra.value || undefined,
155 theme: steerTheme.value || undefined
156 })
157 composing.value = false
158 
⋯ 17 lines hidden (lines 159–175)
159 if (objective) {
160 fresh.value = objective
161 selected.value = objective // auto-select the fresh one so Begin is one tap away
162 } else {
163 composeError.value = 'The archives went quiet — pick one above, or try composing again.'
164 }
166 
167async function begin() {
168 if (!selected.value || beginning.value) return
169 beginning.value = true
170 // Charges one run from the balance. If out of runs, chooseObjective raises the
171 // paywall (gameStore.outOfRuns) and opens the run-packs modal; no run starts.
172 await gameStore.chooseObjective(selected.value)
173 beginning.value = false
175</script>

How it's verified

The bounds logic is the safety-critical part, so it carries the most tests. The generator spec mocks the gateway SDK and drives generateObjective end to end: out-of-bounds compositions (by year, by subject, by moderation) are rejected and rerolled, then fall back to curated; the year boundary is tested at the cutoff, one past it, and at a BC anchor (all derived from the shared constant, never a literal); a null-anchor objective is still subject-checked; and the subject check failing closed on empty content forces a reroll.

coerceSteer has its own spec — valid kept, off-enum and non-string dropped, no present-day era bucket. The component spec proves the chosen era/theme ride the wire as query params (and an axis left at "no preference" is omitted), and an e2e walks steer → compose → begin against mocked routes, asserting the steer reached the route decoded.