What this PR is

This PR adds one file: a design exploration for issue #94, docs/design/skills-modifiers-layer.md. It is a recommendation, not an implementation — no skill schema, no UI, no economy retune. It mirrors the sibling settings-layer note (#90).

The recommendation is "The Editor's Hand": a small per-run draft of diegetic annotations that amplify the craft channel the game already has. Every annotation is craft-gated — its payout is a function of the Message Judge's grade, so a vague or reckless dispatch gets almost nothing back even after spending a charge. Writing stays king by construction: a skill multiplies or floors what a graded dispatch already earned; it never adds a flat number.

The note's credibility rests on two claims about the existing engine. This guide walks the source behind each so a reviewer can vouch for them. Every line range below was read against the tree at HEAD.

The pipeline — exactly the order the note claims

A turn's swing resolves server-side in callTimelineAI, in a fixed order: the model's number is clamped into the rolled band, then the anachronism amplifier widens it, then causal-chain decay diffuses it, then the gains-only craft and momentum multipliers bank the upside, then it is clamped to the per-turn fuse. The note's "resolution pipeline, in order" list is this block.

Band clamp (230-231) → anachronism (233) → chain decay (241) → gains-only craft × momentum (249-251) → ±50 clamp (252).

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

Why "swing-neutral" is a myth here

The obvious lean skill is a reroll or a band-floor that adds no expected value and so (the claim goes) needs no retune. The note argues this is wrong, and the reason is in two places. First, craft already has two compounding channels: a ±2 die-tilt and a ×0.15–1.45 gains multiplier.

CRAFT_MODIFIER (±2 on the die) and CRAFT_GAIN_FACTOR (×0.15–1.45 on the gain) — the two channels a skill would amplify.

utils/craft.ts · 64 lines
utils/craft.ts64 lines · TypeScript
⋯ 13 lines hidden (lines 1–13)
1/**
2 * Message craft — the Judge's grade of the player's words (roadmap slice 5).
3 *
4 * The Judge reads the dispatch BEFORE the dice are thrown and converts its craft
5 * into a small roll modifier: writing a sharper message visibly tilts fate. The
6 * dice still dominate any single turn (±2 at most); craft decides the campaign.
7 * Lives in utils/ because both sides read it: the server maps grade → modifier,
8 * the UI labels the grade in the turn reveal.
9 */
10export type Craft = 'masterful' | 'sharp' | 'sound' | 'vague' | 'reckless'
11 
12export const CRAFT_LEVELS: Craft[] = ['masterful', 'sharp', 'sound', 'vague', 'reckless']
13 
14/** Roll modifier per grade — combined with the 1–20 clamp in `applyCraftModifier`,
15 * a masterful dispatch cannot critically backfire, a reckless one cannot
16 * critically cascade. Both edges are deliberate, legible rewards. */
17export const CRAFT_MODIFIER: Record<Craft, number> = {
18 masterful: 2,
19 sharp: 1,
20 sound: 0,
21 vague: -1,
22 reckless: -2
24 
25/** Multiplicative swing factor per grade — a GAINS-ONLY amplifier applied to a
26 * turn's POSITIVE progress inside the Timeline Engine (after causal decay, before
27 * the per-turn clamp). Losses are never scaled: the downside stays governed by the
28 * rolled band, so a sharp dispatch buys upside without buying immunity. Floor 0.15,
29 * never 0 — a graded dispatch always counts for something (rails, not walls). This
30 * is the craft channel's SECOND, multiplicative half: the ±2 CRAFT_MODIFIER above
31 * tilts the die toward a better band; this scales the gain that band pays out. The
32 * two compound on purpose — that compounding is how craft decides the campaign. */
33export const CRAFT_GAIN_FACTOR: Record<Craft, number> = {
34 masterful: 1.45,
35 sharp: 1.20,
36 sound: 1.00,
37 vague: 0.40,
38 reckless: 0.15
⋯ 25 lines hidden (lines 40–64)
40 
41/** Short player-facing label per grade (for the turn reveal / ledger). */
42export const CRAFT_LABEL: Record<Craft, string> = {
43 masterful: 'Masterful',
44 sharp: 'Sharp',
45 sound: 'Sound',
46 vague: 'Vague',
47 reckless: 'Reckless'
49 
50/** One-line hover hint per grade — what it means and how it tilts the roll.
51 * Lives beside the modifier so the copy and the number can never drift apart. */
52export const CRAFT_HINT: Record<Craft, string> = {
53 masterful: 'precise and period-fluent — adds 2 to the roll; cannot critically backfire',
54 sharp: 'a cut above competent — adds 1 to the roll',
55 sound: 'a clear, honest ask — leaves the roll unchanged',
56 vague: 'wishing more than working — subtracts 1 from the roll',
57 reckless: 'incoherent for the era — subtracts 2 from the roll; cannot critically land'
59 
60/** Coerce an untrusted value to a known grade. Defaults to the neutral 'sound':
61 * a Judge outage must never tilt the roll either way. */
62export function toCraft(value: unknown): Craft {
63 return CRAFT_LEVELS.includes(value as Craft) ? (value as Craft) : 'sound'

Second, the gains multipliers apply only to a positive swing; losses pass through untouched. So the band → progress map is convex on the upside.

gained = decayed > 0 ? decayed * craftFactor * momoFactor : decayed — gains-only. Losses keep the rolled band's verdict.

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

The trust boundary — the charge-economy blocker

The note's resource model is earned-in-play, server-minted charges with a hard cap of 2. That assumes the server can verify "this run earned a charge" and refuse a forged spend. It cannot today, because there is no server-authoritative per-turn run state. The handler reads momentum and the stake flag straight off the client body and only range-clamps them — the code says so itself.

staked = body.stake === true (no server-side canStake recheck); momentum from body.momentum, comment: "no server run-state today".

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

The corridor the retune must not break

The run economy is tuned to a neutral-band mean the bakeoff eval pins to [4, 9]. It measures baseProgressChange — the swing before the four multipliers — on a fixture that sets no craft grade (the neutral ×1.0 factor).

bandMeans use r.baseProgressChange (110); the NEUTRAL anchor (index 2) must stay in [4, 9] (122-123).

evals/bakeoff.eval.ts · 372 lines
evals/bakeoff.eval.ts372 lines · TypeScript
⋯ 105 lines hidden (lines 1–105)
1/**
2 * The provider bake-off (GTM dispatch 1) — the same fixtures driven down BOTH
3 * lanes of the per-task routing seam, scored per layer:
4 *
5 * Timeline — band→swing ordering, the NEUTRAL-band EV corridor the run
6 * economy is tuned around, valence↔sign, anachronism accuracy.
7 * Judge — calibration against the labeled craft ladders (ordinal
8 * agreement + hard fairness violations), the Haiku gate.
9 * Character — dice-band differential, 160-char discipline, era-blindness.
10 * Chronicler— blind pairwise prose (position-debiased, dual-graded), plus a
11 * ledger-consistency gate; the epilogue judged separately.
12 * Cost — real usage per lane from the gateway's telemetry observer.
13 *
14 * Run with: `npm run eval:bakeoff` (needs OPENAI_API_KEY + ANTHROPIC_API_KEY).
15 * Results land in evals/results/ as JSON alongside the printed scorecard.
16 * Lane passes are sequential; REVISIONIST_AI_LANE flips the whole seam per pass.
17 */
18import { describe, it, afterAll } from 'vitest'
19import { mkdirSync, writeFileSync } from 'node:fs'
20import { resolve } from 'node:path'
21import { DiceOutcome } from '~/utils/dice'
22import { callCharacterAI, callTimelineAI, callChroniclerAI, callJudgeAI } from '~/server/utils/openai'
23import { setAiCallObserver, type AiCallTelemetry } from '~/server/utils/ai-gateway'
24import { CRAFT_MODIFIER } from '~/utils/craft'
25import { RUN, record, recordedRows, sample, gradeAll, prefer, mean, printScorecard } from './harness'
26import {
27 ROLL_BY_BAND,
28 TIMELINE_FIXED,
29 ANACHRONISM_INPERIOD,
30 ANACHRONISM_FUTURE,
31 CHARACTER,
32 OBJECTIVE
33} from './fixtures'
34import { JUDGE_LADDERS, EPILOGUE_LEDGER, CHRONICLE_CRITERIA } from './bakeoff-fixtures'
35 
36const LANES = ['anthropic', 'openai'] as const
37type Lane = (typeof LANES)[number]
38 
39/** Samples per cell — sized for a decision, not a census; paired across lanes. */
40const N_TIMELINE = 10
41const N_ANACHRONISM = 8
42const N_JUDGE = 2
43const N_CHARACTER = 8
44const K_CHRONICLE = 5
45 
46/** Anthropic list prices per MTok (verified 2026-06); OpenAI reported as tokens. */
47const ANTHROPIC_PRICES: Record<string, { input: number; output: number }> = {
48 'claude-haiku-4-5': { input: 1, output: 5 },
49 'claude-sonnet-4-6': { input: 3, output: 15 },
50 'claude-opus-4-8': { input: 5, output: 25 }
52 
53interface UsageCell { calls: number; input: number; output: number }
54const usageByLane: Record<Lane, Record<string, UsageCell>> = { anthropic: {}, openai: {} }
55let currentLane: Lane = 'anthropic'
56 
57function collect(t: AiCallTelemetry): void {
58 if (!t.usage) return
59 const key = `${t.task}:${t.model}`
60 const cell = (usageByLane[currentLane][key] ??= { calls: 0, input: 0, output: 0 })
61 cell.calls++
62 cell.input += t.usage.inputTokens
63 cell.output += t.usage.outputTokens
65 
66function enterLane(lane: Lane): void {
67 currentLane = lane
68 process.env.REVISIONIST_AI_LANE = lane
69 setAiCallObserver(collect)
71 
72/** Everything the JSON snapshot carries, accumulated across the it-blocks. */
73const results: Record<string, unknown> = { lanes: {} }
74function laneResults(lane: Lane): Record<string, unknown> {
75 const lanes = results.lanes as Record<string, Record<string, unknown>>
76 return (lanes[lane] ??= {})
78 
79const chronicles: Record<Lane, { mid: string[]; epilogue: string[] }> = {
80 anthropic: { mid: [], epilogue: [] },
81 openai: { mid: [], epilogue: [] }
83 
84afterAll(() => {
85 setAiCallObserver(null)
86 delete process.env.REVISIONIST_AI_LANE
87 printScorecard()
88})
89 
90function skip(layer: string, invariant: string): void {
91 record({ layer, invariant, result: 'skipped (needs both keys)', status: 'skipped' })
93 
94for (const lane of LANES) {
95 describe(`${lane} lane`, () => {
96 it('timeline: bands, corridor, anachronism', async () => {
97 if (!RUN) {
98 skip(`TL·${lane}`, 'band ordering / corridor / anachronism')
99 return
100 }
101 enterLane(lane)
102 
103 const bandMeans: number[] = []
104 let vOK = 0
105 let vTotal = 0
106 for (const band of ROLL_BY_BAND) {
107 const res = await sample(N_TIMELINE, () =>
108 callTimelineAI({ ...TIMELINE_FIXED, diceRoll: band.roll, diceOutcome: band.outcome }))
109 const ripples = res.flatMap(r => (r.success && r.ripple ? [r.ripple] : []))
110 bandMeans.push(Math.round(mean(ripples.map(r => r.baseProgressChange)) * 10) / 10)
111 for (const r of ripples) {
112 vTotal++
113 const s = r.progressChange
114 if ((r.valence === 'positive' && s > 0) || (r.valence === 'negative' && s < 0) || (r.valence === 'neutral' && Math.abs(s) <= 3)) vOK++
115 }
116 }
117 let ordered = 0
118 for (let i = 1; i < bandMeans.length; i++) if (bandMeans[i] >= bandMeans[i - 1]) ordered++
119 // The run economy is tuned around ~+6/turn for sound unaided play —
120 // the NEUTRAL band (0..+12) is the anchor; a lane whose neutral mean
121 // drifts out of [4, 9] silently rebalances the whole game.
122 const neutralMean = bandMeans[2]
123 const corridorOK = neutralMean >= 4 && neutralMean <= 9
⋯ 249 lines hidden (lines 124–372)
124 
125 const inP = await sample(N_ANACHRONISM, () =>
126 callTimelineAI({ ...ANACHRONISM_INPERIOD, diceRoll: 12, diceOutcome: DiceOutcome.NEUTRAL }))
127 const fut = await sample(N_ANACHRONISM, () =>
128 callTimelineAI({ ...ANACHRONISM_FUTURE, diceRoll: 12, diceOutcome: DiceOutcome.NEUTRAL }))
129 const inLevels = inP.flatMap(r => (r.success && r.ripple ? [r.ripple.anachronism] : []))
130 const futLevels = fut.flatMap(r => (r.success && r.ripple ? [r.ripple.anachronism] : []))
131 const inOK = inLevels.filter(a => a === 'in-period' || a === 'ahead').length
132 const futOK = futLevels.filter(a => a === 'far-ahead' || a === 'impossible').length
133 
134 laneResults(lane).timeline = {
135 bandMeans, ordered, neutralMean, corridorOK,
136 valence: { ok: vOK, total: vTotal },
137 anachronism: { inOK, inTotal: inLevels.length, futOK, futTotal: futLevels.length }
138 }
139 record({
140 layer: `TL·${lane}`,
141 invariant: 'band means + ordering',
142 result: vTotal === 0 ? 'no samples' : `[${bandMeans.join(', ')}] · ${ordered}/4 ordered`,
143 status: vTotal === 0 ? 'error' : ordered >= 4 ? 'ok' : 'soft-miss'
144 })
145 record({
146 layer: `TL·${lane}`,
147 invariant: 'neutral EV corridor [4..9]',
148 result: `mean ${neutralMean}`,
149 status: vTotal === 0 ? 'error' : corridorOK ? 'ok' : 'soft-miss'
150 })
151 record({
152 layer: `TL·${lane}`,
153 invariant: 'valence↔sign',
154 result: `${vOK}/${vTotal}`,
155 status: vTotal > 0 && vOK / vTotal >= 0.9 ? 'ok' : 'soft-miss'
156 })
157 record({
158 layer: `TL·${lane}`,
159 invariant: 'anachronism accuracy',
160 result: `in-period ${inOK}/${inLevels.length} · future ${futOK}/${futLevels.length}`,
161 status: inLevels.length + futLevels.length > 0 && (inOK + futOK) / (inLevels.length + futLevels.length) >= 0.75 ? 'ok' : 'soft-miss'
162 })
163 })
164 
165 it('judge: calibration ladders', async () => {
166 if (!RUN) {
167 skip(`JD·${lane}`, 'ladder calibration')
168 return
169 }
170 enterLane(lane)
171 
172 let orderedPairs = 0
173 let totalPairs = 0
174 let hardViolations = 0
175 const ladderMeans: number[][] = []
176 for (const ladder of JUDGE_LADDERS) {
177 const rungMeans: number[] = []
178 for (const dispatch of ladder.rungs) {
179 const res = await sample(N_JUDGE, () =>
180 callJudgeAI({ objective: ladder.objective, figureName: ladder.figureName, when: ladder.when, userMessage: dispatch }))
181 const mods = res.flatMap(r => (r.success && r.judge ? [CRAFT_MODIFIER[r.judge.craft]] : []))
182 rungMeans.push(mean(mods))
183 // Fairness floor: the bottom two rungs must never read masterful
184 // (+2), the top rung must never read reckless (-2).
185 const rungIndex = ladder.rungs.indexOf(dispatch)
186 if (rungIndex >= 2 && mods.some(m => m >= 2)) hardViolations++
187 if (rungIndex === 0 && mods.some(m => m <= -2)) hardViolations++
188 }
189 ladderMeans.push(rungMeans)
190 for (let i = 0; i < rungMeans.length; i++) {
191 for (let j = i + 1; j < rungMeans.length; j++) {
192 totalPairs++
193 if (rungMeans[i] >= rungMeans[j]) orderedPairs++
194 }
195 }
196 }
197 
198 laneResults(lane).judge = { ladderMeans, orderedPairs, totalPairs, hardViolations }
199 record({
200 layer: `JD·${lane}`,
201 invariant: 'ladder ordinal agreement',
202 result: `${orderedPairs}/${totalPairs} pairs ordered`,
203 status: totalPairs > 0 && orderedPairs / totalPairs >= 0.9 ? 'ok' : 'soft-miss'
204 })
205 record({
206 layer: `JD·${lane}`,
207 invariant: 'hard violations',
208 result: `${hardViolations}`,
209 status: hardViolations === 0 ? 'ok' : 'soft-miss'
210 })
211 })
212 
213 it('character: differential, discipline, era-blindness', async () => {
214 if (!RUN) {
215 skip(`CH·${lane}`, 'differential / 160 / era')
216 return
217 }
218 enterLane(lane)
219 
220 const succ = await sample(N_CHARACTER, () =>
221 callCharacterAI(CHARACTER.figureName, CHARACTER.nudgeMessage, [], DiceOutcome.CRITICAL_SUCCESS, CHARACTER.grounding))
222 const fail = await sample(N_CHARACTER, () =>
223 callCharacterAI(CHARACTER.figureName, CHARACTER.nudgeMessage, [], DiceOutcome.CRITICAL_FAILURE, CHARACTER.grounding))
224 const succR = succ.flatMap(r => (r.success && r.characterResponse ? [r.characterResponse] : []))
225 const failR = fail.flatMap(r => (r.success && r.characterResponse ? [r.characterResponse] : []))
226 
227 const q = `Does the figure decisively and favorably act toward this goal — "${CHARACTER.nudgeGoal}"? Answer yes only if they clearly move to do it.`
228 const sG = await gradeAll(q, succR.map(a => a.action))
229 const fG = await gradeAll(q, failR.map(a => a.action))
230 const sRate = sG.graded ? sG.yes / sG.graded : 0
231 const fRate = fG.graded ? fG.yes / fG.graded : 0
232 
233 const all = [...succR, ...failR]
234 const within = all.filter(a => a.message.length <= 160).length
235 
236 const future = await sample(N_CHARACTER, () =>
237 callCharacterAI(CHARACTER.figureName, CHARACTER.futureMessage, [], DiceOutcome.NEUTRAL, CHARACTER.grounding))
238 const futureReplies = future.flatMap(r => (r.success && r.characterResponse ? [r.characterResponse.message] : []))
239 const eraQ = 'Does this reply treat events beyond the speaker’s own lifetime as established fact, or acknowledge knowing the future / being in a game? Answer yes if it breaks the historical illusion.'
240 const eraG = await gradeAll(eraQ, futureReplies)
241 
242 laneResults(lane).character = {
243 actToward: { success: sG, fail: fG },
244 len160: { within, total: all.length },
245 eraBreaks: eraG
246 }
247 record({
248 layer: `CH·${lane}`,
249 invariant: 'band→reaction differential',
250 result: `crit-succ ${sG.yes}/${sG.graded} · crit-fail ${fG.yes}/${fG.graded} (${sG.contested + fG.contested} contested)`,
251 status: sG.graded && fG.graded ? (sRate > fRate ? 'ok' : 'soft-miss') : 'error'
252 })
253 record({
254 layer: `CH·${lane}`,
255 invariant: 'reply ≤160 chars',
256 result: `${within}/${all.length}`,
257 status: all.length > 0 && within / all.length >= 0.8 ? 'ok' : 'soft-miss'
258 })
259 record({
260 layer: `CH·${lane}`,
261 invariant: 'no future-as-fact',
262 result: `${eraG.graded - eraG.yes}/${eraG.graded} stay in-era (${eraG.contested} contested)`,
263 status: eraG.graded > 0 && eraG.yes / eraG.graded <= 0.25 ? 'ok' : 'soft-miss'
264 })
265 })
266 
267 it('chronicler: generate tellings (judged cross-lane later)', async () => {
268 if (!RUN) {
269 skip(`CR·${lane}`, 'tellings generated')
270 return
271 }
272 enterLane(lane)
273 
274 const mid = await sample(K_CHRONICLE, () =>
275 callChroniclerAI({ objective: OBJECTIVE, timeline: EPILOGUE_LEDGER.slice(0, 3), status: 'playing', progress: 55 }))
276 const epi = await sample(K_CHRONICLE, () =>
277 callChroniclerAI({ objective: OBJECTIVE, timeline: EPILOGUE_LEDGER, status: 'victory', progress: 100 }))
278 chronicles[lane].mid = mid.flatMap(r => (r.success && r.chronicle ? [`${r.chronicle.title}\n${r.chronicle.paragraphs.join('\n')}`] : []))
279 chronicles[lane].epilogue = epi.flatMap(r => (r.success && r.chronicle ? [`${r.chronicle.title}\n${r.chronicle.paragraphs.join('\n')}`] : []))
280 
281 const facts = 'These facts are TRUE: (1) Caesar spared the library from fire; (2) Cleopatra endowed it; (3) Octavian later spared Alexandria and endowed the Museion. Does the narrative CONTRADICT any of these (e.g. the library burns or is lost)? Answer yes if it contradicts.'
282 const g = await gradeAll(facts, [...chronicles[lane].mid, ...chronicles[lane].epilogue])
283 
284 laneResults(lane).chronicler = { consistency: g, midCount: chronicles[lane].mid.length, epilogueCount: chronicles[lane].epilogue.length }
285 record({
286 layer: `CR·${lane}`,
287 invariant: 'ledger consistency',
288 result: `${g.graded - g.yes}/${g.graded} consistent (${g.contested} contested)`,
289 status: g.graded > 0 && g.yes / g.graded <= 0.25 ? 'ok' : 'soft-miss'
290 })
291 })
292 })
294 
295describe('cross-lane', () => {
296 it('chronicle pairwise + cost + snapshot', async () => {
297 if (!RUN) {
298 skip('XL', 'pairwise / cost / snapshot')
299 return
300 }
301 setAiCallObserver(null)
302 
303 // Blind pairwise: pair lane outputs by index, both kinds. Votes are
304 // position-debiased and dual-graded inside prefer().
305 async function pairwise(kind: 'mid' | 'epilogue'): Promise<{ anthropic: number; openai: number }> {
306 const a = chronicles.anthropic[kind]
307 const o = chronicles.openai[kind]
308 const pairs = Math.min(a.length, o.length)
309 let aVotes = 0
310 let oVotes = 0
311 for (let i = 0; i < pairs; i++) {
312 const v = await prefer(CHRONICLE_CRITERIA, a[i], o[i])
313 aVotes += v.a
314 oVotes += v.b
315 }
316 return { anthropic: aVotes, openai: oVotes }
317 }
318 
319 const midVotes = await pairwise('mid')
320 const epiVotes = await pairwise('epilogue')
321 results.pairwise = { mid: midVotes, epilogue: epiVotes }
322 record({
323 layer: 'XL',
324 invariant: 'chronicle pairwise (mid-run)',
325 result: `anthropic ${midVotes.anthropic} · openai ${midVotes.openai}`,
326 status: 'ok'
327 })
328 record({
329 layer: 'XL',
330 invariant: 'epilogue pairwise',
331 result: `anthropic ${epiVotes.anthropic} · openai ${epiVotes.openai}`,
332 status: 'ok'
333 })
334 
335 // Cost from real usage. Anthropic priced from the verified list; the
336 // OpenAI lane is reported in raw tokens (its prices live outside the repo).
337 for (const lane of LANES) {
338 const cells = usageByLane[lane]
339 let dollars = 0
340 let input = 0
341 let output = 0
342 let calls = 0
343 for (const [key, cell] of Object.entries(cells)) {
344 input += cell.input
345 output += cell.output
346 calls += cell.calls
347 const model = key.split(':')[1]
348 const price = ANTHROPIC_PRICES[model]
349 if (price) dollars += (cell.input * price.input + cell.output * price.output) / 1_000_000
350 }
351 laneResults(lane).usage = { calls, input, output, dollars: lane === 'anthropic' ? Math.round(dollars * 10000) / 10000 : null, byTask: cells }
352 record({
353 layer: 'COST',
354 invariant: `${lane} battery usage`,
355 result: `${calls} calls · ${input} in · ${output} out${lane === 'anthropic' ? ` · $${dollars.toFixed(4)}` : ''}`,
356 status: 'ok'
357 })
358 }
359 
360 results.rows = recordedRows()
361 results.meta = {
362 date: new Date().toISOString(),
363 samples: { N_TIMELINE, N_ANACHRONISM, N_JUDGE, N_CHARACTER, K_CHRONICLE },
364 graders: 'dual (gpt-5.4 + claude-sonnet-4-6), agreement-gated, position-debiased pairwise'
365 }
366 const dir = resolve(process.cwd(), 'evals/results')
367 mkdirSync(dir, { recursive: true })
368 const file = resolve(dir, `bakeoff-${new Date().toISOString().slice(0, 10)}.json`)
369 writeFileSync(file, JSON.stringify(results, null, 2))
370 process.stdout.write(`\nbake-off snapshot → ${file}\n`)
371 })
372})

Because the corridor measures the pre-multiplier base at craft-neutral, an annotation that fires only at sharp/masterful or only on the gains side is a literal no-op there — the anchor cannot move. That is also why the corridor alone proves nothing about the armed regime, so the note makes an armed-masterful re-test (run the bakeoff at masterful with each annotation armed) a required CI gate. It is the same realized-EV eval the settings note (#90) already calls for; build it once.

Where a ⟐ badge would live

The game discloses its math, so a skill has to as well. The resolved-equation token list already renders ✒ craft, ⚡ anachronism, ⏳ chain, ✦ momentum, and ⚑ stake from server-echoed values, each with a hover hint from a shared constant so the copy and the number can't drift. A new ⟐ annotation token slots in here.

equationTokens(): the badge glyphs ✒ ⚡ ⏳ ✦ ⚑ a ⟐ annotation badge would join, plus the disclosed pre-send chip pattern.

components/MessageHistory.vue · 213 lines
components/MessageHistory.vue213 lines · Vue
⋯ 153 lines hidden (lines 1–153)
1<template>
2 <!-- Conversation with the currently-selected figure (the THREAD rail). -->
3 <div data-testid="message-history"
4 class="message-history-container overflow-y-auto rv-bg max-h-[340px] md:max-h-none md:overflow-visible"
5 aria-label="Conversation history" aria-live="polite" role="log">
6 <!-- Empty state -->
7 <div v-if="thread.length === 0 && !gameStore.isLoading" data-testid="empty-state"
8 class="flex flex-col items-center justify-center p-8 text-center">
9 <div class="text-3xl mb-2 opacity-50" aria-hidden="true">💬</div>
10 <p class="rv-muted text-sm">
11 {{ activeFigure ? `No words exchanged with ${activeFigure} yet` : 'No one contacted yet' }}
12 </p>
13 <p class="rv-faint text-xs mt-0.5">
14 {{ activeFigure ? 'Send a message to begin.' : 'Choose a figure and send your first message.' }}
15 </p>
16 </div>
17 
18 <!-- Conversation -->
19 <ol v-else>
20 <li v-for="(message, index) in thread" :key="index" data-testid="message-item" :data-sender="message.sender"
21 class="message-item py-3 border-b rv-line last:border-b-0">
22 <!-- Player message -->
23 <div v-if="message.sender === 'user'" data-testid="user-message">
24 <p class="text-[11px] rv-faint uppercase tracking-wide mb-0.5">You → {{ message.figureName || activeFigure }}</p>
25 <p class="rv-fg text-sm">{{ message.text }}</p>
26 <p class="text-[11px] rv-faint mt-1 rv-mono" data-testid="message-timestamp">{{ formatTimestamp(message.timestamp) }}</p>
27 </div>
28 
29 <!-- Figure reply -->
30 <div v-else-if="message.sender === 'ai'" data-testid="ai-message">
31 <div class="flex items-center justify-between gap-2 mb-1">
32 <p class="text-sm font-semibold rv-fg flex items-center gap-1.5 min-w-0">
33 <span class="rv-dot" :class="outcomeDot(message.diceOutcome)" aria-hidden="true" />
34 <span class="truncate">{{ message.figureName || activeFigure || 'The past' }}</span>
35 </p>
36 <div v-if="message.diceRoll !== undefined" class="flex items-center gap-1.5 rv-mono text-xs shrink-0">
37 <span data-testid="dice-icon" aria-hidden="true">🎲</span>
38 <!-- Craft tilts fate visibly: natural roll ✒mod = effective. -->
39 <span v-if="message.rollModifier" data-testid="craft-roll" class="rv-faint">
40 {{ message.naturalRoll }} <span :class="message.rollModifier > 0 ? 'rv-ok' : 'rv-bad'">{{ message.rollModifier > 0 ? '+' : '' }}{{ message.rollModifier }}</span> =
41 </span>
42 <span data-testid="dice-result" class="font-bold rv-fg">{{ message.diceRoll }}</span>
43 <span data-testid="dice-outcome" class="font-semibold" :class="outcomeText(message.diceOutcome)">{{ message.diceOutcome }}</span>
44 </div>
45 </div>
46 
47 <!-- The Judge's verdict on the dispatch that caused all this: the craft
48 grade (with a hover hint), a 'builds' mark when it picked up the run's
49 thread (the momentum signal), and the terse reason. -->
50 <div v-if="message.craft && (message.craft !== 'sound' || message.craftReason || message.continuity === 'builds')" data-testid="craft-verdict" class="text-[11px] mb-1.5">
51 <span class="rv-faint">✒ your dispatch · </span><SymbolHint :text="CRAFT_HINT[message.craft]"><span class="font-semibold" :class="craftClass(message.craft)">{{ CRAFT_LABEL[message.craft] }}</span></SymbolHint><span v-if="message.continuity === 'builds'" data-testid="continuity-builds" class="rv-ok font-semibold"> · ✦ builds</span><span v-if="message.craftReason" class="rv-faint">{{ message.craftReason }}</span>
52 </div>
53 
54 <p data-testid="character-message" class="rv-fg text-sm mb-2">{{ message.text }}</p>
55 
56 <div v-if="message.characterAction" data-testid="character-action" class="text-xs rv-muted border-l rv-line pl-2 mb-2">
57 <span class="rv-faint">acts · </span><span class="italic">{{ message.characterAction }}</span>
58 </div>
59 
60 <div v-if="message.timelineImpact" data-testid="timeline-impact" class="text-xs rv-muted border-l rv-line pl-2 mb-1">
61 <span class="rv-faint">ripple · </span>{{ message.timelineImpact }}
62 <span v-if="message.progressChange !== undefined" data-testid="progress-change"
63 class="rv-mono font-bold ml-1" :class="deltaText(message.progressChange)">
64 {{ message.progressChange > 0 ? '+' : '' }}{{ message.progressChange }}%
65 </span>
66 <!-- The wager's work, shown as an equation: base ⚡tier (⚑ staked) → final.
67 Each amplifier token carries its own hover hint for what it did. -->
68 <span v-if="showEquation(message)" data-testid="swing-equation" class="rv-mono rv-faint ml-1 whitespace-nowrap">({{ signed(message.baseProgressChange!) }}<template v-for="tok in equationTokens(message)" :key="tok.kind">{{ ' ' }}<SymbolHint :text="tok.hint"><span :data-testid="`equation-${tok.kind}`">{{ tok.label }}</span></SymbolHint></template>{{ ' → ' + signed(message.progressChange!) + '%' }})</span>
69 </div>
70 
71 <p class="text-[11px] rv-faint rv-mono" data-testid="message-timestamp">{{ formatTimestamp(message.timestamp) }}</p>
72 </div>
73 </li>
74 
75 <!-- Loading indicator -->
76 <li v-if="gameStore.isLoading" data-testid="loading-indicator" class="py-3 flex items-center gap-2 rv-muted text-sm">
77 <span class="rv-spinner" aria-hidden="true" />
78 {{ activeFigure || 'The past' }} is weighing your words…
79 </li>
80 </ol>
81 </div>
82</template>
83 
84<script setup lang="ts">
85/**
86 * MessageHistory — the conversation thread with the active figure, as hairline rows
87 * led by an outcome dot. Figure-aware labels; each figure keeps an independent thread.
88 */
89import { computed } from 'vue'
90import { useGameStore, type Message } from '~/stores/game'
91import { ANACHRONISM_HINT, ANACHRONISM_LABEL } from '~/server/utils/anachronism'
92import { CHAIN_HINT, CHAIN_LABEL } from '~/utils/causal-chain'
93import { CRAFT_HINT, CRAFT_LABEL, type Craft } from '~/utils/craft'
94import { STAKE_HINT } from '~/utils/swing-bands'
95 
96const gameStore = useGameStore()
97 
98const activeFigure = computed(() => gameStore.activeFigureName)
99const thread = computed(() => gameStore.conversationWith(gameStore.activeFigureName))
100 
101const formatTimestamp = (timestamp: Date): string => {
102 return new Intl.DateTimeFormat('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(timestamp)
104 
105function outcomeDot(outcome?: string): string {
106 switch (outcome) {
107 case 'Critical Success':
108 case 'Success': return 'rv-dot--ok'
109 case 'Failure':
110 case 'Critical Failure': return 'rv-dot--bad'
111 default: return 'rv-dot--mut'
112 }
114function outcomeText(outcome?: string): string {
115 switch (outcome) {
116 case 'Critical Success':
117 case 'Success': return 'rv-ok'
118 case 'Failure':
119 case 'Critical Failure': return 'rv-bad'
120 default: return 'rv-muted'
121 }
123function deltaText(change?: number): string {
124 if (change === undefined || change === 0) return 'rv-muted'
125 return change > 0 ? 'rv-ok' : 'rv-bad'
127 
128function craftClass(craft?: Craft): string {
129 switch (craft) {
130 case 'masterful':
131 case 'sharp': return 'rv-ok'
132 case 'vague':
133 case 'reckless': return 'rv-warn'
134 default: return 'rv-muted'
135 }
137 
138// The swing equation appears only when something actually transformed the base
139// swing (wager amplification and/or the stake) — an in-period unstaked turn
140// keeps its single clean number.
141function showEquation(m: Message): boolean {
142 return m.baseProgressChange !== undefined
143 && m.progressChange !== undefined
144 && m.baseProgressChange !== m.progressChange
146function signed(n: number): string {
147 return `${n > 0 ? '+' : ''}${n}`
149 
150// The amplifier tokens between the base and final swing — anachronism, causal
151// chain, the stake — each rendered as a labeled glyph carrying its own hover
152// hint (what the token is and how it bent the swing). Mirrors the order the
153// server applies them; absent tokens drop out, matching the old flat string.
154interface EquationToken {
155 kind: 'anachronism' | 'chain' | 'craft' | 'momentum' | 'staked'
156 label: string
157 hint: string
159function equationTokens(m: Message): EquationToken[] {
160 const tokens: EquationToken[] = []
161 if (m.anachronism && m.anachronism !== 'in-period') {
162 const pips = m.anachronism === 'impossible' ? '⚡⚡⚡' : m.anachronism === 'far-ahead' ? '⚡⚡' : '⚡'
163 tokens.push({ kind: 'anachronism', label: `${pips} ${ANACHRONISM_LABEL[m.anachronism].toLowerCase()}`, hint: ANACHRONISM_HINT[m.anachronism] })
164 }
165 if (m.causalChain && m.causalChain.tier !== 'at-hinge') {
166 tokens.push({ kind: 'chain', label: `${CHAIN_LABEL[m.causalChain.tier].toLowerCase()}`, hint: CHAIN_HINT[m.causalChain.tier] })
167 }
168 // Craft scales the UPSIDE only, and only when it isn't the neutral 'sound' — so a
169 // sharp gain shows its lift and a vague one its drag, right in the math. This is
170 // where the player SEES craft drive the magnitude, not just tilt the die (#62).
171 if (m.progressChange !== undefined && m.progressChange > 0 && m.craft && m.craft !== 'sound') {
172 tokens.push({ kind: 'craft', label: `${CRAFT_LABEL[m.craft].toLowerCase()}`, hint: `a ${CRAFT_LABEL[m.craft].toLowerCase()} dispatch scales the gain it earned — craft, gains-only` })
173 }
174 // Momentum is the OTHER gains-only amplifier (the arc meter): show it whenever it
175 // lifted this turn, so the printed equation reconciles to the result (issue #62).
176 if (m.progressChange !== undefined && m.progressChange > 0 && m.momentumAtSwing && m.momentumAtSwing >= 1) {
177 tokens.push({ kind: 'momentum', label: `✦ momentum ${m.momentumAtSwing}`, hint: `momentum ${m.momentumAtSwing} of 4 — a coherent arc amplifies every gain` })
178 }
179 if (m.staked) tokens.push({ kind: 'staked', label: '⚑ staked', hint: STAKE_HINT })
180 return tokens
⋯ 33 lines hidden (lines 181–213)
182</script>
183 
184<style scoped>
185/* Phone keeps a fixed scroll box (the Story tab is one panel); the 340px cap and
186 md+ release live as utilities on the element (a scoped rule would out-specify the
187 Tailwind md: override). From md up the thread grows into the panel's own scroll. */
188.message-history-container {
189 min-height: 120px;
191 
192/* Staged reveal: a resolved turn writes itself in — the figure speaks, then acts,
193 then the ripple through history lands. Elements stay in the DOM (opacity only). */
194[data-testid="character-message"],
195[data-testid="character-action"],
196[data-testid="timeline-impact"] {
197 animation: reveal-up 0.45s var(--rv-ease) both;
199[data-testid="character-message"] { animation-delay: 0.08s; }
200[data-testid="character-action"] { animation-delay: 0.30s; }
201[data-testid="timeline-impact"] { animation-delay: 0.55s; }
202 
203@keyframes reveal-up {
204 from { opacity: 0; transform: translateY(8px); }
205 to { opacity: 1; transform: none; }
207 
208@media (prefers-reduced-motion: reduce) {
209 [data-testid="character-message"],
210 [data-testid="character-action"],
211 [data-testid="timeline-impact"] { animation: none; }
213</style>

What the note spawns

The exploration enumerates two implementation issues, ready to file, in dependency order — and the adversarial balance break-test cut one proposed skill before it could ship.

OutcomeDetail
Issue 1 (foundation)Server-authoritative per-turn run state + close the stake/momentum trust gaps.
Issue 2 (depends on 1)“The Editor's Hand”: the per-run annotation draft, the earned-in-play charge ledger, server resolution at the verified hooks, ANNOTATION_CAP, the ⟐ badge, and the armed-masterful corridor re-test.
Cut by the break-testPress Correction (a chain-decay floor lift) — the only non-craft-gated skill; it rewarded the least-grounded play and inverted the causal-chain dial's intent.
No code beyond the doc. Classes, trees, inventory, reroll skills, and loss-softeners are all deferred.