What changed, and why

"Compose a fresh objective with AI" already guarded against repeats — but only by normalized title. Fold the case and punctuation, compare as a set. That catches a verbatim or lightly-reskinned curated title, and nothing more. The same counterfactual under a new title sailed through: "Save the Library of Alexandria" and "Keep Alexandria's great library from the flames" read as two different goals.

This PR adds the check a title match can't do — a cheap model judge that decides whether a fresh candidate is essentially an existing objective reworded — plus a smarter reroll that tells the model what it just drew so the second attempt diverges.

Blast radius is small and server-only: one file carries the logic (server/utils/objective-generator.ts), one adds a routing lane, one adds an env knob. The route contract, the client, and the composed-objective shape are all unchanged. The whole thing is gated so it can be turned off or run in observe-only mode.

A new routing lane

Every model call in everwhen names a task in one routing table. The judge gets its own lane, objective-dedup, alongside the sibling bounds check: Haiku at temperature 0, a cheap classifier off the critical path. Haiku rejects the effort and thinking params, so — like its sibling — the lane carries neither. It is added to the AiTask union too, which forces the table to define it (the map is keyed by the union), so a missing route is a compile error, not a runtime surprise.

The new lane — same shape as the objective-check bounds classifier.

server/utils/ai-routing.ts · 270 lines
server/utils/ai-routing.ts270 lines · TypeScript
⋯ 212 lines hidden (lines 1–212)
1/**
2 * The per-task model routing table — the provider seam the go-to-market plan
3 * calls for (dispatch 1's bake-off lives behind this file). Every AI task names
4 * its lane and its full parameter payload here, because valid parameter sets
5 * differ by model family:
6 *
7 * - Haiku 4.5 rejects `effort` and has no adaptive thinking — omit both.
8 * - Sonnet 4.6 defaults `effort` to HIGH — always set it explicitly (this game
9 * wants reflexes, not deliberation). `temperature` is accepted.
10 * - Opus 4.8 rejects `temperature`/`top_p`/`top_k` — steer tone via prompts.
11 * Thinking is adaptive-only; omitting `thinking` runs without thinking.
12 *
13 * Lane defaults were decided by the two-lane bake-off plus the prompt-tuning
14 * campaign (evals/bakeoff.eval.ts, evals/prompt-tune.eval.ts; snapshots +
15 * verdict in evals/results/) — quality-first, with cost a close second. Every
16 * task runs the Anthropic lane: the prose tellings initially lost the blind
17 * pairwise 19-0 under the shared prompt, and the Claude-voice rewrite
18 * (buildChroniclePromptClaude) won them back on Sonnet. The OpenAI lane stays
19 * fully alive behind EVERWHEN_AI_LANE so a forced reverse migration is a
20 * config change, not a rewrite (the GTM's platform-risk counter).
21 */
22 
23export type AiTask =
24 | "judge"
25 | "character"
26 | "timeline"
27 | "reach-rater"
28 | "chronicler"
29 | "epilogue"
30 | "archivist-study"
31 | "archive-lookup"
32 | "objective"
33 | "objective-check"
34 | "objective-dedup"
35 | "suggester"
36 | "lifespan"
37 | "disambiguate"
38 | "sentinel";
39 
40/** 'fixture' is the dev-only deterministic lane (issue #105): a scripted structured
41 * output, no model call. Never selected by `routeFor` (the table is anthropic/openai
42 * only) — the gateway short-circuits to it when a dev fixture is installed, behind
43 * the dev gate. It exists in the union so telemetry can label a fixtured call. */
44export type AiLane = "anthropic" | "openai" | "fixture";
45 
46export interface AnthropicParams {
47 model: string;
48 maxTokens: number;
49 /** Omit on Opus-tier models — the API rejects sampling params there. */
50 temperature?: number;
51 /** Omit on Haiku 4.5 — the API rejects the effort parameter there.
52 * 'xhigh' exists on Opus 4.7+ and Fable 5 only. */
53 effort?: "low" | "medium" | "high" | "xhigh";
54 /** 'adaptive' turns thinking on (billed as output); omitted = no thinking. */
55 thinking?: "adaptive";
57 
58export interface OpenAiParams {
59 model: string;
60 /** gpt-5.5 bills hidden reasoning inside this cap — keep generous headroom. */
61 maxTokens: number;
62 reasoningEffort?: "low" | "medium" | "high";
64 
65export interface TaskRoute {
66 lane: AiLane;
67 anthropic: AnthropicParams;
68 openai: OpenAiParams;
70 
71/** The OpenAI lane's flagship — one place, like the old exported MODEL constant. */
72const GPT = "gpt-5.5";
73const OPENAI_DEFAULTS: OpenAiParams = {
74 model: GPT,
75 maxTokens: 1200,
76 reasoningEffort: "low",
77};
78 
79/**
80 * With thinking off, Anthropic's max_tokens is TRUE output budget — no hidden
81 * reasoning billed inside it — so caps are sized to real output shapes instead
82 * of the old padded 1200.
83 */
84export const AI_ROUTES: Record<AiTask, TaskRoute> = {
85 // A rubric classifier in the critical path of every dispatch (it runs before
86 // the die). Haiku for speed; the calibration eval is its gate.
87 // #164 (SETTLED 2026-06-28): the bake-off ran (judge-tune.eval.ts, anthropic lane)
88 // and KEPT Haiku-with-rubric over the once-planned Sonnet bump. With the #167 rubric
89 // nudge, Haiku makes the top grades reachable (masterful 78% of rung-0 excellent
90 // messages vs Sonnet's 33% — a LIVE top-craft reward, not a dead one), anchors the
91 // middle marginally better (mean dev 0.26 vs 0.30), and holds the discrimination
92 // floor (0 hard violations both). Bumping to Sonnet would have RE-DEADENED the
93 // masterful reward the +2 CRAFT_MODIFIER / 1.45x CRAFT_GAIN_FACTOR ride on. Verdict
94 // + numbers: evals/results/2026-06-28-judge-route-164.md.
95 judge: {
96 lane: "anthropic",
97 anthropic: { model: "claude-haiku-4-5", maxTokens: 300, temperature: 0 },
98 openai: OPENAI_DEFAULTS,
99 },
100 // The player converses with this output every turn — prose is product.
101 character: {
102 lane: "anthropic",
103 anthropic: {
104 model: "claude-sonnet-4-6",
105 maxTokens: 700,
106 temperature: 0.9,
107 effort: "low",
108 },
109 openai: OPENAI_DEFAULTS,
110 },
111 // The rules-critical judgment layer; code clamps swings into the rolled band,
112 // the model places the swing within it (anachronism is rated separately, by the
113 // reach-rater, and amplifies the result deterministically). Output is four small
114 // fields now (#163 moved anachronism out): a ~9-word headline, a 1-2 sentence
115 // detail, an integer swing, and a valence enum — well under 150 output tokens.
116 // maxTokens trimmed 600 -> 300 (#164) to fit that real shape; with thinking off
117 // it's a true output ceiling, and 300 still leaves >2x headroom. temp/effort
118 // unchanged — the last bake-off held them at 4/4 band ordering and a 7.0 neutral
119 // corridor, and no fresh signal moved them.
120 timeline: {
121 lane: "anthropic",
122 anthropic: {
123 model: "claude-sonnet-4-6",
124 maxTokens: 300,
125 temperature: 0.3,
126 effort: "medium",
127 },
128 openai: OPENAI_DEFAULTS,
129 },
130 // The anachronism reach-rater (issue #163): rates how far beyond the figure's
131 // own era a message reaches — message × era only, BLIND to goal, dice, progress,
132 // and ledger, so the wager tier can't flex with the outcome it amplifies. A cheap
133 // temp-0 Haiku classifier off the critical generation path (it runs alongside the
134 // Judge, before the roll); its tier feeds the deterministic anachronism amplifier.
135 // Re-confirmed post-split (#164): Haiku/temp-0 is the cheapest tier that rates
136 // reach reliably (the canonical classifier setting), and the output is two small
137 // fields — a one-phrase reach gloss plus the tier enum, ~40 output tokens — so the
138 // 200 cap stays (a true ceiling well clear of the real shape; trimming it saves no
139 // cost, only risks clipping the tier-anchoring reach phrase). Held as-is.
140 "reach-rater": {
141 lane: "anthropic",
142 anthropic: { model: "claude-haiku-4-5", maxTokens: 200, temperature: 0 },
143 openai: OPENAI_DEFAULTS,
144 },
145 // Re-narrates the whole timeline each turn. The original bake-off lost this
146 // slot 9-0 under the shared prompt; the Claude-voice rewrite (V2's
147 // transmission-chain bar, buildChroniclePromptClaude) won it back 8-5 in
148 // blind pairwise on Sonnet at ~1/4 the prior cost-per-telling — and the
149 // incumbent lane's quota outage made the flip availability-driven too.
150 // Higher-N confirmation pending (evals/results/2026-06-11-verdict.md).
151 chronicler: {
152 lane: "anthropic",
153 anthropic: {
154 model: "claude-sonnet-4-6",
155 maxTokens: 2000,
156 temperature: 1,
157 effort: "high",
158 },
159 openai: OPENAI_DEFAULTS,
160 },
161 // The final telling — the share artifact and the thing people pay for. The
162 // Claude-voice V1 prompt on SONNET beat the incumbent 16-3 across two
163 // dual-graded rounds (7-3, then 9-0 at N=12) — decisively confirmed, and
164 // cheaper than the Opus/Fable candidates it outscored. Sonnet over Opus is
165 // what the data said, twice; Fable won too but at 4x Sonnet's price for no
166 // measured gain over it.
167 epilogue: {
168 lane: "anthropic",
169 anthropic: {
170 model: "claude-sonnet-4-6",
171 maxTokens: 2000,
172 temperature: 1,
173 effort: "high",
174 },
175 openai: OPENAI_DEFAULTS,
176 },
177 // Feeds the player's wager calibration — accuracy matters.
178 "archivist-study": {
179 lane: "anthropic",
180 anthropic: {
181 model: "claude-sonnet-4-6",
182 maxTokens: 800,
183 temperature: 0.5,
184 effort: "low",
185 },
186 openai: OPENAI_DEFAULTS,
187 },
188 // Factual micro-answers with a known-since stamp.
189 "archive-lookup": {
190 lane: "anthropic",
191 anthropic: { model: "claude-haiku-4-5", maxTokens: 500, temperature: 0.3 },
192 openai: OPENAI_DEFAULTS,
193 },
194 // 0-1 calls per run; the objective steers every layer's valence.
195 objective: {
196 lane: "anthropic",
197 anthropic: {
198 model: "claude-sonnet-4-6",
199 maxTokens: 600,
200 temperature: 1,
201 effort: "low",
202 },
203 openai: OPENAI_DEFAULTS,
204 },
205 // Gates a freshly composed objective on the safely-historical bounds (#71): a
206 // cheap, temp-0 classifier (the figure-floor pattern) that catches the
207 // living-people / current-events framings a year bound alone can't. Haiku,
208 // off the critical generation path — at most twice per composition.
209 "objective-check": {
210 lane: "anthropic",
211 anthropic: { model: "claude-haiku-4-5", maxTokens: 200, temperature: 0 },
212 openai: OPENAI_DEFAULTS,
213 },
214 // Semantic de-dup of a freshly composed objective (#95 follow-up): a cheap
215 // temp-0 Haiku judge asked whether the candidate is essentially an existing
216 // objective (the curated pool + the session's seen titles) reworded — the
217 // near-duplicate a normalized-title match can't see. Off the critical path;
218 // at most twice per composition, and only for candidates the free title check
219 // already let through.
220 "objective-dedup": {
221 lane: "anthropic",
222 anthropic: { model: "claude-haiku-4-5", maxTokens: 200, temperature: 0 },
223 openai: OPENAI_DEFAULTS,
224 },
225 // Tool-using agent verified against free grounding, curated floor beneath it.
⋯ 45 lines hidden (lines 226–270)
226 // maxTokens here is per ROUND of the agent loop.
227 suggester: {
228 lane: "anthropic",
229 anthropic: { model: "claude-haiku-4-5", maxTokens: 1500, temperature: 0.7 },
230 openai: { model: GPT, maxTokens: 2500 },
231 },
232 // A dates classifier with hard validation guards behind it (issue #31 bridge).
233 lifespan: {
234 lane: "anthropic",
235 anthropic: { model: "claude-haiku-4-5", maxTokens: 300, temperature: 0 },
236 openai: OPENAI_DEFAULTS,
237 },
238 // Contextual name disambiguation (#186): picks the candidate that fits the run's
239 // objective/era from the same-name people the deterministic floor surfaced. A cheap
240 // temp-0 Haiku classifier, off the critical generation path (the interactive picker),
241 // with the most-notable pick as the floor beneath it. Output is two small fields.
242 disambiguate: {
243 lane: "anthropic",
244 anthropic: { model: "claude-haiku-4-5", maxTokens: 200, temperature: 0 },
245 openai: OPENAI_DEFAULTS,
246 },
247 // The safety Sentinel — classifies a whole exchange against the verbatim
248 // Usage Policy (server/utils/usage-policy.ts). Haiku at temp 0 for a
249 // consistent, cheap classifier, exactly the tier Anthropic's content-
250 // moderation guide recommends. Runs once per user action, never on the
251 // critical generation path's tokens.
252 sentinel: {
253 lane: "anthropic",
254 anthropic: { model: "claude-haiku-4-5", maxTokens: 400, temperature: 0 },
255 openai: OPENAI_DEFAULTS,
256 },
257};
258 
259/**
260 * Resolves a task to its lane + parameters. EVERWHEN_AI_LANE overrides the
261 * whole table ('openai' | 'anthropic') — the emergency lever, and how the eval
262 * matrix runs identical fixtures down each lane.
263 */
264export function routeFor(task: AiTask): { lane: AiLane; route: TaskRoute } {
265 const route = AI_ROUTES[task];
266 const override = process.env.EVERWHEN_AI_LANE;
267 const lane =
268 override === "openai" || override === "anthropic" ? override : route.lane;
269 return { lane, route };

Orchestration: the free check first, then two judges in parallel

generateObjective is the loop that composes, validates, and — at most once — rerolls before falling back to curated. Read it top to bottom:

- The free normalized-title set still runs first (line 114). An exact or reskinned collision short-circuits here and spends nothing — no bounds call, no judge call. - Only a candidate that clears that free check reaches the paid checks, and they run together (lines 119-122): objectiveWithinBounds (safety) and objectiveIsDuplicate (freshness), concurrently. Acceptance needs both to pass: inBounds && !dup.duplicate (line 123). - Whatever the reason for a rejection — out of bounds, or a duplicate — it is captured and pushed onto rejected (lines 129-133), which feeds the next attempt.

Compose → free-title check → parallel bounds+dedup → reroll, capped at 2 attempts.

server/utils/objective-generator.ts · 541 lines
server/utils/objective-generator.ts541 lines · TypeScript
⋯ 99 lines hidden (lines 1–99)
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 * - FRESH (#95) — a composition may reproduce neither the curated pool nor
21 * anything composed earlier this session. Two guards: a free normalized-title
22 * match catches verbatim / lightly-reskinned repeats, and a cheap Haiku judge
23 * (objectiveIsDuplicate) catches the semantic near-duplicate a title match
24 * can't see — the same objective, reworded. A rejected attempt is fed back into
25 * the single reroll so it diverges instead of re-drawing the same subject;
26 * a persistent collision falls back to an unseen curated pick.
27 */
28import {
29 pickCurated,
30 listCuratedObjectives,
31 normalizeObjectiveTitle,
32 type GameObjective,
33 type ObjectiveSteer,
34} from "./objectives";
35import { SAFELY_HISTORICAL_BEFORE_YEAR } from "./historical-floor";
36 
37/**
38 * What generateObjective produced: the objective plus its provenance. `composed`
39 * is true ONLY when the live AI path yielded this objective; it is false for the
40 * curated floor — both the default no-AI path and every AI fallback (a generation,
41 * bounds, or de-dup failure). The endpoint pairs `composed === false` with a
42 * requested steer to tell the player their steer was quietly dropped (#127), instead
43 * of letting a silent curated fallback read as "steering is broken".
44 */
45export interface GeneratedObjective {
46 objective: GameObjective;
47 composed: boolean;
49 
50/**
51 * A composed candidate this call already rejected (out of bounds or a duplicate),
52 * fed back into the reroll so the second attempt is told what NOT to draw again.
53 */
54interface RejectedComposition {
55 title: string;
56 description: string;
57 reason: string;
59 
60/** A semantic de-dup verdict: is the candidate an existing objective reworded? */
61interface DedupVerdict {
62 duplicate: boolean;
63 /** The nearest existing title the judge matched against (telemetry / the reroll). */
64 closestMatch: string;
65 /** One short clause on the call — surfaced to the reroll and the [objdedup] log. */
66 reason: string;
68 
69/**
70 * Returns an objective for a new run, plus whether the live AI path composed it (vs
71 * the curated floor). Curated-random by default (instant, no API call); pass
72 * useAI=true to compose one live under the given steer, with automatic fallback to
73 * curated. Never throws and never ships an out-of-bounds objective.
74 *
75 * `avoid` (#95) is the session's already-composed titles — the only anti-repeat
76 * memory the stateless server has (the client tracks and supplies them; the curated
77 * pool the server already knows). A composed result may reproduce NEITHER the
78 * curated pool NOR anything seen this session: a free normalized-title match catches
79 * exact / reskinned repeats, and a Haiku judge (objectiveIsDuplicate) catches the
80 * semantic near-duplicate a title match misses. A collision is rerolled once — the
81 * rejected attempt fed back so the reroll diverges — then the curated FALLBACK itself
82 * avoids the seen titles, so even the floor stays fresh.
83 */
84export async function generateObjective(
85 useAI = false,
86 steer: ObjectiveSteer = {},
87 avoid: string[] = [],
88): Promise<GeneratedObjective> {
89 // The curated pool — the far half of the free de-dup Set and the candidate list
90 // the semantic judge compares against. (The compose exemplars and the fallback
91 // floor re-read the pool through their own helpers.)
92 const curated = listCuratedObjectives();
93 // Session-seen titles, normalized — used both for the curated fallback (so it
94 // never re-serves a seen objective) and, unioned with the curated pool below,
95 // for the free post-generation collision guard.
96 const seen = new Set(avoid.map(normalizeObjectiveTitle).filter(Boolean));
97 const dedupe = new Set(seen);
98 for (const o of curated) dedupe.add(normalizeObjectiveTitle(o.title));
99 
100 if (useAI) {
101 // Never ship out of bounds OR a duplicate, never hard-fail the run: compose
102 // live, and on an out-of-bounds / colliding result reroll exactly once, then
103 // fall back to the (unseen) curated floor. Each rejected attempt is fed back
104 // into the reroll so the second draw diverges rather than repeating itself. A
105 // transport/parse failure won't heal on a reroll, so it drops straight to
106 // curated instead of a second call.
107 const rejected: RejectedComposition[] = [];
108 for (let attempt = 1; attempt <= 2; attempt++) {
109 try {
110 const candidate = await composeObjective(steer, avoid, rejected);
111 let reason: string;
112 // The free normalized-title match short-circuits ahead of the paid checks:
113 // an exact / reskinned collision never spends a bounds or de-dup call.
114 if (dedupe.has(normalizeObjectiveTitle(candidate.title))) {
115 reason = "duplicates an existing objective";
116 } else {
117 // Bounds (safety, fail-closed) and the semantic de-dup judge (freshness,
118 // fail-open) are independent — run them together; either one rejects.
119 const [inBounds, dup] = await Promise.all([
120 objectiveWithinBounds(candidate),
121 objectiveIsDuplicate(candidate, curated, avoid),
122 ]);
123 if (inBounds && !dup.duplicate)
124 return { objective: candidate, composed: true };
125 reason = !inBounds
126 ? "outside the safely-historical bounds"
127 : dup.reason || "too close to an existing objective";
128 }
129 rejected.push({
130 title: candidate.title,
131 description: candidate.description,
132 reason,
133 });
134 console.warn(
135 `AI objective rejected (${reason}) on attempt ${attempt} of 2; ${attempt < 2 ? "rerolling" : "falling back to curated"}`,
136 );
137 } catch (error) {
138 console.error(
139 "AI objective generation failed; falling back to curated pool:",
140 error,
141 );
142 break;
143 }
144 }
145 }
⋯ 396 lines hidden (lines 146–541)
146 // The curated floor — reached by the default no-AI path or any AI fallback.
147 return { objective: pickCurated(seen), composed: false };
149 
150/**
151 * Composes one fresh objective with the model under the steer, steering away from
152 * the session's seen titles and any attempt already rejected this call. Returns the
153 * same shape as the curated pool so the rest of the game treats them identically.
154 * Throws on an empty/malformed answer (the caller owns the fallback).
155 */
156async function composeObjective(
157 steer: ObjectiveSteer,
158 seenTitles: string[] = [],
159 rejected: RejectedComposition[] = [],
160): Promise<GameObjective> {
161 // Imported lazily so the curated (default) path never pulls the model SDKs
162 // into the bundle.
163 const { completeStructured } = await import("./ai-gateway");
164 
165 const content = await completeStructured({
166 task: "objective",
167 system: composeSystemPrompt(),
168 turns: [
169 { role: "user", content: composeUserPrompt(steer, seenTitles, rejected) },
170 ],
171 schemaName: "game_objective",
172 schema: {
173 type: "object",
174 properties: {
175 title: { type: "string", description: "Punchy objective title" },
176 description: {
177 type: "string",
178 description:
179 '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)',
180 },
181 era: { type: "string", description: "Anchoring era / setting" },
182 icon: {
183 type: "string",
184 description: "A single emoji that fits the objective",
185 },
186 anchorYear: {
187 type: ["integer", "null"],
188 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`,
189 },
190 },
191 required: ["title", "description", "era", "icon", "anchorYear"],
192 additionalProperties: false,
193 },
194 });
195 
196 if (!content) throw new Error("No objective generated");
197 
198 const parsed = JSON.parse(content) as GameObjective;
199 if (!parsed.title || !parsed.description)
200 throw new Error("Invalid objective format generated");
201 return {
202 title: parsed.title,
203 description: parsed.description,
204 era: parsed.era || "Across the ages",
205 icon: parsed.icon || "🕰️",
206 // A finite signed year anchors the chain dial; null / junk → no anchor (no-op).
207 anchorYear:
208 typeof parsed.anchorYear === "number" &&
209 Number.isFinite(parsed.anchorYear)
210 ? Math.round(parsed.anchorYear)
211 : undefined,
212 };
214 
215/**
216 * Validates a composed objective against the safely-historical bounds (#71) —
217 * the prompt is asked to obey them, but never trusted to. Three gates: a pure year
218 * bound, the same moderation seam messages pass through, and a structured subject
219 * check for the living-people / current-events framings a year bound can't see.
220 * Never throws. The year and subject gates fail CLOSED — an inconclusive result
221 * (junk, an empty answer, an outage) reads as out of bounds, so an unvetted
222 * composition falls back to curated rather than shipping. Moderation fails OPEN,
223 * the same fail-open-but-loud seam messages pass through (a safety classifier
224 * being down must never take the game down); it rejects only on an active flag.
225 */
226async function objectiveWithinBounds(o: GameObjective): Promise<boolean> {
227 // 1. Year bound — pure, deterministic, free. A finite anchor must sit at or
228 // before the safely-historical line; a null anchor (spans-all-history) is
229 // allowed, its content still gated by the subject check below.
230 if (
231 typeof o.anchorYear === "number" &&
232 o.anchorYear > SAFELY_HISTORICAL_BEFORE_YEAR
233 )
234 return false;
235 
236 const text = `${o.title}\n${o.description}`;
237 // 2 + 3 are independent model/detector calls — run them together; first "out"
238 // wins. Lazy import keeps moderation (and its SDKs) off the curated path.
239 const { hardBlockCheck } = await import("./moderation");
240 const [mod, subjectInBounds] = await Promise.all([
241 hardBlockCheck(text, "objective", "output"),
242 checkObjectiveSubject(o),
243 ]);
244 if (mod.blocked) return false;
245 return subjectInBounds;
247 
248/**
249 * The structured subject check (the figure-floor pattern, server/utils/
250 * lifespan-estimator.ts): a cheap classifier that gates the free-prose title +
251 * description a year bound can't reach — does this center on living people,
252 * contemporary figures, ongoing conflicts, or post-cutoff events? Fails CLOSED:
253 * an empty answer, junk, or an outage returns false, so an unvetted objective is
254 * never shipped (it falls back to the safe curated floor instead). Never throws.
255 */
256async function checkObjectiveSubject(o: GameObjective): Promise<boolean> {
257 const { completeStructured } = await import("./ai-gateway");
258 try {
259 const content = await completeStructured({
260 task: "objective-check",
261 system: boundsCheckSystemPrompt(),
262 turns: [
263 {
264 role: "user",
265 content: `Title: ${o.title}\nDescription: ${o.description}\nEra: ${o.era}\nAnchor year: ${o.anchorYear ?? "none (spans all history)"}`,
266 },
267 ],
268 schemaName: "objective_bounds",
269 schema: {
270 type: "object",
271 properties: {
272 withinBounds: {
273 type: "boolean",
274 description:
275 "True only when the subject is safely historical (settled past, no living people or current events)",
276 },
277 reason: {
278 type: "string",
279 description: "One short clause on the call",
280 },
281 },
282 required: ["withinBounds", "reason"],
283 additionalProperties: false,
284 },
285 });
286 if (!content) return false; // the model blipped — fail closed to the curated floor
287 return mapBoundsWire(JSON.parse(content)) === true;
288 } catch (error) {
289 console.error("Objective bounds check error:", error);
290 return false; // an outage must never let an unvetted objective through
291 }
293 
294/**
295 * The bounds-check wire → a clean verdict. `null` for an unusable shape (missing /
296 * non-boolean flag), which the caller treats exactly like "out of bounds". Exported
297 * pure so the guard is directly testable, like mapLifespanWire.
298 */
299export function mapBoundsWire(wire: unknown): boolean | null {
300 const w = wire as { withinBounds?: unknown } | null;
301 if (!w || typeof w.withinBounds !== "boolean") return null;
302 return w.withinBounds;
304 
305/**
306 * Semantic de-dup (#95 follow-up). The free normalized-title match in
307 * generateObjective catches verbatim / reskinned repeats; this catches the ones it
308 * can't — the SAME objective reworded under a fresh title ("Save the Library of
309 * Alexandria" vs "Keep Alexandria's great library from the flames"). A cheap Haiku
310 * judge compares the candidate against everything already on offer (the curated pool
311 * in full, plus the session's seen titles) and answers: is this essentially one of
312 * them again?
313 *
314 * Freshness, not safety, so this fails OPEN: an outage, empty answer, or junk verdict
315 * reads as "not a duplicate" and ships (the opposite stance from the bounds gate — a
316 * de-dup classifier being down must never cost the player a composition). Modes via
317 * EVERWHEN_OBJECTIVE_DEDUP mirror moderation: 'off' skips the call, 'shadow' runs and
318 * logs what it WOULD reject without acting, 'enforce' (default) rejects for real.
319 * Never throws.
320 */
321async function objectiveIsDuplicate(
322 candidate: GameObjective,
323 curated: GameObjective[],
324 seenTitles: string[],
325): Promise<DedupVerdict> {
326 const fresh: DedupVerdict = {
327 duplicate: false,
328 closestMatch: "",
329 reason: "",
330 };
331 const mode = objectiveDedupMode();
332 if (mode === "off") return fresh;
333 
334 let verdict: DedupVerdict;
335 try {
336 const { completeStructured } = await import("./ai-gateway");
337 const content = await completeStructured({
338 task: "objective-dedup",
339 system: dedupSystemPrompt(),
340 turns: [
341 {
342 role: "user",
343 content: dedupUserPrompt(candidate, curated, seenTitles),
344 },
345 ],
346 schemaName: "objective_dedup",
347 schema: {
348 type: "object",
349 properties: {
350 // Fact-retrieval before the decision it informs (schema order is
351 // load-bearing): name the nearest existing objective first, then judge.
352 closestMatch: {
353 type: "string",
354 description:
355 'Title of the nearest existing objective, or "none" if nothing is close',
356 },
357 duplicate: {
358 type: "boolean",
359 description:
360 "True only when the candidate is essentially that existing objective reworded — the same counterfactual, not merely the same era or theme",
361 },
362 reason: {
363 type: "string",
364 description: "One short clause on the call",
365 },
366 },
367 required: ["closestMatch", "duplicate", "reason"],
368 additionalProperties: false,
369 },
370 });
371 // Fail open on an unusable verdict — a de-dup blip must not drop a composition.
372 verdict = (content && mapDedupWire(JSON.parse(content))) || fresh;
373 } catch (error) {
374 console.error("Objective de-dup check error:", error);
375 return fresh; // freshness fails open — never take a composition down
376 }
377 
378 if (verdict.duplicate) {
379 emitObjDedup({
380 mode,
381 wouldReject: true,
382 enforced: mode === "enforce",
383 closestMatch: verdict.closestMatch,
384 candidate: candidate.title,
385 });
386 if (mode === "shadow") return fresh; // observe without biting — ship it
387 }
388 return verdict;
390 
391/** EVERWHEN_OBJECTIVE_DEDUP: off | shadow | enforce (default enforce), the same
392 * three-mode shape as EVERWHEN_MODERATION. */
393function objectiveDedupMode(): "off" | "shadow" | "enforce" {
394 const m = process.env.EVERWHEN_OBJECTIVE_DEDUP;
395 return m === "off" || m === "shadow" ? m : "enforce";
397 
398/** One greppable [objdedup] line per flagged duplicate — the calibration
399 * instrument for shadow mode. Quiet under tests, like [mod]. */
400function emitObjDedup(line: Record<string, unknown>): void {
401 if (!process.env.VITEST) console.info(`[objdedup] ${JSON.stringify(line)}`);
403 
404/**
405 * The de-dup wire → a clean verdict, or `null` for an unusable shape (a missing /
406 * non-boolean flag), which the caller treats as "not a duplicate" (fail open).
407 * Exported pure so the guard is directly testable, like mapBoundsWire.
408 */
409export function mapDedupWire(wire: unknown): DedupVerdict | null {
410 const w = wire as {
411 duplicate?: unknown;
412 closestMatch?: unknown;
413 reason?: unknown;
414 } | null;
415 if (!w || typeof w.duplicate !== "boolean") return null;
416 return {
417 duplicate: w.duplicate,
418 closestMatch: typeof w.closestMatch === "string" ? w.closestMatch : "",
419 reason: typeof w.reason === "string" ? w.reason : "",
420 };
422 
423/** System prompt for the semantic de-dup judge. */
424function dedupSystemPrompt(): string {
425 return `You are a de-dup check for a historical strategy game. You are given a freshly composed objective and the list of objectives already on offer. Decide whether the new one is essentially one of the existing objectives reworded.
426 
427Mark it a DUPLICATE (duplicate=true) when it aims at the SAME counterfactual as an existing objective — the same pivotal event, person, or outcome — even if the title, wording, era label, or framing differ ("Save the Library of Alexandria" and "Keep Alexandria's great library from the flames" are the same objective).
428 
429Mark it NOT a duplicate (duplicate=false) when it targets a genuinely different event, person, place, or outcome — even if it shares a broad theme (war, science, empire) or era with an existing one. Sharing a subject is not enough; aiming at the same specific turning point is.
430 
431Name the closest existing title in closestMatch (or "none"), then decide, then give one short reason.`;
433 
434/** De-dup user turn: the candidate, then everything already on offer (the curated
435 * pool in full, plus the session's seen titles). */
436function dedupUserPrompt(
437 candidate: GameObjective,
438 curated: GameObjective[],
439 seenTitles: string[],
440): string {
441 const existing = [
442 ...curated.map((o) => `${o.title}${o.description}`),
443 ...seenTitles.map((t) => `${t}`),
444 ].join("\n");
445 return `CANDIDATE objective:
446Title: ${candidate.title}
447Description: ${candidate.description}
448 
449Objectives ALREADY on offer:
450${existing}
451 
452Is the candidate essentially one of the objectives already on offer, reworded?`;
454 
455/** System prompt for composition: the tone bar, the grounding bar, and the
456 * non-negotiable safety bounds. */
457function composeSystemPrompt(): string {
458 return `You are a historian and game designer inventing grand, evocative counterfactual objectives for a game where players text historical figures to bend history.
459 
460Hold every objective to three bars:
461 
462TONE — sweeping and vivid, a whole world to remake in the spirit of the examples, never a small errand.
463 
464GROUNDING (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.
465 
466BOUNDS (safety, non-negotiable) — the objective must be safely historical:
467- Set in the settled past. Any pivotal anchor year must be at or before ${SAFELY_HISTORICAL_BEFORE_YEAR}.
468- 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.`;
470 
471/** Composition user turn: curated few-shot exemplars (framed as territory already
472 * taken, never a menu to copy), the session avoid-list, any attempt already
473 * rejected this call, the steer, and the ask. */
474function composeUserPrompt(
475 steer: ObjectiveSteer,
476 seenTitles: string[],
477 rejected: RejectedComposition[] = [],
478): string {
479 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"):
480${curatedExemplars()}
481${avoidClause(seenTitles)}${rejectedClause(rejected)}
482Invent 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)}
483 
484Give 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.
485 
486The 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.`;
488 
489/** System prompt for the structured subject check. */
490function boundsCheckSystemPrompt(): string {
491 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.
492 
493Mark it OUT OF BOUNDS (withinBounds=false) if it centers on, or would require contacting, any of:
494- a living person or a present-day public figure,
495- an ongoing conflict or a current event,
496- anything anchored after the year ${SAFELY_HISTORICAL_BEFORE_YEAR}.
497 
498Mark 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.
499 
500When you are unsure, answer withinBounds=false. Give a short reason either way.`;
502 
503/** The curated pool rendered as few-shot exemplars (title — description), read
504 * live from the pool so the bar can never drift from a hardcoded copy. */
505function curatedExemplars(): string {
506 return listCuratedObjectives()
507 .map((o) => `${o.title}${o.description}`)
508 .join("\n");
510 
511/** The session's already-composed titles, listed so the model steers away from
512 * repeating them (#95). Empty when nothing's been rolled yet — the first compose
513 * carries no clause, so an unseeded prompt stays identical to before. */
514function avoidClause(seenTitles: string[]): string {
515 if (!seenTitles.length) return "";
516 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`;
518 
519/** The attempt already rejected in THIS compose (the loop caps at 2, so at most one),
520 * listed so a reroll is told what not to draw again and diverges instead of
521 * reproducing what just failed. Empty on the first attempt, so an un-rerolled prompt
522 * stays byte-identical to before. */
523function rejectedClause(rejected: RejectedComposition[]): string {
524 if (!rejected.length) return "";
525 return `\nAlready rejected this compose — do not repeat or lightly reskin; go somewhere entirely different:\n${rejected.map((r) => `${r.title}${r.reason}`).join("\n")}\n`;
527 
528/** Turns the closed-enum steer into a short clause biasing the model. Absent axes
529 * simply don't appear, leaving them to the model ("no preference"). */
530function steerClause(steer: ObjectiveSteer): string {
531 const parts: string[] = [];
532 if (steer.era) {
533 parts.push(
534 steer.era === "Across the ages"
535 ? "Let it span the ages rather than a single era."
536 : `Anchor it in this stretch of history: ${steer.era}.`,
537 );
538 }
539 if (steer.theme) parts.push(`Center it on the theme of ${steer.theme}.`);
540 return parts.length ? ` ${parts.join(" ")}` : "";

The judge, and why it fails open

objectiveIsDuplicate is the heart of the change. Three things make it safe:

1. Mode gate (line 332). off returns before importing the gateway or spending a token. Only shadow/enforce make the call. 2. Fail open (line 372). content && mapDedupWire(JSON.parse(content)) || fresh — a null/empty answer short-circuits to fresh, a malformed body throws into the catch which also returns fresh, and an unusable shape maps to null which || fresh absorbs. Every failure path resolves to "not a duplicate." 3. Shadow observes without biting (line 386). When the verdict is a duplicate it logs a [objdedup] line, then — in shadow — returns fresh anyway, so the composition still ships. Only enforce returns the real verdict.

Signature + mode gate, then the fail-open tail (the JSON schema between is folded).

server/utils/objective-generator.ts · 541 lines
server/utils/objective-generator.ts541 lines · TypeScript
⋯ 320 lines hidden (lines 1–320)
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 * - FRESH (#95) — a composition may reproduce neither the curated pool nor
21 * anything composed earlier this session. Two guards: a free normalized-title
22 * match catches verbatim / lightly-reskinned repeats, and a cheap Haiku judge
23 * (objectiveIsDuplicate) catches the semantic near-duplicate a title match
24 * can't see — the same objective, reworded. A rejected attempt is fed back into
25 * the single reroll so it diverges instead of re-drawing the same subject;
26 * a persistent collision falls back to an unseen curated pick.
27 */
28import {
29 pickCurated,
30 listCuratedObjectives,
31 normalizeObjectiveTitle,
32 type GameObjective,
33 type ObjectiveSteer,
34} from "./objectives";
35import { SAFELY_HISTORICAL_BEFORE_YEAR } from "./historical-floor";
36 
37/**
38 * What generateObjective produced: the objective plus its provenance. `composed`
39 * is true ONLY when the live AI path yielded this objective; it is false for the
40 * curated floor — both the default no-AI path and every AI fallback (a generation,
41 * bounds, or de-dup failure). The endpoint pairs `composed === false` with a
42 * requested steer to tell the player their steer was quietly dropped (#127), instead
43 * of letting a silent curated fallback read as "steering is broken".
44 */
45export interface GeneratedObjective {
46 objective: GameObjective;
47 composed: boolean;
49 
50/**
51 * A composed candidate this call already rejected (out of bounds or a duplicate),
52 * fed back into the reroll so the second attempt is told what NOT to draw again.
53 */
54interface RejectedComposition {
55 title: string;
56 description: string;
57 reason: string;
59 
60/** A semantic de-dup verdict: is the candidate an existing objective reworded? */
61interface DedupVerdict {
62 duplicate: boolean;
63 /** The nearest existing title the judge matched against (telemetry / the reroll). */
64 closestMatch: string;
65 /** One short clause on the call — surfaced to the reroll and the [objdedup] log. */
66 reason: string;
68 
69/**
70 * Returns an objective for a new run, plus whether the live AI path composed it (vs
71 * the curated floor). Curated-random by default (instant, no API call); pass
72 * useAI=true to compose one live under the given steer, with automatic fallback to
73 * curated. Never throws and never ships an out-of-bounds objective.
74 *
75 * `avoid` (#95) is the session's already-composed titles — the only anti-repeat
76 * memory the stateless server has (the client tracks and supplies them; the curated
77 * pool the server already knows). A composed result may reproduce NEITHER the
78 * curated pool NOR anything seen this session: a free normalized-title match catches
79 * exact / reskinned repeats, and a Haiku judge (objectiveIsDuplicate) catches the
80 * semantic near-duplicate a title match misses. A collision is rerolled once — the
81 * rejected attempt fed back so the reroll diverges — then the curated FALLBACK itself
82 * avoids the seen titles, so even the floor stays fresh.
83 */
84export async function generateObjective(
85 useAI = false,
86 steer: ObjectiveSteer = {},
87 avoid: string[] = [],
88): Promise<GeneratedObjective> {
89 // The curated pool — the far half of the free de-dup Set and the candidate list
90 // the semantic judge compares against. (The compose exemplars and the fallback
91 // floor re-read the pool through their own helpers.)
92 const curated = listCuratedObjectives();
93 // Session-seen titles, normalized — used both for the curated fallback (so it
94 // never re-serves a seen objective) and, unioned with the curated pool below,
95 // for the free post-generation collision guard.
96 const seen = new Set(avoid.map(normalizeObjectiveTitle).filter(Boolean));
97 const dedupe = new Set(seen);
98 for (const o of curated) dedupe.add(normalizeObjectiveTitle(o.title));
99 
100 if (useAI) {
101 // Never ship out of bounds OR a duplicate, never hard-fail the run: compose
102 // live, and on an out-of-bounds / colliding result reroll exactly once, then
103 // fall back to the (unseen) curated floor. Each rejected attempt is fed back
104 // into the reroll so the second draw diverges rather than repeating itself. A
105 // transport/parse failure won't heal on a reroll, so it drops straight to
106 // curated instead of a second call.
107 const rejected: RejectedComposition[] = [];
108 for (let attempt = 1; attempt <= 2; attempt++) {
109 try {
110 const candidate = await composeObjective(steer, avoid, rejected);
111 let reason: string;
112 // The free normalized-title match short-circuits ahead of the paid checks:
113 // an exact / reskinned collision never spends a bounds or de-dup call.
114 if (dedupe.has(normalizeObjectiveTitle(candidate.title))) {
115 reason = "duplicates an existing objective";
116 } else {
117 // Bounds (safety, fail-closed) and the semantic de-dup judge (freshness,
118 // fail-open) are independent — run them together; either one rejects.
119 const [inBounds, dup] = await Promise.all([
120 objectiveWithinBounds(candidate),
121 objectiveIsDuplicate(candidate, curated, avoid),
122 ]);
123 if (inBounds && !dup.duplicate)
124 return { objective: candidate, composed: true };
125 reason = !inBounds
126 ? "outside the safely-historical bounds"
127 : dup.reason || "too close to an existing objective";
128 }
129 rejected.push({
130 title: candidate.title,
131 description: candidate.description,
132 reason,
133 });
134 console.warn(
135 `AI objective rejected (${reason}) on attempt ${attempt} of 2; ${attempt < 2 ? "rerolling" : "falling back to curated"}`,
136 );
137 } catch (error) {
138 console.error(
139 "AI objective generation failed; falling back to curated pool:",
140 error,
141 );
142 break;
143 }
144 }
145 }
146 // The curated floor — reached by the default no-AI path or any AI fallback.
147 return { objective: pickCurated(seen), composed: false };
149 
150/**
151 * Composes one fresh objective with the model under the steer, steering away from
152 * the session's seen titles and any attempt already rejected this call. Returns the
153 * same shape as the curated pool so the rest of the game treats them identically.
154 * Throws on an empty/malformed answer (the caller owns the fallback).
155 */
156async function composeObjective(
157 steer: ObjectiveSteer,
158 seenTitles: string[] = [],
159 rejected: RejectedComposition[] = [],
160): Promise<GameObjective> {
161 // Imported lazily so the curated (default) path never pulls the model SDKs
162 // into the bundle.
163 const { completeStructured } = await import("./ai-gateway");
164 
165 const content = await completeStructured({
166 task: "objective",
167 system: composeSystemPrompt(),
168 turns: [
169 { role: "user", content: composeUserPrompt(steer, seenTitles, rejected) },
170 ],
171 schemaName: "game_objective",
172 schema: {
173 type: "object",
174 properties: {
175 title: { type: "string", description: "Punchy objective title" },
176 description: {
177 type: "string",
178 description:
179 '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)',
180 },
181 era: { type: "string", description: "Anchoring era / setting" },
182 icon: {
183 type: "string",
184 description: "A single emoji that fits the objective",
185 },
186 anchorYear: {
187 type: ["integer", "null"],
188 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`,
189 },
190 },
191 required: ["title", "description", "era", "icon", "anchorYear"],
192 additionalProperties: false,
193 },
194 });
195 
196 if (!content) throw new Error("No objective generated");
197 
198 const parsed = JSON.parse(content) as GameObjective;
199 if (!parsed.title || !parsed.description)
200 throw new Error("Invalid objective format generated");
201 return {
202 title: parsed.title,
203 description: parsed.description,
204 era: parsed.era || "Across the ages",
205 icon: parsed.icon || "🕰️",
206 // A finite signed year anchors the chain dial; null / junk → no anchor (no-op).
207 anchorYear:
208 typeof parsed.anchorYear === "number" &&
209 Number.isFinite(parsed.anchorYear)
210 ? Math.round(parsed.anchorYear)
211 : undefined,
212 };
214 
215/**
216 * Validates a composed objective against the safely-historical bounds (#71) —
217 * the prompt is asked to obey them, but never trusted to. Three gates: a pure year
218 * bound, the same moderation seam messages pass through, and a structured subject
219 * check for the living-people / current-events framings a year bound can't see.
220 * Never throws. The year and subject gates fail CLOSED — an inconclusive result
221 * (junk, an empty answer, an outage) reads as out of bounds, so an unvetted
222 * composition falls back to curated rather than shipping. Moderation fails OPEN,
223 * the same fail-open-but-loud seam messages pass through (a safety classifier
224 * being down must never take the game down); it rejects only on an active flag.
225 */
226async function objectiveWithinBounds(o: GameObjective): Promise<boolean> {
227 // 1. Year bound — pure, deterministic, free. A finite anchor must sit at or
228 // before the safely-historical line; a null anchor (spans-all-history) is
229 // allowed, its content still gated by the subject check below.
230 if (
231 typeof o.anchorYear === "number" &&
232 o.anchorYear > SAFELY_HISTORICAL_BEFORE_YEAR
233 )
234 return false;
235 
236 const text = `${o.title}\n${o.description}`;
237 // 2 + 3 are independent model/detector calls — run them together; first "out"
238 // wins. Lazy import keeps moderation (and its SDKs) off the curated path.
239 const { hardBlockCheck } = await import("./moderation");
240 const [mod, subjectInBounds] = await Promise.all([
241 hardBlockCheck(text, "objective", "output"),
242 checkObjectiveSubject(o),
243 ]);
244 if (mod.blocked) return false;
245 return subjectInBounds;
247 
248/**
249 * The structured subject check (the figure-floor pattern, server/utils/
250 * lifespan-estimator.ts): a cheap classifier that gates the free-prose title +
251 * description a year bound can't reach — does this center on living people,
252 * contemporary figures, ongoing conflicts, or post-cutoff events? Fails CLOSED:
253 * an empty answer, junk, or an outage returns false, so an unvetted objective is
254 * never shipped (it falls back to the safe curated floor instead). Never throws.
255 */
256async function checkObjectiveSubject(o: GameObjective): Promise<boolean> {
257 const { completeStructured } = await import("./ai-gateway");
258 try {
259 const content = await completeStructured({
260 task: "objective-check",
261 system: boundsCheckSystemPrompt(),
262 turns: [
263 {
264 role: "user",
265 content: `Title: ${o.title}\nDescription: ${o.description}\nEra: ${o.era}\nAnchor year: ${o.anchorYear ?? "none (spans all history)"}`,
266 },
267 ],
268 schemaName: "objective_bounds",
269 schema: {
270 type: "object",
271 properties: {
272 withinBounds: {
273 type: "boolean",
274 description:
275 "True only when the subject is safely historical (settled past, no living people or current events)",
276 },
277 reason: {
278 type: "string",
279 description: "One short clause on the call",
280 },
281 },
282 required: ["withinBounds", "reason"],
283 additionalProperties: false,
284 },
285 });
286 if (!content) return false; // the model blipped — fail closed to the curated floor
287 return mapBoundsWire(JSON.parse(content)) === true;
288 } catch (error) {
289 console.error("Objective bounds check error:", error);
290 return false; // an outage must never let an unvetted objective through
291 }
293 
294/**
295 * The bounds-check wire → a clean verdict. `null` for an unusable shape (missing /
296 * non-boolean flag), which the caller treats exactly like "out of bounds". Exported
297 * pure so the guard is directly testable, like mapLifespanWire.
298 */
299export function mapBoundsWire(wire: unknown): boolean | null {
300 const w = wire as { withinBounds?: unknown } | null;
301 if (!w || typeof w.withinBounds !== "boolean") return null;
302 return w.withinBounds;
304 
305/**
306 * Semantic de-dup (#95 follow-up). The free normalized-title match in
307 * generateObjective catches verbatim / reskinned repeats; this catches the ones it
308 * can't — the SAME objective reworded under a fresh title ("Save the Library of
309 * Alexandria" vs "Keep Alexandria's great library from the flames"). A cheap Haiku
310 * judge compares the candidate against everything already on offer (the curated pool
311 * in full, plus the session's seen titles) and answers: is this essentially one of
312 * them again?
313 *
314 * Freshness, not safety, so this fails OPEN: an outage, empty answer, or junk verdict
315 * reads as "not a duplicate" and ships (the opposite stance from the bounds gate — a
316 * de-dup classifier being down must never cost the player a composition). Modes via
317 * EVERWHEN_OBJECTIVE_DEDUP mirror moderation: 'off' skips the call, 'shadow' runs and
318 * logs what it WOULD reject without acting, 'enforce' (default) rejects for real.
319 * Never throws.
320 */
321async function objectiveIsDuplicate(
322 candidate: GameObjective,
323 curated: GameObjective[],
324 seenTitles: string[],
325): Promise<DedupVerdict> {
326 const fresh: DedupVerdict = {
327 duplicate: false,
328 closestMatch: "",
329 reason: "",
330 };
331 const mode = objectiveDedupMode();
332 if (mode === "off") return fresh;
⋯ 37 lines hidden (lines 333–369)
333 
334 let verdict: DedupVerdict;
335 try {
336 const { completeStructured } = await import("./ai-gateway");
337 const content = await completeStructured({
338 task: "objective-dedup",
339 system: dedupSystemPrompt(),
340 turns: [
341 {
342 role: "user",
343 content: dedupUserPrompt(candidate, curated, seenTitles),
344 },
345 ],
346 schemaName: "objective_dedup",
347 schema: {
348 type: "object",
349 properties: {
350 // Fact-retrieval before the decision it informs (schema order is
351 // load-bearing): name the nearest existing objective first, then judge.
352 closestMatch: {
353 type: "string",
354 description:
355 'Title of the nearest existing objective, or "none" if nothing is close',
356 },
357 duplicate: {
358 type: "boolean",
359 description:
360 "True only when the candidate is essentially that existing objective reworded — the same counterfactual, not merely the same era or theme",
361 },
362 reason: {
363 type: "string",
364 description: "One short clause on the call",
365 },
366 },
367 required: ["closestMatch", "duplicate", "reason"],
368 additionalProperties: false,
369 },
370 });
371 // Fail open on an unusable verdict — a de-dup blip must not drop a composition.
372 verdict = (content && mapDedupWire(JSON.parse(content))) || fresh;
373 } catch (error) {
374 console.error("Objective de-dup check error:", error);
375 return fresh; // freshness fails open — never take a composition down
376 }
377 
378 if (verdict.duplicate) {
379 emitObjDedup({
380 mode,
381 wouldReject: true,
382 enforced: mode === "enforce",
383 closestMatch: verdict.closestMatch,
384 candidate: candidate.title,
385 });
386 if (mode === "shadow") return fresh; // observe without biting — ship it
387 }
388 return verdict;
⋯ 152 lines hidden (lines 390–541)
390 
391/** EVERWHEN_OBJECTIVE_DEDUP: off | shadow | enforce (default enforce), the same
392 * three-mode shape as EVERWHEN_MODERATION. */
393function objectiveDedupMode(): "off" | "shadow" | "enforce" {
394 const m = process.env.EVERWHEN_OBJECTIVE_DEDUP;
395 return m === "off" || m === "shadow" ? m : "enforce";
397 
398/** One greppable [objdedup] line per flagged duplicate — the calibration
399 * instrument for shadow mode. Quiet under tests, like [mod]. */
400function emitObjDedup(line: Record<string, unknown>): void {
401 if (!process.env.VITEST) console.info(`[objdedup] ${JSON.stringify(line)}`);
403 
404/**
405 * The de-dup wire → a clean verdict, or `null` for an unusable shape (a missing /
406 * non-boolean flag), which the caller treats as "not a duplicate" (fail open).
407 * Exported pure so the guard is directly testable, like mapBoundsWire.
408 */
409export function mapDedupWire(wire: unknown): DedupVerdict | null {
410 const w = wire as {
411 duplicate?: unknown;
412 closestMatch?: unknown;
413 reason?: unknown;
414 } | null;
415 if (!w || typeof w.duplicate !== "boolean") return null;
416 return {
417 duplicate: w.duplicate,
418 closestMatch: typeof w.closestMatch === "string" ? w.closestMatch : "",
419 reason: typeof w.reason === "string" ? w.reason : "",
420 };
422 
423/** System prompt for the semantic de-dup judge. */
424function dedupSystemPrompt(): string {
425 return `You are a de-dup check for a historical strategy game. You are given a freshly composed objective and the list of objectives already on offer. Decide whether the new one is essentially one of the existing objectives reworded.
426 
427Mark it a DUPLICATE (duplicate=true) when it aims at the SAME counterfactual as an existing objective — the same pivotal event, person, or outcome — even if the title, wording, era label, or framing differ ("Save the Library of Alexandria" and "Keep Alexandria's great library from the flames" are the same objective).
428 
429Mark it NOT a duplicate (duplicate=false) when it targets a genuinely different event, person, place, or outcome — even if it shares a broad theme (war, science, empire) or era with an existing one. Sharing a subject is not enough; aiming at the same specific turning point is.
430 
431Name the closest existing title in closestMatch (or "none"), then decide, then give one short reason.`;
433 
434/** De-dup user turn: the candidate, then everything already on offer (the curated
435 * pool in full, plus the session's seen titles). */
436function dedupUserPrompt(
437 candidate: GameObjective,
438 curated: GameObjective[],
439 seenTitles: string[],
440): string {
441 const existing = [
442 ...curated.map((o) => `${o.title}${o.description}`),
443 ...seenTitles.map((t) => `${t}`),
444 ].join("\n");
445 return `CANDIDATE objective:
446Title: ${candidate.title}
447Description: ${candidate.description}
448 
449Objectives ALREADY on offer:
450${existing}
451 
452Is the candidate essentially one of the objectives already on offer, reworded?`;
454 
455/** System prompt for composition: the tone bar, the grounding bar, and the
456 * non-negotiable safety bounds. */
457function composeSystemPrompt(): string {
458 return `You are a historian and game designer inventing grand, evocative counterfactual objectives for a game where players text historical figures to bend history.
459 
460Hold every objective to three bars:
461 
462TONE — sweeping and vivid, a whole world to remake in the spirit of the examples, never a small errand.
463 
464GROUNDING (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.
465 
466BOUNDS (safety, non-negotiable) — the objective must be safely historical:
467- Set in the settled past. Any pivotal anchor year must be at or before ${SAFELY_HISTORICAL_BEFORE_YEAR}.
468- 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.`;
470 
471/** Composition user turn: curated few-shot exemplars (framed as territory already
472 * taken, never a menu to copy), the session avoid-list, any attempt already
473 * rejected this call, the steer, and the ask. */
474function composeUserPrompt(
475 steer: ObjectiveSteer,
476 seenTitles: string[],
477 rejected: RejectedComposition[] = [],
478): string {
479 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"):
480${curatedExemplars()}
481${avoidClause(seenTitles)}${rejectedClause(rejected)}
482Invent 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)}
483 
484Give 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.
485 
486The 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.`;
488 
489/** System prompt for the structured subject check. */
490function boundsCheckSystemPrompt(): string {
491 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.
492 
493Mark it OUT OF BOUNDS (withinBounds=false) if it centers on, or would require contacting, any of:
494- a living person or a present-day public figure,
495- an ongoing conflict or a current event,
496- anything anchored after the year ${SAFELY_HISTORICAL_BEFORE_YEAR}.
497 
498Mark 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.
499 
500When you are unsure, answer withinBounds=false. Give a short reason either way.`;
502 
503/** The curated pool rendered as few-shot exemplars (title — description), read
504 * live from the pool so the bar can never drift from a hardcoded copy. */
505function curatedExemplars(): string {
506 return listCuratedObjectives()
507 .map((o) => `${o.title}${o.description}`)
508 .join("\n");
510 
511/** The session's already-composed titles, listed so the model steers away from
512 * repeating them (#95). Empty when nothing's been rolled yet — the first compose
513 * carries no clause, so an unseeded prompt stays identical to before. */
514function avoidClause(seenTitles: string[]): string {
515 if (!seenTitles.length) return "";
516 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`;
518 
519/** The attempt already rejected in THIS compose (the loop caps at 2, so at most one),
520 * listed so a reroll is told what not to draw again and diverges instead of
521 * reproducing what just failed. Empty on the first attempt, so an un-rerolled prompt
522 * stays byte-identical to before. */
523function rejectedClause(rejected: RejectedComposition[]): string {
524 if (!rejected.length) return "";
525 return `\nAlready rejected this compose — do not repeat or lightly reskin; go somewhere entirely different:\n${rejected.map((r) => `${r.title}${r.reason}`).join("\n")}\n`;
527 
528/** Turns the closed-enum steer into a short clause biasing the model. Absent axes
529 * simply don't appear, leaving them to the model ("no preference"). */
530function steerClause(steer: ObjectiveSteer): string {
531 const parts: string[] = [];
532 if (steer.era) {
533 parts.push(
534 steer.era === "Across the ages"
535 ? "Let it span the ages rather than a single era."
536 : `Anchor it in this stretch of history: ${steer.era}.`,
537 );
538 }
539 if (steer.theme) parts.push(`Center it on the theme of ${steer.theme}.`);
540 return parts.length ? ` ${parts.join(" ")}` : "";

The mode reader mirrors the moderation seam exactly — off | shadow | enforce, defaulting to enforce — and the wire guard is a pure, exported function so the fail-open mapping is unit-testable on its own.

EVERWHEN_OBJECTIVE_DEDUP — the same three-mode shape as EVERWHEN_MODERATION.

server/utils/objective-generator.ts · 541 lines
server/utils/objective-generator.ts541 lines · TypeScript
⋯ 390 lines hidden (lines 1–390)
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 * - FRESH (#95) — a composition may reproduce neither the curated pool nor
21 * anything composed earlier this session. Two guards: a free normalized-title
22 * match catches verbatim / lightly-reskinned repeats, and a cheap Haiku judge
23 * (objectiveIsDuplicate) catches the semantic near-duplicate a title match
24 * can't see — the same objective, reworded. A rejected attempt is fed back into
25 * the single reroll so it diverges instead of re-drawing the same subject;
26 * a persistent collision falls back to an unseen curated pick.
27 */
28import {
29 pickCurated,
30 listCuratedObjectives,
31 normalizeObjectiveTitle,
32 type GameObjective,
33 type ObjectiveSteer,
34} from "./objectives";
35import { SAFELY_HISTORICAL_BEFORE_YEAR } from "./historical-floor";
36 
37/**
38 * What generateObjective produced: the objective plus its provenance. `composed`
39 * is true ONLY when the live AI path yielded this objective; it is false for the
40 * curated floor — both the default no-AI path and every AI fallback (a generation,
41 * bounds, or de-dup failure). The endpoint pairs `composed === false` with a
42 * requested steer to tell the player their steer was quietly dropped (#127), instead
43 * of letting a silent curated fallback read as "steering is broken".
44 */
45export interface GeneratedObjective {
46 objective: GameObjective;
47 composed: boolean;
49 
50/**
51 * A composed candidate this call already rejected (out of bounds or a duplicate),
52 * fed back into the reroll so the second attempt is told what NOT to draw again.
53 */
54interface RejectedComposition {
55 title: string;
56 description: string;
57 reason: string;
59 
60/** A semantic de-dup verdict: is the candidate an existing objective reworded? */
61interface DedupVerdict {
62 duplicate: boolean;
63 /** The nearest existing title the judge matched against (telemetry / the reroll). */
64 closestMatch: string;
65 /** One short clause on the call — surfaced to the reroll and the [objdedup] log. */
66 reason: string;
68 
69/**
70 * Returns an objective for a new run, plus whether the live AI path composed it (vs
71 * the curated floor). Curated-random by default (instant, no API call); pass
72 * useAI=true to compose one live under the given steer, with automatic fallback to
73 * curated. Never throws and never ships an out-of-bounds objective.
74 *
75 * `avoid` (#95) is the session's already-composed titles — the only anti-repeat
76 * memory the stateless server has (the client tracks and supplies them; the curated
77 * pool the server already knows). A composed result may reproduce NEITHER the
78 * curated pool NOR anything seen this session: a free normalized-title match catches
79 * exact / reskinned repeats, and a Haiku judge (objectiveIsDuplicate) catches the
80 * semantic near-duplicate a title match misses. A collision is rerolled once — the
81 * rejected attempt fed back so the reroll diverges — then the curated FALLBACK itself
82 * avoids the seen titles, so even the floor stays fresh.
83 */
84export async function generateObjective(
85 useAI = false,
86 steer: ObjectiveSteer = {},
87 avoid: string[] = [],
88): Promise<GeneratedObjective> {
89 // The curated pool — the far half of the free de-dup Set and the candidate list
90 // the semantic judge compares against. (The compose exemplars and the fallback
91 // floor re-read the pool through their own helpers.)
92 const curated = listCuratedObjectives();
93 // Session-seen titles, normalized — used both for the curated fallback (so it
94 // never re-serves a seen objective) and, unioned with the curated pool below,
95 // for the free post-generation collision guard.
96 const seen = new Set(avoid.map(normalizeObjectiveTitle).filter(Boolean));
97 const dedupe = new Set(seen);
98 for (const o of curated) dedupe.add(normalizeObjectiveTitle(o.title));
99 
100 if (useAI) {
101 // Never ship out of bounds OR a duplicate, never hard-fail the run: compose
102 // live, and on an out-of-bounds / colliding result reroll exactly once, then
103 // fall back to the (unseen) curated floor. Each rejected attempt is fed back
104 // into the reroll so the second draw diverges rather than repeating itself. A
105 // transport/parse failure won't heal on a reroll, so it drops straight to
106 // curated instead of a second call.
107 const rejected: RejectedComposition[] = [];
108 for (let attempt = 1; attempt <= 2; attempt++) {
109 try {
110 const candidate = await composeObjective(steer, avoid, rejected);
111 let reason: string;
112 // The free normalized-title match short-circuits ahead of the paid checks:
113 // an exact / reskinned collision never spends a bounds or de-dup call.
114 if (dedupe.has(normalizeObjectiveTitle(candidate.title))) {
115 reason = "duplicates an existing objective";
116 } else {
117 // Bounds (safety, fail-closed) and the semantic de-dup judge (freshness,
118 // fail-open) are independent — run them together; either one rejects.
119 const [inBounds, dup] = await Promise.all([
120 objectiveWithinBounds(candidate),
121 objectiveIsDuplicate(candidate, curated, avoid),
122 ]);
123 if (inBounds && !dup.duplicate)
124 return { objective: candidate, composed: true };
125 reason = !inBounds
126 ? "outside the safely-historical bounds"
127 : dup.reason || "too close to an existing objective";
128 }
129 rejected.push({
130 title: candidate.title,
131 description: candidate.description,
132 reason,
133 });
134 console.warn(
135 `AI objective rejected (${reason}) on attempt ${attempt} of 2; ${attempt < 2 ? "rerolling" : "falling back to curated"}`,
136 );
137 } catch (error) {
138 console.error(
139 "AI objective generation failed; falling back to curated pool:",
140 error,
141 );
142 break;
143 }
144 }
145 }
146 // The curated floor — reached by the default no-AI path or any AI fallback.
147 return { objective: pickCurated(seen), composed: false };
149 
150/**
151 * Composes one fresh objective with the model under the steer, steering away from
152 * the session's seen titles and any attempt already rejected this call. Returns the
153 * same shape as the curated pool so the rest of the game treats them identically.
154 * Throws on an empty/malformed answer (the caller owns the fallback).
155 */
156async function composeObjective(
157 steer: ObjectiveSteer,
158 seenTitles: string[] = [],
159 rejected: RejectedComposition[] = [],
160): Promise<GameObjective> {
161 // Imported lazily so the curated (default) path never pulls the model SDKs
162 // into the bundle.
163 const { completeStructured } = await import("./ai-gateway");
164 
165 const content = await completeStructured({
166 task: "objective",
167 system: composeSystemPrompt(),
168 turns: [
169 { role: "user", content: composeUserPrompt(steer, seenTitles, rejected) },
170 ],
171 schemaName: "game_objective",
172 schema: {
173 type: "object",
174 properties: {
175 title: { type: "string", description: "Punchy objective title" },
176 description: {
177 type: "string",
178 description:
179 '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)',
180 },
181 era: { type: "string", description: "Anchoring era / setting" },
182 icon: {
183 type: "string",
184 description: "A single emoji that fits the objective",
185 },
186 anchorYear: {
187 type: ["integer", "null"],
188 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`,
189 },
190 },
191 required: ["title", "description", "era", "icon", "anchorYear"],
192 additionalProperties: false,
193 },
194 });
195 
196 if (!content) throw new Error("No objective generated");
197 
198 const parsed = JSON.parse(content) as GameObjective;
199 if (!parsed.title || !parsed.description)
200 throw new Error("Invalid objective format generated");
201 return {
202 title: parsed.title,
203 description: parsed.description,
204 era: parsed.era || "Across the ages",
205 icon: parsed.icon || "🕰️",
206 // A finite signed year anchors the chain dial; null / junk → no anchor (no-op).
207 anchorYear:
208 typeof parsed.anchorYear === "number" &&
209 Number.isFinite(parsed.anchorYear)
210 ? Math.round(parsed.anchorYear)
211 : undefined,
212 };
214 
215/**
216 * Validates a composed objective against the safely-historical bounds (#71) —
217 * the prompt is asked to obey them, but never trusted to. Three gates: a pure year
218 * bound, the same moderation seam messages pass through, and a structured subject
219 * check for the living-people / current-events framings a year bound can't see.
220 * Never throws. The year and subject gates fail CLOSED — an inconclusive result
221 * (junk, an empty answer, an outage) reads as out of bounds, so an unvetted
222 * composition falls back to curated rather than shipping. Moderation fails OPEN,
223 * the same fail-open-but-loud seam messages pass through (a safety classifier
224 * being down must never take the game down); it rejects only on an active flag.
225 */
226async function objectiveWithinBounds(o: GameObjective): Promise<boolean> {
227 // 1. Year bound — pure, deterministic, free. A finite anchor must sit at or
228 // before the safely-historical line; a null anchor (spans-all-history) is
229 // allowed, its content still gated by the subject check below.
230 if (
231 typeof o.anchorYear === "number" &&
232 o.anchorYear > SAFELY_HISTORICAL_BEFORE_YEAR
233 )
234 return false;
235 
236 const text = `${o.title}\n${o.description}`;
237 // 2 + 3 are independent model/detector calls — run them together; first "out"
238 // wins. Lazy import keeps moderation (and its SDKs) off the curated path.
239 const { hardBlockCheck } = await import("./moderation");
240 const [mod, subjectInBounds] = await Promise.all([
241 hardBlockCheck(text, "objective", "output"),
242 checkObjectiveSubject(o),
243 ]);
244 if (mod.blocked) return false;
245 return subjectInBounds;
247 
248/**
249 * The structured subject check (the figure-floor pattern, server/utils/
250 * lifespan-estimator.ts): a cheap classifier that gates the free-prose title +
251 * description a year bound can't reach — does this center on living people,
252 * contemporary figures, ongoing conflicts, or post-cutoff events? Fails CLOSED:
253 * an empty answer, junk, or an outage returns false, so an unvetted objective is
254 * never shipped (it falls back to the safe curated floor instead). Never throws.
255 */
256async function checkObjectiveSubject(o: GameObjective): Promise<boolean> {
257 const { completeStructured } = await import("./ai-gateway");
258 try {
259 const content = await completeStructured({
260 task: "objective-check",
261 system: boundsCheckSystemPrompt(),
262 turns: [
263 {
264 role: "user",
265 content: `Title: ${o.title}\nDescription: ${o.description}\nEra: ${o.era}\nAnchor year: ${o.anchorYear ?? "none (spans all history)"}`,
266 },
267 ],
268 schemaName: "objective_bounds",
269 schema: {
270 type: "object",
271 properties: {
272 withinBounds: {
273 type: "boolean",
274 description:
275 "True only when the subject is safely historical (settled past, no living people or current events)",
276 },
277 reason: {
278 type: "string",
279 description: "One short clause on the call",
280 },
281 },
282 required: ["withinBounds", "reason"],
283 additionalProperties: false,
284 },
285 });
286 if (!content) return false; // the model blipped — fail closed to the curated floor
287 return mapBoundsWire(JSON.parse(content)) === true;
288 } catch (error) {
289 console.error("Objective bounds check error:", error);
290 return false; // an outage must never let an unvetted objective through
291 }
293 
294/**
295 * The bounds-check wire → a clean verdict. `null` for an unusable shape (missing /
296 * non-boolean flag), which the caller treats exactly like "out of bounds". Exported
297 * pure so the guard is directly testable, like mapLifespanWire.
298 */
299export function mapBoundsWire(wire: unknown): boolean | null {
300 const w = wire as { withinBounds?: unknown } | null;
301 if (!w || typeof w.withinBounds !== "boolean") return null;
302 return w.withinBounds;
304 
305/**
306 * Semantic de-dup (#95 follow-up). The free normalized-title match in
307 * generateObjective catches verbatim / reskinned repeats; this catches the ones it
308 * can't — the SAME objective reworded under a fresh title ("Save the Library of
309 * Alexandria" vs "Keep Alexandria's great library from the flames"). A cheap Haiku
310 * judge compares the candidate against everything already on offer (the curated pool
311 * in full, plus the session's seen titles) and answers: is this essentially one of
312 * them again?
313 *
314 * Freshness, not safety, so this fails OPEN: an outage, empty answer, or junk verdict
315 * reads as "not a duplicate" and ships (the opposite stance from the bounds gate — a
316 * de-dup classifier being down must never cost the player a composition). Modes via
317 * EVERWHEN_OBJECTIVE_DEDUP mirror moderation: 'off' skips the call, 'shadow' runs and
318 * logs what it WOULD reject without acting, 'enforce' (default) rejects for real.
319 * Never throws.
320 */
321async function objectiveIsDuplicate(
322 candidate: GameObjective,
323 curated: GameObjective[],
324 seenTitles: string[],
325): Promise<DedupVerdict> {
326 const fresh: DedupVerdict = {
327 duplicate: false,
328 closestMatch: "",
329 reason: "",
330 };
331 const mode = objectiveDedupMode();
332 if (mode === "off") return fresh;
333 
334 let verdict: DedupVerdict;
335 try {
336 const { completeStructured } = await import("./ai-gateway");
337 const content = await completeStructured({
338 task: "objective-dedup",
339 system: dedupSystemPrompt(),
340 turns: [
341 {
342 role: "user",
343 content: dedupUserPrompt(candidate, curated, seenTitles),
344 },
345 ],
346 schemaName: "objective_dedup",
347 schema: {
348 type: "object",
349 properties: {
350 // Fact-retrieval before the decision it informs (schema order is
351 // load-bearing): name the nearest existing objective first, then judge.
352 closestMatch: {
353 type: "string",
354 description:
355 'Title of the nearest existing objective, or "none" if nothing is close',
356 },
357 duplicate: {
358 type: "boolean",
359 description:
360 "True only when the candidate is essentially that existing objective reworded — the same counterfactual, not merely the same era or theme",
361 },
362 reason: {
363 type: "string",
364 description: "One short clause on the call",
365 },
366 },
367 required: ["closestMatch", "duplicate", "reason"],
368 additionalProperties: false,
369 },
370 });
371 // Fail open on an unusable verdict — a de-dup blip must not drop a composition.
372 verdict = (content && mapDedupWire(JSON.parse(content))) || fresh;
373 } catch (error) {
374 console.error("Objective de-dup check error:", error);
375 return fresh; // freshness fails open — never take a composition down
376 }
377 
378 if (verdict.duplicate) {
379 emitObjDedup({
380 mode,
381 wouldReject: true,
382 enforced: mode === "enforce",
383 closestMatch: verdict.closestMatch,
384 candidate: candidate.title,
385 });
386 if (mode === "shadow") return fresh; // observe without biting — ship it
387 }
388 return verdict;
390 
391/** EVERWHEN_OBJECTIVE_DEDUP: off | shadow | enforce (default enforce), the same
392 * three-mode shape as EVERWHEN_MODERATION. */
393function objectiveDedupMode(): "off" | "shadow" | "enforce" {
394 const m = process.env.EVERWHEN_OBJECTIVE_DEDUP;
395 return m === "off" || m === "shadow" ? m : "enforce";
⋯ 145 lines hidden (lines 397–541)
397 
398/** One greppable [objdedup] line per flagged duplicate — the calibration
399 * instrument for shadow mode. Quiet under tests, like [mod]. */
400function emitObjDedup(line: Record<string, unknown>): void {
401 if (!process.env.VITEST) console.info(`[objdedup] ${JSON.stringify(line)}`);
403 
404/**
405 * The de-dup wire → a clean verdict, or `null` for an unusable shape (a missing /
406 * non-boolean flag), which the caller treats as "not a duplicate" (fail open).
407 * Exported pure so the guard is directly testable, like mapBoundsWire.
408 */
409export function mapDedupWire(wire: unknown): DedupVerdict | null {
410 const w = wire as {
411 duplicate?: unknown;
412 closestMatch?: unknown;
413 reason?: unknown;
414 } | null;
415 if (!w || typeof w.duplicate !== "boolean") return null;
416 return {
417 duplicate: w.duplicate,
418 closestMatch: typeof w.closestMatch === "string" ? w.closestMatch : "",
419 reason: typeof w.reason === "string" ? w.reason : "",
420 };
422 
423/** System prompt for the semantic de-dup judge. */
424function dedupSystemPrompt(): string {
425 return `You are a de-dup check for a historical strategy game. You are given a freshly composed objective and the list of objectives already on offer. Decide whether the new one is essentially one of the existing objectives reworded.
426 
427Mark it a DUPLICATE (duplicate=true) when it aims at the SAME counterfactual as an existing objective — the same pivotal event, person, or outcome — even if the title, wording, era label, or framing differ ("Save the Library of Alexandria" and "Keep Alexandria's great library from the flames" are the same objective).
428 
429Mark it NOT a duplicate (duplicate=false) when it targets a genuinely different event, person, place, or outcome — even if it shares a broad theme (war, science, empire) or era with an existing one. Sharing a subject is not enough; aiming at the same specific turning point is.
430 
431Name the closest existing title in closestMatch (or "none"), then decide, then give one short reason.`;
433 
434/** De-dup user turn: the candidate, then everything already on offer (the curated
435 * pool in full, plus the session's seen titles). */
436function dedupUserPrompt(
437 candidate: GameObjective,
438 curated: GameObjective[],
439 seenTitles: string[],
440): string {
441 const existing = [
442 ...curated.map((o) => `${o.title}${o.description}`),
443 ...seenTitles.map((t) => `${t}`),
444 ].join("\n");
445 return `CANDIDATE objective:
446Title: ${candidate.title}
447Description: ${candidate.description}
448 
449Objectives ALREADY on offer:
450${existing}
451 
452Is the candidate essentially one of the objectives already on offer, reworded?`;
454 
455/** System prompt for composition: the tone bar, the grounding bar, and the
456 * non-negotiable safety bounds. */
457function composeSystemPrompt(): string {
458 return `You are a historian and game designer inventing grand, evocative counterfactual objectives for a game where players text historical figures to bend history.
459 
460Hold every objective to three bars:
461 
462TONE — sweeping and vivid, a whole world to remake in the spirit of the examples, never a small errand.
463 
464GROUNDING (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.
465 
466BOUNDS (safety, non-negotiable) — the objective must be safely historical:
467- Set in the settled past. Any pivotal anchor year must be at or before ${SAFELY_HISTORICAL_BEFORE_YEAR}.
468- 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.`;
470 
471/** Composition user turn: curated few-shot exemplars (framed as territory already
472 * taken, never a menu to copy), the session avoid-list, any attempt already
473 * rejected this call, the steer, and the ask. */
474function composeUserPrompt(
475 steer: ObjectiveSteer,
476 seenTitles: string[],
477 rejected: RejectedComposition[] = [],
478): string {
479 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"):
480${curatedExemplars()}
481${avoidClause(seenTitles)}${rejectedClause(rejected)}
482Invent 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)}
483 
484Give 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.
485 
486The 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.`;
488 
489/** System prompt for the structured subject check. */
490function boundsCheckSystemPrompt(): string {
491 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.
492 
493Mark it OUT OF BOUNDS (withinBounds=false) if it centers on, or would require contacting, any of:
494- a living person or a present-day public figure,
495- an ongoing conflict or a current event,
496- anything anchored after the year ${SAFELY_HISTORICAL_BEFORE_YEAR}.
497 
498Mark 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.
499 
500When you are unsure, answer withinBounds=false. Give a short reason either way.`;
502 
503/** The curated pool rendered as few-shot exemplars (title — description), read
504 * live from the pool so the bar can never drift from a hardcoded copy. */
505function curatedExemplars(): string {
506 return listCuratedObjectives()
507 .map((o) => `${o.title}${o.description}`)
508 .join("\n");
510 
511/** The session's already-composed titles, listed so the model steers away from
512 * repeating them (#95). Empty when nothing's been rolled yet — the first compose
513 * carries no clause, so an unseeded prompt stays identical to before. */
514function avoidClause(seenTitles: string[]): string {
515 if (!seenTitles.length) return "";
516 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`;
518 
519/** The attempt already rejected in THIS compose (the loop caps at 2, so at most one),
520 * listed so a reroll is told what not to draw again and diverges instead of
521 * reproducing what just failed. Empty on the first attempt, so an un-rerolled prompt
522 * stays byte-identical to before. */
523function rejectedClause(rejected: RejectedComposition[]): string {
524 if (!rejected.length) return "";
525 return `\nAlready rejected this compose — do not repeat or lightly reskin; go somewhere entirely different:\n${rejected.map((r) => `${r.title}${r.reason}`).join("\n")}\n`;
527 
528/** Turns the closed-enum steer into a short clause biasing the model. Absent axes
529 * simply don't appear, leaving them to the model ("no preference"). */
530function steerClause(steer: ObjectiveSteer): string {
531 const parts: string[] = [];
532 if (steer.era) {
533 parts.push(
534 steer.era === "Across the ages"
535 ? "Let it span the ages rather than a single era."
536 : `Anchor it in this stretch of history: ${steer.era}.`,
537 );
538 }
539 if (steer.theme) parts.push(`Center it on the theme of ${steer.theme}.`);
540 return parts.length ? ` ${parts.join(" ")}` : "";

mapDedupWire: null on any unusable shape → the caller treats it as not-a-duplicate.

server/utils/objective-generator.ts · 541 lines
server/utils/objective-generator.ts541 lines · TypeScript
⋯ 403 lines hidden (lines 1–403)
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 * - FRESH (#95) — a composition may reproduce neither the curated pool nor
21 * anything composed earlier this session. Two guards: a free normalized-title
22 * match catches verbatim / lightly-reskinned repeats, and a cheap Haiku judge
23 * (objectiveIsDuplicate) catches the semantic near-duplicate a title match
24 * can't see — the same objective, reworded. A rejected attempt is fed back into
25 * the single reroll so it diverges instead of re-drawing the same subject;
26 * a persistent collision falls back to an unseen curated pick.
27 */
28import {
29 pickCurated,
30 listCuratedObjectives,
31 normalizeObjectiveTitle,
32 type GameObjective,
33 type ObjectiveSteer,
34} from "./objectives";
35import { SAFELY_HISTORICAL_BEFORE_YEAR } from "./historical-floor";
36 
37/**
38 * What generateObjective produced: the objective plus its provenance. `composed`
39 * is true ONLY when the live AI path yielded this objective; it is false for the
40 * curated floor — both the default no-AI path and every AI fallback (a generation,
41 * bounds, or de-dup failure). The endpoint pairs `composed === false` with a
42 * requested steer to tell the player their steer was quietly dropped (#127), instead
43 * of letting a silent curated fallback read as "steering is broken".
44 */
45export interface GeneratedObjective {
46 objective: GameObjective;
47 composed: boolean;
49 
50/**
51 * A composed candidate this call already rejected (out of bounds or a duplicate),
52 * fed back into the reroll so the second attempt is told what NOT to draw again.
53 */
54interface RejectedComposition {
55 title: string;
56 description: string;
57 reason: string;
59 
60/** A semantic de-dup verdict: is the candidate an existing objective reworded? */
61interface DedupVerdict {
62 duplicate: boolean;
63 /** The nearest existing title the judge matched against (telemetry / the reroll). */
64 closestMatch: string;
65 /** One short clause on the call — surfaced to the reroll and the [objdedup] log. */
66 reason: string;
68 
69/**
70 * Returns an objective for a new run, plus whether the live AI path composed it (vs
71 * the curated floor). Curated-random by default (instant, no API call); pass
72 * useAI=true to compose one live under the given steer, with automatic fallback to
73 * curated. Never throws and never ships an out-of-bounds objective.
74 *
75 * `avoid` (#95) is the session's already-composed titles — the only anti-repeat
76 * memory the stateless server has (the client tracks and supplies them; the curated
77 * pool the server already knows). A composed result may reproduce NEITHER the
78 * curated pool NOR anything seen this session: a free normalized-title match catches
79 * exact / reskinned repeats, and a Haiku judge (objectiveIsDuplicate) catches the
80 * semantic near-duplicate a title match misses. A collision is rerolled once — the
81 * rejected attempt fed back so the reroll diverges — then the curated FALLBACK itself
82 * avoids the seen titles, so even the floor stays fresh.
83 */
84export async function generateObjective(
85 useAI = false,
86 steer: ObjectiveSteer = {},
87 avoid: string[] = [],
88): Promise<GeneratedObjective> {
89 // The curated pool — the far half of the free de-dup Set and the candidate list
90 // the semantic judge compares against. (The compose exemplars and the fallback
91 // floor re-read the pool through their own helpers.)
92 const curated = listCuratedObjectives();
93 // Session-seen titles, normalized — used both for the curated fallback (so it
94 // never re-serves a seen objective) and, unioned with the curated pool below,
95 // for the free post-generation collision guard.
96 const seen = new Set(avoid.map(normalizeObjectiveTitle).filter(Boolean));
97 const dedupe = new Set(seen);
98 for (const o of curated) dedupe.add(normalizeObjectiveTitle(o.title));
99 
100 if (useAI) {
101 // Never ship out of bounds OR a duplicate, never hard-fail the run: compose
102 // live, and on an out-of-bounds / colliding result reroll exactly once, then
103 // fall back to the (unseen) curated floor. Each rejected attempt is fed back
104 // into the reroll so the second draw diverges rather than repeating itself. A
105 // transport/parse failure won't heal on a reroll, so it drops straight to
106 // curated instead of a second call.
107 const rejected: RejectedComposition[] = [];
108 for (let attempt = 1; attempt <= 2; attempt++) {
109 try {
110 const candidate = await composeObjective(steer, avoid, rejected);
111 let reason: string;
112 // The free normalized-title match short-circuits ahead of the paid checks:
113 // an exact / reskinned collision never spends a bounds or de-dup call.
114 if (dedupe.has(normalizeObjectiveTitle(candidate.title))) {
115 reason = "duplicates an existing objective";
116 } else {
117 // Bounds (safety, fail-closed) and the semantic de-dup judge (freshness,
118 // fail-open) are independent — run them together; either one rejects.
119 const [inBounds, dup] = await Promise.all([
120 objectiveWithinBounds(candidate),
121 objectiveIsDuplicate(candidate, curated, avoid),
122 ]);
123 if (inBounds && !dup.duplicate)
124 return { objective: candidate, composed: true };
125 reason = !inBounds
126 ? "outside the safely-historical bounds"
127 : dup.reason || "too close to an existing objective";
128 }
129 rejected.push({
130 title: candidate.title,
131 description: candidate.description,
132 reason,
133 });
134 console.warn(
135 `AI objective rejected (${reason}) on attempt ${attempt} of 2; ${attempt < 2 ? "rerolling" : "falling back to curated"}`,
136 );
137 } catch (error) {
138 console.error(
139 "AI objective generation failed; falling back to curated pool:",
140 error,
141 );
142 break;
143 }
144 }
145 }
146 // The curated floor — reached by the default no-AI path or any AI fallback.
147 return { objective: pickCurated(seen), composed: false };
149 
150/**
151 * Composes one fresh objective with the model under the steer, steering away from
152 * the session's seen titles and any attempt already rejected this call. Returns the
153 * same shape as the curated pool so the rest of the game treats them identically.
154 * Throws on an empty/malformed answer (the caller owns the fallback).
155 */
156async function composeObjective(
157 steer: ObjectiveSteer,
158 seenTitles: string[] = [],
159 rejected: RejectedComposition[] = [],
160): Promise<GameObjective> {
161 // Imported lazily so the curated (default) path never pulls the model SDKs
162 // into the bundle.
163 const { completeStructured } = await import("./ai-gateway");
164 
165 const content = await completeStructured({
166 task: "objective",
167 system: composeSystemPrompt(),
168 turns: [
169 { role: "user", content: composeUserPrompt(steer, seenTitles, rejected) },
170 ],
171 schemaName: "game_objective",
172 schema: {
173 type: "object",
174 properties: {
175 title: { type: "string", description: "Punchy objective title" },
176 description: {
177 type: "string",
178 description:
179 '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)',
180 },
181 era: { type: "string", description: "Anchoring era / setting" },
182 icon: {
183 type: "string",
184 description: "A single emoji that fits the objective",
185 },
186 anchorYear: {
187 type: ["integer", "null"],
188 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`,
189 },
190 },
191 required: ["title", "description", "era", "icon", "anchorYear"],
192 additionalProperties: false,
193 },
194 });
195 
196 if (!content) throw new Error("No objective generated");
197 
198 const parsed = JSON.parse(content) as GameObjective;
199 if (!parsed.title || !parsed.description)
200 throw new Error("Invalid objective format generated");
201 return {
202 title: parsed.title,
203 description: parsed.description,
204 era: parsed.era || "Across the ages",
205 icon: parsed.icon || "🕰️",
206 // A finite signed year anchors the chain dial; null / junk → no anchor (no-op).
207 anchorYear:
208 typeof parsed.anchorYear === "number" &&
209 Number.isFinite(parsed.anchorYear)
210 ? Math.round(parsed.anchorYear)
211 : undefined,
212 };
214 
215/**
216 * Validates a composed objective against the safely-historical bounds (#71) —
217 * the prompt is asked to obey them, but never trusted to. Three gates: a pure year
218 * bound, the same moderation seam messages pass through, and a structured subject
219 * check for the living-people / current-events framings a year bound can't see.
220 * Never throws. The year and subject gates fail CLOSED — an inconclusive result
221 * (junk, an empty answer, an outage) reads as out of bounds, so an unvetted
222 * composition falls back to curated rather than shipping. Moderation fails OPEN,
223 * the same fail-open-but-loud seam messages pass through (a safety classifier
224 * being down must never take the game down); it rejects only on an active flag.
225 */
226async function objectiveWithinBounds(o: GameObjective): Promise<boolean> {
227 // 1. Year bound — pure, deterministic, free. A finite anchor must sit at or
228 // before the safely-historical line; a null anchor (spans-all-history) is
229 // allowed, its content still gated by the subject check below.
230 if (
231 typeof o.anchorYear === "number" &&
232 o.anchorYear > SAFELY_HISTORICAL_BEFORE_YEAR
233 )
234 return false;
235 
236 const text = `${o.title}\n${o.description}`;
237 // 2 + 3 are independent model/detector calls — run them together; first "out"
238 // wins. Lazy import keeps moderation (and its SDKs) off the curated path.
239 const { hardBlockCheck } = await import("./moderation");
240 const [mod, subjectInBounds] = await Promise.all([
241 hardBlockCheck(text, "objective", "output"),
242 checkObjectiveSubject(o),
243 ]);
244 if (mod.blocked) return false;
245 return subjectInBounds;
247 
248/**
249 * The structured subject check (the figure-floor pattern, server/utils/
250 * lifespan-estimator.ts): a cheap classifier that gates the free-prose title +
251 * description a year bound can't reach — does this center on living people,
252 * contemporary figures, ongoing conflicts, or post-cutoff events? Fails CLOSED:
253 * an empty answer, junk, or an outage returns false, so an unvetted objective is
254 * never shipped (it falls back to the safe curated floor instead). Never throws.
255 */
256async function checkObjectiveSubject(o: GameObjective): Promise<boolean> {
257 const { completeStructured } = await import("./ai-gateway");
258 try {
259 const content = await completeStructured({
260 task: "objective-check",
261 system: boundsCheckSystemPrompt(),
262 turns: [
263 {
264 role: "user",
265 content: `Title: ${o.title}\nDescription: ${o.description}\nEra: ${o.era}\nAnchor year: ${o.anchorYear ?? "none (spans all history)"}`,
266 },
267 ],
268 schemaName: "objective_bounds",
269 schema: {
270 type: "object",
271 properties: {
272 withinBounds: {
273 type: "boolean",
274 description:
275 "True only when the subject is safely historical (settled past, no living people or current events)",
276 },
277 reason: {
278 type: "string",
279 description: "One short clause on the call",
280 },
281 },
282 required: ["withinBounds", "reason"],
283 additionalProperties: false,
284 },
285 });
286 if (!content) return false; // the model blipped — fail closed to the curated floor
287 return mapBoundsWire(JSON.parse(content)) === true;
288 } catch (error) {
289 console.error("Objective bounds check error:", error);
290 return false; // an outage must never let an unvetted objective through
291 }
293 
294/**
295 * The bounds-check wire → a clean verdict. `null` for an unusable shape (missing /
296 * non-boolean flag), which the caller treats exactly like "out of bounds". Exported
297 * pure so the guard is directly testable, like mapLifespanWire.
298 */
299export function mapBoundsWire(wire: unknown): boolean | null {
300 const w = wire as { withinBounds?: unknown } | null;
301 if (!w || typeof w.withinBounds !== "boolean") return null;
302 return w.withinBounds;
304 
305/**
306 * Semantic de-dup (#95 follow-up). The free normalized-title match in
307 * generateObjective catches verbatim / reskinned repeats; this catches the ones it
308 * can't — the SAME objective reworded under a fresh title ("Save the Library of
309 * Alexandria" vs "Keep Alexandria's great library from the flames"). A cheap Haiku
310 * judge compares the candidate against everything already on offer (the curated pool
311 * in full, plus the session's seen titles) and answers: is this essentially one of
312 * them again?
313 *
314 * Freshness, not safety, so this fails OPEN: an outage, empty answer, or junk verdict
315 * reads as "not a duplicate" and ships (the opposite stance from the bounds gate — a
316 * de-dup classifier being down must never cost the player a composition). Modes via
317 * EVERWHEN_OBJECTIVE_DEDUP mirror moderation: 'off' skips the call, 'shadow' runs and
318 * logs what it WOULD reject without acting, 'enforce' (default) rejects for real.
319 * Never throws.
320 */
321async function objectiveIsDuplicate(
322 candidate: GameObjective,
323 curated: GameObjective[],
324 seenTitles: string[],
325): Promise<DedupVerdict> {
326 const fresh: DedupVerdict = {
327 duplicate: false,
328 closestMatch: "",
329 reason: "",
330 };
331 const mode = objectiveDedupMode();
332 if (mode === "off") return fresh;
333 
334 let verdict: DedupVerdict;
335 try {
336 const { completeStructured } = await import("./ai-gateway");
337 const content = await completeStructured({
338 task: "objective-dedup",
339 system: dedupSystemPrompt(),
340 turns: [
341 {
342 role: "user",
343 content: dedupUserPrompt(candidate, curated, seenTitles),
344 },
345 ],
346 schemaName: "objective_dedup",
347 schema: {
348 type: "object",
349 properties: {
350 // Fact-retrieval before the decision it informs (schema order is
351 // load-bearing): name the nearest existing objective first, then judge.
352 closestMatch: {
353 type: "string",
354 description:
355 'Title of the nearest existing objective, or "none" if nothing is close',
356 },
357 duplicate: {
358 type: "boolean",
359 description:
360 "True only when the candidate is essentially that existing objective reworded — the same counterfactual, not merely the same era or theme",
361 },
362 reason: {
363 type: "string",
364 description: "One short clause on the call",
365 },
366 },
367 required: ["closestMatch", "duplicate", "reason"],
368 additionalProperties: false,
369 },
370 });
371 // Fail open on an unusable verdict — a de-dup blip must not drop a composition.
372 verdict = (content && mapDedupWire(JSON.parse(content))) || fresh;
373 } catch (error) {
374 console.error("Objective de-dup check error:", error);
375 return fresh; // freshness fails open — never take a composition down
376 }
377 
378 if (verdict.duplicate) {
379 emitObjDedup({
380 mode,
381 wouldReject: true,
382 enforced: mode === "enforce",
383 closestMatch: verdict.closestMatch,
384 candidate: candidate.title,
385 });
386 if (mode === "shadow") return fresh; // observe without biting — ship it
387 }
388 return verdict;
390 
391/** EVERWHEN_OBJECTIVE_DEDUP: off | shadow | enforce (default enforce), the same
392 * three-mode shape as EVERWHEN_MODERATION. */
393function objectiveDedupMode(): "off" | "shadow" | "enforce" {
394 const m = process.env.EVERWHEN_OBJECTIVE_DEDUP;
395 return m === "off" || m === "shadow" ? m : "enforce";
397 
398/** One greppable [objdedup] line per flagged duplicate — the calibration
399 * instrument for shadow mode. Quiet under tests, like [mod]. */
400function emitObjDedup(line: Record<string, unknown>): void {
401 if (!process.env.VITEST) console.info(`[objdedup] ${JSON.stringify(line)}`);
403 
404/**
405 * The de-dup wire → a clean verdict, or `null` for an unusable shape (a missing /
406 * non-boolean flag), which the caller treats as "not a duplicate" (fail open).
407 * Exported pure so the guard is directly testable, like mapBoundsWire.
408 */
409export function mapDedupWire(wire: unknown): DedupVerdict | null {
410 const w = wire as {
411 duplicate?: unknown;
412 closestMatch?: unknown;
413 reason?: unknown;
414 } | null;
415 if (!w || typeof w.duplicate !== "boolean") return null;
416 return {
417 duplicate: w.duplicate,
418 closestMatch: typeof w.closestMatch === "string" ? w.closestMatch : "",
419 reason: typeof w.reason === "string" ? w.reason : "",
420 };
⋯ 120 lines hidden (lines 422–541)
422 
423/** System prompt for the semantic de-dup judge. */
424function dedupSystemPrompt(): string {
425 return `You are a de-dup check for a historical strategy game. You are given a freshly composed objective and the list of objectives already on offer. Decide whether the new one is essentially one of the existing objectives reworded.
426 
427Mark it a DUPLICATE (duplicate=true) when it aims at the SAME counterfactual as an existing objective — the same pivotal event, person, or outcome — even if the title, wording, era label, or framing differ ("Save the Library of Alexandria" and "Keep Alexandria's great library from the flames" are the same objective).
428 
429Mark it NOT a duplicate (duplicate=false) when it targets a genuinely different event, person, place, or outcome — even if it shares a broad theme (war, science, empire) or era with an existing one. Sharing a subject is not enough; aiming at the same specific turning point is.
430 
431Name the closest existing title in closestMatch (or "none"), then decide, then give one short reason.`;
433 
434/** De-dup user turn: the candidate, then everything already on offer (the curated
435 * pool in full, plus the session's seen titles). */
436function dedupUserPrompt(
437 candidate: GameObjective,
438 curated: GameObjective[],
439 seenTitles: string[],
440): string {
441 const existing = [
442 ...curated.map((o) => `${o.title}${o.description}`),
443 ...seenTitles.map((t) => `${t}`),
444 ].join("\n");
445 return `CANDIDATE objective:
446Title: ${candidate.title}
447Description: ${candidate.description}
448 
449Objectives ALREADY on offer:
450${existing}
451 
452Is the candidate essentially one of the objectives already on offer, reworded?`;
454 
455/** System prompt for composition: the tone bar, the grounding bar, and the
456 * non-negotiable safety bounds. */
457function composeSystemPrompt(): string {
458 return `You are a historian and game designer inventing grand, evocative counterfactual objectives for a game where players text historical figures to bend history.
459 
460Hold every objective to three bars:
461 
462TONE — sweeping and vivid, a whole world to remake in the spirit of the examples, never a small errand.
463 
464GROUNDING (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.
465 
466BOUNDS (safety, non-negotiable) — the objective must be safely historical:
467- Set in the settled past. Any pivotal anchor year must be at or before ${SAFELY_HISTORICAL_BEFORE_YEAR}.
468- 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.`;
470 
471/** Composition user turn: curated few-shot exemplars (framed as territory already
472 * taken, never a menu to copy), the session avoid-list, any attempt already
473 * rejected this call, the steer, and the ask. */
474function composeUserPrompt(
475 steer: ObjectiveSteer,
476 seenTitles: string[],
477 rejected: RejectedComposition[] = [],
478): string {
479 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"):
480${curatedExemplars()}
481${avoidClause(seenTitles)}${rejectedClause(rejected)}
482Invent 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)}
483 
484Give 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.
485 
486The 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.`;
488 
489/** System prompt for the structured subject check. */
490function boundsCheckSystemPrompt(): string {
491 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.
492 
493Mark it OUT OF BOUNDS (withinBounds=false) if it centers on, or would require contacting, any of:
494- a living person or a present-day public figure,
495- an ongoing conflict or a current event,
496- anything anchored after the year ${SAFELY_HISTORICAL_BEFORE_YEAR}.
497 
498Mark 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.
499 
500When you are unsure, answer withinBounds=false. Give a short reason either way.`;
502 
503/** The curated pool rendered as few-shot exemplars (title — description), read
504 * live from the pool so the bar can never drift from a hardcoded copy. */
505function curatedExemplars(): string {
506 return listCuratedObjectives()
507 .map((o) => `${o.title}${o.description}`)
508 .join("\n");
510 
511/** The session's already-composed titles, listed so the model steers away from
512 * repeating them (#95). Empty when nothing's been rolled yet — the first compose
513 * carries no clause, so an unseeded prompt stays identical to before. */
514function avoidClause(seenTitles: string[]): string {
515 if (!seenTitles.length) return "";
516 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`;
518 
519/** The attempt already rejected in THIS compose (the loop caps at 2, so at most one),
520 * listed so a reroll is told what not to draw again and diverges instead of
521 * reproducing what just failed. Empty on the first attempt, so an un-rerolled prompt
522 * stays byte-identical to before. */
523function rejectedClause(rejected: RejectedComposition[]): string {
524 if (!rejected.length) return "";
525 return `\nAlready rejected this compose — do not repeat or lightly reskin; go somewhere entirely different:\n${rejected.map((r) => `${r.title}${r.reason}`).join("\n")}\n`;
527 
528/** Turns the closed-enum steer into a short clause biasing the model. Absent axes
529 * simply don't appear, leaving them to the model ("no preference"). */
530function steerClause(steer: ObjectiveSteer): string {
531 const parts: string[] = [];
532 if (steer.era) {
533 parts.push(
534 steer.era === "Across the ages"
535 ? "Let it span the ages rather than a single era."
536 : `Anchor it in this stretch of history: ${steer.era}.`,
537 );
538 }
539 if (steer.theme) parts.push(`Center it on the theme of ${steer.theme}.`);
540 return parts.length ? ` ${parts.join(" ")}` : "";

Reroll feedback, and the byte-identical default prompt

The second half of the change is small but easy to get wrong. A rejected attempt is now fed back into the compose prompt so the reroll diverges. That means the prompt gained a new conditional clause — and the reviewer's fair question is whether the common path (a first compose, nothing seen, nothing rejected) still produces the exact same bytes it did before.

It does. The new rejectedClause is spliced immediately after avoidClause on the same line (line 481: ${avoidClause(seenTitles)}${rejectedClause(rejected)}). Both return "" when empty, so an un-rerolled, un-seeded compose renders the identical string in the identical slot the single avoidClause occupied before.

The interpolation slot — both clauses empty on the first attempt.

server/utils/objective-generator.ts · 541 lines
server/utils/objective-generator.ts541 lines · TypeScript
⋯ 470 lines hidden (lines 1–470)
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 * - FRESH (#95) — a composition may reproduce neither the curated pool nor
21 * anything composed earlier this session. Two guards: a free normalized-title
22 * match catches verbatim / lightly-reskinned repeats, and a cheap Haiku judge
23 * (objectiveIsDuplicate) catches the semantic near-duplicate a title match
24 * can't see — the same objective, reworded. A rejected attempt is fed back into
25 * the single reroll so it diverges instead of re-drawing the same subject;
26 * a persistent collision falls back to an unseen curated pick.
27 */
28import {
29 pickCurated,
30 listCuratedObjectives,
31 normalizeObjectiveTitle,
32 type GameObjective,
33 type ObjectiveSteer,
34} from "./objectives";
35import { SAFELY_HISTORICAL_BEFORE_YEAR } from "./historical-floor";
36 
37/**
38 * What generateObjective produced: the objective plus its provenance. `composed`
39 * is true ONLY when the live AI path yielded this objective; it is false for the
40 * curated floor — both the default no-AI path and every AI fallback (a generation,
41 * bounds, or de-dup failure). The endpoint pairs `composed === false` with a
42 * requested steer to tell the player their steer was quietly dropped (#127), instead
43 * of letting a silent curated fallback read as "steering is broken".
44 */
45export interface GeneratedObjective {
46 objective: GameObjective;
47 composed: boolean;
49 
50/**
51 * A composed candidate this call already rejected (out of bounds or a duplicate),
52 * fed back into the reroll so the second attempt is told what NOT to draw again.
53 */
54interface RejectedComposition {
55 title: string;
56 description: string;
57 reason: string;
59 
60/** A semantic de-dup verdict: is the candidate an existing objective reworded? */
61interface DedupVerdict {
62 duplicate: boolean;
63 /** The nearest existing title the judge matched against (telemetry / the reroll). */
64 closestMatch: string;
65 /** One short clause on the call — surfaced to the reroll and the [objdedup] log. */
66 reason: string;
68 
69/**
70 * Returns an objective for a new run, plus whether the live AI path composed it (vs
71 * the curated floor). Curated-random by default (instant, no API call); pass
72 * useAI=true to compose one live under the given steer, with automatic fallback to
73 * curated. Never throws and never ships an out-of-bounds objective.
74 *
75 * `avoid` (#95) is the session's already-composed titles — the only anti-repeat
76 * memory the stateless server has (the client tracks and supplies them; the curated
77 * pool the server already knows). A composed result may reproduce NEITHER the
78 * curated pool NOR anything seen this session: a free normalized-title match catches
79 * exact / reskinned repeats, and a Haiku judge (objectiveIsDuplicate) catches the
80 * semantic near-duplicate a title match misses. A collision is rerolled once — the
81 * rejected attempt fed back so the reroll diverges — then the curated FALLBACK itself
82 * avoids the seen titles, so even the floor stays fresh.
83 */
84export async function generateObjective(
85 useAI = false,
86 steer: ObjectiveSteer = {},
87 avoid: string[] = [],
88): Promise<GeneratedObjective> {
89 // The curated pool — the far half of the free de-dup Set and the candidate list
90 // the semantic judge compares against. (The compose exemplars and the fallback
91 // floor re-read the pool through their own helpers.)
92 const curated = listCuratedObjectives();
93 // Session-seen titles, normalized — used both for the curated fallback (so it
94 // never re-serves a seen objective) and, unioned with the curated pool below,
95 // for the free post-generation collision guard.
96 const seen = new Set(avoid.map(normalizeObjectiveTitle).filter(Boolean));
97 const dedupe = new Set(seen);
98 for (const o of curated) dedupe.add(normalizeObjectiveTitle(o.title));
99 
100 if (useAI) {
101 // Never ship out of bounds OR a duplicate, never hard-fail the run: compose
102 // live, and on an out-of-bounds / colliding result reroll exactly once, then
103 // fall back to the (unseen) curated floor. Each rejected attempt is fed back
104 // into the reroll so the second draw diverges rather than repeating itself. A
105 // transport/parse failure won't heal on a reroll, so it drops straight to
106 // curated instead of a second call.
107 const rejected: RejectedComposition[] = [];
108 for (let attempt = 1; attempt <= 2; attempt++) {
109 try {
110 const candidate = await composeObjective(steer, avoid, rejected);
111 let reason: string;
112 // The free normalized-title match short-circuits ahead of the paid checks:
113 // an exact / reskinned collision never spends a bounds or de-dup call.
114 if (dedupe.has(normalizeObjectiveTitle(candidate.title))) {
115 reason = "duplicates an existing objective";
116 } else {
117 // Bounds (safety, fail-closed) and the semantic de-dup judge (freshness,
118 // fail-open) are independent — run them together; either one rejects.
119 const [inBounds, dup] = await Promise.all([
120 objectiveWithinBounds(candidate),
121 objectiveIsDuplicate(candidate, curated, avoid),
122 ]);
123 if (inBounds && !dup.duplicate)
124 return { objective: candidate, composed: true };
125 reason = !inBounds
126 ? "outside the safely-historical bounds"
127 : dup.reason || "too close to an existing objective";
128 }
129 rejected.push({
130 title: candidate.title,
131 description: candidate.description,
132 reason,
133 });
134 console.warn(
135 `AI objective rejected (${reason}) on attempt ${attempt} of 2; ${attempt < 2 ? "rerolling" : "falling back to curated"}`,
136 );
137 } catch (error) {
138 console.error(
139 "AI objective generation failed; falling back to curated pool:",
140 error,
141 );
142 break;
143 }
144 }
145 }
146 // The curated floor — reached by the default no-AI path or any AI fallback.
147 return { objective: pickCurated(seen), composed: false };
149 
150/**
151 * Composes one fresh objective with the model under the steer, steering away from
152 * the session's seen titles and any attempt already rejected this call. Returns the
153 * same shape as the curated pool so the rest of the game treats them identically.
154 * Throws on an empty/malformed answer (the caller owns the fallback).
155 */
156async function composeObjective(
157 steer: ObjectiveSteer,
158 seenTitles: string[] = [],
159 rejected: RejectedComposition[] = [],
160): Promise<GameObjective> {
161 // Imported lazily so the curated (default) path never pulls the model SDKs
162 // into the bundle.
163 const { completeStructured } = await import("./ai-gateway");
164 
165 const content = await completeStructured({
166 task: "objective",
167 system: composeSystemPrompt(),
168 turns: [
169 { role: "user", content: composeUserPrompt(steer, seenTitles, rejected) },
170 ],
171 schemaName: "game_objective",
172 schema: {
173 type: "object",
174 properties: {
175 title: { type: "string", description: "Punchy objective title" },
176 description: {
177 type: "string",
178 description:
179 '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)',
180 },
181 era: { type: "string", description: "Anchoring era / setting" },
182 icon: {
183 type: "string",
184 description: "A single emoji that fits the objective",
185 },
186 anchorYear: {
187 type: ["integer", "null"],
188 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`,
189 },
190 },
191 required: ["title", "description", "era", "icon", "anchorYear"],
192 additionalProperties: false,
193 },
194 });
195 
196 if (!content) throw new Error("No objective generated");
197 
198 const parsed = JSON.parse(content) as GameObjective;
199 if (!parsed.title || !parsed.description)
200 throw new Error("Invalid objective format generated");
201 return {
202 title: parsed.title,
203 description: parsed.description,
204 era: parsed.era || "Across the ages",
205 icon: parsed.icon || "🕰️",
206 // A finite signed year anchors the chain dial; null / junk → no anchor (no-op).
207 anchorYear:
208 typeof parsed.anchorYear === "number" &&
209 Number.isFinite(parsed.anchorYear)
210 ? Math.round(parsed.anchorYear)
211 : undefined,
212 };
214 
215/**
216 * Validates a composed objective against the safely-historical bounds (#71) —
217 * the prompt is asked to obey them, but never trusted to. Three gates: a pure year
218 * bound, the same moderation seam messages pass through, and a structured subject
219 * check for the living-people / current-events framings a year bound can't see.
220 * Never throws. The year and subject gates fail CLOSED — an inconclusive result
221 * (junk, an empty answer, an outage) reads as out of bounds, so an unvetted
222 * composition falls back to curated rather than shipping. Moderation fails OPEN,
223 * the same fail-open-but-loud seam messages pass through (a safety classifier
224 * being down must never take the game down); it rejects only on an active flag.
225 */
226async function objectiveWithinBounds(o: GameObjective): Promise<boolean> {
227 // 1. Year bound — pure, deterministic, free. A finite anchor must sit at or
228 // before the safely-historical line; a null anchor (spans-all-history) is
229 // allowed, its content still gated by the subject check below.
230 if (
231 typeof o.anchorYear === "number" &&
232 o.anchorYear > SAFELY_HISTORICAL_BEFORE_YEAR
233 )
234 return false;
235 
236 const text = `${o.title}\n${o.description}`;
237 // 2 + 3 are independent model/detector calls — run them together; first "out"
238 // wins. Lazy import keeps moderation (and its SDKs) off the curated path.
239 const { hardBlockCheck } = await import("./moderation");
240 const [mod, subjectInBounds] = await Promise.all([
241 hardBlockCheck(text, "objective", "output"),
242 checkObjectiveSubject(o),
243 ]);
244 if (mod.blocked) return false;
245 return subjectInBounds;
247 
248/**
249 * The structured subject check (the figure-floor pattern, server/utils/
250 * lifespan-estimator.ts): a cheap classifier that gates the free-prose title +
251 * description a year bound can't reach — does this center on living people,
252 * contemporary figures, ongoing conflicts, or post-cutoff events? Fails CLOSED:
253 * an empty answer, junk, or an outage returns false, so an unvetted objective is
254 * never shipped (it falls back to the safe curated floor instead). Never throws.
255 */
256async function checkObjectiveSubject(o: GameObjective): Promise<boolean> {
257 const { completeStructured } = await import("./ai-gateway");
258 try {
259 const content = await completeStructured({
260 task: "objective-check",
261 system: boundsCheckSystemPrompt(),
262 turns: [
263 {
264 role: "user",
265 content: `Title: ${o.title}\nDescription: ${o.description}\nEra: ${o.era}\nAnchor year: ${o.anchorYear ?? "none (spans all history)"}`,
266 },
267 ],
268 schemaName: "objective_bounds",
269 schema: {
270 type: "object",
271 properties: {
272 withinBounds: {
273 type: "boolean",
274 description:
275 "True only when the subject is safely historical (settled past, no living people or current events)",
276 },
277 reason: {
278 type: "string",
279 description: "One short clause on the call",
280 },
281 },
282 required: ["withinBounds", "reason"],
283 additionalProperties: false,
284 },
285 });
286 if (!content) return false; // the model blipped — fail closed to the curated floor
287 return mapBoundsWire(JSON.parse(content)) === true;
288 } catch (error) {
289 console.error("Objective bounds check error:", error);
290 return false; // an outage must never let an unvetted objective through
291 }
293 
294/**
295 * The bounds-check wire → a clean verdict. `null` for an unusable shape (missing /
296 * non-boolean flag), which the caller treats exactly like "out of bounds". Exported
297 * pure so the guard is directly testable, like mapLifespanWire.
298 */
299export function mapBoundsWire(wire: unknown): boolean | null {
300 const w = wire as { withinBounds?: unknown } | null;
301 if (!w || typeof w.withinBounds !== "boolean") return null;
302 return w.withinBounds;
304 
305/**
306 * Semantic de-dup (#95 follow-up). The free normalized-title match in
307 * generateObjective catches verbatim / reskinned repeats; this catches the ones it
308 * can't — the SAME objective reworded under a fresh title ("Save the Library of
309 * Alexandria" vs "Keep Alexandria's great library from the flames"). A cheap Haiku
310 * judge compares the candidate against everything already on offer (the curated pool
311 * in full, plus the session's seen titles) and answers: is this essentially one of
312 * them again?
313 *
314 * Freshness, not safety, so this fails OPEN: an outage, empty answer, or junk verdict
315 * reads as "not a duplicate" and ships (the opposite stance from the bounds gate — a
316 * de-dup classifier being down must never cost the player a composition). Modes via
317 * EVERWHEN_OBJECTIVE_DEDUP mirror moderation: 'off' skips the call, 'shadow' runs and
318 * logs what it WOULD reject without acting, 'enforce' (default) rejects for real.
319 * Never throws.
320 */
321async function objectiveIsDuplicate(
322 candidate: GameObjective,
323 curated: GameObjective[],
324 seenTitles: string[],
325): Promise<DedupVerdict> {
326 const fresh: DedupVerdict = {
327 duplicate: false,
328 closestMatch: "",
329 reason: "",
330 };
331 const mode = objectiveDedupMode();
332 if (mode === "off") return fresh;
333 
334 let verdict: DedupVerdict;
335 try {
336 const { completeStructured } = await import("./ai-gateway");
337 const content = await completeStructured({
338 task: "objective-dedup",
339 system: dedupSystemPrompt(),
340 turns: [
341 {
342 role: "user",
343 content: dedupUserPrompt(candidate, curated, seenTitles),
344 },
345 ],
346 schemaName: "objective_dedup",
347 schema: {
348 type: "object",
349 properties: {
350 // Fact-retrieval before the decision it informs (schema order is
351 // load-bearing): name the nearest existing objective first, then judge.
352 closestMatch: {
353 type: "string",
354 description:
355 'Title of the nearest existing objective, or "none" if nothing is close',
356 },
357 duplicate: {
358 type: "boolean",
359 description:
360 "True only when the candidate is essentially that existing objective reworded — the same counterfactual, not merely the same era or theme",
361 },
362 reason: {
363 type: "string",
364 description: "One short clause on the call",
365 },
366 },
367 required: ["closestMatch", "duplicate", "reason"],
368 additionalProperties: false,
369 },
370 });
371 // Fail open on an unusable verdict — a de-dup blip must not drop a composition.
372 verdict = (content && mapDedupWire(JSON.parse(content))) || fresh;
373 } catch (error) {
374 console.error("Objective de-dup check error:", error);
375 return fresh; // freshness fails open — never take a composition down
376 }
377 
378 if (verdict.duplicate) {
379 emitObjDedup({
380 mode,
381 wouldReject: true,
382 enforced: mode === "enforce",
383 closestMatch: verdict.closestMatch,
384 candidate: candidate.title,
385 });
386 if (mode === "shadow") return fresh; // observe without biting — ship it
387 }
388 return verdict;
390 
391/** EVERWHEN_OBJECTIVE_DEDUP: off | shadow | enforce (default enforce), the same
392 * three-mode shape as EVERWHEN_MODERATION. */
393function objectiveDedupMode(): "off" | "shadow" | "enforce" {
394 const m = process.env.EVERWHEN_OBJECTIVE_DEDUP;
395 return m === "off" || m === "shadow" ? m : "enforce";
397 
398/** One greppable [objdedup] line per flagged duplicate — the calibration
399 * instrument for shadow mode. Quiet under tests, like [mod]. */
400function emitObjDedup(line: Record<string, unknown>): void {
401 if (!process.env.VITEST) console.info(`[objdedup] ${JSON.stringify(line)}`);
403 
404/**
405 * The de-dup wire → a clean verdict, or `null` for an unusable shape (a missing /
406 * non-boolean flag), which the caller treats as "not a duplicate" (fail open).
407 * Exported pure so the guard is directly testable, like mapBoundsWire.
408 */
409export function mapDedupWire(wire: unknown): DedupVerdict | null {
410 const w = wire as {
411 duplicate?: unknown;
412 closestMatch?: unknown;
413 reason?: unknown;
414 } | null;
415 if (!w || typeof w.duplicate !== "boolean") return null;
416 return {
417 duplicate: w.duplicate,
418 closestMatch: typeof w.closestMatch === "string" ? w.closestMatch : "",
419 reason: typeof w.reason === "string" ? w.reason : "",
420 };
422 
423/** System prompt for the semantic de-dup judge. */
424function dedupSystemPrompt(): string {
425 return `You are a de-dup check for a historical strategy game. You are given a freshly composed objective and the list of objectives already on offer. Decide whether the new one is essentially one of the existing objectives reworded.
426 
427Mark it a DUPLICATE (duplicate=true) when it aims at the SAME counterfactual as an existing objective — the same pivotal event, person, or outcome — even if the title, wording, era label, or framing differ ("Save the Library of Alexandria" and "Keep Alexandria's great library from the flames" are the same objective).
428 
429Mark it NOT a duplicate (duplicate=false) when it targets a genuinely different event, person, place, or outcome — even if it shares a broad theme (war, science, empire) or era with an existing one. Sharing a subject is not enough; aiming at the same specific turning point is.
430 
431Name the closest existing title in closestMatch (or "none"), then decide, then give one short reason.`;
433 
434/** De-dup user turn: the candidate, then everything already on offer (the curated
435 * pool in full, plus the session's seen titles). */
436function dedupUserPrompt(
437 candidate: GameObjective,
438 curated: GameObjective[],
439 seenTitles: string[],
440): string {
441 const existing = [
442 ...curated.map((o) => `${o.title}${o.description}`),
443 ...seenTitles.map((t) => `${t}`),
444 ].join("\n");
445 return `CANDIDATE objective:
446Title: ${candidate.title}
447Description: ${candidate.description}
448 
449Objectives ALREADY on offer:
450${existing}
451 
452Is the candidate essentially one of the objectives already on offer, reworded?`;
454 
455/** System prompt for composition: the tone bar, the grounding bar, and the
456 * non-negotiable safety bounds. */
457function composeSystemPrompt(): string {
458 return `You are a historian and game designer inventing grand, evocative counterfactual objectives for a game where players text historical figures to bend history.
459 
460Hold every objective to three bars:
461 
462TONE — sweeping and vivid, a whole world to remake in the spirit of the examples, never a small errand.
463 
464GROUNDING (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.
465 
466BOUNDS (safety, non-negotiable) — the objective must be safely historical:
467- Set in the settled past. Any pivotal anchor year must be at or before ${SAFELY_HISTORICAL_BEFORE_YEAR}.
468- 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.`;
470 
471/** Composition user turn: curated few-shot exemplars (framed as territory already
472 * taken, never a menu to copy), the session avoid-list, any attempt already
473 * rejected this call, the steer, and the ask. */
474function composeUserPrompt(
475 steer: ObjectiveSteer,
476 seenTitles: string[],
477 rejected: RejectedComposition[] = [],
478): string {
479 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"):
480${curatedExemplars()}
481${avoidClause(seenTitles)}${rejectedClause(rejected)}
482Invent 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)}
483 
484Give 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.
485 
486The 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.`;
⋯ 54 lines hidden (lines 488–541)
488 
489/** System prompt for the structured subject check. */
490function boundsCheckSystemPrompt(): string {
491 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.
492 
493Mark it OUT OF BOUNDS (withinBounds=false) if it centers on, or would require contacting, any of:
494- a living person or a present-day public figure,
495- an ongoing conflict or a current event,
496- anything anchored after the year ${SAFELY_HISTORICAL_BEFORE_YEAR}.
497 
498Mark 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.
499 
500When you are unsure, answer withinBounds=false. Give a short reason either way.`;
502 
503/** The curated pool rendered as few-shot exemplars (title — description), read
504 * live from the pool so the bar can never drift from a hardcoded copy. */
505function curatedExemplars(): string {
506 return listCuratedObjectives()
507 .map((o) => `${o.title}${o.description}`)
508 .join("\n");
510 
511/** The session's already-composed titles, listed so the model steers away from
512 * repeating them (#95). Empty when nothing's been rolled yet — the first compose
513 * carries no clause, so an unseeded prompt stays identical to before. */
514function avoidClause(seenTitles: string[]): string {
515 if (!seenTitles.length) return "";
516 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`;
518 
519/** The attempt already rejected in THIS compose (the loop caps at 2, so at most one),
520 * listed so a reroll is told what not to draw again and diverges instead of
521 * reproducing what just failed. Empty on the first attempt, so an un-rerolled prompt
522 * stays byte-identical to before. */
523function rejectedClause(rejected: RejectedComposition[]): string {
524 if (!rejected.length) return "";
525 return `\nAlready rejected this compose — do not repeat or lightly reskin; go somewhere entirely different:\n${rejected.map((r) => `${r.title}${r.reason}`).join("\n")}\n`;
527 
528/** Turns the closed-enum steer into a short clause biasing the model. Absent axes
529 * simply don't appear, leaving them to the model ("no preference"). */
530function steerClause(steer: ObjectiveSteer): string {
531 const parts: string[] = [];
532 if (steer.era) {
533 parts.push(
534 steer.era === "Across the ages"
535 ? "Let it span the ages rather than a single era."
536 : `Anchor it in this stretch of history: ${steer.era}.`,
537 );
538 }
539 if (steer.theme) parts.push(`Center it on the theme of ${steer.theme}.`);
540 return parts.length ? ` ${parts.join(" ")}` : "";

rejectedClause: empty ⇒ no bytes; otherwise names what was rejected, and why.

server/utils/objective-generator.ts · 541 lines
server/utils/objective-generator.ts541 lines · TypeScript
⋯ 518 lines hidden (lines 1–518)
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 * - FRESH (#95) — a composition may reproduce neither the curated pool nor
21 * anything composed earlier this session. Two guards: a free normalized-title
22 * match catches verbatim / lightly-reskinned repeats, and a cheap Haiku judge
23 * (objectiveIsDuplicate) catches the semantic near-duplicate a title match
24 * can't see — the same objective, reworded. A rejected attempt is fed back into
25 * the single reroll so it diverges instead of re-drawing the same subject;
26 * a persistent collision falls back to an unseen curated pick.
27 */
28import {
29 pickCurated,
30 listCuratedObjectives,
31 normalizeObjectiveTitle,
32 type GameObjective,
33 type ObjectiveSteer,
34} from "./objectives";
35import { SAFELY_HISTORICAL_BEFORE_YEAR } from "./historical-floor";
36 
37/**
38 * What generateObjective produced: the objective plus its provenance. `composed`
39 * is true ONLY when the live AI path yielded this objective; it is false for the
40 * curated floor — both the default no-AI path and every AI fallback (a generation,
41 * bounds, or de-dup failure). The endpoint pairs `composed === false` with a
42 * requested steer to tell the player their steer was quietly dropped (#127), instead
43 * of letting a silent curated fallback read as "steering is broken".
44 */
45export interface GeneratedObjective {
46 objective: GameObjective;
47 composed: boolean;
49 
50/**
51 * A composed candidate this call already rejected (out of bounds or a duplicate),
52 * fed back into the reroll so the second attempt is told what NOT to draw again.
53 */
54interface RejectedComposition {
55 title: string;
56 description: string;
57 reason: string;
59 
60/** A semantic de-dup verdict: is the candidate an existing objective reworded? */
61interface DedupVerdict {
62 duplicate: boolean;
63 /** The nearest existing title the judge matched against (telemetry / the reroll). */
64 closestMatch: string;
65 /** One short clause on the call — surfaced to the reroll and the [objdedup] log. */
66 reason: string;
68 
69/**
70 * Returns an objective for a new run, plus whether the live AI path composed it (vs
71 * the curated floor). Curated-random by default (instant, no API call); pass
72 * useAI=true to compose one live under the given steer, with automatic fallback to
73 * curated. Never throws and never ships an out-of-bounds objective.
74 *
75 * `avoid` (#95) is the session's already-composed titles — the only anti-repeat
76 * memory the stateless server has (the client tracks and supplies them; the curated
77 * pool the server already knows). A composed result may reproduce NEITHER the
78 * curated pool NOR anything seen this session: a free normalized-title match catches
79 * exact / reskinned repeats, and a Haiku judge (objectiveIsDuplicate) catches the
80 * semantic near-duplicate a title match misses. A collision is rerolled once — the
81 * rejected attempt fed back so the reroll diverges — then the curated FALLBACK itself
82 * avoids the seen titles, so even the floor stays fresh.
83 */
84export async function generateObjective(
85 useAI = false,
86 steer: ObjectiveSteer = {},
87 avoid: string[] = [],
88): Promise<GeneratedObjective> {
89 // The curated pool — the far half of the free de-dup Set and the candidate list
90 // the semantic judge compares against. (The compose exemplars and the fallback
91 // floor re-read the pool through their own helpers.)
92 const curated = listCuratedObjectives();
93 // Session-seen titles, normalized — used both for the curated fallback (so it
94 // never re-serves a seen objective) and, unioned with the curated pool below,
95 // for the free post-generation collision guard.
96 const seen = new Set(avoid.map(normalizeObjectiveTitle).filter(Boolean));
97 const dedupe = new Set(seen);
98 for (const o of curated) dedupe.add(normalizeObjectiveTitle(o.title));
99 
100 if (useAI) {
101 // Never ship out of bounds OR a duplicate, never hard-fail the run: compose
102 // live, and on an out-of-bounds / colliding result reroll exactly once, then
103 // fall back to the (unseen) curated floor. Each rejected attempt is fed back
104 // into the reroll so the second draw diverges rather than repeating itself. A
105 // transport/parse failure won't heal on a reroll, so it drops straight to
106 // curated instead of a second call.
107 const rejected: RejectedComposition[] = [];
108 for (let attempt = 1; attempt <= 2; attempt++) {
109 try {
110 const candidate = await composeObjective(steer, avoid, rejected);
111 let reason: string;
112 // The free normalized-title match short-circuits ahead of the paid checks:
113 // an exact / reskinned collision never spends a bounds or de-dup call.
114 if (dedupe.has(normalizeObjectiveTitle(candidate.title))) {
115 reason = "duplicates an existing objective";
116 } else {
117 // Bounds (safety, fail-closed) and the semantic de-dup judge (freshness,
118 // fail-open) are independent — run them together; either one rejects.
119 const [inBounds, dup] = await Promise.all([
120 objectiveWithinBounds(candidate),
121 objectiveIsDuplicate(candidate, curated, avoid),
122 ]);
123 if (inBounds && !dup.duplicate)
124 return { objective: candidate, composed: true };
125 reason = !inBounds
126 ? "outside the safely-historical bounds"
127 : dup.reason || "too close to an existing objective";
128 }
129 rejected.push({
130 title: candidate.title,
131 description: candidate.description,
132 reason,
133 });
134 console.warn(
135 `AI objective rejected (${reason}) on attempt ${attempt} of 2; ${attempt < 2 ? "rerolling" : "falling back to curated"}`,
136 );
137 } catch (error) {
138 console.error(
139 "AI objective generation failed; falling back to curated pool:",
140 error,
141 );
142 break;
143 }
144 }
145 }
146 // The curated floor — reached by the default no-AI path or any AI fallback.
147 return { objective: pickCurated(seen), composed: false };
149 
150/**
151 * Composes one fresh objective with the model under the steer, steering away from
152 * the session's seen titles and any attempt already rejected this call. Returns the
153 * same shape as the curated pool so the rest of the game treats them identically.
154 * Throws on an empty/malformed answer (the caller owns the fallback).
155 */
156async function composeObjective(
157 steer: ObjectiveSteer,
158 seenTitles: string[] = [],
159 rejected: RejectedComposition[] = [],
160): Promise<GameObjective> {
161 // Imported lazily so the curated (default) path never pulls the model SDKs
162 // into the bundle.
163 const { completeStructured } = await import("./ai-gateway");
164 
165 const content = await completeStructured({
166 task: "objective",
167 system: composeSystemPrompt(),
168 turns: [
169 { role: "user", content: composeUserPrompt(steer, seenTitles, rejected) },
170 ],
171 schemaName: "game_objective",
172 schema: {
173 type: "object",
174 properties: {
175 title: { type: "string", description: "Punchy objective title" },
176 description: {
177 type: "string",
178 description:
179 '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)',
180 },
181 era: { type: "string", description: "Anchoring era / setting" },
182 icon: {
183 type: "string",
184 description: "A single emoji that fits the objective",
185 },
186 anchorYear: {
187 type: ["integer", "null"],
188 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`,
189 },
190 },
191 required: ["title", "description", "era", "icon", "anchorYear"],
192 additionalProperties: false,
193 },
194 });
195 
196 if (!content) throw new Error("No objective generated");
197 
198 const parsed = JSON.parse(content) as GameObjective;
199 if (!parsed.title || !parsed.description)
200 throw new Error("Invalid objective format generated");
201 return {
202 title: parsed.title,
203 description: parsed.description,
204 era: parsed.era || "Across the ages",
205 icon: parsed.icon || "🕰️",
206 // A finite signed year anchors the chain dial; null / junk → no anchor (no-op).
207 anchorYear:
208 typeof parsed.anchorYear === "number" &&
209 Number.isFinite(parsed.anchorYear)
210 ? Math.round(parsed.anchorYear)
211 : undefined,
212 };
214 
215/**
216 * Validates a composed objective against the safely-historical bounds (#71) —
217 * the prompt is asked to obey them, but never trusted to. Three gates: a pure year
218 * bound, the same moderation seam messages pass through, and a structured subject
219 * check for the living-people / current-events framings a year bound can't see.
220 * Never throws. The year and subject gates fail CLOSED — an inconclusive result
221 * (junk, an empty answer, an outage) reads as out of bounds, so an unvetted
222 * composition falls back to curated rather than shipping. Moderation fails OPEN,
223 * the same fail-open-but-loud seam messages pass through (a safety classifier
224 * being down must never take the game down); it rejects only on an active flag.
225 */
226async function objectiveWithinBounds(o: GameObjective): Promise<boolean> {
227 // 1. Year bound — pure, deterministic, free. A finite anchor must sit at or
228 // before the safely-historical line; a null anchor (spans-all-history) is
229 // allowed, its content still gated by the subject check below.
230 if (
231 typeof o.anchorYear === "number" &&
232 o.anchorYear > SAFELY_HISTORICAL_BEFORE_YEAR
233 )
234 return false;
235 
236 const text = `${o.title}\n${o.description}`;
237 // 2 + 3 are independent model/detector calls — run them together; first "out"
238 // wins. Lazy import keeps moderation (and its SDKs) off the curated path.
239 const { hardBlockCheck } = await import("./moderation");
240 const [mod, subjectInBounds] = await Promise.all([
241 hardBlockCheck(text, "objective", "output"),
242 checkObjectiveSubject(o),
243 ]);
244 if (mod.blocked) return false;
245 return subjectInBounds;
247 
248/**
249 * The structured subject check (the figure-floor pattern, server/utils/
250 * lifespan-estimator.ts): a cheap classifier that gates the free-prose title +
251 * description a year bound can't reach — does this center on living people,
252 * contemporary figures, ongoing conflicts, or post-cutoff events? Fails CLOSED:
253 * an empty answer, junk, or an outage returns false, so an unvetted objective is
254 * never shipped (it falls back to the safe curated floor instead). Never throws.
255 */
256async function checkObjectiveSubject(o: GameObjective): Promise<boolean> {
257 const { completeStructured } = await import("./ai-gateway");
258 try {
259 const content = await completeStructured({
260 task: "objective-check",
261 system: boundsCheckSystemPrompt(),
262 turns: [
263 {
264 role: "user",
265 content: `Title: ${o.title}\nDescription: ${o.description}\nEra: ${o.era}\nAnchor year: ${o.anchorYear ?? "none (spans all history)"}`,
266 },
267 ],
268 schemaName: "objective_bounds",
269 schema: {
270 type: "object",
271 properties: {
272 withinBounds: {
273 type: "boolean",
274 description:
275 "True only when the subject is safely historical (settled past, no living people or current events)",
276 },
277 reason: {
278 type: "string",
279 description: "One short clause on the call",
280 },
281 },
282 required: ["withinBounds", "reason"],
283 additionalProperties: false,
284 },
285 });
286 if (!content) return false; // the model blipped — fail closed to the curated floor
287 return mapBoundsWire(JSON.parse(content)) === true;
288 } catch (error) {
289 console.error("Objective bounds check error:", error);
290 return false; // an outage must never let an unvetted objective through
291 }
293 
294/**
295 * The bounds-check wire → a clean verdict. `null` for an unusable shape (missing /
296 * non-boolean flag), which the caller treats exactly like "out of bounds". Exported
297 * pure so the guard is directly testable, like mapLifespanWire.
298 */
299export function mapBoundsWire(wire: unknown): boolean | null {
300 const w = wire as { withinBounds?: unknown } | null;
301 if (!w || typeof w.withinBounds !== "boolean") return null;
302 return w.withinBounds;
304 
305/**
306 * Semantic de-dup (#95 follow-up). The free normalized-title match in
307 * generateObjective catches verbatim / reskinned repeats; this catches the ones it
308 * can't — the SAME objective reworded under a fresh title ("Save the Library of
309 * Alexandria" vs "Keep Alexandria's great library from the flames"). A cheap Haiku
310 * judge compares the candidate against everything already on offer (the curated pool
311 * in full, plus the session's seen titles) and answers: is this essentially one of
312 * them again?
313 *
314 * Freshness, not safety, so this fails OPEN: an outage, empty answer, or junk verdict
315 * reads as "not a duplicate" and ships (the opposite stance from the bounds gate — a
316 * de-dup classifier being down must never cost the player a composition). Modes via
317 * EVERWHEN_OBJECTIVE_DEDUP mirror moderation: 'off' skips the call, 'shadow' runs and
318 * logs what it WOULD reject without acting, 'enforce' (default) rejects for real.
319 * Never throws.
320 */
321async function objectiveIsDuplicate(
322 candidate: GameObjective,
323 curated: GameObjective[],
324 seenTitles: string[],
325): Promise<DedupVerdict> {
326 const fresh: DedupVerdict = {
327 duplicate: false,
328 closestMatch: "",
329 reason: "",
330 };
331 const mode = objectiveDedupMode();
332 if (mode === "off") return fresh;
333 
334 let verdict: DedupVerdict;
335 try {
336 const { completeStructured } = await import("./ai-gateway");
337 const content = await completeStructured({
338 task: "objective-dedup",
339 system: dedupSystemPrompt(),
340 turns: [
341 {
342 role: "user",
343 content: dedupUserPrompt(candidate, curated, seenTitles),
344 },
345 ],
346 schemaName: "objective_dedup",
347 schema: {
348 type: "object",
349 properties: {
350 // Fact-retrieval before the decision it informs (schema order is
351 // load-bearing): name the nearest existing objective first, then judge.
352 closestMatch: {
353 type: "string",
354 description:
355 'Title of the nearest existing objective, or "none" if nothing is close',
356 },
357 duplicate: {
358 type: "boolean",
359 description:
360 "True only when the candidate is essentially that existing objective reworded — the same counterfactual, not merely the same era or theme",
361 },
362 reason: {
363 type: "string",
364 description: "One short clause on the call",
365 },
366 },
367 required: ["closestMatch", "duplicate", "reason"],
368 additionalProperties: false,
369 },
370 });
371 // Fail open on an unusable verdict — a de-dup blip must not drop a composition.
372 verdict = (content && mapDedupWire(JSON.parse(content))) || fresh;
373 } catch (error) {
374 console.error("Objective de-dup check error:", error);
375 return fresh; // freshness fails open — never take a composition down
376 }
377 
378 if (verdict.duplicate) {
379 emitObjDedup({
380 mode,
381 wouldReject: true,
382 enforced: mode === "enforce",
383 closestMatch: verdict.closestMatch,
384 candidate: candidate.title,
385 });
386 if (mode === "shadow") return fresh; // observe without biting — ship it
387 }
388 return verdict;
390 
391/** EVERWHEN_OBJECTIVE_DEDUP: off | shadow | enforce (default enforce), the same
392 * three-mode shape as EVERWHEN_MODERATION. */
393function objectiveDedupMode(): "off" | "shadow" | "enforce" {
394 const m = process.env.EVERWHEN_OBJECTIVE_DEDUP;
395 return m === "off" || m === "shadow" ? m : "enforce";
397 
398/** One greppable [objdedup] line per flagged duplicate — the calibration
399 * instrument for shadow mode. Quiet under tests, like [mod]. */
400function emitObjDedup(line: Record<string, unknown>): void {
401 if (!process.env.VITEST) console.info(`[objdedup] ${JSON.stringify(line)}`);
403 
404/**
405 * The de-dup wire → a clean verdict, or `null` for an unusable shape (a missing /
406 * non-boolean flag), which the caller treats as "not a duplicate" (fail open).
407 * Exported pure so the guard is directly testable, like mapBoundsWire.
408 */
409export function mapDedupWire(wire: unknown): DedupVerdict | null {
410 const w = wire as {
411 duplicate?: unknown;
412 closestMatch?: unknown;
413 reason?: unknown;
414 } | null;
415 if (!w || typeof w.duplicate !== "boolean") return null;
416 return {
417 duplicate: w.duplicate,
418 closestMatch: typeof w.closestMatch === "string" ? w.closestMatch : "",
419 reason: typeof w.reason === "string" ? w.reason : "",
420 };
422 
423/** System prompt for the semantic de-dup judge. */
424function dedupSystemPrompt(): string {
425 return `You are a de-dup check for a historical strategy game. You are given a freshly composed objective and the list of objectives already on offer. Decide whether the new one is essentially one of the existing objectives reworded.
426 
427Mark it a DUPLICATE (duplicate=true) when it aims at the SAME counterfactual as an existing objective — the same pivotal event, person, or outcome — even if the title, wording, era label, or framing differ ("Save the Library of Alexandria" and "Keep Alexandria's great library from the flames" are the same objective).
428 
429Mark it NOT a duplicate (duplicate=false) when it targets a genuinely different event, person, place, or outcome — even if it shares a broad theme (war, science, empire) or era with an existing one. Sharing a subject is not enough; aiming at the same specific turning point is.
430 
431Name the closest existing title in closestMatch (or "none"), then decide, then give one short reason.`;
433 
434/** De-dup user turn: the candidate, then everything already on offer (the curated
435 * pool in full, plus the session's seen titles). */
436function dedupUserPrompt(
437 candidate: GameObjective,
438 curated: GameObjective[],
439 seenTitles: string[],
440): string {
441 const existing = [
442 ...curated.map((o) => `${o.title}${o.description}`),
443 ...seenTitles.map((t) => `${t}`),
444 ].join("\n");
445 return `CANDIDATE objective:
446Title: ${candidate.title}
447Description: ${candidate.description}
448 
449Objectives ALREADY on offer:
450${existing}
451 
452Is the candidate essentially one of the objectives already on offer, reworded?`;
454 
455/** System prompt for composition: the tone bar, the grounding bar, and the
456 * non-negotiable safety bounds. */
457function composeSystemPrompt(): string {
458 return `You are a historian and game designer inventing grand, evocative counterfactual objectives for a game where players text historical figures to bend history.
459 
460Hold every objective to three bars:
461 
462TONE — sweeping and vivid, a whole world to remake in the spirit of the examples, never a small errand.
463 
464GROUNDING (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.
465 
466BOUNDS (safety, non-negotiable) — the objective must be safely historical:
467- Set in the settled past. Any pivotal anchor year must be at or before ${SAFELY_HISTORICAL_BEFORE_YEAR}.
468- 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.`;
470 
471/** Composition user turn: curated few-shot exemplars (framed as territory already
472 * taken, never a menu to copy), the session avoid-list, any attempt already
473 * rejected this call, the steer, and the ask. */
474function composeUserPrompt(
475 steer: ObjectiveSteer,
476 seenTitles: string[],
477 rejected: RejectedComposition[] = [],
478): string {
479 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"):
480${curatedExemplars()}
481${avoidClause(seenTitles)}${rejectedClause(rejected)}
482Invent 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)}
483 
484Give 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.
485 
486The 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.`;
488 
489/** System prompt for the structured subject check. */
490function boundsCheckSystemPrompt(): string {
491 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.
492 
493Mark it OUT OF BOUNDS (withinBounds=false) if it centers on, or would require contacting, any of:
494- a living person or a present-day public figure,
495- an ongoing conflict or a current event,
496- anything anchored after the year ${SAFELY_HISTORICAL_BEFORE_YEAR}.
497 
498Mark 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.
499 
500When you are unsure, answer withinBounds=false. Give a short reason either way.`;
502 
503/** The curated pool rendered as few-shot exemplars (title — description), read
504 * live from the pool so the bar can never drift from a hardcoded copy. */
505function curatedExemplars(): string {
506 return listCuratedObjectives()
507 .map((o) => `${o.title}${o.description}`)
508 .join("\n");
510 
511/** The session's already-composed titles, listed so the model steers away from
512 * repeating them (#95). Empty when nothing's been rolled yet — the first compose
513 * carries no clause, so an unseeded prompt stays identical to before. */
514function avoidClause(seenTitles: string[]): string {
515 if (!seenTitles.length) return "";
516 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`;
518 
519/** The attempt already rejected in THIS compose (the loop caps at 2, so at most one),
520 * listed so a reroll is told what not to draw again and diverges instead of
521 * reproducing what just failed. Empty on the first attempt, so an un-rerolled prompt
522 * stays byte-identical to before. */
523function rejectedClause(rejected: RejectedComposition[]): string {
524 if (!rejected.length) return "";
525 return `\nAlready rejected this compose — do not repeat or lightly reskin; go somewhere entirely different:\n${rejected.map((r) => `${r.title}${r.reason}`).join("\n")}\n`;
⋯ 15 lines hidden (lines 527–541)
527 
528/** Turns the closed-enum steer into a short clause biasing the model. Absent axes
529 * simply don't appear, leaving them to the model ("no preference"). */
530function steerClause(steer: ObjectiveSteer): string {
531 const parts: string[] = [];
532 if (steer.era) {
533 parts.push(
534 steer.era === "Across the ages"
535 ? "Let it span the ages rather than a single era."
536 : `Anchor it in this stretch of history: ${steer.era}.`,
537 );
538 }
539 if (steer.theme) parts.push(`Center it on the theme of ${steer.theme}.`);
540 return parts.length ? ` ${parts.join(" ")}` : "";

The tests that pin the risky claims

The suite drives the real generateObjective and mocks only the model boundary, dispatching canned responses by JSON-schema name — so a test says "these composes, these bounds verdicts, these de-dup verdicts" without depending on call order. The de-dup judge defaults to off in the harness (mirroring how moderation is off by default), and the dedicated tests flip it on.

Two of the new tests are the ones a skeptic wants, both under enforce:

- Fail open end-to-end — the judge returns blank content, and the composition still ships (composed === true). This exercises the fail-open path through the real function, not just the mapDedupWire unit. - Short-circuit holds under enforce — a verbatim curated title is caught by the free set and spends neither a bounds nor a de-dup call; only the clean reroll consults each. This is the regression guard for the "free check first" claim.

Fail-open-through-the-real-function, and the enforce-mode short-circuit.

tests/unit/server/utils/objective-generator.spec.ts · 675 lines
tests/unit/server/utils/objective-generator.spec.ts675 lines · TypeScript
⋯ 544 lines hidden (lines 1–544)
1import {
2 describe,
3 it,
4 expect,
5 vi,
6 beforeAll,
7 afterAll,
8 beforeEach,
9} from "vitest";
10import {
11 generateObjective,
12 mapBoundsWire,
13 mapDedupWire,
14} from "../../../../server/utils/objective-generator";
15import { listCuratedObjectives } from "../../../../server/utils/objectives";
16import { SAFELY_HISTORICAL_BEFORE_YEAR } from "../../../../server/utils/historical-floor";
17import { MAX_OBJECTIVE_DESC_CHARS } from "../../../../server/utils/validate";
18 
19// The AI path routes through the gateway; mock the OpenAI SDK (chat completions +
20// moderations) and pin the legacy lane, the same shape the lifespan spec uses. The
21// curated (default) path below never constructs a client, so it is unaffected.
22const createMock = vi.hoisted(() => vi.fn());
23const modMock = vi.hoisted(() => vi.fn());
24vi.mock("openai", () => ({
25 default: vi.fn(function () {
26 return {
27 chat: { completions: { create: createMock } },
28 moderations: { create: modMock },
29 };
30 }),
31}));
32 
33describe("Objective Generator — curated path (no API call)", () => {
34 it("returns a well-formed objective", async () => {
35 const { objective, composed } = await generateObjective();
36 
37 // The default (no-AI) path is the curated floor — never a composition.
38 expect(composed).toBe(false);
39 expect(typeof objective.title).toBe("string");
40 expect(objective.title.length).toBeGreaterThan(0);
41 expect(typeof objective.description).toBe("string");
42 expect(objective.description.length).toBeGreaterThan(10);
43 expect(typeof objective.era).toBe("string");
44 expect(typeof objective.icon).toBe("string");
45 expect(objective.icon.length).toBeGreaterThan(0);
46 });
47 
48 it("draws from the curated pool by default (no API call)", async () => {
49 const titles = listCuratedObjectives().map((o) => o.title);
50 const { objective } = await generateObjective();
51 expect(titles).toContain(objective.title);
52 });
53 
54 it("exposes a pool of several distinct objectives", () => {
55 const pool = listCuratedObjectives();
56 expect(pool.length).toBeGreaterThanOrEqual(5);
57 const titles = new Set(pool.map((o) => o.title));
58 expect(titles.size).toBe(pool.length);
59 });
60 
61 it("offers variety across runs rather than a single hardcoded objective", async () => {
62 const seen = new Set<string>();
63 for (let i = 0; i < 40; i++) {
64 seen.add((await generateObjective()).objective.title);
65 }
66 expect(seen.size).toBeGreaterThan(1);
67 });
68});
69 
70/**
71 * The bounds (#71): a composition is independently validated, never trusted to the
72 * prompt. Out of bounds → reroll once → curated floor; the moderation seam runs over
73 * generated text. Curated stays the floor, so a failure never hard-fails run start.
74 */
75describe("Objective Generator — AI bounds + steer (#71)", () => {
76 const VALID = {
77 title: "Chart the Southern Stars",
78 description:
79 "Map the unknown skies a full century early and redraw every voyage of the age.",
80 era: "Age of sail",
81 icon: "🌌",
82 anchorYear: 1600,
83 };
84 const FUTURE = {
85 ...VALID,
86 title: "Settle the Red Planet",
87 description: "Plant a thriving city on Mars within the decade.",
88 anchorYear: 2200,
89 };
90 
91 // A queue per call kind, dispatched by the request's schema name, so a test
92 // declares "these composes, these checks" without depending on call ordering.
93 let composeQueue: unknown[];
94 let checkQueue: unknown[];
95 let dedupeQueue: unknown[];
96 const asContent = (payload: unknown) => ({
97 choices: [{ message: { content: JSON.stringify(payload) } }],
98 });
99 const inBounds = { withinBounds: true, reason: "settled past" };
100 
101 beforeAll(() => {
102 process.env.OPENAI_API_KEY = "test-key";
103 process.env.EVERWHEN_AI_LANE = "openai";
104 });
105 afterAll(() => {
106 delete process.env.EVERWHEN_AI_LANE;
107 delete process.env.EVERWHEN_MODERATION;
108 delete process.env.EVERWHEN_OBJECTIVE_DEDUP;
109 });
110 
111 beforeEach(() => {
112 createMock.mockReset();
113 modMock.mockReset();
114 composeQueue = [];
115 checkQueue = [];
116 dedupeQueue = [];
117 // Most tests isolate the year + subject logic from moderation and the semantic
118 // de-dup judge; the dedicated tests flip these to 'enforce'/'shadow' themselves.
119 process.env.EVERWHEN_MODERATION = "off";
120 process.env.EVERWHEN_OBJECTIVE_DEDUP = "off";
121 createMock.mockImplementation(
122 async (req: {
123 response_format?: { json_schema?: { name?: string } };
124 }) => {
125 const name = req?.response_format?.json_schema?.name;
126 const q =
127 name === "objective_bounds"
128 ? checkQueue
129 : name === "objective_dedup"
130 ? dedupeQueue
131 : composeQueue;
132 return asContent(q.shift());
133 },
134 );
135 // Default: nothing flagged (only consulted when moderation is enforced).
136 modMock.mockResolvedValue({ results: [{ category_scores: {} }] });
137 });
138 
139 it("returns a composition that clears every bound", async () => {
140 composeQueue = [VALID];
141 checkQueue = [inBounds];
142 
143 const { objective: result, composed } = await generateObjective(true);
144 
145 expect(composed).toBe(true); // the AI path produced it — not a fallback
146 expect(result.title).toBe(VALID.title);
147 expect(result.anchorYear).toBe(1600);
148 // One compose + one subject check — no reroll.
149 expect(createMock).toHaveBeenCalledTimes(2);
150 });
151 
152 it("rejects an out-of-bounds anchor year, rerolls once, and returns the valid reroll", async () => {
153 composeQueue = [FUTURE, VALID];
154 checkQueue = [inBounds];
155 
156 const { objective: result } = await generateObjective(true);
157 
158 expect(result.title).toBe(VALID.title);
159 // The year bound is pure: FUTURE is rejected before any subject-check call,
160 // so only the in-bounds reroll consults the checker.
161 const checkCalls = createMock.mock.calls.filter(
162 (c) => c[0]?.response_format?.json_schema?.name === "objective_bounds",
163 );
164 expect(checkCalls).toHaveLength(1);
165 });
166 
167 it('accepts an anchor exactly at the cutoff (the gate is "> cutoff")', async () => {
168 // Derived from the constant — the cutoff is a moving target (year - 100).
169 composeQueue = [{ ...VALID, anchorYear: SAFELY_HISTORICAL_BEFORE_YEAR }];
170 checkQueue = [inBounds];
171 
172 const { objective: result } = await generateObjective(true);
173 
174 expect(result.anchorYear).toBe(SAFELY_HISTORICAL_BEFORE_YEAR);
175 });
176 
177 it("rejects an anchor one year past the cutoff, before any subject check", async () => {
178 composeQueue = [
179 { ...VALID, anchorYear: SAFELY_HISTORICAL_BEFORE_YEAR + 1 },
180 VALID,
181 ];
182 checkQueue = [inBounds];
183 
184 const { objective: result } = await generateObjective(true);
185 
186 expect(result.title).toBe(VALID.title);
187 // cutoff+1 is rejected by the pure year gate, so only the reroll is checked.
188 const checkCalls = createMock.mock.calls.filter(
189 (c) => c[0]?.response_format?.json_schema?.name === "objective_bounds",
190 );
191 expect(checkCalls).toHaveLength(1);
192 });
193 
194 it("accepts a BC (negative) anchor through the signed year bound", async () => {
195 composeQueue = [{ ...VALID, anchorYear: -48 }];
196 checkQueue = [inBounds];
197 
198 const { objective: result } = await generateObjective(true);
199 
200 expect(result.anchorYear).toBe(-48);
201 });
202 
203 it("still subject-checks a null-anchor (spans-all-history) composition", async () => {
204 // A null anchor skips the year gate by design, so the subject check is the
205 // only thing between a "spans all history" framing and the player.
206 composeQueue = [{ ...VALID, anchorYear: null }, VALID];
207 checkQueue = [
208 { withinBounds: false, reason: "centers on a living person" },
209 inBounds,
210 ];
211 
212 const { objective: result } = await generateObjective(true);
213 
214 expect(result.title).toBe(VALID.title);
215 // Both compositions reached the subject check — the null-anchor one included.
216 const checkCalls = createMock.mock.calls.filter(
217 (c) => c[0]?.response_format?.json_schema?.name === "objective_bounds",
218 );
219 expect(checkCalls).toHaveLength(2);
220 });
221 
222 it("fails closed and rerolls when the subject check returns no content", async () => {
223 createMock.mockReset();
224 let checks = 0;
225 createMock.mockImplementation(
226 async (req: {
227 response_format?: { json_schema?: { name?: string } };
228 }) => {
229 if (req?.response_format?.json_schema?.name === "objective_bounds") {
230 checks++;
231 // First subject check blips (empty content) → fail closed; next is clean.
232 return checks === 1
233 ? { choices: [{ message: { content: "" } }] }
234 : asContent(inBounds);
235 }
236 return asContent(VALID); // both composes are year-valid
237 },
238 );
239 
240 const { objective: result } = await generateObjective(true);
241 
242 expect(result.title).toBe(VALID.title);
243 const checkCalls = createMock.mock.calls.filter(
244 (c) => c[0]?.response_format?.json_schema?.name === "objective_bounds",
245 );
246 expect(checkCalls).toHaveLength(2);
247 });
248 
249 it("rejects a composition the subject check flags, then rerolls", async () => {
250 composeQueue = [VALID, { ...VALID, title: "A Safer Frame" }];
251 checkQueue = [
252 { withinBounds: false, reason: "centers on a living person" },
253 inBounds,
254 ];
255 
256 const { objective: result } = await generateObjective(true);
257 
258 expect(result.title).toBe("A Safer Frame");
259 });
260 
261 it("falls back to the curated floor when both attempts are out of bounds", async () => {
262 composeQueue = [FUTURE, { ...FUTURE, title: "Still Out of Bounds" }];
263 checkQueue = [];
264 
265 const { objective: result, composed } = await generateObjective(true);
266 
267 expect(composed).toBe(false); // both attempts out of bounds → the curated floor
268 const curatedTitles = listCuratedObjectives().map((o) => o.title);
269 expect(curatedTitles).toContain(result.title);
270 // Exactly two compose attempts (the original + one reroll), then curated.
271 expect(createMock).toHaveBeenCalledTimes(2);
272 });
273 
274 it("falls back to curated, without a reroll, on a transport failure", async () => {
275 const quiet = vi.spyOn(console, "error").mockImplementation(() => {});
276 createMock.mockReset();
277 createMock.mockRejectedValue(new Error("model down"));
278 
279 const { objective: result, composed } = await generateObjective(true);
280 
281 expect(composed).toBe(false); // a transport failure drops straight to curated
282 const curatedTitles = listCuratedObjectives().map((o) => o.title);
283 expect(curatedTitles).toContain(result.title);
284 // A transport error won't heal on a reroll — one attempt, then curated.
285 expect(createMock).toHaveBeenCalledTimes(1);
286 quiet.mockRestore();
287 });
288 
289 it("runs generated text through the moderation seam", async () => {
290 process.env.EVERWHEN_MODERATION = "enforce";
291 // First composition trips the detector; the reroll is clean.
292 modMock.mockReset();
293 modMock
294 .mockResolvedValueOnce({
295 results: [{ category_scores: { sexual: 0.95 } }],
296 })
297 .mockResolvedValue({ results: [{ category_scores: {} }] });
298 composeQueue = [VALID, { ...VALID, title: "Clean Frame" }];
299 checkQueue = [inBounds, inBounds];
300 
301 const { objective: result } = await generateObjective(true);
302 
303 expect(result.title).toBe("Clean Frame");
304 // Moderation actually ran over the generated text (title + description).
305 expect(modMock).toHaveBeenCalled();
306 expect(modMock.mock.calls[0][0].input).toContain(VALID.title);
307 expect(modMock.mock.calls[0][0].input).toContain(VALID.description);
308 });
309 
310 it("threads the session avoid-list into the compose prompt (#95)", async () => {
311 composeQueue = [VALID];
312 checkQueue = [inBounds];
313 
314 await generateObjective(true, {}, [
315 "Reach the Moon by 1900",
316 "Forge the First Aqueducts",
317 ]);
318 
319 const composeCall = createMock.mock.calls.find(
320 (c) => c[0]?.response_format?.json_schema?.name === "game_objective",
321 )!;
322 const user = (
323 composeCall[0].messages as Array<{ role: string; content: string }>
324 ).find((m) => m.role === "user")!.content;
325 // The already-seen titles are listed for the model to steer away from.
326 expect(user).toContain("Reach the Moon by 1900");
327 expect(user).toContain("Forge the First Aqueducts");
328 expect(user.toLowerCase()).toContain("already composed");
329 });
330 
331 it("rejects a composition that duplicates the curated pool, then rerolls (#95)", async () => {
332 // The model reproduces a curated title verbatim (the exact bug #95 reports);
333 // the reroll is fresh. The de-dup is free, so the rejected one never reaches
334 // the (paid) subject check — only the clean reroll is checked.
335 composeQueue = [
336 { ...VALID, title: "Save the Library of Alexandria" },
337 VALID,
338 ];
339 checkQueue = [inBounds];
340 
341 const { objective: result } = await generateObjective(true);
342 
343 expect(result.title).toBe(VALID.title);
344 const checkCalls = createMock.mock.calls.filter(
345 (c) => c[0]?.response_format?.json_schema?.name === "objective_bounds",
346 );
347 expect(checkCalls).toHaveLength(1);
348 });
349 
350 it("catches a duplicate that only differs in case/punctuation (#95)", async () => {
351 // Normalized comparison: a re-skin of a curated title must still collide.
352 composeQueue = [
353 { ...VALID, title: "save the LIBRARY of alexandria!!!" },
354 VALID,
355 ];
356 checkQueue = [inBounds];
357 
358 const { objective: result } = await generateObjective(true);
359 
360 expect(result.title).toBe(VALID.title);
361 });
362 
363 it("rejects a composition that repeats a title seen this session, then rerolls (#95)", async () => {
364 // The session avoid-list (not curated) catches the repeat; the reroll is fresh.
365 composeQueue = [{ ...VALID, title: "My Earlier Roll" }, VALID];
366 checkQueue = [inBounds];
367 
368 const { objective: result } = await generateObjective(true, {}, [
369 "My Earlier Roll",
370 ]);
371 
372 expect(result.title).toBe(VALID.title);
373 });
374 
375 it("falls back to an UNSEEN curated objective when both attempts collide (#95)", async () => {
376 // Both compositions duplicate the curated pool; the curated FALLBACK must then
377 // avoid the one title we mark as already-seen, so even the floor stays fresh.
378 const seen = "Save the Library of Alexandria";
379 composeQueue = [
380 { ...VALID, title: seen },
381 { ...VALID, title: "Break the Black Death" },
382 ];
383 checkQueue = [];
384 
385 const { objective: result, composed } = await generateObjective(true, {}, [
386 seen,
387 ]);
388 
389 expect(composed).toBe(false); // both attempts collided → the curated floor
390 const curatedTitles = listCuratedObjectives().map((o) => o.title);
391 expect(curatedTitles).toContain(result.title);
392 expect(result.title).not.toBe(seen);
393 // Two compose attempts, both duplicates → no subject check spent, then curated.
394 expect(createMock).toHaveBeenCalledTimes(2);
395 const checkCalls = createMock.mock.calls.filter(
396 (c) => c[0]?.response_format?.json_schema?.name === "objective_bounds",
397 );
398 expect(checkCalls).toHaveLength(0);
399 });
400 
401 it("constrains and steers the compose prompt", async () => {
402 composeQueue = [VALID];
403 checkQueue = [inBounds];
404 
405 await generateObjective(true, {
406 era: "Antiquity",
407 theme: "Science & Medicine",
408 });
409 
410 const composeCall = createMock.mock.calls.find(
411 (c) => c[0]?.response_format?.json_schema?.name === "game_objective",
412 );
413 expect(composeCall).toBeDefined();
414 const messages = composeCall![0].messages as Array<{
415 role: string;
416 content: string;
417 }>;
418 const system = messages.find((m) => m.role === "system")!.content;
419 const user = messages.find((m) => m.role === "user")!.content;
420 
421 // The bounds are in the prompt (the model is asked to obey them)…
422 expect(system).toContain(String(SAFELY_HISTORICAL_BEFORE_YEAR));
423 expect(system.toLowerCase()).toContain("living people");
424 // …the steer is injected…
425 expect(user).toContain("Antiquity");
426 expect(user).toContain("Science & Medicine");
427 // …and the curated pool is shown as few-shot exemplars.
428 expect(user).toContain("Save the Library of Alexandria");
429 });
430 
431 it("de-dup off (the spec default) never spends a judge call", async () => {
432 // Guards against the judge silently running on every compose: with the mode
433 // off, a clean composition ships with zero objective_dedup calls.
434 composeQueue = [VALID];
435 checkQueue = [inBounds];
436 
437 const { objective: result, composed } = await generateObjective(true);
438 
439 expect(composed).toBe(true);
440 expect(result.title).toBe(VALID.title);
441 const dedupCalls = createMock.mock.calls.filter(
442 (c) => c[0]?.response_format?.json_schema?.name === "objective_dedup",
443 );
444 expect(dedupCalls).toHaveLength(0);
445 });
446 
447 it("enforce: rejects a semantic near-duplicate a title match misses, then rerolls", async () => {
448 process.env.EVERWHEN_OBJECTIVE_DEDUP = "enforce";
449 // A fresh TITLE (no normalized collision) the judge flags as the same objective
450 // reworded — the exact case a pure title match cannot catch.
451 composeQueue = [
452 { ...VALID, title: "Keep Alexandria's Great Library from the Flames" },
453 { ...VALID, title: "A Genuinely Fresh Frontier" },
454 ];
455 checkQueue = [inBounds, inBounds];
456 dedupeQueue = [
457 {
458 closestMatch: "Save the Library of Alexandria",
459 duplicate: true,
460 reason: "same library, reworded",
461 },
462 { closestMatch: "none", duplicate: false, reason: "" },
463 ];
464 
465 const { objective: result } = await generateObjective(true);
466 
467 expect(result.title).toBe("A Genuinely Fresh Frontier");
468 // The judge ran on both attempts — the near-dup, then the clean reroll.
469 const dedupCalls = createMock.mock.calls.filter(
470 (c) => c[0]?.response_format?.json_schema?.name === "objective_dedup",
471 );
472 expect(dedupCalls).toHaveLength(2);
473 });
474 
475 it("enforce: the judge sees the curated pool AND the session's seen titles", async () => {
476 process.env.EVERWHEN_OBJECTIVE_DEDUP = "enforce";
477 composeQueue = [VALID];
478 checkQueue = [inBounds];
479 dedupeQueue = [{ closestMatch: "none", duplicate: false, reason: "" }];
480 
481 await generateObjective(true, {}, ["Forge the First Aqueducts"]);
482 
483 const dedupCall = createMock.mock.calls.find(
484 (c) => c[0]?.response_format?.json_schema?.name === "objective_dedup",
485 )!;
486 const user = (
487 dedupCall[0].messages as Array<{ role: string; content: string }>
488 ).find((m) => m.role === "user")!.content;
489 // The candidate, a curated title, and the session-seen title are all present.
490 expect(user).toContain(VALID.title);
491 expect(user).toContain("Save the Library of Alexandria");
492 expect(user).toContain("Forge the First Aqueducts");
493 });
494 
495 it("shadow: logs the would-be duplicate but still ships it", async () => {
496 process.env.EVERWHEN_OBJECTIVE_DEDUP = "shadow";
497 composeQueue = [
498 { ...VALID, title: "Keep Alexandria's Great Library from the Flames" },
499 ];
500 checkQueue = [inBounds];
501 dedupeQueue = [
502 {
503 closestMatch: "Save the Library of Alexandria",
504 duplicate: true,
505 reason: "same library, reworded",
506 },
507 ];
508 
509 const { objective: result, composed } = await generateObjective(true);
510 
511 // Shadow observes without acting — the flagged composition ships, no reroll.
512 expect(composed).toBe(true);
513 expect(result.title).toBe(
514 "Keep Alexandria's Great Library from the Flames",
515 );
516 const dedupCalls = createMock.mock.calls.filter(
517 (c) => c[0]?.response_format?.json_schema?.name === "objective_dedup",
518 );
519 expect(dedupCalls).toHaveLength(1);
520 });
521 
522 it("feeds the rejected attempt back into the reroll prompt", async () => {
523 // The year bound rejects FUTURE; the reroll must be TOLD it was rejected, so
524 // the second draw diverges instead of re-rolling the same subject.
525 composeQueue = [FUTURE, VALID];
526 checkQueue = [inBounds];
527 
528 await generateObjective(true);
529 
530 const composeCalls = createMock.mock.calls.filter(
531 (c) => c[0]?.response_format?.json_schema?.name === "game_objective",
532 );
533 const firstUser = (
534 composeCalls[0][0].messages as Array<{ role: string; content: string }>
535 ).find((m) => m.role === "user")!.content;
536 const rerollUser = (
537 composeCalls[1][0].messages as Array<{ role: string; content: string }>
538 ).find((m) => m.role === "user")!.content;
539 // Attempt 1 carries no rejection clause; the reroll names what was rejected.
540 expect(firstUser.toLowerCase()).not.toContain("rejected");
541 expect(rerollUser).toContain(FUTURE.title);
542 expect(rerollUser.toLowerCase()).toContain("rejected");
543 });
544 
545 it("enforce: fails open — a blank de-dup verdict still ships the composition", async () => {
546 process.env.EVERWHEN_OBJECTIVE_DEDUP = "enforce";
547 // The judge returns nothing (an outage / empty content). Freshness fails OPEN:
548 // the composition ships rather than being dropped — unlike the bounds gate.
549 composeQueue = [VALID];
550 checkQueue = [inBounds];
551 dedupeQueue = []; // empty → the mock yields blank content for the dedup call
552 
553 const { objective: result, composed } = await generateObjective(true);
554 
555 expect(composed).toBe(true);
556 expect(result.title).toBe(VALID.title);
557 // The judge WAS consulted under enforce — it just failed open.
558 const dedupCalls = createMock.mock.calls.filter(
559 (c) => c[0]?.response_format?.json_schema?.name === "objective_dedup",
560 );
561 expect(dedupCalls).toHaveLength(1);
562 });
563 
564 it("enforce: an exact title collision short-circuits — no de-dup call spent", async () => {
565 process.env.EVERWHEN_OBJECTIVE_DEDUP = "enforce";
566 // A verbatim curated title is caught by the free normalized-title Set BEFORE any
567 // paid call — so even under enforce the colliding attempt spends neither a bounds
568 // nor a de-dup call; only the clean reroll consults each judge.
569 composeQueue = [
570 { ...VALID, title: "Save the Library of Alexandria" },
571 VALID,
572 ];
573 checkQueue = [inBounds];
574 dedupeQueue = [{ closestMatch: "none", duplicate: false, reason: "" }];
575 
576 const { objective: result } = await generateObjective(true);
577 
578 expect(result.title).toBe(VALID.title);
⋯ 97 lines hidden (lines 579–675)
579 const dedupCalls = createMock.mock.calls.filter(
580 (c) => c[0]?.response_format?.json_schema?.name === "objective_dedup",
581 );
582 const checkCalls = createMock.mock.calls.filter(
583 (c) => c[0]?.response_format?.json_schema?.name === "objective_bounds",
584 );
585 expect(dedupCalls).toHaveLength(1);
586 expect(checkCalls).toHaveLength(1);
587 });
588});
589 
590describe("mapBoundsWire", () => {
591 it("reads a clean verdict", () => {
592 expect(mapBoundsWire({ withinBounds: true, reason: "" })).toBe(true);
593 expect(
594 mapBoundsWire({ withinBounds: false, reason: "living person" }),
595 ).toBe(false);
596 });
597 
598 it("returns null for an unusable shape (treated as out of bounds)", () => {
599 expect(mapBoundsWire(null)).toBeNull();
600 expect(mapBoundsWire("blocked")).toBeNull();
601 expect(mapBoundsWire({})).toBeNull();
602 expect(mapBoundsWire({ withinBounds: "yes" })).toBeNull();
603 });
604});
605 
606describe("mapDedupWire", () => {
607 it("reads a clean verdict", () => {
608 expect(
609 mapDedupWire({ duplicate: true, closestMatch: "X", reason: "same" }),
610 ).toEqual({ duplicate: true, closestMatch: "X", reason: "same" });
611 expect(
612 mapDedupWire({ duplicate: false, closestMatch: "none", reason: "" }),
613 ).toEqual({ duplicate: false, closestMatch: "none", reason: "" });
614 });
615 
616 it("keeps the boolean decision, defaulting missing string fields", () => {
617 expect(mapDedupWire({ duplicate: true })).toEqual({
618 duplicate: true,
619 closestMatch: "",
620 reason: "",
621 });
622 });
623 
624 it("returns null for an unusable shape (treated as not a duplicate)", () => {
625 expect(mapDedupWire(null)).toBeNull();
626 expect(mapDedupWire("dup")).toBeNull();
627 expect(mapDedupWire({})).toBeNull();
628 expect(mapDedupWire({ duplicate: "yes" })).toBeNull();
629 });
630});
631 
632describe("Curated briefs are grounded (issue #82)", () => {
633 const pool = listCuratedObjectives();
634 
635 it("every brief states a real-timeline date and reads as bullet lines", () => {
636 for (const obj of pool) {
637 // A concrete date so a player who does not already hold one (non-US /
638 // non-Western) gets an anchor: every brief names at least one 3-4 digit
639 // year (1914, 476, 285, 221, …) or none of the issue's point is met.
640 expect(obj.description, `${obj.title}: brief needs a real date`).toMatch(
641 /\d{3,4}/,
642 );
643 // Bulleted, multi-line — not the old single vague sentence.
644 expect(
645 obj.description,
646 `${obj.title}: brief should be bullet lines`,
647 ).toContain("\n");
648 expect(
649 obj.description,
650 `${obj.title}: bullets start with "• "`,
651 ).toContain("• ");
652 }
653 });
654 
655 it("every brief fits the (raised) description cap", () => {
656 for (const obj of pool) {
657 expect(
658 obj.description.length,
659 `${obj.title}: brief exceeds the cap`,
660 ).toBeLessThanOrEqual(MAX_OBJECTIVE_DESC_CHARS);
661 }
662 });
663 
664 it("states the real abolition dates distinct from the in-game anchor (anchorYear is not the real date)", () => {
665 // Slavery is the canonical case the issue calls out: anchor 1750 is the
666 // counterfactual target, NOT when abolition actually happened. The brief must
667 // carry the real dates, proving the two are kept distinct, not conflated.
668 const slavery = pool.find(
669 (o) => o.title === "Abolish Slavery a Century Early",
670 )!;
671 expect(slavery.anchorYear).toBe(1750);
672 // The real abolition dates appear in the brief, and they are not the anchor.
673 expect(slavery.description).toMatch(/1807|1833|1865|1888/);
674 });
675});