What changed, and why

In Everwhen, the Message Judge grades your dispatch and that craft grade bends the turn's swing. Until now it bent the swing one way only. A positive swing was multiplied by CRAFT_GAIN_FACTOR (masterful banks 1.45x, reckless almost nothing); a negative swing was passed through untouched. So a masterful message that rolled a Failure lost exactly as hard as a reckless one. On the ~40% of turns that go badly (Failure + Critical Failure), skill did nothing. Skill was a sword, never a shield.

This change makes craft cut both ways. It adds a CRAFT_LOSS_FACTOR table and applies it to the loss branch, inverted: good craft softens a loss, bad craft deepens it. The whole change is one engine line, one new table, and the coherence work around them: a UI token that now fires on a loss, the tests that pin the new tuning, and the docs/comments that used to say "gains-only".

Two guardrails frame the design. It is a shield, not an eraser (masterful caps the softening at 0.6, so a masterful loss still bites). And it is craft only — momentum, the other gains-only amplifier, stays gains-only. Both are load-bearing, and both are checked below.

The loss factor: an inverted mirror of the gain factor

The new table lives beside the gain table it mirrors. Read the two as a pair: the gain factor descends with quality (masterful 1.45 down to reckless 0.15), the loss factor ascends (masterful 0.6 up to reckless 1.4). A grade that amplifies a win softens a loss, and the reverse. Sound sits at 1.0 on both, so a clean, unremarkable ask neither lifts a gain nor bends a loss.

CRAFT_LOSS_FACTOR — the downside mirror, with the design encoded in the doc.

utils/craft.ts · 84 lines
utils/craft.ts84 lines · TypeScript
⋯ 40 lines hidden (lines 1–40)
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 on a turn's POSITIVE progress — the
26 * UPSIDE half of the craft amplifier, applied inside the Timeline Engine (after
27 * causal decay, before the per-turn clamp). Floor 0.15, never 0 — a graded
28 * dispatch always counts for something (rails, not walls). This is the craft
29 * channel's SECOND, multiplicative half: the ±2 CRAFT_MODIFIER above tilts the
30 * die toward a better band; this scales the gain that band pays out. The two
31 * compound on purpose — that compounding is how craft decides the campaign. Its
32 * downside mirror is CRAFT_LOSS_FACTOR below, which scales a LOSS instead. */
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
40 
41/** Multiplicative swing factor per grade on a turn's NEGATIVE progress — the
42 * DOWNSIDE mirror of CRAFT_GAIN_FACTOR, INVERTED: good craft SOFTENS a loss
43 * (factor < 1), bad craft DEEPENS it (factor > 1). So skill is a shield as well
44 * as a sword — it bears on the ~40% of turns that go badly (Failure + Critical
45 * Failure), not just the ones that go well (issue #166). A SHIELD, NOT AN ERASER:
46 * masterful bottoms out at 0.6, so a masterful loss still bites — it never near-
47 * cancels the dice (that would kill the drama). The effect: skilled play earns a
48 * tighter distribution (wins amplified, losses softened) while sloppy play keeps
49 * the full brutal variance (vague/reckless lose MORE). Anchored at sound = 1.0 (no
50 * tilt) and monotonic with the grade, the inverse order of the gain ladder. Craft
51 * only — momentum stays gains-only (softening a loss is a per-message reward, not
52 * a run-state one); the validated EV corridor lives in utils/swing-bands.ts. */
53export const CRAFT_LOSS_FACTOR: Record<Craft, number> = {
54 masterful: 0.6,
55 sharp: 0.8,
56 sound: 1.0,
57 vague: 1.2,
58 reckless: 1.4
60 
⋯ 24 lines hidden (lines 61–84)
61/** Short player-facing label per grade (for the turn reveal / ledger). */
62export const CRAFT_LABEL: Record<Craft, string> = {
63 masterful: 'Masterful',
64 sharp: 'Sharp',
65 sound: 'Sound',
66 vague: 'Vague',
67 reckless: 'Reckless'
69 
70/** One-line hover hint per grade — what it means and how it tilts the roll.
71 * Lives beside the modifier so the copy and the number can never drift apart. */
72export const CRAFT_HINT: Record<Craft, string> = {
73 masterful: 'precise and period-fluent — adds 2 to the roll; cannot critically backfire',
74 sharp: 'a cut above competent — adds 1 to the roll',
75 sound: 'a clear, honest ask — leaves the roll unchanged',
76 vague: 'wishing more than working — subtracts 1 from the roll',
77 reckless: 'incoherent for the era — subtracts 2 from the roll; cannot critically land'
79 
80/** Coerce an untrusted value to a known grade. Defaults to the neutral 'sound':
81 * a Judge outage must never tilt the roll either way. */
82export function toCraft(value: unknown): Craft {
83 return CRAFT_LEVELS.includes(value as Craft) ? (value as Craft) : 'sound'

One line in the engine

The swing is assembled in the Timeline Engine: the rolled band is clamped, widened by the anachronism wager, then decayed by causal distance into decayed. Craft and momentum are the last multipliers before the per-turn fuse. The change is the ternary on the final line — the loss branch was : decayed, and is now : decayed * craftLoss.

The gain branch keeps craft x momentum; the loss branch now multiplies by craftLoss alone.

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

A shield, not an eraser: the EV check

The tuning claim is that skilled play gets a tighter distribution while sloppy play keeps the full brutal variance — and that the shield is modest, not an eraser. A small helper makes that testable: it computes a turn's realized expected value from the band midpoints, routed to the gain or loss factor exactly as the engine routes them, weighted by the face distribution a craft modifier produces.

The realizedEV helper, then the two tests that pin the shield's two edges.

tests/unit/utils/swing-bands.spec.ts · 161 lines
tests/unit/utils/swing-bands.spec.ts161 lines · TypeScript
⋯ 5 lines hidden (lines 1–5)
1import { describe, it, expect } from 'vitest'
2import { SWING_BANDS, BAND_CEILING, applyStake, STAKED_SWING_CAP, STAKE_HINT } from '../../../utils/swing-bands'
3import { DiceOutcome, CRIT_FAIL_MAX, FAILURE_MAX, NEUTRAL_MAX, CRIT_SUCCESS_MIN } from '../../../utils/dice'
4import { MAX_PROGRESS_SWING } from '../../../utils/game-config'
5import { CRAFT_GAIN_FACTOR, CRAFT_LOSS_FACTOR } from '../../../utils/craft'
6 
7/**
8 * Realized per-turn EV — the band-EV's sibling that folds in the multiplicative
9 * craft factors (issue #166). Band MIDPOINTS routed by sign exactly as the engine
10 * routes them (`decayed > 0 ? gain : loss`), weighted by the effective face-count
11 * distribution a craft modifier produces. Lets a test pin what skill actually does
12 * to the corridor, not just what the raw bands pay.
13 */
14function realizedEV(faces: Record<DiceOutcome, number>, gain: number, loss: number): number {
15 const total = Object.values(faces).reduce((a, b) => a + b, 0)
16 return Object.entries(SWING_BANDS).reduce((sum, [outcome, band]) => {
17 const mid = (band.min + band.max) / 2
18 const factor = mid > 0 ? gain : loss
19 return sum + mid * factor * faces[outcome as DiceOutcome]
20 }, 0) / total
22 
⋯ 74 lines hidden (lines 23–96)
23/**
24 * The run economy's load-bearing numbers. These tests don't chase coverage —
25 * they pin the tuning intent so a casual band edit can't silently turn the game
26 * back into a lottery (EV too low) or a cakewalk (EV too high).
27 */
28describe('swing bands (the run economy)', () => {
29 it('every band stays inside the hard safety clamp', () => {
30 for (const band of Object.values(SWING_BANDS)) {
31 expect(Math.abs(band.min)).toBeLessThanOrEqual(MAX_PROGRESS_SWING)
32 expect(Math.abs(band.max)).toBeLessThanOrEqual(MAX_PROGRESS_SWING)
33 expect(band.min).toBeLessThanOrEqual(band.max)
34 }
35 })
36 
37 it('the ceiling is derived from the table and sits under the clamp', () => {
38 expect(BAND_CEILING).toBe(45)
39 expect(BAND_CEILING).toBeLessThanOrEqual(MAX_PROGRESS_SWING)
40 })
41 
42 it('bands escalate monotonically from crit-fail to crit-success', () => {
43 const order = [
44 DiceOutcome.CRITICAL_FAILURE,
45 DiceOutcome.FAILURE,
46 DiceOutcome.NEUTRAL,
47 DiceOutcome.SUCCESS,
48 DiceOutcome.CRITICAL_SUCCESS
49 ]
50 for (let i = 1; i < order.length; i++) {
51 expect(SWING_BANDS[order[i]].min).toBeGreaterThanOrEqual(SWING_BANDS[order[i - 1]].min)
52 expect(SWING_BANDS[order[i]].max).toBeGreaterThanOrEqual(SWING_BANDS[order[i - 1]].max)
53 }
54 })
55 
56 it('keeps the per-turn expected value in the tuned corridor (slightly uphill to lose, winnable with craft)', () => {
57 // Face counts per band, derived from the dice constants.
58 const faces: Record<DiceOutcome, number> = {
59 [DiceOutcome.CRITICAL_FAILURE]: CRIT_FAIL_MAX, // 1..2
60 [DiceOutcome.FAILURE]: FAILURE_MAX - CRIT_FAIL_MAX, // 3..8
61 [DiceOutcome.NEUTRAL]: NEUTRAL_MAX - FAILURE_MAX, // 9..12
62 [DiceOutcome.SUCCESS]: (CRIT_SUCCESS_MIN - 1) - NEUTRAL_MAX, // 13..18
63 [DiceOutcome.CRITICAL_SUCCESS]: 20 - CRIT_SUCCESS_MIN + 1 // 19..20
64 }
65 const totalFaces = Object.values(faces).reduce((a, b) => a + b, 0)
66 expect(totalFaces).toBe(20)
67 
68 const ev = Object.entries(SWING_BANDS).reduce((sum, [outcome, band]) => {
69 const mid = (band.min + band.max) / 2
70 return sum + mid * faces[outcome as DiceOutcome]
71 }, 0) / totalFaces
72 
73 // ~+6/turn: five sound-but-unaided turns land near 30/100 (an honorable
74 // loss); craft and a calibrated wager are what close the gap to 100.
75 expect(ev).toBeGreaterThanOrEqual(4)
76 expect(ev).toBeLessThanOrEqual(9)
77 })
78 
79 it('a masterful (+2) tilt lifts the corridor to genuinely-winnable (~+11..16/turn)', () => {
80 // Effective-roll distribution under a +2 modifier (clamped 1..20):
81 // CF unreachable, F ← rolls 1-6, N ← 7-10, S ← 11-16, CS ← 17-20.
82 const tilted: Record<DiceOutcome, number> = {
83 [DiceOutcome.CRITICAL_FAILURE]: 0,
84 [DiceOutcome.FAILURE]: 6,
85 [DiceOutcome.NEUTRAL]: 4,
86 [DiceOutcome.SUCCESS]: 6,
87 [DiceOutcome.CRITICAL_SUCCESS]: 4
88 }
89 const ev = Object.entries(SWING_BANDS).reduce((sum, [outcome, band]) => {
90 const mid = (band.min + band.max) / 2
91 return sum + mid * tilted[outcome as DiceOutcome]
92 }, 0) / 20
93 expect(ev).toBeGreaterThanOrEqual(11)
94 expect(ev).toBeLessThanOrEqual(16)
95 })
96 
97 it('the craft loss-shield deepens reckless losses below the old gains-only model (issue #166)', () => {
98 // Under a −2 modifier the roll tilts down: CF widens, CS becomes unreachable.
99 const reckless: Record<DiceOutcome, number> = {
100 [DiceOutcome.CRITICAL_FAILURE]: 4,
101 [DiceOutcome.FAILURE]: 6,
102 [DiceOutcome.NEUTRAL]: 4,
103 [DiceOutcome.SUCCESS]: 6,
104 [DiceOutcome.CRITICAL_SUCCESS]: 0
105 }
106 const gain = CRAFT_GAIN_FACTOR.reckless // ×0.15
107 const withShield = realizedEV(reckless, gain, CRAFT_LOSS_FACTOR.reckless) // loss ×1.4
108 const oldModel = realizedEV(reckless, gain, 1) // the old gains-only behavior
109 // Sloppy play loses hard — and the loss factor makes it lose HARDER than before.
110 expect(withShield).toBeLessThanOrEqual(-9)
111 expect(withShield).toBeLessThan(oldModel)
112 })
113 
114 it('the craft loss-shield only bends the loss branch — masterful lifts modestly, a shield not an eraser (issue #166)', () => {
115 // The +2 masterful tilt (same distribution as the corridor test above).
116 const masterful: Record<DiceOutcome, number> = {
117 [DiceOutcome.CRITICAL_FAILURE]: 0,
118 [DiceOutcome.FAILURE]: 6,
119 [DiceOutcome.NEUTRAL]: 4,
120 [DiceOutcome.SUCCESS]: 6,
121 [DiceOutcome.CRITICAL_SUCCESS]: 4
122 }
123 const gain = CRAFT_GAIN_FACTOR.masterful // ×1.45
124 const withShield = realizedEV(masterful, gain, CRAFT_LOSS_FACTOR.masterful) // loss ×0.6
125 const noShield = realizedEV(masterful, gain, 1) // gains identical, loss unscaled
126 // The shield helps only by softening the Failure band: a small, bounded lift —
127 // it never erases the loss (which would near-cancel the dice drama).
128 expect(withShield).toBeGreaterThan(noShield)
129 expect(withShield - noShield).toBeLessThan(2)
130 })
⋯ 31 lines hidden (lines 131–161)
131 
132 it('neutral is the narrowest band — most rolls commit', () => {
133 const neutralFaces = NEUTRAL_MAX - FAILURE_MAX
134 expect(neutralFaces).toBeLessThanOrEqual(4)
135 })
136 
137 describe('applyStake (the last stand)', () => {
138 it('doubles the resolved swing, both ways', () => {
139 expect(applyStake(30)).toBe(60)
140 expect(applyStake(-25)).toBe(-50)
141 })
142 
143 it('can reach the full meter — no run is mathematically dead with a message left', () => {
144 expect(applyStake(50)).toBe(STAKED_SWING_CAP)
145 expect(applyStake(64)).toBe(100) // already past half the meter → all of it
146 })
147 
148 it('clamps at the meter, both ways', () => {
149 expect(applyStake(80)).toBe(100)
150 expect(applyStake(-80)).toBe(-100)
151 })
152 
153 it('a staked nothing is still nothing', () => {
154 expect(applyStake(0)).toBe(0)
155 })
156 
157 it('carries a player-facing hover hint for the ⚑ badge', () => {
158 expect(STAKE_HINT).toBeTruthy()
159 })
160 })
161})

The player sees the shield (composes with #135)

A softened loss is only legible if the player can see why the number moved. The turn's swing renders as an equation — base [tokens] -> final% (the template at line 68) — where each amplifier drops a labelled token. The craft token was gated to gains (progressChange > 0), so on a loss the number would soften with nothing to explain it. The gate now fires on any non-zero swing for a non-sound grade, and the hover hint is sign-aware: it speaks of banking a gain on the upside, and of shielding or deepening a loss on the downside.

The craft token now renders on a loss; the momentum token below it stays gains-only.

components/MessageHistory.vue · 224 lines
components/MessageHistory.vue224 lines · Vue
⋯ 169 lines hidden (lines 1–169)
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 ew-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="ew-muted text-sm">
11 {{ activeFigure ? `No words exchanged with ${activeFigure} yet` : 'No one contacted yet' }}
12 </p>
13 <p class="ew-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 ew-line last:border-b-0">
22 <!-- Player message -->
23 <div v-if="message.sender === 'user'" data-testid="user-message">
24 <p class="text-[11px] ew-faint uppercase tracking-wide mb-0.5">You → {{ message.figureName || activeFigure }}</p>
25 <p class="ew-fg text-sm">{{ message.text }}</p>
26 <p class="text-[11px] ew-faint mt-1 ew-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 ew-fg flex items-center gap-1.5 min-w-0">
33 <span class="ew-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 ew-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="ew-faint">
40 {{ message.naturalRoll }} <span :class="message.rollModifier > 0 ? 'ew-ok' : 'ew-bad'">{{ message.rollModifier > 0 ? '+' : '' }}{{ message.rollModifier }}</span> =
41 </span>
42 <span data-testid="dice-result" class="font-bold ew-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="ew-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="ew-ok font-semibold"> · ✦ builds</span><span v-if="message.craftReason" class="ew-faint">{{ message.craftReason }}</span>
52 </div>
53 
54 <p data-testid="character-message" class="ew-fg text-sm mb-2">{{ message.text }}</p>
55 
56 <div v-if="message.characterAction" data-testid="character-action" class="text-xs ew-muted border-l ew-line pl-2 mb-2">
57 <span class="ew-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 ew-muted border-l ew-line pl-2 mb-1">
61 <span class="ew-faint">ripple · </span>{{ message.timelineImpact }}
62 <span v-if="message.progressChange !== undefined" data-testid="progress-change"
63 class="ew-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="ew-mono ew-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] ew-faint ew-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 ew-muted text-sm">
77 <span class="ew-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 'ew-dot--ok'
109 case 'Failure':
110 case 'Critical Failure': return 'ew-dot--bad'
111 default: return 'ew-dot--mut'
112 }
114function outcomeText(outcome?: string): string {
115 switch (outcome) {
116 case 'Critical Success':
117 case 'Success': return 'ew-ok'
118 case 'Failure':
119 case 'Critical Failure': return 'ew-bad'
120 default: return 'ew-muted'
121 }
123function deltaText(change?: number): string {
124 if (change === undefined || change === 0) return 'ew-muted'
125 return change > 0 ? 'ew-ok' : 'ew-bad'
127 
128function craftClass(craft?: Craft): string {
129 switch (craft) {
130 case 'masterful':
131 case 'sharp': return 'ew-ok'
132 case 'vague':
133 case 'reckless': return 'ew-warn'
134 default: return 'ew-muted'
135 }
137 
138// The swing equation renders on every resolved turn (issue #135) — always
139// disclosing base → final. On a plain turn it collapses to a single clean number
140// like (+22 → +22%), which is fine; consistency beats hiding the math, and a loss
141// now shows its breakdown too — including how craft bent it (a masterful shield
142// softens the blow, a reckless drag deepens it, issue #166). Needs only that both
143// base and final are known.
144function showEquation(m: Message): boolean {
145 return m.baseProgressChange !== undefined
146 && m.progressChange !== undefined
148function signed(n: number): string {
149 return `${n > 0 ? '+' : ''}${n}`
151 
152// The amplifier tokens between the base and final swing — anachronism, causal
153// chain, the stake — each rendered as a labeled glyph carrying its own hover
154// hint (what the token is and how it bent the swing). Mirrors the order the
155// server applies them; absent tokens drop out, matching the old flat string.
156interface EquationToken {
157 kind: 'anachronism' | 'chain' | 'craft' | 'momentum' | 'staked'
158 label: string
159 hint: string
161function equationTokens(m: Message): EquationToken[] {
162 const tokens: EquationToken[] = []
163 if (m.anachronism && m.anachronism !== 'in-period') {
164 const pips = m.anachronism === 'impossible' ? '⚡⚡⚡' : m.anachronism === 'far-ahead' ? '⚡⚡' : '⚡'
165 tokens.push({ kind: 'anachronism', label: `${pips} ${ANACHRONISM_LABEL[m.anachronism].toLowerCase()}`, hint: ANACHRONISM_HINT[m.anachronism] })
166 }
167 if (m.causalChain && m.causalChain.tier !== 'at-hinge') {
168 tokens.push({ kind: 'chain', label: `${CHAIN_LABEL[m.causalChain.tier].toLowerCase()}`, hint: CHAIN_HINT[m.causalChain.tier] })
169 }
170 // Craft drives the magnitude on BOTH signs now (issue #166): on a gain it banks
171 // more (or less) of the band the roll earned; on a loss it works INVERTED — good
172 // craft shields the blow, poor craft deepens it. Skip the neutral 'sound' (no tilt)
173 // and a zero swing (nothing to attribute). This is where the player SEES craft
174 // drive the magnitude, not just tilt the die (#62).
175 if (m.progressChange !== undefined && m.progressChange !== 0 && m.craft && m.craft !== 'sound') {
176 const grade = CRAFT_LABEL[m.craft].toLowerCase()
177 tokens.push({
178 kind: 'craft',
179 label: `${grade}`,
180 hint: m.progressChange > 0
181 ? `a ${grade} dispatch scales the gain it earned — craft drives the swing`
182 : `a ${grade} dispatch bends the loss it took — good craft shields it, poor craft deepens it`
183 })
184 }
⋯ 40 lines hidden (lines 185–224)
185 // Momentum is the OTHER gains-only amplifier (the arc meter): show it whenever it
186 // lifted this turn, so the printed equation reconciles to the result (issue #62).
187 if (m.progressChange !== undefined && m.progressChange > 0 && m.momentumAtSwing && m.momentumAtSwing >= 1) {
188 tokens.push({ kind: 'momentum', label: `✦ momentum ${m.momentumAtSwing}`, hint: `momentum ${m.momentumAtSwing} of 4 — a coherent arc amplifies every gain` })
189 }
190 if (m.staked) tokens.push({ kind: 'staked', label: '⚑ staked', hint: STAKE_HINT })
191 return tokens
193</script>
194 
195<style scoped>
196/* Phone keeps a fixed scroll box (the Story tab is one panel); the 340px cap and
197 md+ release live as utilities on the element (a scoped rule would out-specify the
198 Tailwind md: override). From md up the thread grows into the panel's own scroll. */
199.message-history-container {
200 min-height: 120px;
202 
203/* Staged reveal: a resolved turn writes itself in — the figure speaks, then acts,
204 then the ripple through history lands. Elements stay in the DOM (opacity only). */
205[data-testid="character-message"],
206[data-testid="character-action"],
207[data-testid="timeline-impact"] {
208 animation: reveal-up 0.45s var(--ew-ease) both;
210[data-testid="character-message"] { animation-delay: 0.08s; }
211[data-testid="character-action"] { animation-delay: 0.30s; }
212[data-testid="timeline-impact"] { animation-delay: 0.55s; }
213 
214@keyframes reveal-up {
215 from { opacity: 0; transform: translateY(8px); }
216 to { opacity: 1; transform: none; }
218 
219@media (prefers-reduced-motion: reduce) {
220 [data-testid="character-message"],
221 [data-testid="character-action"],
222 [data-testid="timeline-impact"] { animation: none; }
224</style>

So a masterful Failure now reads -15 ✒ masterful -> -9% in the thread — the player watches skill soften the blow. The momentum token directly below keeps its progressChange > 0 gate, matching the engine.

Keeping the codebase honest

The rest of the change is coherence: making sure nothing in the repo still claims craft is gains-only. The load-bearing test is the integration check — a -10 Failure fed through every grade now yields the inverted ladder, and asserts the shield never cancels the loss.

The old test asserted a constant -10 across grades; it now asserts the inverted shield.

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

Three more spec files moved with it: craft.spec.ts pins the table, the shield-not-eraser bound (masterful in [0.5, 1)), and the inverted-mirror invariant (a grade is never on the same side of 1 in both tables); MessageHistory.spec.ts now expects the craft token on a loss. The stale prose followed too — the CRAFT_GAIN_FACTOR doc, the engine comment, the UI hint, and two docs/design notes all said "gains-only / losses untouched", and now describe a two-sided craft with momentum still gains-only.