What changed, and why

"Compose a fresh objective with AI" had a tell: it almost always handed back Save the Library of Alexandria. That title is one of the strongest counterfactual attractors a language model has, and it happens to be curated objective #3 — so the model was free to, and did, reproduce it. Worse, re-clicking compose could repeat objectives the player had already seen that session. The generator carried no exclusion list and no memory of prior rolls.

The fix has two halves. A session avoid-list (the titles already composed this run) now rides from the mission-select screen all the way into the compose prompt. And a de-dup guard rejects any composed title that collides with the curated pool or a seen title, rerolls once, then falls back to an unseen curated pick. The prompt is the soft steer; the guard is the hard backstop. Nothing else about objective generation changes, and the curated pool is still the always-available floor.

LayerFileRole in the fix
Domainserver/utils/objectives.tsNormalize key + an avoid-aware curated pick
Generatorserver/utils/objective-generator.tsThread the list, guard, prompt, fall back
Boundaryserver/utils/validate.tsBound the untrusted avoid-list off the wire
Routeserver/api/objective.get.tsRead + coerce the avoid query param
Clientstores/game.ts · components/MissionSelect.vueTrack seen titles, send them back

The comparison key and the unseen floor

Two small, pure additions to the client-safe domain module do the load-bearing work. normalizeObjectiveTitle folds a title to a comparison key, so a composed title that merely re-skins a curated one in case, punctuation, or accents still compares equal. Without it, "save the library of alexandria!" would slip straight past a naive string match.

Fold accents, lower-case, collapse non-alphanumerics to one space.

server/utils/objectives.ts · 233 lines
server/utils/objectives.ts233 lines · TypeScript
⋯ 163 lines hidden (lines 1–163)
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 },
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 
145/**
146 * Picks a curated objective at random, optionally excluding any whose normalized
147 * title is in `avoid` (#95) — so the curated FALLBACK never re-serves an objective
148 * already rolled this session. If the avoid-list somehow covers the whole pool, it
149 * falls back to the full pool: a curated objective always beats none.
150 */
151export function pickCurated(avoid: ReadonlySet<string> = new Set()): GameObjective {
152 const fresh = avoid.size
153 ? CURATED_OBJECTIVES.filter(o => !avoid.has(normalizeObjectiveTitle(o.title)))
154 : CURATED_OBJECTIVES
155 const pool = fresh.length ? fresh : CURATED_OBJECTIVES
156 return pool[Math.floor(Math.random() * pool.length)]
158 
159/** Exposed so the UI can offer the player a hand-picked objective if desired. */
160export function listCuratedObjectives(): GameObjective[] {
161 return [...CURATED_OBJECTIVES]
163 
164/**
165 * A title folded to a comparison key (#95): accents stripped, lower-cased, every run
166 * of non-alphanumeric characters collapsed to one space, trimmed. So "Save the
167 * Library of Alexandria!" and "save the library, of alexandria" compare equal —
168 * the anti-repeat guard catches a composed title that merely re-skins a curated or
169 * already-seen one in case or punctuation. Pure + exported so the guard is testable.
170 */
171export function normalizeObjectiveTitle(title: string): string {
172 return title
173 .normalize('NFD').replace(/\p{M}/gu, '') // fold accents: é → e
174 .toLowerCase()
175 .replace(/[^a-z0-9]+/g, ' ')
176 .trim()
⋯ 56 lines hidden (lines 178–233)
178 
179/**
180 * Steering for a fresh composition (#71). The player biases the model along two
181 * CLOSED enums — an era bucket and a theme — set before composing; "no preference"
182 * on an axis is simply its absence, leaving that axis to the model. Closed enums
183 * only, never free text: text the player typed would have to be committed verbatim
184 * to mean anything, and that would bypass the server's safety bounds + moderation
185 * (the hole #72's safely-historical floor must not have). So the player shapes
186 * INTENT; the generator owns the artifact.
187 *
188 * The labels double as the wire values and the prompt phrasing — one string per
189 * option, no value/label map to drift. Every era bucket sits at or before the
190 * safely-historical line (historical-floor.ts); there is deliberately no "present
191 * day" bucket to pick.
192 */
193export const OBJECTIVE_ERAS = [
194 'Antiquity',
195 'Classical',
196 'Medieval',
197 'Renaissance',
198 'Early Modern',
199 'Age of Revolutions / Industrial',
200 'Across the ages'
201] as const
202export type ObjectiveEra = typeof OBJECTIVE_ERAS[number]
203 
204export const OBJECTIVE_THEMES = [
205 'Science & Medicine',
206 'War & Peace',
207 'Power & Politics',
208 'Exploration',
209 'Culture & Ideas',
210 'Faith & Philosophy'
211] as const
212export type ObjectiveTheme = typeof OBJECTIVE_THEMES[number]
213 
214/** A composition steer — each axis present only when the player picked it. */
215export interface ObjectiveSteer {
216 era?: ObjectiveEra
217 theme?: ObjectiveTheme
219 
220/**
221 * The one gate from raw input to a trusted steer, shared by the API route (which
222 * sees query strings) and any caller. Anything not in the closed enum — a typo, an
223 * injected string, a stale value — drops to "no preference" rather than reaching
224 * the prompt, so the closed-enum guarantee has no client- or wire-side bypass.
225 */
226export function coerceSteer(era?: unknown, theme?: unknown): ObjectiveSteer {
227 const eras: readonly string[] = OBJECTIVE_ERAS
228 const themes: readonly string[] = OBJECTIVE_THEMES
229 return {
230 ...(typeof era === 'string' && eras.includes(era) ? { era: era as ObjectiveEra } : {}),
231 ...(typeof theme === 'string' && themes.includes(theme) ? { theme: theme as ObjectiveTheme } : {})
232 }

pickCurated gains an optional avoid-set. The fallback must never re-serve an objective the player already saw, yet it must always return something — so if the avoid-list somehow covers the whole pool, it falls back to the full pool. A curated objective always beats none.

Filter out avoided titles; never return nothing.

server/utils/objectives.ts · 233 lines
server/utils/objectives.ts233 lines · TypeScript
⋯ 144 lines hidden (lines 1–144)
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 },
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 
145/**
146 * Picks a curated objective at random, optionally excluding any whose normalized
147 * title is in `avoid` (#95) — so the curated FALLBACK never re-serves an objective
148 * already rolled this session. If the avoid-list somehow covers the whole pool, it
149 * falls back to the full pool: a curated objective always beats none.
150 */
151export function pickCurated(avoid: ReadonlySet<string> = new Set()): GameObjective {
152 const fresh = avoid.size
153 ? CURATED_OBJECTIVES.filter(o => !avoid.has(normalizeObjectiveTitle(o.title)))
154 : CURATED_OBJECTIVES
155 const pool = fresh.length ? fresh : CURATED_OBJECTIVES
156 return pool[Math.floor(Math.random() * pool.length)]
⋯ 76 lines hidden (lines 158–233)
158 
159/** Exposed so the UI can offer the player a hand-picked objective if desired. */
160export function listCuratedObjectives(): GameObjective[] {
161 return [...CURATED_OBJECTIVES]
163 
164/**
165 * A title folded to a comparison key (#95): accents stripped, lower-cased, every run
166 * of non-alphanumeric characters collapsed to one space, trimmed. So "Save the
167 * Library of Alexandria!" and "save the library, of alexandria" compare equal —
168 * the anti-repeat guard catches a composed title that merely re-skins a curated or
169 * already-seen one in case or punctuation. Pure + exported so the guard is testable.
170 */
171export function normalizeObjectiveTitle(title: string): string {
172 return title
173 .normalize('NFD').replace(/\p{M}/gu, '') // fold accents: é → e
174 .toLowerCase()
175 .replace(/[^a-z0-9]+/g, ' ')
176 .trim()
178 
179/**
180 * Steering for a fresh composition (#71). The player biases the model along two
181 * CLOSED enums — an era bucket and a theme — set before composing; "no preference"
182 * on an axis is simply its absence, leaving that axis to the model. Closed enums
183 * only, never free text: text the player typed would have to be committed verbatim
184 * to mean anything, and that would bypass the server's safety bounds + moderation
185 * (the hole #72's safely-historical floor must not have). So the player shapes
186 * INTENT; the generator owns the artifact.
187 *
188 * The labels double as the wire values and the prompt phrasing — one string per
189 * option, no value/label map to drift. Every era bucket sits at or before the
190 * safely-historical line (historical-floor.ts); there is deliberately no "present
191 * day" bucket to pick.
192 */
193export const OBJECTIVE_ERAS = [
194 'Antiquity',
195 'Classical',
196 'Medieval',
197 'Renaissance',
198 'Early Modern',
199 'Age of Revolutions / Industrial',
200 'Across the ages'
201] as const
202export type ObjectiveEra = typeof OBJECTIVE_ERAS[number]
203 
204export const OBJECTIVE_THEMES = [
205 'Science & Medicine',
206 'War & Peace',
207 'Power & Politics',
208 'Exploration',
209 'Culture & Ideas',
210 'Faith & Philosophy'
211] as const
212export type ObjectiveTheme = typeof OBJECTIVE_THEMES[number]
213 
214/** A composition steer — each axis present only when the player picked it. */
215export interface ObjectiveSteer {
216 era?: ObjectiveEra
217 theme?: ObjectiveTheme
219 
220/**
221 * The one gate from raw input to a trusted steer, shared by the API route (which
222 * sees query strings) and any caller. Anything not in the closed enum — a typo, an
223 * injected string, a stale value — drops to "no preference" rather than reaching
224 * the prompt, so the closed-enum guarantee has no client- or wire-side bypass.
225 */
226export function coerceSteer(era?: unknown, theme?: unknown): ObjectiveSteer {
227 const eras: readonly string[] = OBJECTIVE_ERAS
228 const themes: readonly string[] = OBJECTIVE_THEMES
229 return {
230 ...(typeof era === 'string' && eras.includes(era) ? { era: era as ObjectiveEra } : {}),
231 ...(typeof theme === 'string' && themes.includes(theme) ? { theme: theme as ObjectiveTheme } : {})
232 }

The generator: thread, guard, fall back

generateObjective takes a third argument now — avoid, the session-seen titles. It builds two sets from them. seen is the normalized session titles alone; dedupe is seen unioned with the whole curated pool. The distinction matters, and the callout below explains why.

Two sets, the reroll-once loop, the unseen-curated fallback.

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

Steering the prompt away from the greatest-hits

The hard guard catches collisions after the fact; the prompt tries to avoid them in the first place. The curated exemplars #87 introduced are now framed as territory already taken — match their tone, scale, and brief shape, but vary away in subject, era, and region. The session avoid-list, when there is one, is listed explicitly so the model steers around its own prior rolls.

Exemplars as vary-away-from, the avoid-list, a different-subject demand.

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

The session clause appears only once something's been rolled.

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

Bounding the untrusted avoid-list

The avoid-list is untrusted client input that reaches a model prompt, so it passes the same boundary discipline the rest of validate.ts applies. coerceAvoidTitles folds a repeated query key (an array) or a single value (a string) into a count-capped, per-title-length-capped list with empties dropped. A crafted request can't stuff the prompt or inflate token cost.

Reuses cleanArray/cleanString + the existing title cap; new count cap.

server/utils/validate.ts · 62 lines
server/utils/validate.ts62 lines · TypeScript
⋯ 49 lines hidden (lines 1–49)
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
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// The session avoid-list a fresh-objective compose carries (#95): the titles already
30// rolled this run, so the generator steers + de-dups away from them. A run composes a
31// handful at most; this cap keeps a crafted request from stuffing the prompt.
32export const MAX_AVOID_TITLES = 30
33 
34/** Coerce to a trimmed string capped at `max`; '' when not a usable string. */
35export function cleanString(value: unknown, max: number): string {
36 return typeof value === 'string' ? value.trim().slice(0, max) : ''
38 
39/** `Array.isArray(value)` ? a length-capped copy : [] — guards `.map`/`.filter`. */
40export function cleanArray(value: unknown, max: number): unknown[] {
41 return Array.isArray(value) ? value.slice(0, max) : []
43 
44/** A finite number rounded + clamped to ±`bound`; 0 otherwise. */
45export function clampInt(value: unknown, bound: number): number {
46 const n = typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : 0
47 return Math.max(-bound, Math.min(bound, n))
49 
50/**
51 * The fresh-objective avoid-list (#95) off the wire → a bounded array of trimmed
52 * titles. A repeated query key (`?avoid=A&avoid=B`) arrives as an array, a single one
53 * as a string; both fold to a count-capped, per-title-length-capped list with empties
54 * dropped, so a crafted request can't stuff the compose prompt. The curated half of
55 * the de-dup set is server-built and never trusted from the client.
56 */
57export function coerceAvoidTitles(raw: unknown): string[] {
58 const arr = Array.isArray(raw) ? raw : raw == null ? [] : [raw]
59 return cleanArray(arr, MAX_AVOID_TITLES)
60 .map(v => cleanString(v, MAX_OBJECTIVE_TITLE_CHARS))
61 .filter(Boolean)
⋯ 1 line hidden (lines 62–62)

The route's single trusted gate: coerce steer + avoid, then generate.

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

Carrying the memory on the client

The server is stateless per request, so the session memory lives on the client. MissionSelect keeps a small list of titles composed this run and pushes each new one onto it. The list resets for free: the component is mounted only while there's no chosen objective, so a new run remounts it empty.

seenTitles, fed into the compose call and grown after each success.

components/MissionSelect.vue · 183 lines
components/MissionSelect.vue183 lines · Vue
⋯ 127 lines hidden (lines 1–127)
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>
31 </span>
32 </button>
33 </div>
34 
35 <!-- Steer a fresh composition: two closed-enum dials, set before composing.
36 "No preference" leaves an axis to the model. Closed enums only (no free
37 text), so an out-of-bounds objective can't be steered into existence. -->
38 <fieldset class="mt-6 pt-4 border-t rv-line">
39 <legend class="rv-label">Steer a fresh composition <span class="rv-faint">· optional</span></legend>
40 <div class="mt-2 flex flex-col sm:flex-row gap-3 sm:gap-4">
41 <label class="flex-1 min-w-0 text-sm">
42 <span class="rv-label block mb-1">Era</span>
43 <select v-model="steerEra" data-testid="steer-era" class="rv-select text-sm" aria-label="Steer the era">
44 <option value="">No preference</option>
45 <option v-for="era in OBJECTIVE_ERAS" :key="era" :value="era">{{ era }}</option>
46 </select>
47 </label>
48 <label class="flex-1 min-w-0 text-sm">
49 <span class="rv-label block mb-1">Theme</span>
50 <select v-model="steerTheme" data-testid="steer-theme" class="rv-select text-sm" aria-label="Steer the theme">
51 <option value="">No preference</option>
52 <option v-for="theme in OBJECTIVE_THEMES" :key="theme" :value="theme">{{ theme }}</option>
53 </select>
54 </label>
55 </div>
56 </fieldset>
57 
58 <!-- Actions: compose a fresh objective · begin the run -->
59 <div class="mt-5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
60 <div class="flex flex-col items-start gap-1">
61 <button type="button" data-testid="compose-objective" class="rv-btn text-sm" :disabled="composing" @click="rollFresh">
62 <span aria-hidden="true"></span> {{ composing ? 'Composing a fresh fate…' : 'Compose a fresh objective with AI' }}
63 </button>
64 <p v-if="activeSteer" data-testid="active-steer" class="text-xs rv-muted">Steering: {{ activeSteer }}</p>
65 <p v-if="composeError" data-testid="compose-error" class="text-xs rv-bad">{{ composeError }}</p>
66 </div>
67 
68 <button type="button" data-testid="begin-button" class="rv-btn rv-btn--primary" :disabled="!selected || beginning" @click="begin">
69 {{ beginning ? 'Charting the timeline…' : 'Begin rewriting history' }} <span aria-hidden="true"></span>
70 </button>
71 </div>
72 
73 <!-- Paywall: the free run is spent. The run-packs modal opens automatically on
74 the refused commit; this persistent notice lets the player reopen it if they
75 dismissed it. -->
76 <div v-if="gameStore.outOfRuns" data-testid="paywall" class="mt-4 pt-4 border-t rv-line">
77 <p class="text-sm rv-fg font-semibold">You're out of runs — your free run is spent.</p>
78 <p class="rv-muted text-xs mt-0.5">Grab a pack to keep rewriting history. One-time, never expires.</p>
79 <button type="button" data-testid="open-packs" class="rv-btn rv-btn--primary mt-3"
80 @click="gameStore.openBuyModal()">
81 See run packs <span aria-hidden="true"></span>
82 </button>
83 </div>
84 
85 <!-- Capacity: the global spend cap paused new runs (in-progress runs continue). -->
86 <p v-else-if="gameStore.atCapacity" data-testid="at-capacity" class="text-sm rv-bad mt-3">
87 The timeline is at capacity right now — please try again shortly.
88 </p>
89 
90 <!-- Compliance disclosure, shown at the start of every run (Anthropic Usage
91 Policy: AI disclosure at session start; outputs may be inaccurate; an
92 adults-only posture). -->
93 <p data-testid="ai-disclosure" class="rv-faint text-xs leading-relaxed mt-8 pt-4 border-t rv-line max-w-2xl">
94 The historical figures here are played by AI, not real people, and every reply is
95 AI-generated <span class="rv-fg">alternate history — dramatized fiction, not historical fact</span>;
96 don't rely on it as accurate. Intended for adults (18+).
97 </p>
98 </div>
99</template>
100 
101<script setup lang="ts">
102/**
103 * MissionSelect — the opening briefing. Curated objectives + a live-composed one, as
104 * borderless hairline rows; selection uses identity, not titles. Nothing commits
105 * until Begin, so a freshly composed objective can be previewed and reconsidered.
106 */
107import { ref, shallowRef, computed } from 'vue'
108import { useGameStore } from '~/stores/game'
109import {
110 listCuratedObjectives,
111 OBJECTIVE_ERAS,
112 OBJECTIVE_THEMES,
113 type GameObjective,
114 type ObjectiveEra,
115 type ObjectiveTheme
116} from '~/server/utils/objectives'
117 
118const gameStore = useGameStore()
119 
120const curated = listCuratedObjectives()
121// shallowRef, not ref: selection is by object identity (isSelected uses ===), and the
122// curated options are plain objects. A deep ref would return a *reactive proxy* on
123// `.value`, so `selected.value === obj` (raw) would be false — the row would highlight
124// on hover but its radio dot / aria-checked would never flip. shallowRef stores the
125// value as-is, keeping identity intact.
126const fresh = shallowRef<GameObjective | null>(null)
127const selected = shallowRef<GameObjective | null>(null)
128// Titles composed this session, fed back as the avoid-list so each reroll lands
129// somewhere new (#95). Resets naturally: MissionSelect is mounted only while there's
130// no objective (v-if in play.vue), so a new run remounts it with an empty list.
131const seenTitles = ref<string[]>([])
132const composing = ref(false)
⋯ 22 lines hidden (lines 133–154)
133const composeError = ref<string | null>(null)
134const beginning = ref(false)
135 
136// Composition steer (closed enums; '' = no preference, left to the model).
137const steerEra = ref<ObjectiveEra | ''>('')
138const steerTheme = ref<ObjectiveTheme | ''>('')
139 
140const options = computed(() => (fresh.value ? [fresh.value, ...curated] : curated))
141 
142// A plain-language echo of the active steer, surfaced by the compose button.
143const activeSteer = computed(() =>
144 [steerEra.value, steerTheme.value].filter(Boolean).join(' · ') || null
146 
147function isSelected(obj: GameObjective) {
148 return selected.value === obj
150 
151function select(obj: GameObjective) {
152 selected.value = obj
154 
155async function rollFresh() {
156 composing.value = true
157 composeError.value = null
158 const objective = await gameStore.fetchAIObjective({
159 era: steerEra.value || undefined,
160 theme: steerTheme.value || undefined
161 }, seenTitles.value)
162 composing.value = false
163 
164 if (objective) {
165 fresh.value = objective
166 selected.value = objective // auto-select the fresh one so Begin is one tap away
167 // Remember it so the next reroll avoids it (a curated fallback lands here too,
168 // so even a fallback won't be re-served).
169 if (!seenTitles.value.includes(objective.title)) seenTitles.value.push(objective.title)
170 } else {
171 composeError.value = 'The archives went quiet — pick one above, or try composing again.'
172 }
⋯ 10 lines hidden (lines 174–183)
174 
175async function begin() {
176 if (!selected.value || beginning.value) return
177 beginning.value = true
178 // Charges one run from the balance. If out of runs, chooseObjective raises the
179 // paywall (gameStore.outOfRuns) and opens the run-packs modal; no run starts.
180 await gameStore.chooseObjective(selected.value)
181 beginning.value = false
183</script>

The store action simply forwards the list as a query param when it's non-empty, alongside the existing era/theme steer. A curated fallback's title gets recorded too, so even a fallback won't be re-served on the next reroll.

Optional avoid param, sent only when there's something to avoid.

stores/game.ts · 1297 lines
stores/game.ts1297 lines · TypeScript
⋯ 1140 lines hidden (lines 1–1140)
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. `avoid` is the titles already composed this session, so
1137 * a reroll lands somewhere new (#95) — the stateless server only knows what
1138 * the client supplies. Returns null rather than throwing if composing fails,
1139 * so the UI can quietly fall back to the curated pool.
1140 */
1141 async fetchAIObjective(steer: ObjectiveSteer = {}, avoid: string[] = []): Promise<GameObjective | null> {
1142 try {
1143 const query: Record<string, string | string[]> = {}
1144 if (steer.era) query.era = steer.era
1145 if (steer.theme) query.theme = steer.theme
1146 if (avoid.length) query.avoid = avoid
1147 const res = await $fetch('/api/objective', { method: 'GET', query, headers: await this.aiHeaders() }) as {
1148 success?: boolean
1149 objective?: GameObjective
1151 return res?.success && res.objective ? res.objective : null
⋯ 146 lines hidden (lines 1152–1297)
1152 } catch (error) {
1153 console.error('Failed to compose a fresh objective:', error)
1154 return null
1156 },
1158 /**
1159 * Loads the device's run balance for the header gauge + account popover.
1160 * Grants the free trial on a brand-new device (server-side). Graceful: on
1161 * failure it leaves the prior value (the gauge simply doesn't update).
1162 */
1163 async loadBalance(): Promise<void> {
1164 try {
1165 const res = await $fetch('/api/balance') as { runsRemaining?: number; freeRuns?: number; deviceRef?: string; email?: string | null; isAnonymous?: boolean }
1166 if (typeof res?.runsRemaining === 'number') this.runsRemaining = res.runsRemaining
1167 if (typeof res?.freeRuns === 'number') this.freeRuns = res.freeRuns
1168 if (typeof res?.deviceRef === 'string') this.deviceRef = res.deviceRef
1169 this.accountEmail = res?.email ?? null
1170 this.accountAnonymous = res?.isAnonymous === true
1171 } catch (error) {
1172 console.error('Failed to load balance:', error)
1174 },
1176 /** Opens the run-packs sales modal, loading the catalog first. */
1177 async openBuyModal(): Promise<void> {
1178 this.buyModalOpen = true
1179 await this.loadPacks()
1180 },
1182 /** Closes the sales modal. */
1183 closeBuyModal(): void {
1184 this.buyModalOpen = false
1185 },
1187 /** Records the Stripe-return outcome and refreshes the balance on success
1188 * (the webhook credits asynchronously; re-read so the gauge reflects it). */
1189 async notePurchaseReturn(outcome: 'success' | 'cancel'): Promise<void> {
1190 this.purchaseNotice = outcome
1191 this.buyModalOpen = false
1192 if (outcome === 'success') {
1193 this.outOfRuns = false
1194 await this.loadBalance()
1196 },
1198 /** Dismisses the post-purchase notice. */
1199 clearPurchaseNotice(): void {
1200 this.purchaseNotice = null
1201 },
1203 /** Loads the run-pack catalog for the paywall. Caches only a NON-empty
1204 * result: a transient empty/failed read must not poison the cache and
1205 * strand the modal with zero packs for the rest of the session. */
1206 async loadPacks(): Promise<void> {
1207 if (this.packs.length) return
1208 try {
1209 const res = await $fetch('/api/packs') as { packs?: Pack[] }
1210 const packs = res?.packs ?? []
1211 if (packs.length) this.packs = packs
1212 } catch (error) {
1213 console.error('Failed to load packs:', error)
1215 },
1217 /** Starts checkout for a pack; returns the Stripe Checkout URL to redirect
1218 * to (null on failure). The caller does the redirect. */
1219 async buyPack(packId: string): Promise<string | null> {
1220 try {
1221 const res = await $fetch('/api/checkout', {
1222 method: 'POST',
1223 headers: { 'Content-Type': 'application/json' },
1224 body: { packId }
1225 }) as { url?: string }
1226 return res?.url ?? null
1227 } catch (error) {
1228 console.error('Failed to start checkout:', error)
1229 return null
1231 },
1233 getVictoryEfficiency() {
1234 if (this.gameStatus === 'victory') {
1235 const messagesSaved = this.remainingMessages
1236 const messagesUsed = TOTAL_MESSAGES - this.remainingMessages
1237 const efficiencyPercentage = Math.round((messagesSaved / TOTAL_MESSAGES) * 100)
1239 const efficiencyRating = rateEfficiency(messagesSaved)
1241 return {
1242 messagesSaved,
1243 messagesUsed,
1244 efficiencyPercentage,
1245 efficiencyRating,
1246 isEarlyVictory: messagesSaved > 0
1249 return null
1250 },
1252 resetGame() {
1253 // Tear the epoch first: anything still in flight belongs to the old run
1254 // and must find no purchase here. (The seq counters deliberately survive.)
1255 this.runEpoch++
1256 this.remainingMessages = TOTAL_MESSAGES
1257 this.messageHistory = []
1258 this.gameStatus = 'playing'
1259 this.isLoading = false
1260 this.error = null
1261 this.lastMessageTime = null
1262 this.isRateLimited = false
1263 // A new run gets a fresh id on its next server call; abandon any
1264 // in-flight mint so a dead run's id can't land in the new run.
1265 this.runId = null
1266 this.runIdInflight = null
1267 // The paywall / capacity notices re-check on the next commit.
1268 this.outOfRuns = false
1269 this.atCapacity = false
1270 this.currentObjective = null
1271 this.objectiveProgress = 0
1272 this.momentum = 0
1273 this.timelineEvents = []
1274 this.figures = []
1275 this.activeFigureName = ''
1276 this.figureGrounding = null
1277 this.groundingLoading = false
1278 this.contactWhen = null
1279 this.contactMoment = null
1280 this.figureSuggestions = []
1281 this.suggestionsLoading = false
1282 this.suggestionsFor = ''
1283 this.chronicle = null
1284 this.chronicleLoading = false
1285 this.figureStudy = null
1286 this.studyLoading = false
1287 this.studyFor = ''
1288 this.studyWhen = null
1289 this.archiveResult = null
1290 this.lookupLoading = false
1291 this.moderationNotice = null
1292 this.archiveNotice = null
1293 this.studyNotice = null
1294 this.suggestionsNotice = null

What the tests prove

The suite pins each acceptance criterion to a discriminating test — one that would fail if the feature broke, not coverage padding. The gateway is mocked, so the unit tests are deterministic and key-free.

ClaimWhere it's proven
The avoid-list reaches the compose promptobjective-generator.spec.ts — asserts the seen titles appear in the user turn
A curated duplicate is rejected and rerolledsame — composes "Save the Library of Alexandria", expects the clean reroll
Case/punctuation re-skins still collidesame — composes "save the LIBRARY of alexandria!!!"
A session repeat is rejected and rerolledsame — passes a seen title via avoid
Both-collide falls back to an unseen curated picksame — asserts the result is curated and not the seen title
The normalize key + avoid-aware pickobjectives.spec.ts — folding, never-re-serve, always-yields
The wire input is boundedobjective.spec.tscoerceAvoidTitles array/string/junk/caps
The avoid-list rides the wire on a re-composemission-select.spec.ts (e2e) — second compose sends the first title