What changed, and why

The craft verdict is the Judge's one-line reason for a grade — the player's main cue for how to write a sharper message next turn. For longer reasons it was cut off mid-word: …lacks specifics on medi for "mediation". The tail is the actionable part, so losing it reads as a glitch.

The cause was a defensive slice(0, 120) on the reason field in callJudgeAI. That field is structured output, asked to stay "under 90 characters" — but the model overruns that soft hint, and the raw slice then severed whatever word it landed in.

The fix keeps a defensive cap and makes it cut cleanly. A small pure helper trims on a word boundary and marks the elision; the seam calls it in place of the slice. Blast radius is one call site plus a new util — the grade, the roll modifier, and the rest of the pipeline are untouched.

The word-safe trim

trimVerdictReason is a pure function. If the text fits the cap it returns unchanged. Otherwise it finds the last space at or before the cap, cuts there, and appends an ellipsis. One detail is worth a second look: it searches slice(0, cap + 1), so a space sitting exactly at the cap still counts as a boundary.

The cap, then the trim: boundary search, runaway fallback, punctuation strip.

utils/verdict.ts · 44 lines
utils/verdict.ts44 lines · TypeScript
⋯ 16 lines hidden (lines 1–16)
1/**
2 * The craft verdict's one-line reason — the Judge's player-facing "why" for a
3 * grade (issue #126).
4 *
5 * The reason is structured output asked to stay "under 90 characters"
6 * (server/utils/openai.ts), but that's a SOFT hint the model regularly overruns,
7 * and providers don't enforce string length even in strict structured outputs. So
8 * we still cap defensively before the reason reaches the turn reveal — but cut on a
9 * word boundary, never mid-letter. The old hard `slice(0, 120)` severed words
10 * ("…lacks specifics on medi" for "mediation"), stripping exactly the actionable
11 * part of the player's main learning signal and reading as a glitch.
12 *
13 * Lives in utils/ as a pure transform, beside the rest of the verdict vocabulary
14 * (craft.ts, continuity.ts) the seam and the UI both read.
15 */
16 
17/** Defensive cap on the verdict reason, in characters. Generous over the 90-char
18 * prompt target so a typical overrun survives whole; the trim only bites the rare
19 * runaway, and cuts it cleanly. */
20export const VERDICT_REASON_CAP = 160
21 
22/**
23 * Trim a verdict reason to `cap` characters without cutting a word in half. Returns
24 * the text unchanged when it fits; otherwise cuts at the last word boundary at or
25 * before the cap and marks the elision with an ellipsis. Falls back to a hard cut
26 * only for one unbroken token with no boundary to cut on.
27 */
28export function trimVerdictReason(reason: string, cap: number = VERDICT_REASON_CAP): string {
29 const text = reason.trim()
30 if (text.length <= cap) return text
31 
32 // Search the window up to and including the cap-th char, so a boundary sitting
33 // exactly at the cap still counts.
34 const window = text.slice(0, cap + 1)
35 const lastSpace = window.lastIndexOf(' ')
36 
37 // No word boundary at all — a single runaway token. A hard cut is the only move
38 // left, but a marked one still beats a silent severed word.
39 if (lastSpace <= 0) return text.slice(0, cap).trimEnd() + '…'
40 
41 // Cut before the boundary word's trailing space, drop any dangling clause
42 // punctuation, then mark the elision.
43 return text.slice(0, lastSpace).replace(/[\s.,;:]+$/, '') + '…'

One line at the judge seam

The only behavioral change at the seam is the reason line (379). The JSON parse, the craft coercion, and the closed-door timing rule just above it are unchanged. The string guard stays too: a non-string reason still falls back to the empty string.

The judge return — line 379 is the only call site changed.

server/utils/openai.ts · 578 lines
server/utils/openai.ts578 lines · TypeScript
⋯ 367 lines hidden (lines 1–367)
1/**
2 * The game's AI layers. Historically OpenAI-only (hence the filename, kept to
3 * avoid churning every reference); today each layer is routed per task through
4 * the AI gateway (ai-gateway.ts) to its bake-off-chosen lane — Anthropic tiers
5 * by default, the OpenAI lane alive behind REVISIONIST_AI_LANE.
6 *
7 * Every layer keeps the same contract it always had: build prompt → structured
8 * call → parse → guard → never-throw envelope. The catch-block console.errors
9 * are intentional (ops signal, not debug cruft).
10 */
11import {
12 buildCharacterPrompt,
13 buildTimelinePrompt,
14 buildChroniclePrompt,
15 buildChroniclePromptClaude,
16 buildResearchPrompt,
17 buildLookupPrompt,
18 buildJudgePrompt,
19 type ObjectiveContext,
20 type TimelineContextEntry,
21 type CharacterGrounding,
22 type ChronicleStatus,
23 type FigureStudy,
24 type ArchiveLookup
25} from './prompt-builder'
26import { completeStructured, ModelRefusalError, type ChatTurn } from './ai-gateway'
27import { routeFor } from './ai-routing'
28import { amplifyForAnachronism, toAnachronism, type Anachronism } from './anachronism'
29import { chainStatus, decayForChain, type ChainStatus } from '~/utils/causal-chain'
30import { toCraft, CRAFT_LEVELS, CRAFT_MODIFIER, CRAFT_GAIN_FACTOR, type Craft } from '~/utils/craft'
31import { toContinuity, CONTINUITY_LEVELS, type Continuity } from '~/utils/continuity'
32import { trimVerdictReason } from '~/utils/verdict'
33import type { DiceOutcome } from '~/utils/dice'
34import { BAND_CEILING, SWING_BANDS, VALENCE_SIGN_GUARD_MIN } from '~/utils/swing-bands'
35import { MAX_PROGRESS_SWING } from '~/utils/game-config'
36import { momentumFactor } from '~/utils/momentum'
37 
38/**
39 * What a figure returns each turn: an in-character reply and action, plus factual
40 * metadata (era, persona) the UI uses to label them.
41 */
42export interface StructuredCharacterResponse {
43 message: string
44 action: string
45 era: string
46 persona: string
48 
49export interface CharacterAIResponse {
50 success: boolean
51 characterResponse?: StructuredCharacterResponse
52 error?: string
53 /** Claude's own safety classifier declined this turn (stop_reason refusal) —
54 * the caller surfaces it as a moderation block, not an infra refund. */
55 refused?: boolean
57 
58/**
59 * The Timeline Engine's verdict for a turn: how history bent.
60 */
61export interface TimelineRipple {
62 headline: string
63 detail: string
64 progressChange: number
65 /** The engine's swing BEFORE the anachronism amplifier — kept so the UI can
66 * show the wager's work ("+8 ⚡ far-ahead → +11%") instead of an opaque total. */
67 baseProgressChange: number
68 valence: 'positive' | 'negative' | 'neutral'
69 /** How far beyond the figure's era the player reached — widens the swing. */
70 anachronism: Anachronism
71 /** How far this moment lands from the nearest foothold (objective anchor or a
72 * prior change) — DECAYS the swing toward zero. Absent for ungrounded turns
73 * or anchorless objectives (the dial no-ops). The opposite of anachronism. */
74 causalChain?: ChainStatus
76 
77export interface TimelineAIResponse {
78 success: boolean
79 ripple?: TimelineRipple
80 error?: string
81 /** Model-side safety refusal — surfaced as a block, not an infra refund. */
82 refused?: boolean
84 
85/**
86 * The Chronicler's telling: a titled, multi-paragraph prose account of the altered
87 * timeline as it currently stands. Rewritten each turn; the final one is the epilogue.
88 */
89export interface ChronicleEntry {
90 title: string
91 paragraphs: string[]
93 
94export interface ChronicleAIResponse {
95 success: boolean
96 chronicle?: ChronicleEntry
97 error?: string
99 
100interface HistoryItem { text: string; sender: string }
101 
102/**
103 * Maps the stored conversation thread into chat turns. The thread is expected to
104 * end with the current user message; if it doesn't (e.g. a direct call with no
105 * history), the current message is appended so the figure has it.
106 */
107function toChatMessages(conversationHistory: HistoryItem[], currentUserMessage: string): ChatTurn[] {
108 const mapped: ChatTurn[] = (conversationHistory || [])
109 .filter(m => m && typeof m.text === 'string' && m.sender !== 'system')
110 .map(m => ({
111 role: (m.sender === 'user' ? 'user' : 'assistant') as 'user' | 'assistant',
112 content: m.text
113 }))
114 
115 const last = mapped[mapped.length - 1]
116 if (!last || last.role !== 'user' || last.content !== currentUserMessage) {
117 mapped.push({ role: 'user', content: currentUserMessage })
118 }
119 return mapped
121 
122/**
123 * Layer 1 — role-plays the chosen figure (any name, any era), objective-blind,
124 * returning a structured reply + action + factual era/persona metadata.
125 */
126export async function callCharacterAI(
127 figureName: string,
128 userMessage: string,
129 conversationHistory: HistoryItem[] = [],
130 diceOutcome: DiceOutcome,
131 grounding?: CharacterGrounding,
132 worldBrief?: string[],
133 /** True when the dispatch carries no actionable substance — keeps the figure
134 * honest so a favorable roll on noise yields confusion, not a fabricated deed. */
135 contentless?: boolean
136): Promise<CharacterAIResponse> {
137 try {
138 const content = await completeStructured({
139 task: 'character',
140 system: buildCharacterPrompt(figureName, diceOutcome, grounding, worldBrief, contentless),
141 turns: toChatMessages(conversationHistory, userMessage),
142 schemaName: 'character_response',
143 schema: {
144 type: 'object',
145 properties: {
146 message: { type: 'string', description: `${figureName}'s in-character reply, 160 chars max` },
147 action: { type: 'string', description: `The concrete thing ${figureName} decides to do` },
148 era: { type: 'string', description: 'Short factual when/where tag, e.g. "Vienna, 1914"' },
149 persona: { type: 'string', description: 'A 3-6 word factual descriptor of the figure' }
150 },
151 required: ['message', 'action', 'era', 'persona'],
152 additionalProperties: false
153 }
154 })
155 
156 if (!content) {
157 return { success: false, error: 'Character AI generated an empty response' }
158 }
159 
160 const parsed = JSON.parse(content) as StructuredCharacterResponse
161 if (!parsed.message || !parsed.action) {
162 return { success: false, error: 'Character AI response missing required fields' }
163 }
164 
165 return { success: true, characterResponse: parsed }
166 } catch (error) {
167 if (error instanceof ModelRefusalError) {
168 return { success: false, refused: true, error: 'This dispatch was declined by content moderation and cannot be sent.' }
169 }
170 console.error('Character AI error:', error)
171 return { success: false, error: 'Character AI could not process the request' }
172 }
174 
175/**
176 * Layer 2 — judges how the figure's action ripples through the already-altered
177 * world toward the live objective, returning a structured timeline ripple.
178 */
179export async function callTimelineAI(args: {
180 objective: ObjectiveContext
181 figureName: string
182 era: string
183 userMessage: string
184 characterMessage: string
185 characterAction: string
186 diceRoll: number
187 diceOutcome: DiceOutcome
188 timeline?: TimelineContextEntry[]
189 figureYear?: number
190 figureMoment?: string
191 /** The objective's far anchor year — a foothold for the causal-chain dial. */
192 objectiveAnchorYear?: number
193 /** The Judge's craft grade for this dispatch — drives the gains-only craft
194 * amplifier (issue #62). Absent → no amplification (factor 1). */
195 craft?: Craft
196 /** The run's PRE-turn momentum (0..4) — the run-level gains amplifier that rides
197 * alongside craft. Absent → no amplification (factor 1). */
198 momentum?: number
199}): Promise<TimelineAIResponse> {
200 try {
201 const content = await completeStructured({
202 task: 'timeline',
203 system: buildTimelinePrompt({ ...args, timeline: args.timeline ?? [] }),
204 schemaName: 'timeline_ripple',
205 schema: {
206 type: 'object',
207 properties: {
208 headline: { type: 'string', description: 'Punchy headline, ~9 words max' },
209 detail: { type: 'string', description: '1-2 sentences describing the ripple through history' },
210 progressChange: { type: 'integer', description: `Integer from -${BAND_CEILING} to ${BAND_CEILING}, within the rolled band` },
211 valence: { type: 'string', enum: ['positive', 'negative', 'neutral'] },
212 anachronism: { type: 'string', enum: ['in-period', 'ahead', 'far-ahead', 'impossible'], description: 'How far beyond the figure’s era the message reached' }
213 },
214 required: ['headline', 'detail', 'progressChange', 'valence', 'anachronism'],
215 additionalProperties: false
216 }
217 })
218 
219 if (!content) {
220 return { success: false, error: 'Timeline AI generated an empty response' }
221 }
222 
223 const parsed = JSON.parse(content)
224 if (typeof parsed.headline !== 'string' || typeof parsed.detail !== 'string' || typeof parsed.progressChange !== 'number') {
225 return { success: false, error: 'Timeline AI response missing required fields' }
226 }
227 
228 // The band table is law — in code, not just prompt: the model's base swing
229 // is clamped into the ROLLED band before the amplifier touches it, so the
230 // disclosed legend and the engine can never disagree about a roll's range.
231 const band = SWING_BANDS[args.diceOutcome]
232 const baseProgress = Math.max(band.min, Math.min(band.max, Math.round(parsed.progressChange)))
233 const anachronism = toAnachronism(parsed.anachronism)
234 const amplified = amplifyForAnachronism(baseProgress, anachronism)
235 // Causal-chain decay (the opposite dial): a reach far from any foothold is
236 // diffused toward zero. Footholds are the objective's anchor year plus every
237 // dated change already on the ledger — so landing a change plants a new
238 // foothold and chaining forward overcomes the distance. No-ops (factor 1)
239 // for ungrounded turns or anchorless objectives.
240 const footholds = [args.objectiveAnchorYear, ...(args.timeline ?? []).map(e => e.whenSigned)]
241 const causalChain = chainStatus(args.figureYear, footholds) ?? undefined
242 const decayed = causalChain ? decayForChain(amplified, causalChain.factor) : amplified
243 // Gains-only amplifiers (issue #62): a sharper dispatch (craft) and a more
244 // coherent arc (momentum) each bank more of the band the roll earned — but ONLY
245 // on the upside. Losses keep the rolled band's verdict untouched (mirrors
246 // amplifyForAnachronism's sign branch), so skill buys reach, never immunity.
247 // After this the value can exceed the per-turn fuse, and nothing downstream
248 // re-clamps an UPWARD push before the (post-stake) ±100 cap — so re-clamp to
249 // ±MAX_PROGRESS_SWING here.
250 const craftFactor = args.craft ? CRAFT_GAIN_FACTOR[args.craft] : 1
251 const momoFactor = momentumFactor(args.momentum ?? 0)
252 const gained = decayed > 0 ? decayed * craftFactor * momoFactor : decayed
253 const progressChange = Math.max(-MAX_PROGRESS_SWING, Math.min(MAX_PROGRESS_SWING, Math.round(gained)))
254 const modelValence: TimelineRipple['valence'] =
255 parsed.valence === 'positive' || parsed.valence === 'negative' || parsed.valence === 'neutral'
256 ? parsed.valence
257 : (progressChange > 0 ? 'positive' : progressChange < 0 ? 'negative' : 'neutral')
258 // Sign guard: a swing beyond the neutral window (±3, the eval's own line)
259 // must wear the color of its sign — a "positive" badge on a -12 turn is a
260 // straight "this game is arbitrary" signal. Small swings keep the model's
261 // shading, where "neutral" or a soft contradiction is fair judgment.
262 const signValence: TimelineRipple['valence'] = progressChange > 0 ? 'positive' : 'negative'
263 const valence = Math.abs(progressChange) > VALENCE_SIGN_GUARD_MIN ? signValence : modelValence
264 
265 return {
266 success: true,
267 ripple: { headline: parsed.headline, detail: parsed.detail, progressChange, baseProgressChange: baseProgress, valence, anachronism, causalChain }
268 }
269 } catch (error) {
270 if (error instanceof ModelRefusalError) {
271 return { success: false, refused: true, error: 'This dispatch was declined by content moderation and cannot be sent.' }
272 }
273 console.error('Timeline AI error:', error)
274 return { success: false, error: 'Timeline AI could not process the request' }
275 }
277 
278/**
279 * The Message Judge (roadmap slice 5) — grades the craft of the player's dispatch
280 * BEFORE the dice are thrown; the grade becomes a small roll modifier. Objective-
281 * aware (it judges leverage toward the goal) but outcome-blind: it never sees the
282 * roll. A failure here must never punish the player — callers fall back to the
283 * neutral 'sound' (modifier 0), so a Judge outage just means an untilted die.
284 */
285export interface JudgeVerdict {
286 craft: Craft
287 /** Whether the dispatch builds on the run's thread — feeds the momentum meter
288 * (issue #62). 'neutral' on turn 1 or a Judge outage (never moves momentum). */
289 continuity: Continuity
290 reason: string
292 
293export interface JudgeAIResponse {
294 success: boolean
295 judge?: JudgeVerdict
296 error?: string
298 
299export async function callJudgeAI(args: {
300 objective: ObjectiveContext
301 figureName: string
302 when?: string
303 momentRefined?: boolean
304 userMessage: string
305 /** The figure's reply on the PRIOR turn — their last revealed condition/price.
306 * Empty on turn 1. The Judge reads it to see if THIS dispatch meets it. */
307 lastFigureReply?: string
308 /** A short digest of recent ledger headlines so the Judge can spot a dispatch
309 * that deliberately exploits a change already on the record. */
310 ledgerDigest?: string[]
311}): Promise<JudgeAIResponse> {
312 try {
313 // With a pinned moment the schema gains two leading fields: "event"
314 // makes the model WRITE OUT the real event and its real-world date, so
315 // the "timing" enum right after it compares two dates sitting on the
316 // page instead of inferring silently inside one token. Probed 24/24 on
317 // Haiku and Sonnet where a bare timing enum failed 0/12 — date retrieval
318 // must be a generation step, not an implication. The CAP is still
319 // enforced in code below (legend-is-law, like the band clamp).
320 const timingFields = args.momentRefined
321 ? {
322 event: {
323 type: 'string',
324 description: 'The real recorded historical event this dispatch is trying to influence, and the exact real-world date it occurred (e.g. "the Hindenburg disaster at Lakehurst — May 6, 1937")'
325 },
326 timing: {
327 type: 'string',
328 enum: ['in-time', 'too-late'],
329 description: '"too-late" if the event\'s real date above is earlier than the stated moment the dispatch arrives — its chance already spent; otherwise "in-time"'
330 }
331 }
332 : {}
333 // Continuity is read only when there's a thread to build on. On turn 1 (no
334 // prior reply, empty ledger) the prompt + schema stay byte-identical to the
335 // pre-feature build and continuity defaults to 'neutral' — momentum can't
336 // build out of nothing anyway.
337 const hasThread = !!(args.lastFigureReply || args.ledgerDigest?.length)
338 const continuityField = hasThread
339 ? {
340 continuity: {
341 type: 'string',
342 enum: [...CONTINUITY_LEVELS],
343 description: '"builds" if this dispatch meets a condition/price the figure just revealed, deliberately exploits a change already on the record, or coheres with and escalates the run\'s strategy; "resets" if it abandons that thread and starts cold; "neutral" otherwise'
344 }
345 }
346 : {}
347 const content = await completeStructured({
348 task: 'judge',
349 system: buildJudgePrompt(args),
350 schemaName: 'craft_verdict',
351 schema: {
352 type: 'object',
353 properties: {
354 ...timingFields,
355 craft: { type: 'string', enum: [...CRAFT_LEVELS], description: 'The dispatch’s craft grade' },
356 ...continuityField,
357 reason: { type: 'string', description: 'One terse player-facing sentence, under 90 characters' }
358 },
359 required: [...(args.momentRefined ? ['event', 'timing'] : []), 'craft', ...(hasThread ? ['continuity'] : []), 'reason'],
360 additionalProperties: false
361 }
362 })
363 
364 if (!content) {
365 return { success: false, error: 'Judge generated an empty response' }
366 }
367 
368 const parsed = JSON.parse(content) as { timing?: unknown; craft?: unknown; continuity?: unknown; reason?: unknown }
369 let craft = toCraft(parsed.craft)
370 // The closed-door cap, in code: a too-late pin grades vague at best.
371 if (args.momentRefined && parsed.timing === 'too-late' && CRAFT_MODIFIER[craft] > CRAFT_MODIFIER.vague) {
372 craft = 'vague'
373 }
374 return {
375 success: true,
376 judge: {
377 craft,
378 continuity: toContinuity(parsed.continuity),
379 reason: typeof parsed.reason === 'string' ? trimVerdictReason(parsed.reason) : ''
380 }
381 }
⋯ 197 lines hidden (lines 382–578)
382 } catch (error) {
383 console.error('Judge AI error:', error)
384 return { success: false, error: 'Judge could not assess the dispatch' }
385 }
387 
388/**
389 * The Archivist (prototype) — a grounded, OBJECTIVE-BLIND brief on a real figure at
390 * a chosen point in their life: who they are, what they grasp, what they're reaching
391 * for, and what lies beyond their era. In-game research so the player needn't break
392 * out to a search engine — it enriches the world, never makes their move.
393 */
394export interface ArchivistAIResponse {
395 success: boolean
396 study?: FigureStudy
397 error?: string
398 /** Claude's own safety classifier declined the study — surfaced as a moderation
399 * block (the study-blocked banner), not an infra failure. */
400 refused?: boolean
402 
403export async function callArchivistAI(args: {
404 figureName: string
405 when?: string
406 description?: string
407 extract?: string
408}): Promise<ArchivistAIResponse> {
409 try {
410 const content = await completeStructured({
411 task: 'archivist-study',
412 system: buildResearchPrompt(args),
413 schemaName: 'figure_study',
414 schema: {
415 type: 'object',
416 properties: {
417 atThisMoment: { type: 'string', description: 'Where the figure stands in life and work at the chosen moment' },
418 grasp: { type: 'array', items: { type: 'string' }, description: '2-4 things they genuinely understand' },
419 reaching: { type: 'array', items: { type: 'string' }, description: '2-4 things they pursue or are stuck on' },
420 cannotYetKnow: { type: 'string', description: 'One sentence on what lies beyond their era' }
421 },
422 required: ['atThisMoment', 'grasp', 'reaching', 'cannotYetKnow'],
423 additionalProperties: false
424 }
425 })
426 
427 if (!content) {
428 return { success: false, error: 'Archivist generated an empty response' }
429 }
430 
431 const parsed = JSON.parse(content) as { atThisMoment?: unknown; grasp?: unknown; reaching?: unknown; cannotYetKnow?: unknown }
432 const asStrings = (v: unknown): string[] =>
433 Array.isArray(v) ? v.filter((s): s is string => typeof s === 'string' && s.trim().length > 0) : []
434 const grasp = asStrings(parsed.grasp)
435 const reaching = asStrings(parsed.reaching)
436 if (typeof parsed.atThisMoment !== 'string' || !parsed.atThisMoment.trim() || (!grasp.length && !reaching.length)) {
437 return { success: false, error: 'Archivist response missing required fields' }
438 }
439 
440 return {
441 success: true,
442 study: {
443 atThisMoment: parsed.atThisMoment.trim(),
444 grasp,
445 reaching,
446 cannotYetKnow: typeof parsed.cannotYetKnow === 'string' ? parsed.cannotYetKnow.trim() : ''
447 }
448 }
449 } catch (error) {
450 if (error instanceof ModelRefusalError) {
451 return { success: false, refused: true, error: 'This study was declined by content moderation.' }
452 }
453 console.error('Archivist AI error:', error)
454 return { success: false, error: 'Archivist could not process the request' }
455 }
457 
458/**
459 * The Archive lookup (prototype, "yellow") — a concise, factual answer to a freeform
460 * topic query, stamped with when the knowledge first emerged so the player can read
461 * its anachronism. Pure domain facts (what/ingredients/when), never strategy.
462 */
463export interface ArchiveLookupResponse {
464 success: boolean
465 lookup?: ArchiveLookup
466 error?: string
467 /** Claude's own safety classifier declined the lookup — surfaced as a moderation
468 * block (the lookup-blocked banner), not an infra failure. */
469 refused?: boolean
471 
472export async function callArchiveLookupAI(query: string): Promise<ArchiveLookupResponse> {
473 try {
474 const content = await completeStructured({
475 task: 'archive-lookup',
476 system: buildLookupPrompt(query),
477 schemaName: 'archive_lookup',
478 schema: {
479 type: 'object',
480 properties: {
481 topic: { type: 'string', description: 'Short title for what was asked' },
482 summary: { type: 'string', description: 'One or two plain sentences — the essential fact' },
483 requires: { type: 'array', items: { type: 'string' }, description: 'Concrete ingredients / prerequisites, or empty' },
484 knownSince: { type: 'string', description: 'When/where this knowledge first emerged' }
485 },
486 required: ['topic', 'summary', 'requires', 'knownSince'],
487 additionalProperties: false
488 }
489 })
490 
491 if (!content) {
492 return { success: false, error: 'Archive generated an empty response' }
493 }
494 
495 const parsed = JSON.parse(content) as { topic?: unknown; summary?: unknown; requires?: unknown; knownSince?: unknown }
496 if (typeof parsed.topic !== 'string' || typeof parsed.summary !== 'string' || !parsed.summary.trim()) {
497 return { success: false, error: 'Archive response missing required fields' }
498 }
499 const requires = Array.isArray(parsed.requires)
500 ? parsed.requires.filter((s): s is string => typeof s === 'string' && s.trim().length > 0)
501 : []
502 
503 return {
504 success: true,
505 lookup: {
506 topic: parsed.topic.trim() || query,
507 summary: parsed.summary.trim(),
508 requires,
509 knownSince: typeof parsed.knownSince === 'string' ? parsed.knownSince.trim() : ''
510 }
511 }
512 } catch (error) {
513 if (error instanceof ModelRefusalError) {
514 return { success: false, refused: true, error: 'That lookup was declined by content moderation.' }
515 }
516 console.error('Archive lookup error:', error)
517 return { success: false, error: 'Archive could not process the request' }
518 }
520 
521/**
522 * Layer 3 — the Chronicler. Narrates the whole altered timeline as it now stands,
523 * rewritten each turn from the running ledger. It judges nothing, so a failure is
524 * non-fatal: the caller simply keeps the prior telling rather than blanking the panel.
525 *
526 * The FINAL telling (victory/defeat) is the run's epilogue — the share artifact —
527 * and routes as its own task so the flagship model can carry that one moment.
528 */
529export async function callChroniclerAI(args: {
530 objective: ObjectiveContext
531 timeline: TimelineContextEntry[]
532 status: ChronicleStatus
533 progress: number
534}): Promise<ChronicleAIResponse> {
535 try {
536 const task = args.status === 'playing' ? 'chronicler' : 'epilogue'
537 // Prompts are per-model artifacts: the Claude voice won its lane in
538 // blind pairwise (see buildChroniclePromptClaude); the incumbent voice
539 // stays exactly as-is for the OpenAI lane, so the fallback lever keeps
540 // its tuned prompt too.
541 const { lane } = routeFor(task)
542 const content = await completeStructured({
543 task,
544 system: lane === 'anthropic' ? buildChroniclePromptClaude(args) : buildChroniclePrompt(args),
545 schemaName: 'chronicle',
546 schema: {
547 type: 'object',
548 properties: {
549 title: { type: 'string', description: 'Short, evocative title for this version of history (3-6 words)' },
550 paragraphs: {
551 type: 'array',
552 items: { type: 'string' },
553 description: '2-4 short paragraphs of prose, in reading order'
554 }
555 },
556 required: ['title', 'paragraphs'],
557 additionalProperties: false
558 }
559 })
560 
561 if (!content) {
562 return { success: false, error: 'Chronicle AI generated an empty response' }
563 }
564 
565 const parsed = JSON.parse(content) as { title?: unknown; paragraphs?: unknown }
566 const paragraphs = Array.isArray(parsed.paragraphs)
567 ? parsed.paragraphs.filter((p): p is string => typeof p === 'string' && p.trim().length > 0)
568 : []
569 if (typeof parsed.title !== 'string' || !parsed.title.trim() || paragraphs.length === 0) {
570 return { success: false, error: 'Chronicle AI response missing required fields' }
571 }
572 
573 return { success: true, chronicle: { title: parsed.title.trim(), paragraphs } }
574 } catch (error) {
575 console.error('Chronicle AI error:', error)
576 return { success: false, error: 'Chronicle AI could not process the request' }
577 }

Tests pin the behavior

The util's behavior is fixed with small, readable cases: the exact-cap edge, a boundary landing on the cap, the punctuation strip, and the runaway-token fallback. Two cases speak to the bug directly.

Boundary + fallback cases, then the two that mirror the bug.

tests/unit/utils/verdict.spec.ts · 58 lines
tests/unit/utils/verdict.spec.ts58 lines · TypeScript
⋯ 16 lines hidden (lines 1–16)
1import { describe, it, expect } from 'vitest'
2import { VERDICT_REASON_CAP, trimVerdictReason } from '../../../utils/verdict'
3 
4describe('verdict reason trim (issue #126)', () => {
5 it('returns a reason that already fits, unchanged', () => {
6 expect(trimVerdictReason('Names the lever and the moment.')).toBe('Names the lever and the moment.')
7 })
8 
9 it('trims surrounding whitespace', () => {
10 expect(trimVerdictReason(' A clear, honest ask. ')).toBe('A clear, honest ask.')
11 })
12 
13 it('keeps a reason exactly at the cap', () => {
14 expect(trimVerdictReason('exactly10!', 10)).toBe('exactly10!')
15 })
16 
17 it('cuts on a word boundary and marks the elision — never mid-word', () => {
18 expect(trimVerdictReason('hello world foo', 11)).toBe('hello world…')
19 })
20 
21 it('counts a boundary sitting exactly at the cap', () => {
22 // The space lands at index 10 (== cap); the window reaches cap+1 so it counts.
23 expect(trimVerdictReason('exactlyten yes', 10)).toBe('exactlyten…')
24 })
25 
26 it('drops dangling clause punctuation before the ellipsis', () => {
27 expect(trimVerdictReason('alpha beta, gamma delta', 14)).toBe('alpha beta…')
28 })
29 
30 it('hard-cuts a single runaway token with no boundary, but still marks it', () => {
31 expect(trimVerdictReason('supercalifragilistic', 10)).toBe('supercalif…')
32 })
⋯ 10 lines hidden (lines 33–42)
33 
34 it('lets a typical overrun past the OLD 120-char cap survive whole (the bug)', () => {
35 // 124 chars: the old slice(0, 120) severed this mid-word; the roomier cap
36 // returns it intact, so the actionable tail is never lost.
37 const reason = 'Names the lever and the moment, but it leans on Wilhelm restraint outweighing his alliance with Austria over the July plans.'
38 expect(reason.length).toBeGreaterThan(120)
39 expect(reason.length).toBeLessThanOrEqual(VERDICT_REASON_CAP)
40 expect(trimVerdictReason(reason)).toBe(reason)
41 })
42 
43 it('cuts a genuinely overlong reason on a word boundary, keeping whole words', () => {
44 const reason = 'Names the lever and the moment, but it leans hard on Wilhelm restraint outweighing his alliance with Austria-Hungary over the July mobilization timetables already locked in by the staff.'
45 const out = trimVerdictReason(reason)
46 expect(reason.length).toBeGreaterThan(VERDICT_REASON_CAP)
47 expect(out.length).toBeLessThanOrEqual(VERDICT_REASON_CAP + 1) // + the ellipsis char
48 expect(out.endsWith('…')).toBe(true)
49 const body = out.slice(0, -1)
50 expect(reason.startsWith(body)).toBe(true) // a clean prefix, nothing inserted
51 expect(out).toContain('alliance') // the actionable word survives whole, not "…allia"
52 expect(reason[body.length]).toMatch(/[\s.,;:]/) // cut fell on a boundary, not mid-word
53 })
⋯ 5 lines hidden (lines 54–58)
54 
55 it('caps generously over the 90-char prompt target', () => {
56 expect(VERDICT_REASON_CAP).toBe(160)
57 })
58})

The 124-char case is the bug's exact shape: longer than the old 120 cap, within the new one, returned whole. The overlong case asserts the real invariant — the cut falls on a boundary in the original string, so no word is severed — and that the actionable word ("alliance") survives.

The same guarantee, driven end to end through callJudgeAI.

tests/unit/server/utils/openai-pipeline.spec.ts · 276 lines
tests/unit/server/utils/openai-pipeline.spec.ts276 lines · TypeScript
⋯ 237 lines hidden (lines 1–237)
1import { describe, it, expect, vi, beforeEach, beforeAll, afterAll } from 'vitest'
2 
3/**
4 * The riskiest seam in the codebase, driven end to end with a mocked OpenAI SDK:
5 * model JSON → parse → base clamp → anachronism amplify (asymmetric + soft knee)
6 * → valence sign-guard. This is where roadmap slice 3 will bolt on its second
7 * multiplier — a silent break here only ever surfaced as "the game feels off".
8 */
9const createMock = vi.hoisted(() => vi.fn())
10vi.mock('openai', () => ({
11 // A real `function` (not an arrow): the SUT calls `new OpenAI(...)`, and
12 // vitest only allows construction of function/class mock implementations.
13 default: vi.fn(function () { return { chat: { completions: { create: createMock } } } })
14}))
15 
16import { callTimelineAI, callJudgeAI } from '../../../../server/utils/openai'
17import { DiceOutcome } from '../../../../utils/dice'
18 
19function modelReturns(payload: unknown) {
20 createMock.mockResolvedValueOnce({ choices: [{ message: { content: JSON.stringify(payload) } }] })
22 
23const TURN = {
24 objective: { title: 'Spare the Library', description: 'The scrolls survive.' },
25 figureName: 'Julius Caesar',
26 era: 'Alexandria, 48 BC',
27 userMessage: 'Guard the scrolls.',
28 characterMessage: 'It is done.',
29 characterAction: 'Caesar shields the library.',
30 diceRoll: 16,
31 diceOutcome: DiceOutcome.SUCCESS,
32 timeline: []
34 
35const ripple = (over: Record<string, unknown> = {}) => ({
36 headline: 'The scrolls survive',
37 detail: 'Rome guards wisdom.',
38 progressChange: 20,
39 valence: 'positive',
40 anachronism: 'in-period',
41 ...over
42})
43 
44beforeAll(() => {
45 process.env.OPENAI_API_KEY = 'test-key'
46 // This spec drives the LEGACY lane end to end — routing now defaults these
47 // tasks to Anthropic, so pin the override (and prove the lever works).
48 process.env.REVISIONIST_AI_LANE = 'openai'
49})
50 
51afterAll(() => {
52 delete process.env.REVISIONIST_AI_LANE
53})
54 
55beforeEach(() => {
56 createMock.mockReset()
57})
58 
59describe('callTimelineAI — band-clamp → amplify → valence pipeline', () => {
60 it('passes an in-period swing through untouched, reporting base = final', async () => {
61 modelReturns(ripple({ progressChange: 20, anachronism: 'in-period' }))
62 const res = await callTimelineAI(TURN)
63 expect(res.success).toBe(true)
64 expect(res.ripple?.progressChange).toBe(20)
65 expect(res.ripple?.baseProgressChange).toBe(20)
66 })
67 
68 it('the band table is law: a model overshoot is clamped into the ROLLED band', async () => {
69 // A Success (band +15..+30) cannot pay like a crit, whatever the model says.
70 modelReturns(ripple({ progressChange: 80, anachronism: 'in-period' }))
71 const res = await callTimelineAI(TURN)
72 expect(res.ripple?.baseProgressChange).toBe(30)
73 expect(res.ripple?.progressChange).toBe(30)
74 })
75 
76 it('…and clamps a wrong-signed swing back into the band too', async () => {
77 // The roll said Success; the engine may not turn it into a setback.
78 modelReturns(ripple({ progressChange: -20, anachronism: 'in-period', valence: 'negative' }))
79 const res = await callTimelineAI(TURN)
80 expect(res.ripple?.baseProgressChange).toBe(15) // Success band floor
81 expect(res.ripple?.valence).toBe('positive') // sign-guard re-colors it
82 })
83 
84 it('amplifies gains gently and reports the pre-amplifier base', async () => {
85 modelReturns(ripple({ progressChange: 20, anachronism: 'far-ahead' }))
86 const res = await callTimelineAI(TURN)
87 expect(res.ripple?.baseProgressChange).toBe(20)
88 expect(res.ripple?.progressChange).toBe(27) // 20 × 1.35
89 })
90 
91 it('cuts losses harder than gains at the same tier', async () => {
92 modelReturns(ripple({ progressChange: -12, anachronism: 'far-ahead', valence: 'negative' }))
93 const res = await callTimelineAI({ ...TURN, diceRoll: 5, diceOutcome: DiceOutcome.FAILURE })
94 expect(res.ripple?.baseProgressChange).toBe(-12)
95 expect(res.ripple?.progressChange).toBe(-19) // 12 × 1.6 = 19.2 → -19
96 })
97 
98 it('soft-knees a big amplified swing into the cap instead of pinning past it', async () => {
99 modelReturns(ripple({ progressChange: 40, anachronism: 'impossible' }))
100 const res = await callTimelineAI({ ...TURN, diceRoll: 20, diceOutcome: DiceOutcome.CRITICAL_SUCCESS })
101 // 40 × 1.6 = 64 → knee 40 + 12 = 52 → clamp 50
102 expect(res.ripple?.progressChange).toBe(50)
103 expect(res.ripple?.baseProgressChange).toBe(40)
104 })
105 
106 it('overrides a contradictory valence once the swing is beyond the neutral window', async () => {
107 modelReturns(ripple({ progressChange: -12, anachronism: 'far-ahead', valence: 'positive' }))
108 const res = await callTimelineAI({ ...TURN, diceRoll: 5, diceOutcome: DiceOutcome.FAILURE })
109 expect(res.ripple?.progressChange).toBe(-19)
110 expect(res.ripple?.valence).toBe('negative')
111 })
112 
113 it('keeps the model’s shading for small swings inside the ±3 window', async () => {
114 modelReturns(ripple({ progressChange: 2, anachronism: 'in-period', valence: 'neutral' }))
115 const res = await callTimelineAI({ ...TURN, diceRoll: 10, diceOutcome: DiceOutcome.NEUTRAL })
116 expect(res.ripple?.valence).toBe('neutral')
117 })
118 
119 it('coerces a garbage anachronism level to the safe in-period', async () => {
120 modelReturns(ripple({ progressChange: 20, anachronism: 'bananas' }))
121 const res = await callTimelineAI(TURN)
122 expect(res.ripple?.anachronism).toBe('in-period')
123 expect(res.ripple?.progressChange).toBe(20)
124 })
125 
126 it('fails closed when required fields are missing', async () => {
127 modelReturns({ detail: 'no headline', progressChange: 10 })
128 const res = await callTimelineAI(TURN)
129 expect(res.success).toBe(false)
130 })
131})
132 
133describe('callTimelineAI — gains-only craft amplifier (issue #62)', () => {
134 it('collapses a junk gain and amplifies a masterful one — same roll, same band', async () => {
135 modelReturns(ripple({ progressChange: 20, anachronism: 'in-period' }))
136 const reckless = await callTimelineAI({ ...TURN, craft: 'reckless' })
137 expect(reckless.ripple?.progressChange).toBe(3) // 20 × 0.15
138 
139 modelReturns(ripple({ progressChange: 20, anachronism: 'in-period' }))
140 const masterful = await callTimelineAI({ ...TURN, craft: 'masterful' })
141 expect(masterful.ripple?.progressChange).toBe(29) // 20 × 1.45
142 
143 // The pre-amplifier base swing is the band figure — craft never moves it.
144 expect(reckless.ripple?.baseProgressChange).toBe(20)
145 expect(masterful.ripple?.baseProgressChange).toBe(20)
146 })
147 
148 it('never scales the downside — a loss is identical across every craft grade', async () => {
149 const losses: number[] = []
150 for (const craft of ['masterful', 'sharp', 'sound', 'vague', 'reckless'] as const) {
151 modelReturns(ripple({ progressChange: -10, anachronism: 'in-period', valence: 'negative' }))
152 const res = await callTimelineAI({ ...TURN, diceRoll: 5, diceOutcome: DiceOutcome.FAILURE, craft })
153 losses.push(res.ripple!.progressChange)
154 }
155 expect(new Set(losses)).toEqual(new Set([-10])) // craft buys reach, never immunity
156 })
157 
158 it('re-clamps the amplified gain to the per-turn fuse (±MAX_PROGRESS_SWING)', async () => {
159 modelReturns(ripple({ progressChange: 45, anachronism: 'in-period' }))
160 const res = await callTimelineAI({ ...TURN, diceRoll: 20, diceOutcome: DiceOutcome.CRITICAL_SUCCESS, craft: 'masterful' })
161 // 45 × 1.45 = 65 → pinned to the 50 fuse, never past it.
162 expect(res.ripple?.progressChange).toBe(50)
163 expect(Math.abs(res.ripple!.progressChange)).toBeLessThanOrEqual(50)
164 })
165 
166 it('the craft EV ladder is monotonic on a fixed roll: masterful ≥ sharp ≥ sound ≥ vague ≥ reckless', async () => {
167 const gains: number[] = []
168 for (const craft of ['masterful', 'sharp', 'sound', 'vague', 'reckless'] as const) {
169 modelReturns(ripple({ progressChange: 20, anachronism: 'in-period' }))
170 const res = await callTimelineAI({ ...TURN, craft })
171 gains.push(res.ripple!.progressChange)
172 }
173 expect(gains).toEqual([29, 24, 20, 8, 3])
174 for (let i = 1; i < gains.length; i++) expect(gains[i]).toBeLessThanOrEqual(gains[i - 1])
175 })
176 
177 it('leaves the swing untouched when no craft is supplied (back-compat)', async () => {
178 modelReturns(ripple({ progressChange: 20, anachronism: 'in-period' }))
179 const res = await callTimelineAI(TURN)
180 expect(res.ripple?.progressChange).toBe(20)
181 })
182 
183 it('momentum amplifies the gain alongside craft, re-clamped to the fuse', async () => {
184 modelReturns(ripple({ progressChange: 20, anachronism: 'in-period' }))
185 const m4 = await callTimelineAI({ ...TURN, craft: 'sound', momentum: 4 })
186 expect(m4.ripple?.progressChange).toBe(28) // 20 × 1.0 × 1.4
187 
188 modelReturns(ripple({ progressChange: 45, anachronism: 'in-period' }))
189 const big = await callTimelineAI({ ...TURN, diceRoll: 20, diceOutcome: DiceOutcome.CRITICAL_SUCCESS, craft: 'masterful', momentum: 4 })
190 expect(big.ripple?.progressChange).toBe(50) // 45 × 1.45 × 1.4 = 91 → pinned to 50
191 })
192 
193 it('momentum never scales the downside (gains-only)', async () => {
194 modelReturns(ripple({ progressChange: -10, anachronism: 'in-period', valence: 'negative' }))
195 const res = await callTimelineAI({ ...TURN, diceRoll: 5, diceOutcome: DiceOutcome.FAILURE, momentum: 4 })
196 expect(res.ripple?.progressChange).toBe(-10)
197 })
198 
199 it('is monotonic in momentum on a fixed roll', async () => {
200 const gains: number[] = []
201 for (const momentum of [0, 1, 2, 3, 4]) {
202 modelReturns(ripple({ progressChange: 20, anachronism: 'in-period' }))
203 const res = await callTimelineAI({ ...TURN, craft: 'sound', momentum })
204 gains.push(res.ripple!.progressChange)
205 }
206 expect(gains).toEqual([20, 22, 24, 26, 28])
207 for (let i = 1; i < gains.length; i++) expect(gains[i]).toBeGreaterThanOrEqual(gains[i - 1])
208 })
209 
210 it('composes causal decay with the craft amplifier — a far reach keeps only a sliver', async () => {
211 modelReturns(ripple({ progressChange: 20, anachronism: 'in-period' }))
212 // Figure in AD 1500, objective anchored at 48 BC → a ~1548-year unbridged gap,
213 // so the swing is decayed toward the floor BEFORE the craft factor. Even
214 // masterful craft can't undo the distance (rails, not walls).
215 const res = await callTimelineAI({ ...TURN, craft: 'masterful', figureYear: 1500, objectiveAnchorYear: -48 })
216 expect(res.ripple?.baseProgressChange).toBe(20) // pre-amplifier, unchanged
217 expect(res.ripple!.progressChange).toBeGreaterThan(0) // a sliver survives the floor
218 expect(res.ripple!.progressChange).toBeLessThan(8) // far below the no-decay masterful 29
219 })
220})
221 
222describe('callJudgeAI — the craft verdict seam', () => {
223 const DISPATCH = {
224 objective: { title: 'Spare the Library', description: 'The scrolls survive.' },
225 figureName: 'Julius Caesar',
226 when: '48 BC',
227 userMessage: 'Guard the scrolls when the docks burn.'
228 }
229 
230 it('returns the parsed verdict', async () => {
231 modelReturns({ craft: 'sharp', reason: 'Names the lever and the moment.' })
232 const res = await callJudgeAI(DISPATCH)
233 expect(res.success).toBe(true)
234 expect(res.judge?.craft).toBe('sharp')
235 expect(res.judge?.reason).toBe('Names the lever and the moment.')
236 })
237 
238 it('word-trims an overlong reason at the seam — never mid-word (issue #126)', async () => {
239 // The "under 90 chars" prompt hint is soft and the model overruns it. The seam
240 // caps defensively, but on a word boundary so the verdict never reads as a glitch.
241 const reason = 'Names the lever and the moment, but it leans hard on Wilhelm restraint outweighing his alliance with Austria-Hungary over the July mobilization timetables already locked in by the staff.'
242 modelReturns({ craft: 'sound', reason })
243 const res = await callJudgeAI(DISPATCH)
244 expect(res.judge!.reason.length).toBeLessThan(reason.length)
245 expect(res.judge!.reason.endsWith('…')).toBe(true)
246 expect(res.judge!.reason).toContain('alliance') // the actionable word kept whole
247 })
⋯ 29 lines hidden (lines 248–276)
248 
249 it('coerces an off-menu grade to the neutral sound', async () => {
250 modelReturns({ craft: 'legendary', reason: 'x' })
251 const res = await callJudgeAI(DISPATCH)
252 expect(res.judge?.craft).toBe('sound')
253 })
254 
255 it('fails closed (so the caller falls back to modifier 0) on an empty response', async () => {
256 createMock.mockResolvedValueOnce({ choices: [{ message: { content: '' } }] })
257 const res = await callJudgeAI(DISPATCH)
258 expect(res.success).toBe(false)
259 })
260 
261 it('parses the continuity verdict when the run has a thread (issue #62)', async () => {
262 modelReturns({ craft: 'sharp', continuity: 'builds', reason: 'Meets the price he named.' })
263 const res = await callJudgeAI({ ...DISPATCH, lastFigureReply: 'Bring me grain and I will act.' })
264 expect(res.judge?.continuity).toBe('builds')
265 })
266 
267 it('defaults continuity to neutral on turn 1 (no thread) and coerces garbage', async () => {
268 modelReturns({ craft: 'sound', reason: 'A clear ask.' }) // threadless → continuity not asked
269 const turn1 = await callJudgeAI(DISPATCH)
270 expect(turn1.judge?.continuity).toBe('neutral')
271 
272 modelReturns({ craft: 'sound', continuity: 'combo', reason: 'x' })
273 const garbage = await callJudgeAI({ ...DISPATCH, ledgerDigest: ['[Rome] A change'] })
274 expect(garbage.judge?.continuity).toBe('neutral')
275 })
276})