What changed, and why

A run's objective brief is the description field on GameObjective. It shows at mission select and folds behind "▾ brief" in the HUD — but it is not flavor text. The same string threads into the Character, Timeline Engine, Chronicler, research, and figure-suggester prompts every turn, so the model reads it constantly.

The old briefs were vague and quietly assumed a US/Western frame. "Abolish Slavery a century early" — a century early relative to what? It leaned on an unstated 1865. A player who does not hold that date had no anchor.

This change rewrites all 10 curated briefs to carry, explicitly: the real historical date(s) the counterfactual departs from, and where on Earth it is centered — in short bullet lines. The AI generator that composes fresh objectives is taught the same shape. The fields and types are unchanged (description stays a string), so the blast radius is small: the pool data, one prompt, a char cap, two render sites, and tests.

The grounded briefs

The shape decision was the issue's open question: keep description a string, or add a structured contextBullets field? This took the KISS path — a string of newline-separated lines — so nothing downstream (schema, guards, e2e mocks) has to move. The type doc now spells out the contract, including the one trap worth naming.

The contract: a bulleted brief, rendered newline-aware, and explicitly not the same as anchorYear.

server/utils/objectives.ts · 152 lines
server/utils/objectives.ts152 lines · TypeScript
⋯ 8 lines hidden (lines 1–8)
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, the grounded brief, an anchoring era for flavor, and an icon.
12 */
13export interface GameObjective {
14 title: string
15 /**
16 * The brief: a few newline-separated "• " bullet lines carrying the real-timeline
17 * date(s) and the place this counterfactual departs from, plus what winning looks
18 * like (issue #82). Rendered newline-aware (whitespace-pre-line) at mission select
19 * and in the folded HUD brief, and threaded verbatim into the Judge / Timeline /
20 * Chronicler / research prompts. NOT the same as anchorYear: the brief states the
21 * actual historical date(s), while anchorYear is the in-game counterfactual target.
22 */
23 description: string
24 era: string
25 icon: string
26 /**
27 * Signed year (AD positive, BC negative) the objective is aimed at — the far
28 * anchor the causal-chain dial decays toward (utils/causal-chain.ts). Omitted
29 * for objectives that genuinely span all of history, where there is no single
30 * hinge to chain toward and the dial simply no-ops.
⋯ 122 lines hidden (lines 31–152)
31 */
32 anchorYear?: number
34 
35/**
36 * Curated pool of diverse, opinionated objectives. Selecting at random gives an
37 * instant, reliable, offline-friendly start, and each one points the player at a
38 * different stretch of history — reinforcing that you can reach for anyone, anywhen.
39 *
40 * Each brief is grounded (issue #82): it names the real-timeline date(s) and the
41 * place this counterfactual departs from, in a few "• " bullet lines, so a player
42 * who doesn't already share a US/Western frame still gets a concrete anchor — and
43 * every AI layer the brief feeds gets a firmer factual footing too.
44 */
45const CURATED_OBJECTIVES: GameObjective[] = [
46 {
47 title: 'Prevent the Great War',
48 description:
49 "• In our timeline, the assassination of Archduke Franz Ferdinand in Sarajevo (28 June 1914) tipped Europe's web of alliances into war — August 1914 to November 1918, some 20 million dead.\n" +
50 "• Your aim: defuse the July crisis, cool the hotheads, or remove the spark before the mobilizations cascade.\n" +
51 "• Centered on Europe — the Balkan flashpoint and the capitals of Vienna, Berlin, St Petersburg, Paris, and London — though the war it became drew in their empires worldwide.",
52 era: 'Europe, 1914',
53 icon: '🕊️',
54 anchorYear: 1914
55 },
56 {
57 title: 'Advance Medicine by 300 Years',
58 description:
59 "• In our timeline, the tools came late: smallpox vaccination in 1796, germ theory in the 1860s–80s, antiseptic surgery in 1867, antibiotics not until the 1940s.\n" +
60 "• Your aim: plant vaccination, germ theory, and antisepsis roughly three centuries early — by the 1500s–1600s — so plague, fever, and infection lose their grip far sooner.\n" +
61 "• Global by nature: the real breakthroughs were European (England, France, Germany), but the lever could be pulled in any center of learning, from Baghdad to Florence.",
62 era: 'Across the ages',
63 icon: '⚕️'
64 },
65 {
66 title: 'Save the Library of Alexandria',
67 description:
68 "• In our timeline, Alexandria's great library (founded c. 285 BC) was lost in stages: fire during Caesar's siege in 48 BC, slow decline under Roman rule, and the burning of its daughter library, the Serapeum, in AD 391.\n" +
69 "• Your aim: keep that store of knowledge whole — through the fires and the centuries — into the modern age.\n" +
70 "• Centered on Alexandria in Ptolemaic and Roman Egypt, the Mediterranean's hub of learning.",
71 era: 'Alexandria, antiquity',
72 icon: '📜',
73 anchorYear: -48
74 },
75 {
76 title: 'Establish Global Democracy',
77 description:
78 "• In our timeline, self-rule spread slowly: Athens tried it around 508 BC, but representative government only took hold with the English Bill of Rights (1689), the American Constitution (1787), and the French Revolution (1789); broad suffrage waited for the 1800s–1900s.\n" +
79 "• Your aim: nudge empires, kings, and conquerors toward representative rule generations earlier, so the world learns to govern itself well before our timeline did.\n" +
80 "• Global by nature — no single homeland; the idea would have to take root across every continent.",
81 era: 'Across the ages',
82 icon: '🗳️'
83 },
84 {
85 title: 'Reach the Moon by 1900',
86 description:
87 "• In our timeline, humans first walked on the Moon on 20 July 1969 (Apollo 11, United States); the rocketry behind it — Tsiolkovsky's theory (1903), Goddard's liquid-fuel rocket (1926) — was still unborn in the 1800s.\n" +
88 "• Your aim: accelerate rocketry, mathematics, and ambition to slip Earth's bonds by 1900, nearly seventy years early.\n" +
89 "• A worldwide race in spirit — the real Space Age was led by the United States and the Soviet Union, but the dream was old and shared.",
90 era: '19th century',
91 icon: '🚀',
92 anchorYear: 1900
93 },
94 {
95 title: 'Avert the Fall of Rome',
96 description:
97 "• In our timeline, the Western Roman Empire fell in AD 476, when the commander Odoacer deposed its last emperor, Romulus Augustulus — after the sacks of Rome in 410 and 455.\n" +
98 "• Your aim: shore up the West against the pressures that broke it, and keep the lights of Rome from going dark.\n" +
99 "• Centered on the Western Mediterranean and Rome's provinces (Italy, Gaul, Hispania, North Africa); the Eastern Empire, ruled from Constantinople, endured until 1453 regardless.",
100 era: 'Rome, late antiquity',
101 icon: '🏛️',
102 anchorYear: 476
103 },
104 {
105 title: 'Abolish Slavery a Century Early',
106 description:
107 "• In our timeline, legal abolition came late and piecemeal: the British Empire ended the slave trade in 1807 and slavery in 1833, the United States in 1865, and Brazil — last in the Americas — in 1888.\n" +
108 "• Your aim: turn hearts, faiths, and laws against chattel slavery roughly a century ahead of that; the in-game anchor is 1750.\n" +
109 "• Centered on the Atlantic world — Europe, the Americas, and West Africa — though the lever can be pulled anywhere the trade reached.",
110 era: '18th century',
111 icon: '⛓️',
112 anchorYear: 1750
113 },
114 {
115 title: 'Spark an Early Industrial East',
116 description:
117 "• In our timeline, the steam-and-steel Industrial Revolution began in Britain around 1760 (James Watt's steam engine, patented 1769) — yet Song-dynasty China (960–1279) already had advanced ironworking, printing, gunpowder, and the compass.\n" +
118 "• Your aim: light that revolution in the East centuries before the West — the in-game anchor is 1400 — and rewrite the global order.\n" +
119 "• Centered on Asia, China foremost, where the preconditions were already in hand.",
120 era: 'Asia, 2nd millennium',
121 icon: '⚙️',
122 anchorYear: 1400
123 },
124 {
125 title: 'Break the Black Death',
126 description:
127 "• In our timeline, plague spreading out of Central Asia from the 1330s reached Europe in 1347 — carried by ship from the Black Sea to Sicily — and by 1351 had killed perhaps a third of Europe, tens of millions, ravaging the Middle East and North Africa too.\n" +
128 "• Your aim: sever the chain before it crosses into Europe, sparing that third.\n" +
129 "• Its path ran from Central Asia along the Eurasian trade routes to the Black Sea, then by ship to the Mediterranean.",
130 era: 'Eurasia, 14th century',
131 icon: '🐀',
132 anchorYear: 1347
133 },
134 {
135 title: 'Unite Rivals Without War',
136 description:
137 "• In our timeline, great unions were forged in blood: Qin conquered the warring states into one China in 221 BC, Italy was welded together by war in 1859–1871, and Germany by \"blood and iron\" in 1871.\n" +
138 "• Your aim: bind rival kingdoms through diplomacy and dynastic marriage instead — as the Habsburgs did across 15th–16th-century Europe — sparing generations of bloodshed.\n" +
139 "• Not tied to one place or age: wherever rival powers have clashed, from antiquity to the modern era.",
140 era: 'Across the ages',
141 icon: '🤝'
142 }
144 
145export function pickCurated(): GameObjective {
146 return CURATED_OBJECTIVES[Math.floor(Math.random() * CURATED_OBJECTIVES.length)]
148 
149/** Exposed so the UI can offer the player a hand-picked objective if desired. */
150export function listCuratedObjectives(): GameObjective[] {
151 return [...CURATED_OBJECTIVES]

Two briefs below show the bar. Each leads with the real dates and place, then what winning looks like. The dates were adversarially fact-checked (two independent lenses per brief), and the corrections show: the Black Death brief names the 1330s Central-Asian origin — distinct from the 1347 anchor — and the Industrial-East brief dates Watt's engine to its 1769 patent, not the 1776 first install.

Slavery (anchor 1750, real dates 1807-1888) and Black Death (1330s origin vs 1347 anchor).

server/utils/objectives.ts · 152 lines
server/utils/objectives.ts152 lines · TypeScript
⋯ 103 lines hidden (lines 1–103)
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, the grounded brief, an anchoring era for flavor, and an icon.
12 */
13export interface GameObjective {
14 title: string
15 /**
16 * The brief: a few newline-separated "• " bullet lines carrying the real-timeline
17 * date(s) and the place this counterfactual departs from, plus what winning looks
18 * like (issue #82). Rendered newline-aware (whitespace-pre-line) at mission select
19 * and in the folded HUD brief, and threaded verbatim into the Judge / Timeline /
20 * Chronicler / research prompts. NOT the same as anchorYear: the brief states the
21 * actual historical date(s), while anchorYear is the in-game counterfactual target.
22 */
23 description: string
24 era: string
25 icon: string
26 /**
27 * Signed year (AD positive, BC negative) the objective is aimed at — the far
28 * anchor the causal-chain dial decays toward (utils/causal-chain.ts). Omitted
29 * for objectives that genuinely span all of history, where there is no single
30 * hinge to chain toward and the dial simply no-ops.
31 */
32 anchorYear?: number
34 
35/**
36 * Curated pool of diverse, opinionated objectives. Selecting at random gives an
37 * instant, reliable, offline-friendly start, and each one points the player at a
38 * different stretch of history — reinforcing that you can reach for anyone, anywhen.
39 *
40 * Each brief is grounded (issue #82): it names the real-timeline date(s) and the
41 * place this counterfactual departs from, in a few "• " bullet lines, so a player
42 * who doesn't already share a US/Western frame still gets a concrete anchor — and
43 * every AI layer the brief feeds gets a firmer factual footing too.
44 */
45const CURATED_OBJECTIVES: GameObjective[] = [
46 {
47 title: 'Prevent the Great War',
48 description:
49 "• In our timeline, the assassination of Archduke Franz Ferdinand in Sarajevo (28 June 1914) tipped Europe's web of alliances into war — August 1914 to November 1918, some 20 million dead.\n" +
50 "• Your aim: defuse the July crisis, cool the hotheads, or remove the spark before the mobilizations cascade.\n" +
51 "• Centered on Europe — the Balkan flashpoint and the capitals of Vienna, Berlin, St Petersburg, Paris, and London — though the war it became drew in their empires worldwide.",
52 era: 'Europe, 1914',
53 icon: '🕊️',
54 anchorYear: 1914
55 },
56 {
57 title: 'Advance Medicine by 300 Years',
58 description:
59 "• In our timeline, the tools came late: smallpox vaccination in 1796, germ theory in the 1860s–80s, antiseptic surgery in 1867, antibiotics not until the 1940s.\n" +
60 "• Your aim: plant vaccination, germ theory, and antisepsis roughly three centuries early — by the 1500s–1600s — so plague, fever, and infection lose their grip far sooner.\n" +
61 "• Global by nature: the real breakthroughs were European (England, France, Germany), but the lever could be pulled in any center of learning, from Baghdad to Florence.",
62 era: 'Across the ages',
63 icon: '⚕️'
64 },
65 {
66 title: 'Save the Library of Alexandria',
67 description:
68 "• In our timeline, Alexandria's great library (founded c. 285 BC) was lost in stages: fire during Caesar's siege in 48 BC, slow decline under Roman rule, and the burning of its daughter library, the Serapeum, in AD 391.\n" +
69 "• Your aim: keep that store of knowledge whole — through the fires and the centuries — into the modern age.\n" +
70 "• Centered on Alexandria in Ptolemaic and Roman Egypt, the Mediterranean's hub of learning.",
71 era: 'Alexandria, antiquity',
72 icon: '📜',
73 anchorYear: -48
74 },
75 {
76 title: 'Establish Global Democracy',
77 description:
78 "• In our timeline, self-rule spread slowly: Athens tried it around 508 BC, but representative government only took hold with the English Bill of Rights (1689), the American Constitution (1787), and the French Revolution (1789); broad suffrage waited for the 1800s–1900s.\n" +
79 "• Your aim: nudge empires, kings, and conquerors toward representative rule generations earlier, so the world learns to govern itself well before our timeline did.\n" +
80 "• Global by nature — no single homeland; the idea would have to take root across every continent.",
81 era: 'Across the ages',
82 icon: '🗳️'
83 },
84 {
85 title: 'Reach the Moon by 1900',
86 description:
87 "• In our timeline, humans first walked on the Moon on 20 July 1969 (Apollo 11, United States); the rocketry behind it — Tsiolkovsky's theory (1903), Goddard's liquid-fuel rocket (1926) — was still unborn in the 1800s.\n" +
88 "• Your aim: accelerate rocketry, mathematics, and ambition to slip Earth's bonds by 1900, nearly seventy years early.\n" +
89 "• A worldwide race in spirit — the real Space Age was led by the United States and the Soviet Union, but the dream was old and shared.",
90 era: '19th century',
91 icon: '🚀',
92 anchorYear: 1900
93 },
94 {
95 title: 'Avert the Fall of Rome',
96 description:
97 "• In our timeline, the Western Roman Empire fell in AD 476, when the commander Odoacer deposed its last emperor, Romulus Augustulus — after the sacks of Rome in 410 and 455.\n" +
98 "• Your aim: shore up the West against the pressures that broke it, and keep the lights of Rome from going dark.\n" +
99 "• Centered on the Western Mediterranean and Rome's provinces (Italy, Gaul, Hispania, North Africa); the Eastern Empire, ruled from Constantinople, endured until 1453 regardless.",
100 era: 'Rome, late antiquity',
101 icon: '🏛️',
102 anchorYear: 476
103 },
104 {
105 title: 'Abolish Slavery a Century Early',
106 description:
107 "• In our timeline, legal abolition came late and piecemeal: the British Empire ended the slave trade in 1807 and slavery in 1833, the United States in 1865, and Brazil — last in the Americas — in 1888.\n" +
108 "• Your aim: turn hearts, faiths, and laws against chattel slavery roughly a century ahead of that; the in-game anchor is 1750.\n" +
109 "• Centered on the Atlantic world — Europe, the Americas, and West Africa — though the lever can be pulled anywhere the trade reached.",
110 era: '18th century',
111 icon: '⛓️',
112 anchorYear: 1750
113 },
⋯ 10 lines hidden (lines 114–123)
114 {
115 title: 'Spark an Early Industrial East',
116 description:
117 "• In our timeline, the steam-and-steel Industrial Revolution began in Britain around 1760 (James Watt's steam engine, patented 1769) — yet Song-dynasty China (960–1279) already had advanced ironworking, printing, gunpowder, and the compass.\n" +
118 "• Your aim: light that revolution in the East centuries before the West — the in-game anchor is 1400 — and rewrite the global order.\n" +
119 "• Centered on Asia, China foremost, where the preconditions were already in hand.",
120 era: 'Asia, 2nd millennium',
121 icon: '⚙️',
122 anchorYear: 1400
123 },
124 {
125 title: 'Break the Black Death',
126 description:
127 "• In our timeline, plague spreading out of Central Asia from the 1330s reached Europe in 1347 — carried by ship from the Black Sea to Sicily — and by 1351 had killed perhaps a third of Europe, tens of millions, ravaging the Middle East and North Africa too.\n" +
128 "• Your aim: sever the chain before it crosses into Europe, sparing that third.\n" +
129 "• Its path ran from Central Asia along the Eurasian trade routes to the Black Sea, then by ship to the Mediterranean.",
130 era: 'Eurasia, 14th century',
131 icon: '🐀',
132 anchorYear: 1347
133 },
⋯ 19 lines hidden (lines 134–152)
134 {
135 title: 'Unite Rivals Without War',
136 description:
137 "• In our timeline, great unions were forged in blood: Qin conquered the warring states into one China in 221 BC, Italy was welded together by war in 1859–1871, and Germany by \"blood and iron\" in 1871.\n" +
138 "• Your aim: bind rival kingdoms through diplomacy and dynastic marriage instead — as the Habsburgs did across 15th–16th-century Europe — sparing generations of bloodshed.\n" +
139 "• Not tied to one place or age: wherever rival powers have clashed, from antiquity to the modern era.",
140 era: 'Across the ages',
141 icon: '🤝'
142 }
144 
145export function pickCurated(): GameObjective {
146 return CURATED_OBJECTIVES[Math.floor(Math.random() * CURATED_OBJECTIVES.length)]
148 
149/** Exposed so the UI can offer the player a hand-picked objective if desired. */
150export function listCuratedObjectives(): GameObjective[] {
151 return [...CURATED_OBJECTIVES]

Teaching the generator the same shape

Objectives can also be composed live by the model. The old prompt asked for "a vivid one-or-two-sentence description." It now requires the same dated, located, bulleted form, with a few-shot exemplar so the model has the shape in front of it, and the schema's field annotation matches the prompt. So a composed objective reads like a curated one.

The reworked user prompt (with exemplar) and the matching schema annotation.

server/utils/objective-generator.ts · 78 lines
server/utils/objective-generator.ts78 lines · TypeScript
⋯ 37 lines hidden (lines 1–37)
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). The curated symbols are re-exported so existing server/test importers
7 * keep their path; client code imports them from ./objectives directly.
8 */
9import { pickCurated, type GameObjective } from './objectives'
10 
11/**
12 * Returns an objective for a new run. Curated-random by default (instant, no API
13 * call); pass useAI=true to compose one live, with automatic fallback to curated.
14 */
15export async function generateObjective(useAI = false): Promise<GameObjective> {
16 if (useAI) {
17 try {
18 return await generateAIObjective()
19 } catch (error) {
20 console.error('AI objective generation failed; falling back to curated pool:', error)
21 }
22 }
23 return pickCurated()
25 
26/**
27 * Composes a fresh objective with the model. Returns the same shape as the
28 * curated pool so the rest of the game treats them identically.
29 */
30async function generateAIObjective(): Promise<GameObjective> {
31 // Imported lazily so the curated (default) path never pulls the model SDKs
32 // into the bundle.
33 const { completeStructured } = await import('./ai-gateway')
34 
35 const content = await completeStructured({
36 task: 'objective',
37 system: 'You are a historian and game designer inventing grand, evocative counterfactual objectives for a game where players text historical figures to bend history. Your briefs are historically grounded: each one names the real dates and places it departs from in our timeline, and never assumes the reader is American or already knows the date.',
38 turns: [{
39 role: 'user',
40 content:
41 'Invent one fresh objective: a sweeping, achievable-in-spirit goal that spans real history, distinct from "prevent World War I".\n\n' +
42 'Give it a punchy title; an anchoring era; a single emoji; and the anchor year the objective is aimed at (the pivotal in-game year where winning is decided) as a signed integer — negative for BC — or null if it genuinely spans all of history with no single hinge.\n\n' +
43 'The description is a GROUNDED BRIEF: a few short bullet lines, each beginning with "• " and separated by newlines, that together state (1) when and where this actually happened or ended in OUR timeline, with real dates; (2) what winning looks like; and (3) where on Earth it is centered — name the region or country, or, if it genuinely spans the globe or all eras, say so and name the span rather than forcing one country. Never assume the reader already knows the date or comes from any one country; put the real date IN the brief. The real-timeline date(s) are NOT the same as the anchor year (the in-game target) — state the actual history, do not merely restate the anchor. Keep each bullet tight.\n\n' +
44 'Example of the shape (a different objective):\n' +
45 '"• In our timeline, legal abolition came late and piecemeal: the British Empire ended slavery in 1833, the United States in 1865, and Brazil — last in the Americas — in 1888.\n' +
46 '• Your aim: turn hearts, faiths, and laws against chattel slavery roughly a century ahead of that.\n' +
47 '• Centered on the Atlantic world — Europe, the Americas, and West Africa."'
48 }],
49 schemaName: 'game_objective',
50 schema: {
51 type: 'object',
52 properties: {
53 title: { type: 'string', description: 'Punchy objective title' },
54 description: { type: 'string', description: 'A grounded brief: a few newline-separated "• " bullet lines stating the real-timeline date(s) and place this departs from in our history, what winning looks like, and where it is centered (or, if global/cross-era, said so with the span named)' },
55 era: { type: 'string', description: 'Anchoring era / setting' },
56 icon: { type: 'string', description: 'A single emoji that fits the objective' },
57 anchorYear: { type: ['integer', 'null'], description: 'Signed year the objective is aimed at (negative for BC), or null if it spans all history' }
58 },
59 required: ['title', 'description', 'era', 'icon', 'anchorYear'],
60 additionalProperties: false
⋯ 18 lines hidden (lines 61–78)
61 }
62 })
63 
64 if (!content) throw new Error('No objective generated')
65 
66 const parsed = JSON.parse(content) as GameObjective
67 if (!parsed.title || !parsed.description) throw new Error('Invalid objective format generated')
68 return {
69 title: parsed.title,
70 description: parsed.description,
71 era: parsed.era || 'Across the ages',
72 icon: parsed.icon || '🕰️',
73 // A finite signed year anchors the chain dial; null / junk → no anchor (no-op).
74 anchorYear: typeof parsed.anchorYear === 'number' && Number.isFinite(parsed.anchorYear)
75 ? Math.round(parsed.anchorYear)
76 : undefined
77 }

Raising one cap without moving another

Dated, bulleted briefs run longer than the old one-liner, so the objective-description cap rises from 400 to 700. But that constant had a second, unrelated tenant: the timeline-ledger detail field (a model-written ripple sentence) borrowed the same MAX_OBJECTIVE_DESC_CHARS. Raising it would have silently widened the ledger bound too. So the ledger cap is split out as its own MAX_LEDGER_DETAIL_CHARS, holding the old 400.

The description cap rises to 700; the ledger-detail cap splits out and keeps 400.

server/utils/validate.ts · 44 lines
server/utils/validate.ts44 lines · TypeScript
⋯ 12 lines hidden (lines 1–12)
1/**
2 * Server-side validation for the untrusted client boundary.
3 *
4 * The client UI caps the message and never sends a malformed objective / timeline /
5 * conversation thread — but a direct POST can, and every one of those fields feeds a
6 * gpt-5.5 prompt. The server is the only real gate in front of the AI (the char cap
7 * and rate limit live in the client store, which a raw request bypasses), so the
8 * routes re-check here. The bounds stop a crafted body from injecting an unbounded
9 * prompt, forging the prior ledger, amplifying token cost, or crashing a handler
10 * with a bad shape. (Surfaced by the input-validation-gap loop.)
11 */
12 
13// Server-side abuse caps. These inputs are model- or client-built (not UI-bounded
14// like the message), so their bound lives here rather than in the shared game-config.
15export const MAX_FIGURE_NAME_CHARS = 80
16export const MAX_OBJECTIVE_TITLE_CHARS = 120
17// The objective brief now carries real-timeline dates + geography as a few short
18// bulleted lines (issue #82), so it runs longer than the old one-sentence blurb.
19export const MAX_OBJECTIVE_DESC_CHARS = 700
20// A timeline ledger entry's ripple text — one or two model-written sentences. It
21// once borrowed the objective-description cap above; split out so raising that
22// (for the longer briefs) doesn't silently widen this unrelated bound.
23export const MAX_LEDGER_DETAIL_CHARS = 400
⋯ 21 lines hidden (lines 24–44)
24export const MAX_ERA_CHARS = 80
25export const MAX_GROUNDING_CHARS = 300
26export const MAX_TIMELINE_ENTRIES = 50
27export const MAX_HISTORY_ENTRIES = 40
28export const MAX_LEDGER_SWING = 100
29 
30/** Coerce to a trimmed string capped at `max`; '' when not a usable string. */
31export function cleanString(value: unknown, max: number): string {
32 return typeof value === 'string' ? value.trim().slice(0, max) : ''
34 
35/** `Array.isArray(value)` ? a length-capped copy : [] — guards `.map`/`.filter`. */
36export function cleanArray(value: unknown, max: number): unknown[] {
37 return Array.isArray(value) ? value.slice(0, max) : []
39 
40/** A finite number rounded + clamped to ±`bound`; 0 otherwise. */
41export function clampInt(value: unknown, bound: number): number {
42 const n = typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : 0
43 return Math.max(-bound, Math.min(bound, n))

The ledger detail now uses MAX_LEDGER_DETAIL_CHARS — the same fix landed in chronicle.post.ts.

server/api/send-message.post.ts · 365 lines
server/api/send-message.post.ts365 lines · TypeScript
⋯ 92 lines hidden (lines 1–92)
1import { MAX_MESSAGE_CHARS } from '~/utils/game-config'
2import { toContactMoment, formatContactMoment } from '~/utils/contact-moment'
3import { isContentless } from '~/utils/substance'
4import { nextMomentum, MOMENTUM_MAX } from '~/utils/momentum'
5import {
6 cleanArray,
7 cleanString,
8 clampInt,
9 MAX_ERA_CHARS,
10 MAX_FIGURE_NAME_CHARS,
11 MAX_GROUNDING_CHARS,
12 MAX_HISTORY_ENTRIES,
13 MAX_LEDGER_DETAIL_CHARS,
14 MAX_LEDGER_SWING,
15 MAX_OBJECTIVE_DESC_CHARS,
16 MAX_OBJECTIVE_TITLE_CHARS,
17 MAX_TIMELINE_ENTRIES
18} from '~/server/utils/validate'
19 
20export default defineEventHandler(async (event) => {
21 // Only allow POST requests
22 if (getMethod(event) !== 'POST') {
23 throw createError({
24 statusCode: 405,
25 statusMessage: 'Method Not Allowed'
26 })
27 }
28 
29 const body = await readBody(event)
30 
31 // The client UI bounds these, but a direct POST is untrusted and every field
32 // below feeds a gpt-5.5 prompt — so validate the boundary here, not just in the
33 // store. (Surfaced by the input-validation-gap loop.)
34 if (!body || typeof body.message !== 'string' || !body.message.trim()) {
35 throw createError({
36 statusCode: 400,
37 statusMessage: 'Bad Request: message parameter is required'
38 })
39 }
40 if (body.message.trim().length > MAX_MESSAGE_CHARS) {
41 throw createError({
42 statusCode: 400,
43 statusMessage: `Bad Request: message exceeds ${MAX_MESSAGE_CHARS} characters`
44 })
45 }
46 if (typeof body.figureName !== 'string' || !body.figureName.trim()) {
47 throw createError({
48 statusCode: 400,
49 statusMessage: 'Bad Request: figureName parameter is required'
50 })
51 }
52 
53 const { when = null, figureContext = null, objective = null } = body
54 const message = body.message.trim()
55 // A name is interpolated into every prompt (and the Wikipedia lookup upstream),
56 // so it gets a hard cap like every other prompt-feeding string.
57 const figure = cleanString(body.figureName, MAX_FIGURE_NAME_CHARS)
58 // The grounded contact year (signed: AD positive / BC negative) — anchors the
59 // causal-distance read and the character's world-brief. Optional + untrusted.
60 const figureYear = typeof body.whenSigned === 'number' && Number.isFinite(body.whenSigned)
61 ? clampInt(body.whenSigned, 12000)
62 : undefined
63 // The pinned moment (issue #32) arrives as untrusted integers and is
64 // re-validated here; the display string the prompts see is DERIVED, never
65 // the client's. Sub-year detail is prompt flavor only — every mechanic
66 // below (world-brief, liveness, the wager) still computes on figureYear.
67 const figureMoment = figureYear !== undefined ? toContactMoment(body.whenMonth, body.whenDay) : null
68 const whenLabel = figureYear !== undefined ? formatContactMoment(figureYear, figureMoment) : undefined
69 // The last-stand wager: the client offers it only on the final dispatch; the
70 // doubling is applied after amplification, and the response echoes it.
71 const staked = body.stake === true
72 // The run's PRE-turn momentum (0..4), client-authoritative between turns (no
73 // server run-state today). The server reads it to compute the gains factor and
74 // returns the advanced value; untrusted, so coerce + clamp to the meter (#62).
75 const momentum = Math.max(0, Math.min(MOMENTUM_MAX, Math.floor(Number(body.momentum) || 0)))
76 
77 // Normalize the objective into the context the Timeline Engine needs (coerced +
78 // bounded — a non-string or megabyte field would otherwise reach the prompt raw).
79 const objectiveContext = {
80 title: cleanString(objective?.title, MAX_OBJECTIVE_TITLE_CHARS) || 'Alter the course of history',
81 description: cleanString(objective?.description, MAX_OBJECTIVE_DESC_CHARS) || 'Bend the chain of events toward a better world.',
82 era: cleanString(objective?.era, MAX_ERA_CHARS)
83 }
84 // The objective's far anchor year (signed: AD+/BC−) — the foothold the
85 // causal-chain dial decays toward. Optional + untrusted; absent → no-op.
86 const objectiveAnchorYear = typeof objective?.anchorYear === 'number' && Number.isFinite(objective.anchorYear)
87 ? clampInt(objective.anchorYear, 12000)
88 : undefined
89 
90 // Sanitize the client-supplied ledger + thread: cap the counts and coerce each
91 // field, so a crafted body can't crash a `.map`, forge an unbounded prior
92 // history, or amplify token cost.
93 const timeline = cleanArray(body.timeline, MAX_TIMELINE_ENTRIES).map((raw) => {
94 const e = (raw ?? {}) as Record<string, unknown>
95 return {
96 era: cleanString(e.era, MAX_ERA_CHARS),
97 headline: cleanString(e.headline, MAX_OBJECTIVE_TITLE_CHARS),
98 detail: cleanString(e.detail, MAX_LEDGER_DETAIL_CHARS),
99 progressChange: clampInt(e.progressChange, MAX_LEDGER_SWING),
⋯ 266 lines hidden (lines 100–365)
100 whenSigned: typeof e.whenSigned === 'number' && Number.isFinite(e.whenSigned)
101 ? clampInt(e.whenSigned, 12000)
102 : undefined
103 }
104 })
105 const conversationHistory = cleanArray(body.conversationHistory, MAX_HISTORY_ENTRIES)
106 .map((raw) => (raw ?? {}) as Record<string, unknown>)
107 .filter((m) => (m.sender === 'user' || m.sender === 'ai') && typeof m.text === 'string')
108 .map((m) => ({ sender: m.sender as 'user' | 'ai', text: cleanString(m.text, MAX_MESSAGE_CHARS) }))
109 
110 try {
111 // Paywall enforcement: the run must be charged and owned by this device,
112 // or a forged / unpaid run id could draw free generation. Fails open if
113 // the balance store is unreachable (see run-guard) so an outage never
114 // blocks a legit in-progress run.
115 const { runIsActive } = await import('~/server/utils/run-guard')
116 if (!(await runIsActive(event))) {
117 return {
118 success: false as const,
119 message: 'Run not active',
120 data: { userMessage: message, error: 'This run is no longer active. Start a new run to continue.' }
121 }
122 }
123 
124 const { callCharacterAI, callTimelineAI, callJudgeAI } = await import('~/server/utils/openai')
125 const { rollD20, applyCraftModifier } = await import('~/utils/dice')
126 const { CRAFT_MODIFIER } = await import('~/utils/craft')
127 const { hardBlockCheck, moderateAction } = await import('~/server/utils/moderation')
128 
129 // A blocked turn never burns a message: success:false + data.blocked lets
130 // the store show a distinct, honest "blocked" banner (not an infra retry).
131 const blockedResponse = (reason: string) => ({
132 success: false as const,
133 message: 'Blocked by moderation',
134 data: { userMessage: message, blocked: true as const, moderationReason: reason, error: reason }
135 })
136 
137 // Moderation, stage 1 — a deterministic hard block on the raw dispatch
138 // BEFORE we spend a generation token. The unforgivable categories (sexual
139 // content, anything involving minors, self-harm facilitation) need no
140 // context; the contextual Sentinel runs post-generation over the exchange.
141 const inputBlock = await hardBlockCheck(message, 'turn', 'input')
142 if (inputBlock.blocked) return blockedResponse(inputBlock.block.reason)
143 
144 // Contact gating, enforced authoritatively here (#72 deceased-only + #73
145 // require-grounding): the client payload is untrusted, and a direct POST (or
146 // a send racing the grounding debounce) could otherwise reach a living or
147 // ungrounded figure. Re-ground the name ourselves — cache-backed in the warm
148 // path (the same in-process cache /api/figure just filled), one bounded
149 // external lookup on a cold cache — and:
150 // • resolved + not deceased → block (living, or undatable: fail closed)
151 // • unresolved + transient → retry (a lookup we couldn't complete)
152 // • unresolved + definitive → block (#73: no free-form; reach a real figure)
153 // This gate enforces existence + liveness (the safety floor); a deceased
154 // figure's lifetime window is a client-side UX nicety (anachronism is
155 // mechanically allowed via the wager), so it is deliberately not re-checked here.
156 const { groundFigure, isDeceased } = await import('~/server/utils/figure-grounding')
157 const grounded = await groundFigure(figure)
158 if (grounded.resolved && !isDeceased(grounded)) {
159 return blockedResponse('This figure is still living and cannot be contacted — Revisionist only reaches figures from history.')
160 }
161 if (!grounded.resolved) {
162 return grounded.transient
163 ? {
164 success: false as const,
165 message: 'Could not verify the figure',
166 data: { userMessage: message, error: "Couldn't reach the record to verify who this is — please try again in a moment." }
167 }
168 : blockedResponse('No historical record found for this name — reach for a real, documented figure.')
169 }
170 
171 // The client supplies figureContext (it grounded the figure via /api/figure),
172 // so treat it as untrusted: coerce + bound each field before it becomes a
173 // "fact" in the character system prompt.
174 const grounding = {
175 when: whenLabel ?? (cleanString(when, MAX_ERA_CHARS) || undefined),
176 // The figure should take a pinned moment with period-appropriate
177 // granularity rather than false precision (the prompt line is
178 // conditional so year-only turns stay byte-identical).
179 momentRefined: figureMoment !== null,
180 description: cleanString(figureContext?.description, MAX_GROUNDING_CHARS) || undefined,
181 lifespan: cleanString(figureContext?.lifespan, MAX_ERA_CHARS) || undefined
182 }
183 
184 // The Judge also reads the run's thread — the figure's last reply (their last
185 // revealed condition/price) and a digest of recent ledger headlines — to grade
186 // CONTINUITY: does this dispatch build on what came before? Both are empty on
187 // turn 1, so the Judge prompt stays byte-identical there.
188 const lastFigureReply = [...conversationHistory].reverse().find(m => m.sender === 'ai')?.text ?? ''
189 const ledgerDigest = timeline
190 .filter(e => e.headline)
191 .slice(-5)
192 .map(e => `${e.era ? `[${e.era}] ` : ''}${e.headline}`)
193 
194 // Step 1: The Judge grades the dispatch's craft — the one place player skill
195 // directly tilts fate. A Judge outage grades 'sound' (modifier 0): an infra
196 // hiccup must never tilt the die either way.
197 const judged = await callJudgeAI({
198 objective: objectiveContext,
199 figureName: figure,
200 when: grounding.when,
201 momentRefined: figureMoment !== null,
202 userMessage: message,
203 lastFigureReply,
204 ledgerDigest
205 })
206 const craft = judged.success && judged.judge ? judged.judge.craft : 'sound'
207 const craftReason = judged.success && judged.judge ? judged.judge.reason : ''
208 // Continuity feeds the run-level momentum meter (slice 3); an outage → 'neutral'.
209 const continuity = judged.success && judged.judge ? judged.judge.continuity : 'neutral'
210 const rollModifier = CRAFT_MODIFIER[craft]
211 
212 // Step 2: Roll the dice of fate, tilted by craft (clamped to the die's faces).
213 const natural = rollD20()
214 const diceResult = applyCraftModifier(natural.roll, rollModifier)
215 
216 // Step 3: The chosen figure replies and acts, in character, blind to the goal.
217 // Grounding (when + real facts) anchors the role-play; the world-brief hands
218 // them the altered history their own moment would already know (changes at or
219 // before their year), so a turn-4 figure doesn't role-play the unaltered world.
220 // Headlines only — `detail` narrates downstream ripples (often stamped with
221 // later eras) and would hand the figure the future outright.
222 const worldBrief = figureYear === undefined
223 ? []
224 : timeline
225 .filter(e => typeof e.whenSigned === 'number' && e.whenSigned <= figureYear && e.headline)
226 .slice(-5)
227 .map(e => `${e.era ? `[${e.era}] ` : ''}${e.headline}`)
228 // Keep the figure honest: a contentless dispatch gives them nothing to act
229 // on, so even a favorable roll yields confusion, not a fabricated deed (#62).
230 const contentless = isContentless(message)
231 const character = await callCharacterAI(figure, message, conversationHistory, diceResult.outcome, grounding, worldBrief, contentless)
232 if (!character.success || !character.characterResponse) {
233 // A model-side safety refusal is a block, not an infra hiccup.
234 if (character.refused) return blockedResponse(character.error || 'This dispatch was declined by content moderation and cannot be sent.')
235 return {
236 success: false,
237 message: 'Failed to generate character response',
238 data: {
239 userMessage: message,
240 error: character.error || 'Character AI failed'
241 }
242 }
243 }
244 
245 const reply = character.characterResponse
246 
247 // Enforce the message-length constraint on the figure's reply.
248 if (reply.message && reply.message.length > MAX_MESSAGE_CHARS) {
249 console.warn(`Figure reply exceeded ${MAX_MESSAGE_CHARS} characters (${reply.message.length}); truncating.`)
250 reply.message = reply.message.substring(0, MAX_MESSAGE_CHARS - 3) + '...'
251 }
252 
253 // Step 4: The Timeline Engine judges how the action ripples toward the objective.
254 const timelineResult = await callTimelineAI({
255 objective: objectiveContext,
256 figureName: figure,
257 era: reply.era || objectiveContext.era,
258 userMessage: message,
259 characterMessage: reply.message,
260 characterAction: reply.action,
261 diceRoll: diceResult.roll,
262 diceOutcome: diceResult.outcome,
263 timeline,
264 figureYear,
265 figureMoment: figureMoment ? whenLabel : undefined,
266 objectiveAnchorYear,
267 craft,
268 momentum
269 })
270 
271 if (!timelineResult.success || !timelineResult.ripple) {
272 if (timelineResult.refused) return blockedResponse(timelineResult.error || 'This dispatch was declined by content moderation and cannot be sent.')
273 return {
274 success: false,
275 message: 'Failed to generate timeline analysis',
276 data: {
277 userMessage: message,
278 diceRoll: diceResult.roll,
279 diceOutcome: diceResult.outcome,
280 characterResponse: { message: reply.message, action: reply.action },
281 error: timelineResult.error || 'Timeline AI failed'
282 }
283 }
284 }
285 
286 const ripple = timelineResult.ripple
287 
288 // Moderation, stage 2 — the contextual Sentinel reviews the WHOLE exchange
289 // (full thread + this dispatch + the figure's reply and the timeline's
290 // narration) against the verbatim Usage Policy, and the detector hard-blocks
291 // the outputs. A block here suppresses the reveal: the generated text never
292 // ships, and the turn is refunded with an honest banner.
293 const moderation = await moderateAction({
294 surface: 'turn',
295 objective: { title: objectiveContext.title, description: objectiveContext.description },
296 figureName: figure,
297 era: reply.era || objectiveContext.era,
298 conversation: conversationHistory.map(m => ({ role: m.sender === 'user' ? 'user' : 'assistant', text: m.text })),
299 userText: message,
300 outputs: [reply.message, reply.action, ripple.headline, ripple.detail].filter((t): t is string => !!t)
301 })
302 if (moderation.blocked) return blockedResponse(moderation.block.reason)
303 
304 // The last stand: a staked dispatch doubles its resolved swing, both ways,
305 // and may escape the per-turn fuse up to the full meter. The valence badge
306 // is re-asserted AFTER the doubling — the sign-guard inside callTimelineAI
307 // ran on the pre-stake value, and a doubled swing must wear its own color.
308 const { applyStake, VALENCE_SIGN_GUARD_MIN } = await import('~/utils/swing-bands')
309 const finalProgressChange = staked ? applyStake(ripple.progressChange) : ripple.progressChange
310 const finalValence = staked && Math.abs(finalProgressChange) > VALENCE_SIGN_GUARD_MIN
311 ? (finalProgressChange > 0 ? 'positive' as const : 'negative' as const)
312 : ripple.valence
313 
314 // Advance the run-level momentum meter from this turn's continuity + roll; the
315 // client overwrites its meter from this on ingest (issue #62).
316 const nextM = nextMomentum(momentum, continuity, diceResult.outcome)
317 
318 return {
319 success: true,
320 message: 'Timeline updated',
321 data: {
322 userMessage: message,
323 figure: {
324 name: figure,
325 era: reply.era,
326 descriptor: reply.persona
327 },
328 // The effective (craft-tilted) roll drives the bands and the UI; the
329 // natural roll + modifier ride along so the reveal can show the tilt.
330 diceRoll: diceResult.roll,
331 diceOutcome: diceResult.outcome,
332 naturalRoll: natural.roll,
333 rollModifier,
334 craft,
335 craftReason,
336 continuity,
337 momentum: nextM,
338 staked,
339 characterResponse: { message: reply.message, action: reply.action },
340 timeline: {
341 headline: ripple.headline,
342 detail: ripple.detail,
343 era: reply.era || objectiveContext.era,
344 progressChange: finalProgressChange,
345 baseProgressChange: ripple.baseProgressChange,
346 // The PRE-turn momentum that amplified this swing — for the
347 // reveal's equation (distinct from data.momentum, the new meter).
348 momentumAtSwing: momentum,
349 valence: finalValence,
350 anachronism: ripple.anachronism,
351 causalChain: ripple.causalChain
352 },
353 error: null
354 }
355 }
356 } catch (error) {
357 // Log the detail server-side; the wire gets a clean line (internal error
358 // text — stack hints, key issues, SDK messages — is not for the client).
359 console.error('API endpoint error:', error)
360 throw createError({
361 statusCode: 500,
362 statusMessage: 'Internal Server Error'
363 })
364 }
365})

Rendering, and the tests that pin it

Both render sites showed description in a single element where newlines collapse. The fix is one Tailwind utility — whitespace-pre-line — so the lines render as separate rows. Mustache ({{ }}) still auto-escapes, so no new injection surface; the unsafe-render and a11y-source loops were run over the touched components.

The folded HUD brief, now newline-aware.

components/ObjectiveDisplay.vue · 30 lines
components/ObjectiveDisplay.vue30 lines · Vue
⋯ 9 lines hidden (lines 1–9)
1<template>
2 <!-- The grand objective as the HUD mission-strip; description folds behind ▾brief. -->
3 <div data-testid="objective-display" class="min-w-0">
4 <template v-if="objective">
5 <div class="flex items-center gap-2 min-w-0">
6 <span class="text-base leading-none select-none" aria-hidden="true">{{ objective.icon }}</span>
7 <h2 data-testid="objective-title" class="rv-fg font-bold truncate text-sm sm:text-base">{{ objective.title }}</h2>
8 <span v-if="objective.era" class="rv-label hidden md:inline shrink-0">· {{ objective.era }}</span>
9 </div>
10 <details v-if="objective.description" class="mt-0.5">
11 <summary class="rv-accent text-xs cursor-pointer select-none list-none">▾ brief</summary>
12 <!-- whitespace-pre-line: the brief is newline-separated "• " bullet lines (issue #82). -->
13 <p class="rv-muted text-xs mt-1 max-w-3xl leading-relaxed whitespace-pre-line">{{ objective.description }}</p>
⋯ 17 lines hidden (lines 14–30)
14 </details>
15 </template>
16 <div v-else class="rv-faint italic text-sm">Consulting the archives for your objective…</div>
17 </div>
18</template>
19 
20<script setup lang="ts">
21/**
22 * ObjectiveDisplay — the grand objective, fully state-driven. Rendered as the HUD
23 * mission-strip: icon + title + era always visible; the description folds behind ▾brief.
24 */
25import { computed } from 'vue'
26import { useGameStore } from '~/stores/game'
27 
28const gameStore = useGameStore()
29const objective = computed(() => gameStore.currentObjective)
30</script>

The mission-select row, same treatment.

components/MissionSelect.vue · 133 lines
components/MissionSelect.vue133 lines · Vue
⋯ 28 lines hidden (lines 1–28)
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 <!-- whitespace-pre-line: the brief is newline-separated "• " bullet lines (issue #82). -->
30 <span class="block rv-muted text-sm mt-0.5 leading-relaxed whitespace-pre-line">{{ obj.description }}</span>
⋯ 103 lines hidden (lines 31–133)
31 </span>
32 </button>
33 </div>
34 
35 <!-- Actions: compose a fresh objective · begin the run -->
36 <div class="mt-5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
37 <div class="flex flex-col items-start gap-1">
38 <button type="button" data-testid="compose-objective" class="rv-btn text-sm" :disabled="composing" @click="rollFresh">
39 <span aria-hidden="true"></span> {{ composing ? 'Composing a fresh fate…' : 'Compose a fresh objective with AI' }}
40 </button>
41 <p v-if="composeError" data-testid="compose-error" class="text-xs rv-bad">{{ composeError }}</p>
42 </div>
43 
44 <button type="button" data-testid="begin-button" class="rv-btn rv-btn--primary" :disabled="!selected || beginning" @click="begin">
45 {{ beginning ? 'Charting the timeline…' : 'Begin rewriting history' }} <span aria-hidden="true"></span>
46 </button>
47 </div>
48 
49 <!-- Paywall: the free run is spent. The run-packs modal opens automatically on
50 the refused commit; this persistent notice lets the player reopen it if they
51 dismissed it. -->
52 <div v-if="gameStore.outOfRuns" data-testid="paywall" class="mt-4 pt-4 border-t rv-line">
53 <p class="text-sm rv-fg font-semibold">You're out of runs — your free run is spent.</p>
54 <p class="rv-muted text-xs mt-0.5">Grab a pack to keep rewriting history. One-time, never expires.</p>
55 <button type="button" data-testid="open-packs" class="rv-btn rv-btn--primary mt-3"
56 @click="gameStore.openBuyModal()">
57 See run packs <span aria-hidden="true"></span>
58 </button>
59 </div>
60 
61 <!-- Capacity: the global spend cap paused new runs (in-progress runs continue). -->
62 <p v-else-if="gameStore.atCapacity" data-testid="at-capacity" class="text-sm rv-bad mt-3">
63 The timeline is at capacity right now — please try again shortly.
64 </p>
65 
66 <!-- Compliance disclosure, shown at the start of every run (Anthropic Usage
67 Policy: AI disclosure at session start; outputs may be inaccurate; an
68 adults-only posture). -->
69 <p data-testid="ai-disclosure" class="rv-faint text-xs leading-relaxed mt-8 pt-4 border-t rv-line max-w-2xl">
70 The historical figures here are played by AI, not real people, and every reply is
71 AI-generated <span class="rv-fg">alternate history — dramatized fiction, not historical fact</span>;
72 don't rely on it as accurate. Intended for adults (18+).
73 </p>
74 </div>
75</template>
76 
77<script setup lang="ts">
78/**
79 * MissionSelect — the opening briefing. Curated objectives + a live-composed one, as
80 * borderless hairline rows; selection uses identity, not titles. Nothing commits
81 * until Begin, so a freshly composed objective can be previewed and reconsidered.
82 */
83import { ref, shallowRef, computed } from 'vue'
84import { useGameStore } from '~/stores/game'
85import { listCuratedObjectives, type GameObjective } from '~/server/utils/objectives'
86 
87const gameStore = useGameStore()
88 
89const curated = listCuratedObjectives()
90// shallowRef, not ref: selection is by object identity (isSelected uses ===), and the
91// curated options are plain objects. A deep ref would return a *reactive proxy* on
92// `.value`, so `selected.value === obj` (raw) would be false — the row would highlight
93// on hover but its radio dot / aria-checked would never flip. shallowRef stores the
94// value as-is, keeping identity intact.
95const fresh = shallowRef<GameObjective | null>(null)
96const selected = shallowRef<GameObjective | null>(null)
97const composing = ref(false)
98const composeError = ref<string | null>(null)
99const beginning = ref(false)
100 
101const options = computed(() => (fresh.value ? [fresh.value, ...curated] : curated))
102 
103function isSelected(obj: GameObjective) {
104 return selected.value === obj
106 
107function select(obj: GameObjective) {
108 selected.value = obj
110 
111async function rollFresh() {
112 composing.value = true
113 composeError.value = null
114 const objective = await gameStore.fetchAIObjective()
115 composing.value = false
116 
117 if (objective) {
118 fresh.value = objective
119 selected.value = objective // auto-select the fresh one so Begin is one tap away
120 } else {
121 composeError.value = 'The archives went quiet — pick one above, or try composing again.'
122 }
124 
125async function begin() {
126 if (!selected.value || beginning.value) return
127 beginning.value = true
128 // Charges one run from the balance. If out of runs, chooseObjective raises the
129 // paywall (gameStore.outOfRuns) and opens the run-packs modal; no run starts.
130 await gameStore.chooseObjective(selected.value)
131 beginning.value = false
133</script>

The discriminating tests assert the new layer, not coverage. Every curated brief must carry a real date, read as bullet lines, and fit the cap; and the slavery brief's real dates must be present and distinct from its 1750 anchor — the conflation the issue warns against.

The grounded-brief contract: real date + bullets + cap, and anchorYear kept distinct.

tests/unit/server/utils/objective-generator.spec.ts · 71 lines
tests/unit/server/utils/objective-generator.spec.ts71 lines · TypeScript
⋯ 40 lines hidden (lines 1–40)
1import { describe, it, expect } from 'vitest'
2import { generateObjective } from '../../../../server/utils/objective-generator'
3import { listCuratedObjectives } from '../../../../server/utils/objectives'
4import { MAX_OBJECTIVE_DESC_CHARS } from '../../../../server/utils/validate'
5 
6describe('Objective Generator', () => {
7 it('returns a well-formed objective', async () => {
8 const objective = await generateObjective()
9 
10 expect(typeof objective.title).toBe('string')
11 expect(objective.title.length).toBeGreaterThan(0)
12 expect(typeof objective.description).toBe('string')
13 expect(objective.description.length).toBeGreaterThan(10)
14 expect(typeof objective.era).toBe('string')
15 expect(typeof objective.icon).toBe('string')
16 expect(objective.icon.length).toBeGreaterThan(0)
17 })
18 
19 it('draws from the curated pool by default (no API call)', async () => {
20 const titles = listCuratedObjectives().map(o => o.title)
21 const objective = await generateObjective()
22 expect(titles).toContain(objective.title)
23 })
24 
25 it('exposes a pool of several distinct objectives', () => {
26 const pool = listCuratedObjectives()
27 expect(pool.length).toBeGreaterThanOrEqual(5)
28 const titles = new Set(pool.map(o => o.title))
29 expect(titles.size).toBe(pool.length)
30 })
31 
32 it('offers variety across runs rather than a single hardcoded objective', async () => {
33 const seen = new Set<string>()
34 for (let i = 0; i < 40; i++) {
35 seen.add((await generateObjective()).title)
36 }
37 expect(seen.size).toBeGreaterThan(1)
38 })
39})
40 
41describe('Curated briefs are grounded (issue #82)', () => {
42 const pool = listCuratedObjectives()
43 
44 it('every brief states a real-timeline date and reads as bullet lines', () => {
45 for (const obj of pool) {
46 // A concrete date so a player who does not already hold one (non-US /
47 // non-Western) gets an anchor: every brief names at least one 3-4 digit
48 // year (1914, 476, 285, 221, …) or none of the issue's point is met.
49 expect(obj.description, `${obj.title}: brief needs a real date`).toMatch(/\d{3,4}/)
50 // Bulleted, multi-line — not the old single vague sentence.
51 expect(obj.description, `${obj.title}: brief should be bullet lines`).toContain('\n')
52 expect(obj.description, `${obj.title}: bullets start with "• "`).toContain('• ')
53 }
54 })
55 
56 it('every brief fits the (raised) description cap', () => {
57 for (const obj of pool) {
58 expect(obj.description.length, `${obj.title}: brief exceeds the cap`).toBeLessThanOrEqual(MAX_OBJECTIVE_DESC_CHARS)
59 }
60 })
61 
62 it('states the real abolition dates distinct from the in-game anchor (anchorYear is not the real date)', () => {
63 // Slavery is the canonical case the issue calls out: anchor 1750 is the
64 // counterfactual target, NOT when abolition actually happened. The brief must
65 // carry the real dates, proving the two are kept distinct, not conflated.
66 const slavery = pool.find(o => o.title === 'Abolish Slavery a Century Early')!
⋯ 5 lines hidden (lines 67–71)
67 expect(slavery.anchorYear).toBe(1750)
68 // The real abolition dates appear in the brief, and they are not the anchor.
69 expect(slavery.description).toMatch(/1807|1833|1865|1888/)
70 })
71})