The mechanic, and why

History should bend by STEPPING STONES, not blasts. A message decays in power the further, in years, it lands from your nearest FOOTHOLD — and your footholds are the objective's anchor year plus every change you've already landed.

So chaining forward is the rewarded path: a far-back seed (Leonardo + gunpowder, aiming at the Moon by 1900) lands modestly but plants a foothold; the next move near it lands strong; the chain marches to the event, compounding. A lone deep-past blast (Caesar in 50 BC to change 1960) is diffuse by design — a sliver even on a crit. It's the opposite-pulling sibling of the anachronism amplifier, and it closes the roadmap's known-open balance hole (max-anachronism was EV-positive; the decay diffuses far, unbridged reaches toward zero).

The pure dial

The whole mechanic is one pure module — no model judgment, no calendar math, just signed years we already hold. nearestFootholdGap finds the smallest year-gap to any foothold; chainDecay maps that gap to a factor: full power within a bridge-free radius, then exponential decay to a floor that's never zero (diffuse, not blocked — rails, not walls). The constants are calibrated to the two anchor cases in the docstring: a ~400yr opening reach stays a worthwhile seed (~0.55); a ~2,000yr lone leap bottoms out at the floor.

nearestFootholdGap → chainDecay → chainStatus. A factor of 1 (near a foothold, or no foothold to measure) is a clean no-op.

utils/causal-chain.ts · 129 lines
utils/causal-chain.ts129 lines · TypeScript
⋯ 81 lines hidden (lines 1–81)
1/**
2 * The causal chain — a deterministic dial that makes history bend by STEPPING
3 * STONES, not single blasts. It is the opposite-pulling sibling of the
4 * anachronism amplifier (server/utils/anachronism.ts): anachronism WIDENS the
5 * swing for reaching beyond a figure's own era; this DECAYS the swing for a
6 * message that lands far from anything you've already changed.
7 *
8 * The philosophy (see docs/ROADMAP.md, CLAUDE.md):
9 * A message should lose its power the further it lands, in time, from your
10 * nearest FOOTHOLD — and your footholds are the objective itself plus every
11 * change you have already landed. So:
12 * - CHAINING is the intended, rewarded path. A far-back seed (tell Leonardo
13 * about gunpowder, aiming at the Moon by 1900) lands only modestly on its
14 * own, but it plants a foothold; the next move near it is now strong, and
15 * the chain marches forward to the event, compounding.
16 * - A LONE BLAST is diffuse by design. Message Caesar in 50 BC to change
17 * 1960 and the nudge fizzles across two millennia with nothing bridging
18 * it — even a critical success lands a sliver. You cannot one-shot a
19 * rewrite of deep history; you must build the chain forward to it.
20 *
21 * Rails, not walls: a far reach is never BLOCKED, only diffuse (the factor has
22 * a floor, never zero), so an expert can still open from antiquity and grind a
23 * chain up through the centuries. Deterministic + legible: the gap is computed
24 * from signed years we already hold (no model judgment, no calendar math), and
25 * a ⏳ chip shows the tier before you send, the way ⚡ shows the wager.
26 *
27 * Engages only for GROUNDED contacts (a known contact year) against a known
28 * objective anchor or a dated prior change. Free-form / ungrounded play, or an
29 * objective with no anchor year, simply no-ops (factor 1).
30 */
31 
32export type ChainTier = 'at-hinge' | 'bridged' | 'reaching' | 'diffuse'
33 
34export const CHAIN_TIERS: ChainTier[] = ['at-hinge', 'bridged', 'reaching', 'diffuse']
35 
36export interface ChainStatus {
37 /** Years to the nearest foothold (objective anchor or a landed change). */
38 gap: number
39 /** Decay multiplier applied to the swing magnitude, in [FLOOR, 1]. */
40 factor: number
41 tier: ChainTier
43 
44/**
45 * Tuning intent. Within BRIDGE_FREE years of a foothold the move is at full
46 * power — adjacent-era chaining (a step every couple of generations) is
47 * frictionless, which is what makes building a chain forward feel fluid. Beyond
48 * that the factor decays exponentially with SCALE and never falls below FLOOR
49 * (diffuse, never inert; the reach is weak, not forbidden).
50 *
51 * Calibrated against the two anchor cases:
52 * - a ~400yr opening reach (Leonardo 1500 → Moon-by-1900) ≈ 0.55: a real,
53 * worthwhile seed that needs the chain built behind it to pay off;
54 * - a ~2000yr lone reach (Caesar 50 BC → 1960) → FLOOR: a sliver even on a
55 * crit, so the one-shot rewrite is structurally near-impossible.
56 */
57export const BRIDGE_FREE = 75
58const SCALE = 550
59export const CHAIN_FLOOR = 0.12
60 
61export const CHAIN_LABEL: Record<ChainTier, string> = {
62 'at-hinge': 'At the hinge',
63 bridged: 'Bridged',
64 reaching: 'Reaching',
65 diffuse: 'Diffuse'
67 
68/** A one-line player hint per tier (for the ⏳ chip / ledger badge). */
69export const CHAIN_HINT: Record<ChainTier, string> = {
70 'at-hinge': 'right at the hinge of events — full force',
71 bridged: 'bridged to your chain — lands strong',
72 reaching: 'reaching past your footholds — landing diluted',
73 diffuse: 'adrift in time — build a chain toward it first'
75 
76/**
77 * Years from a contact year to the NEAREST foothold. Footholds are the
78 * objective's anchor year plus every dated change already landed. Returns null
79 * when there is nothing to measure against (no anchor, no dated changes) or no
80 * contact year — the dial no-ops.
81 */
82export function nearestFootholdGap(
83 figureYear: number | null | undefined,
84 footholds: ReadonlyArray<number | null | undefined>
85): number | null {
86 if (figureYear == null || !Number.isFinite(figureYear)) return null
87 const valid = footholds.filter((f): f is number => typeof f === 'number' && Number.isFinite(f))
88 if (!valid.length) return null
89 return Math.min(...valid.map(f => Math.abs(figureYear - f)))
91 
92/** Gap (years to nearest foothold) → decay factor in [CHAIN_FLOOR, 1]. */
93export function chainDecay(gap: number | null): number {
94 if (gap == null) return 1 // no foothold to measure against — no-op
95 if (gap <= BRIDGE_FREE) return 1
96 return Math.max(CHAIN_FLOOR, Math.exp(-(gap - BRIDGE_FREE) / SCALE))
98 
99/** Bucket a decay factor into a legible tier for the player-facing signal. */
100export function chainTier(factor: number): ChainTier {
101 if (factor >= 0.85) return 'at-hinge'
102 if (factor >= 0.55) return 'bridged'
103 if (factor >= 0.25) return 'reaching'
104 return 'diffuse'
106 
107/**
108 * The full chain status for a contact year against a set of footholds, or null
109 * when the dial doesn't engage (ungrounded / no anchor). Shared by the server
110 * (the real decay) and the client (the pre-send ⏳ chip).
111 */
112export function chainStatus(
113 figureYear: number | null | undefined,
114 footholds: ReadonlyArray<number | null | undefined>
115): ChainStatus | null {
116 const gap = nearestFootholdGap(figureYear, footholds)
117 if (gap == null) return null
118 const factor = chainDecay(gap)
119 return { gap, factor, tier: chainTier(factor) }
121 
122/**
123 * Decays a swing magnitude toward zero by the chain factor — both signs (a far,
124 * unbridged reach is a faint nudge whether it would have helped or hurt). A
⋯ 5 lines hidden (lines 125–129)
125 * factor of 1 (engaged near a foothold, or a no-op) returns the swing unchanged.
126 */
127export function decayForChain(progressChange: number, factor: number): number {
128 return Math.round(progressChange * factor)

Applied after the amplifier

In the Timeline Engine the dials compose in order: the rolled band value, then the anachronism amplifier widens it, then the chain decay diffuses it. The footholds are assembled from the objective's anchor plus every dated change on the ledger — so landing a change plants a new foothold and the next nearby move is cheap. The result rides back on the ripple as causalChain for the UI.

amplify → decay → the footholds (objective anchor + ledger years). Ungrounded turns / anchorless objectives → chainStatus null → no-op.

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

The model is now told NOT to shrink its own swing for distance — the engine does it once, deterministically — so the old prompt-only causal-distance guidance is retired (no double-counting).

Objectives gained a signed anchorYear — the chain's far foothold. The composer emits one; sweeping 'across the ages' goals omit it and the dial no-ops.

server/utils/objective-generator.ts · 163 lines
server/utils/objective-generator.ts163 lines · TypeScript
⋯ 4 lines hidden (lines 1–4)
1/**
2 * A grand counterfactual objective — the frame for a whole run. Kept deliberately
3 * lean: a title, what winning looks like, an anchoring era for flavor, and an icon.
4 */
5export interface GameObjective {
6 title: string
7 description: string
8 era: string
9 icon: string
10 /**
11 * Signed year (AD positive, BC negative) the objective is aimed at — the far
12 * anchor the causal-chain dial decays toward (utils/causal-chain.ts). Omitted
13 * for objectives that genuinely span all of history, where there is no single
14 * hinge to chain toward and the dial simply no-ops.
15 */
16 anchorYear?: number
⋯ 10 lines hidden (lines 18–27)
18 
19/**
20 * Curated pool of diverse, opinionated objectives. Selecting at random gives an
21 * instant, reliable, offline-friendly start, and each one points the player at a
22 * different stretch of history — reinforcing that you can reach for anyone, anywhen.
23 */
24const CURATED_OBJECTIVES: GameObjective[] = [
25 {
26 title: 'Prevent the Great War',
27 description: "Keep the tangle of alliances in 1914 from collapsing into world war. Defuse the crises, cool the hotheads, or remove the spark before Europe marches.",
28 era: 'Europe, 1914',
29 icon: '🕊️',
30 anchorYear: 1914
31 },
⋯ 132 lines hidden (lines 32–163)
32 {
33 title: 'Advance Medicine by 300 Years',
34 description: "Plant germ theory, vaccination, and antiseptics centuries early so that plague, fever, and infection lose their ancient grip on humankind.",
35 era: 'Across the ages',
36 icon: '⚕️'
37 },
38 {
39 title: 'Save the Library of Alexandria',
40 description: "Ensure the ancient world's greatest store of knowledge survives the fires and the centuries, intact, into the modern age.",
41 era: 'Alexandria, antiquity',
42 icon: '📜',
43 anchorYear: -48
44 },
45 {
46 title: 'Establish Global Democracy',
47 description: "Nudge empires, kings, and conquerors toward representative rule, so that by the modern day the world has learned to govern itself.",
48 era: 'Across the ages',
49 icon: '🗳️'
50 },
51 {
52 title: 'Reach the Moon by 1900',
53 description: "Accelerate rocketry, mathematics, and sheer ambition so that humanity slips the bonds of Earth half a century ahead of schedule.",
54 era: '19th century',
55 icon: '🚀',
56 anchorYear: 1900
57 },
58 {
59 title: 'Avert the Fall of Rome',
60 description: "Shore up the Empire against the pressures that broke it — and keep the lights of Rome from going dark across the West.",
61 era: 'Rome, late antiquity',
62 icon: '🏛️',
63 anchorYear: 476
64 },
65 {
66 title: 'Abolish Slavery a Century Early',
67 description: "Turn hearts, faiths, and laws against human bondage generations ahead of history, sparing untold millions.",
68 era: '18th century',
69 icon: '⛓️',
70 anchorYear: 1750
71 },
72 {
73 title: 'Spark an Early Industrial East',
74 description: "Light the steam-and-steel revolution in the East centuries before the West, and rewrite the whole global order.",
75 era: 'Asia, 2nd millennium',
76 icon: '⚙️',
77 anchorYear: 1400
78 },
79 {
80 title: 'Break the Black Death',
81 description: "Sever the chain of the plague before it crosses into Europe and erases a third of its people.",
82 era: 'Eurasia, 14th century',
83 icon: '🐀',
84 anchorYear: 1347
85 },
86 {
87 title: 'Unite Rivals Without War',
88 description: "Forge unity among warring kingdoms through diplomacy and marriage rather than conquest, sparing generations of bloodshed.",
89 era: 'Antiquity',
90 icon: '🤝'
91 }
93 
94function pickCurated(): GameObjective {
95 return CURATED_OBJECTIVES[Math.floor(Math.random() * CURATED_OBJECTIVES.length)]
97 
98/**
99 * Returns an objective for a new run. Curated-random by default (instant, no API
100 * call); pass useAI=true to compose one live, with automatic fallback to curated.
101 */
102export async function generateObjective(useAI = false): Promise<GameObjective> {
103 if (useAI) {
104 try {
105 return await generateAIObjective()
106 } catch (error) {
107 console.error('AI objective generation failed; falling back to curated pool:', error)
108 }
109 }
110 return pickCurated()
112 
113/** Exposed so the UI can offer the player a hand-picked objective if desired. */
114export function listCuratedObjectives(): GameObjective[] {
115 return [...CURATED_OBJECTIVES]
117 
118/**
119 * Composes a fresh objective with the model. Returns the same shape as the
120 * curated pool so the rest of the game treats them identically.
121 */
122async function generateAIObjective(): Promise<GameObjective> {
123 // Imported lazily so the curated (default) path never pulls the model SDKs
124 // into the client bundle.
125 const { completeStructured } = await import('./ai-gateway')
126 
127 const content = await completeStructured({
128 task: 'objective',
129 system: 'You are a historian and game designer inventing grand, evocative counterfactual objectives for a game where players text historical figures to bend history.',
130 turns: [{
131 role: 'user',
132 content: 'Invent one fresh objective. It should be a sweeping, achievable-in-spirit goal that spans real history, distinct from "prevent World War I". Give it a punchy title, a vivid one-or-two-sentence description of what winning looks like, an anchoring era, a single emoji, and the anchor year the objective is aimed at (the pivotal year where winning is decided) as a signed integer — negative for BC — or null if it genuinely spans all of history with no single hinge.'
133 }],
134 schemaName: 'game_objective',
135 schema: {
136 type: 'object',
137 properties: {
138 title: { type: 'string', description: 'Punchy objective title' },
139 description: { type: 'string', description: 'One or two sentences on what winning looks like' },
140 era: { type: 'string', description: 'Anchoring era / setting' },
141 icon: { type: 'string', description: 'A single emoji that fits the objective' },
142 anchorYear: { type: ['integer', 'null'], description: 'Signed year the objective is aimed at (negative for BC), or null if it spans all history' }
143 },
144 required: ['title', 'description', 'era', 'icon', 'anchorYear'],
145 additionalProperties: false
146 }
147 })
148 
149 if (!content) throw new Error('No objective generated')
150 
151 const parsed = JSON.parse(content) as GameObjective
152 if (!parsed.title || !parsed.description) throw new Error('Invalid objective format generated')
153 return {
154 title: parsed.title,
155 description: parsed.description,
156 era: parsed.era || 'Across the ages',
157 icon: parsed.icon || '🕰️',
158 // A finite signed year anchors the chain dial; null / junk → no anchor (no-op).
159 anchorYear: typeof parsed.anchorYear === 'number' && Number.isFinite(parsed.anchorYear)
160 ? Math.round(parsed.anchorYear)
161 : undefined
162 }

Legible both ends

The dial is shown, not hidden — the same discipline as the ⚡ wager chip. Before the roll, a ⏳ chip in the compose dock reads the tier for the chosen contact (hidden at the hinge, where there's nothing to warn). After the roll, the chain step joins the resolved equation: +20 ⏳ diffuse → +2%.

The pre-send ⏳ chip; chainRead (lower in the script) maps the store's causalChainRead getter to a tier + hint.

pages/index.vue · 446 lines
pages/index.vue446 lines · Vue
⋯ 132 lines hidden (lines 1–132)
1<template>
2 <div class="min-h-screen lg:h-screen rv-bg flex flex-col lg:overflow-hidden">
3 <!-- HUD: wordmark · objective mission-strip · gauge · status · dark · new-timeline -->
4 <header class="border-b rv-line">
5 <!-- On phones the bar wraps: controls stay on the top line, and the objective
6 (with its foldable brief) drops to its own full-width line so the brief
7 reads at full width instead of a thin column. Single row from sm up. -->
8 <div class="px-3 sm:px-5 py-2.5 flex flex-wrap items-center gap-x-2 gap-y-1.5 sm:flex-nowrap sm:gap-4">
9 <GameTitle />
10 
11 <div v-if="gameStore.currentObjective" class="order-last basis-full min-w-0 sm:order-none sm:basis-auto sm:flex-1">
12 <ObjectiveDisplay data-testid="objective-display" />
13 </div>
14 <div v-else class="flex-1" />
15 
16 <div v-if="gameStore.currentObjective" class="flex items-center gap-1.5 sm:gap-2 shrink-0 ml-auto sm:ml-0">
17 <MessagesCounter data-testid="messages-counter" />
18 <span class="flex items-center gap-1.5 pl-1">
19 <span class="rv-dot" :class="statusDot" aria-hidden="true" />
20 <span class="rv-mono text-[11px] uppercase tracking-wide rv-muted sr-only sm:not-sr-only">{{ statusWord }}</span>
21 </span>
22 <!-- One mis-tap must not erase five AI-resolved turns: with a run in
23 progress the reset arms a tiny inline confirm (auto-reverts); an
24 untouched run still resets in one tap. -->
25 <span v-if="resetArmed" class="flex items-center gap-1.5">
26 <span class="text-[11px] rv-warn">abandon this history?</span>
27 <button type="button" data-testid="reset-confirm" class="rv-tap rv-hover-fg text-sm leading-none rv-warn"
28 aria-label="Yes — abandon this history" @click="performReset"></button>
29 <button type="button" data-testid="reset-cancel" class="rv-tap rv-hover-fg text-sm leading-none"
30 aria-label="Keep playing" @click="disarmReset"></button>
31 </span>
32 <button v-else type="button" data-testid="reset-button" class="rv-tap rv-hover-fg text-base leading-none"
33 title="New timeline" aria-label="New timeline" @click="handleResetGame"></button>
34 </div>
35 
36 <button type="button" class="rv-tap rv-hover-fg text-base leading-none shrink-0"
37 :aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'" @click="toggleDark"></button>
38 </div>
39 </header>
40 
41 <!-- Waiting mode: a thin indeterminate sweep while a turn resolves — the whole
42 board reads as "history is being rewritten," in concert with the shaking die. -->
43 <div v-if="gameStore.isLoading" class="rv-loading-bar" role="status">
44 <span class="sr-only">Resolving your move — history is being rewritten…</span>
45 </div>
46 
47 <!-- At lg the board owns the viewport and scrolls inside its own panes, so main is
48 clipped (lg:overflow-hidden). The mission briefing has no such inner scroll, so
49 on a short viewport that clipping hid the Begin button under the footer — give
50 the briefing a scrollable main instead. -->
51 <main class="px-5 py-6 pb-24 md:pb-6 flex-1"
52 :class="gameStore.currentObjective ? 'lg:min-h-0 lg:py-0 lg:overflow-hidden' : 'lg:overflow-y-auto'">
53 <!-- Briefing: choose (or compose) an objective before the run begins -->
54 <MissionSelect v-if="!gameStore.currentObjective" />
55 
56 <template v-else>
57 <!-- The board, only while the run is live — once it ends, the end-screen
58 takeover fully replaces it (no board bleeding under the verdict).
59 On phones the three zones become one-at-a-time panels driven by the
60 bottom tab bar; from md up every zone is shown together as before. -->
61 <template v-if="gameStore.gameStatus === 'playing'">
62 <!-- THE WORKSPACE — at lg the board splits into two panes that fit the viewport:
63 a scrolling read-model (roll · shift · Spine · Chronicle · Thread) on the
64 left, and the act-model (the compose dock) pinned on the right, so the move
65 is never buried below the read. Below lg this wrapper is inert and the zones
66 fall back to the stacked page (md) / phone tab panels (sm). -->
67 <div class="rv-fade-in lg:h-full lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(360px,440px)] lg:grid-rows-1">
68 <!-- LEFT pane — the read-model. At lg it's a flex column the height of the
69 pane: the board/Spine on top at its natural height, then the story zone
70 flexes to fill the rest, so the Chronicle & Thread are full-height panels
71 (each scrolls inside itself) rather than a single scrolling stack. -->
72 <div class="lg:min-h-0 lg:min-w-0 lg:flex lg:flex-col lg:overflow-hidden lg:pr-6 lg:py-6">
73 <!-- ZONE 1 — the board state: the roll + the shift, then the Spine -->
74 <section class="rv-panel space-y-4 lg:space-y-6 lg:flex-none" :class="[{ 'rv-dim': isFirstTurn }, mobileTab === 'board' ? '' : 'hidden', 'md:block']">
75 <div class="grid sm:grid-cols-2 gap-4">
76 <div class="sm:border-r rv-line sm:pr-6 py-1">
77 <span class="rv-label mb-2 block">Dice of fate</span>
78 <DiceRoller />
79 <!-- The bands, disclosed: the player can always see how fate maps to
80 swing — the same table the Timeline Engine is instructed with. -->
81 <details data-testid="roll-legend" class="mt-2 text-[11px]">
82 <summary class="rv-faint cursor-pointer select-none hover:underline">how fate is weighed</summary>
83 <ul class="mt-1.5 space-y-0.5 rv-mono rv-muted">
84 <li v-for="row in rollLegend" :key="row.label" class="flex justify-between gap-3">
85 <span>{{ row.range }} · {{ row.label }}</span><span :class="row.cls">{{ row.swing }}</span>
86 </li>
87 </ul>
88 <p class="rv-faint mt-1.5 leading-snug">✒ craft tilts the roll (±2) · ⚡ anachronism widens the result — gains gently, losses hard · ⚑ a staked final dispatch doubles everything</p>
89 </details>
90 </div>
91 <div class="sm:pl-2 flex flex-col justify-center">
92 <ProgressTracker />
93 </div>
94 </div>
95 <TimelineLedger />
96 </section>
97 
98 <!-- ZONE 2 — the story: the living chronicle · the conversation thread. At lg
99 this flexes to fill the rest of the pane and lays the two out side by side
100 as equal full-height columns (md keeps the two-up grid in the page flow). -->
101 <section class="rv-panel mt-9 lg:mt-10 gap-x-8 gap-y-2" :class="[{ 'rv-dim': isFirstTurn }, mobileTab === 'story' ? 'grid' : 'hidden', 'md:grid md:grid-cols-2', 'lg:grid-rows-1 lg:flex-1 lg:min-h-0']">
102 <Chronicle :force-open="isMd" />
103 <!-- Thread: a <div> from md up (see Chronicle for why a <details> can't fill the
104 column height), a collapsible <details> on phones. -->
105 <component :is="isMd ? 'div' : 'details'" class="rv-rail lg:h-full lg:min-h-0 lg:flex lg:flex-col" :open="isMd ? null : threadOpen" @toggle="onThreadToggle">
106 <summary class="rv-summary" :class="{ 'rv-summary--static': isMd }">
107 <span class="rv-label">Thread<span class="normal-case tracking-normal rv-faint font-normal"> · your messages<span v-if="gameStore.activeFigureName"> · {{ gameStore.activeFigureName }}</span></span></span>
108 </summary>
109 <div class="pt-2 lg:flex-1 lg:min-h-0 lg:overflow-y-auto">
110 <MessageHistory data-testid="message-history" />
111 </div>
112 </component>
113 </section>
114 </div><!-- /LEFT pane -->
115 
116 <!-- RIGHT pane — the act-model, pinned beside the read column at lg -->
117 <div class="lg:min-h-0 lg:overflow-y-auto lg:border-l rv-line lg:pl-6 lg:py-6">
118 <!-- ZONE 3 — your turn: the compose dock (the page-stack divider is dropped at
119 lg, where it's a column of its own, not a section below the read). -->
120 <section class="rv-panel mt-9 border-t rv-line pt-5 lg:mt-0 lg:border-t-0 lg:pt-0" :class="[mobileTab === 'compose' ? '' : 'hidden', 'md:block']">
121 <div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
122 <span class="rv-label">Send a message into the past</span>
123 <!-- The wager, read BEFORE the roll: when the Archive has stamped a
124 known-since and a contact year is chosen, the chip translates the
125 reach into the same ⚡ tiers the spine uses. Otherwise the general
126 principle is stated — on the first turn too, where it teaches most. -->
127 <span v-if="wagerRead" data-testid="wager-chip" class="text-[11px]"
128 :class="wagerRead.tier === 'in-period' ? 'rv-muted' : 'rv-warn'">
129 <span aria-hidden="true">{{ wagerRead.pips }}</span> {{ wagerRead.text }}
130 </span>
131 <span v-else class="text-[11px] rv-warn"><span aria-hidden="true"></span> the further your words reach beyond their era, the harder the timeline swings — both ways</span>
132 <!-- The chain, read BEFORE the roll: how far this moment lands from your
133 nearest foothold (the objective's era or a change you've made). A
134 lone leap into the deep past is diffuse; building a chain forward
135 closes the gap. Hidden at the hinge, where there's nothing to warn. -->
136 <span v-if="chainRead" data-testid="chain-chip" class="text-[11px]"
137 :class="chainRead.tier === 'diffuse' ? 'rv-warn' : 'rv-muted'">
138 <span aria-hidden="true"></span> {{ chainRead.text }}
139 </span>
140 </div>
141 
⋯ 305 lines hidden (lines 142–446)
142 <p v-if="isFirstTurn" class="rv-accent text-sm mb-4">
143 Reach someone in history, write up to 160 characters, and send — one message can bend the whole timeline.
144 </p>
145 
146 <div class="grid md:grid-cols-2 lg:grid-cols-1 gap-x-8 gap-y-6">
147 <div>
148 <span class="rv-label block mb-2"><span class="rv-accent">1</span> · choose who &amp; when</span>
149 <FigurePicker v-model="figure" :disabled="!gameStore.canSendMessage" />
150 </div>
151 
152 <div class="space-y-3 rv-line lg:border-t lg:pt-6">
153 <span class="rv-label block mb-2"><span class="rv-accent">2</span> · write &amp; send</span>
154 <MessageInput ref="messageInputRef" />
155 <!-- The last stand: offered only when the final dispatch can no
156 longer win inside the normal per-turn cap — a true last resort,
157 never a free doubling from a winnable position. -->
158 <div v-if="gameStore.canStake" data-testid="stake-control"
159 class="border rounded-sm px-2.5 py-2 flex items-start gap-2"
160 :class="stakeArmed ? 'stake-armed' : 'rv-line'">
161 <input id="stake-toggle" v-model="stakeArmed" type="checkbox" class="mt-0.5"
162 :style="{ accentColor: 'var(--rv-accent)' }" />
163 <label for="stake-toggle" class="text-[11px] leading-snug cursor-pointer">
164 <span class="rv-warn font-semibold uppercase tracking-wide"><span aria-hidden="true"></span> Stake the timeline</span>
165 <span class="rv-muted block">Your final dispatch cuts twice as deep — both ways — and can move the whole meter. The last stand.</span>
166 </label>
167 </div>
168 <div class="flex items-center gap-3 flex-wrap">
169 <SendButton data-testid="send-button" :input-valid="canSend" @click="handleSendMessage" />
170 <div v-if="gameStore.error" data-testid="error-alert" role="status" aria-live="polite"
171 class="flex items-center gap-2 text-xs flex-1 min-w-0 border-l-2 rv-line pl-2 py-0.5">
172 <span class="rv-label shrink-0">notice</span>
173 <span class="rv-muted truncate">{{ gameStore.error }}</span>
174 <button type="button" data-testid="error-close" class="rv-tap rv-hover-fg shrink-0 ml-auto" aria-label="Dismiss notice" @click="gameStore.setError(null)"></button>
175 </div>
176 <div v-if="gameStore.moderationNotice" data-testid="moderation-alert" role="alert" aria-live="assertive"
177 class="flex items-start gap-2 text-xs flex-1 min-w-0 border-l-2 border-red-500/70 bg-red-500/5 pl-2 py-1 text-red-300">
178 <span class="shrink-0 font-semibold uppercase tracking-wide">⚠ blocked</span>
179 <span class="min-w-0">{{ gameStore.moderationNotice }}</span>
180 <button type="button" data-testid="moderation-close" class="rv-tap shrink-0 ml-auto hover:text-red-100" aria-label="Dismiss" @click="gameStore.clearModerationNotice()"></button>
181 </div>
182 </div>
183 <ArchiveLookup />
184 </div>
185 </div>
186 </section>
187 </div><!-- /RIGHT pane -->
188 </div><!-- /workspace -->
189 </template>
190 </template>
191 </main>
192 
193 <!-- Phone-only tab bar: the three zones as fixed panels you switch between, so the
194 board never becomes one infinite scroll and your next move is always one tap
195 away. Hidden from md up (where every zone is shown at once). -->
196 <!-- A labelled nav of view-switch buttons (NOT an ARIA tablist: there's no roving
197 tabindex / arrow-key tabset here, and the zones are simple show/hide regions).
198 The active view is conveyed with aria-current, the honest, complete pattern. -->
199 <nav v-if="gameStore.currentObjective && gameStore.gameStatus === 'playing'"
200 class="md:hidden fixed bottom-0 inset-x-0 z-30 border-t rv-line rv-bg grid grid-cols-3"
201 aria-label="Switch board view">
202 <button v-for="t in mobileTabs" :key="t.id" type="button" :aria-current="mobileTab === t.id ? 'true' : undefined"
203 class="mobile-tab rv-press" :class="{ 'is-active': mobileTab === t.id }" @click="mobileTab = t.id">
204 <span class="text-lg leading-none" aria-hidden="true">{{ t.glyph }}</span>
205 <span class="rv-label flex items-center gap-1">
206 {{ t.label }}
207 <span v-if="t.id === 'compose' && yourMove" class="rv-dot rv-dot--accent" aria-hidden="true" />
208 </span>
209 </button>
210 </nav>
211 
212 <!-- A ledger running-foot: header + footer frame the page as a document.
213 (Hidden on phones, where the tab bar is the foot.) -->
214 <footer class="border-t rv-line px-5 py-3 hidden md:block text-[11px] rv-faint">
215 <div class="flex items-center gap-2">
216 <span class="rv-mono uppercase tracking-wide">Revisionist</span>
217 <span class="rv-hair-c" aria-hidden="true">·</span>
218 <span class="rv-mono">a history you are writing</span>
219 <span class="ml-auto rv-serif italic">the past is not fixed</span>
220 </div>
221 <!-- Persistent compliance line (AI disclosure + fiction + adults-only). -->
222 <p data-testid="footer-disclosure" class="mt-1.5">
223 Figures are AI, not real people · replies are AI-generated fiction, not historical fact · for adults (18+)
224 </p>
225 </footer>
226 
227 <EndGameScreen @play-again="performReset" />
228 </div>
229</template>
230 
231<script setup lang="ts">
232/**
233 * Main game page — Revisionist, "The Spine Console" layout.
234 *
235 * A full-bleed board grouped into three super-zones — board-state (roll + shift +
236 * Spine), story (chronicle · thread), and your-turn (the compose dock) — separated
237 * by space, not chrome. State lives in the store; this only arranges it.
238 */
239import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
240import { useGameStore, formatContactYear } from '~/stores/game'
241import { SWING_BANDS } from '~/utils/swing-bands'
242import { DiceOutcome, CRIT_FAIL_MAX, FAILURE_MAX, NEUTRAL_MAX, CRIT_SUCCESS_MIN } from '~/utils/dice'
243import { parseKnownSinceYear, wagerTier } from '~/utils/known-since'
244import { CHAIN_HINT } from '~/utils/causal-chain'
245import { ANACHRONISM_LABEL } from '~/server/utils/anachronism'
246 
247const gameStore = useGameStore()
248 
249// The disclosed band table — rendered from the same constants the Timeline
250// Engine is instructed with, so the legend can't lie.
251const fmtSwing = (b: { min: number; max: number }) =>
252 `${b.min >= 0 ? '+' : ''}${b.min}${b.max >= 0 ? '+' : ''}${b.max}%`
253const rollLegend = [
254 { range: `${CRIT_SUCCESS_MIN}–20`, label: 'Critical Success', swing: fmtSwing(SWING_BANDS[DiceOutcome.CRITICAL_SUCCESS]), cls: 'rv-ok' },
255 { range: `${NEUTRAL_MAX + 1}${CRIT_SUCCESS_MIN - 1}`, label: 'Success', swing: fmtSwing(SWING_BANDS[DiceOutcome.SUCCESS]), cls: 'rv-ok' },
256 { range: `${FAILURE_MAX + 1}${NEUTRAL_MAX}`, label: 'Neutral', swing: fmtSwing(SWING_BANDS[DiceOutcome.NEUTRAL]), cls: 'rv-muted' },
257 { range: `${CRIT_FAIL_MAX + 1}${FAILURE_MAX}`, label: 'Failure', swing: fmtSwing(SWING_BANDS[DiceOutcome.FAILURE]), cls: 'rv-warn' },
258 { range: `1–${CRIT_FAIL_MAX}`, label: 'Critical Failure', swing: fmtSwing(SWING_BANDS[DiceOutcome.CRITICAL_FAILURE]), cls: 'rv-bad' }
260 
261// The pre-send wager chip: Archive known-since × chosen contact year → ⚡ tier.
262const wagerRead = computed(() => {
263 const lookup = gameStore.archiveResult
264 const when = gameStore.contactWhen
265 if (!lookup?.knownSince || when == null) return null
266 const knownYear = parseKnownSinceYear(lookup.knownSince)
267 if (knownYear == null) return null
268 const tier = wagerTier(knownYear, when)
269 const pips = tier === 'impossible' ? '⚡⚡⚡' : tier === 'far-ahead' ? '⚡⚡' : tier === 'ahead' ? '⚡' : '✓'
270 // Hedged copy: this is a year-gap compass, not the engine's verdict.
271 const text = tier === 'in-period'
272 ? `${lookup.topic}: already known by ${formatContactYear(when)} — steady ground`
273 : `${lookup.topic}: reads ≈ ${ANACHRONISM_LABEL[tier].toLowerCase()} for ${formatContactYear(when)} — swings harder, both ways`
274 return { tier, pips, text }
275})
276 
277// The pre-send chain chip: how far the chosen moment lands from the nearest
278// foothold (the objective's era or a prior change). Hidden at the hinge (full
279// force, nothing to flag); otherwise it reads the dilution + how to close it.
280const chainRead = computed(() => {
281 const s = gameStore.causalChainRead
282 if (!s || s.tier === 'at-hinge') return null
283 return { tier: s.tier, text: CHAIN_HINT[s.tier] }
284})
285 
286// The last stand, armed by the player on their final dispatch.
287const stakeArmed = ref(false)
288 
289const figure = ref('')
290watch(figure, (name) => gameStore.setActiveFigure((name || '').trim()))
291 
292const messageInputRef = ref()
293const messageInputValid = computed(() => messageInputRef.value?.isValid ?? false)
294const canSend = computed(() => messageInputValid.value && figure.value.trim().length > 0 && gameStore.canContact)
295 
296// The very first turn: the read-model is empty, so quiet it and lead the eye to the dock.
297const isFirstTurn = computed(() => gameStore.timelineEvents.length === 0)
298 
299// Phone tab bar: the three zones as switchable panels (md+ shows them all at once, so
300// this state is inert there). The run opens on the move; the instant a turn is rolling
301// we flip to the Board so the die + shift + spine land as the payoff beat, then the
302// player taps back to Compose. A nudge dot marks Compose when it's their move.
303type MobileTab = 'board' | 'story' | 'compose'
304const mobileTabs = [
305 { id: 'board', glyph: '🎲', label: 'Board' },
306 { id: 'story', glyph: '📖', label: 'Story' },
307 { id: 'compose', glyph: '✎', label: 'Compose' }
308] as const
309const mobileTab = ref<MobileTab>('compose')
310const yourMove = computed(() => gameStore.canSendMessage && !gameStore.isLoading && mobileTab.value !== 'compose')
311watch(() => gameStore.isLoading, (loading) => { if (loading) mobileTab.value = 'board' })
312watch(() => gameStore.currentObjective, (obj) => { if (obj) mobileTab.value = 'compose' })
313 
314const statusDot = computed(() =>
315 gameStore.gameStatus === 'victory' ? 'rv-dot--ok' : gameStore.gameStatus === 'defeat' ? 'rv-dot--bad' : 'rv-dot--accent'
317const statusWord = computed(() =>
318 gameStore.gameStatus === 'victory' ? 'Victory' : gameStore.gameStatus === 'defeat' ? 'Defeat' : 'Active'
320 
321// Thread rail (below md): open by default (the figure's replies are the game's most
322// charming content — collapsed-by-default made them too easy to miss). A fold is the
323// player's explicit choice now, so nothing force-reopens it. From md up it's forced
324// open as a full panel beside the Chronicle (isMd), so its toggle is inert there —
325// guard the handler so the native toggle event can't flip our own state.
326const threadOpen = ref(true)
327function onThreadToggle(e: Event) { if (!isMd.value) threadOpen.value = (e.target as HTMLDetailsElement).open }
328 
329// When a run begins, drop the cursor straight into the "who" field.
330function focusFigure() {
331 nextTick(() => (document.querySelector('[data-testid="figure-input"]') as HTMLElement | null)?.focus())
333watch(() => gameStore.currentObjective, (obj) => { if (obj) focusFigure() })
334 
335// Lock body scroll while the end-screen takeover is up (so the board can't peek).
336watch(() => gameStore.gameStatus, (s) => {
337 if (typeof document === 'undefined') return
338 document.body.style.overflow = (s === 'victory' || s === 'defeat') ? 'hidden' : ''
339})
340 
341// md+ breakpoint — from md up every zone is shown together, so the Chronicle is
342// forced open as the prose payoff rather than a tap-to-open rail (below md it stays
343// a collapsible rail in the Story tab); tracked reactively.
344const isMd = ref(false)
345let mdMql: MediaQueryList | null = null
346function syncMd() { isMd.value = mdMql?.matches ?? false }
347onMounted(() => {
348 mdMql = window.matchMedia('(min-width: 768px)')
349 syncMd()
350 mdMql.addEventListener('change', syncMd)
351})
352onUnmounted(() => { mdMql?.removeEventListener('change', syncMd) })
353 
354// Dark-mode toggle via @nuxtjs/color-mode (bundled with @nuxt/ui): persists across
355// reloads, respects the OS preference, and ships a no-flash inline script — replacing
356// the old manual classList toggle that did none of those.
357const colorMode = useColorMode()
358const isDark = computed(() => colorMode.value === 'dark')
359function toggleDark() {
360 colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
362 
363const handleSendMessage = async () => {
364 if (!messageInputRef.value) return
365 const messageText = messageInputRef.value.message?.trim()
366 const target = figure.value.trim()
367 if (!messageText || !messageInputRef.value.isValid || !target) return
368 
369 // Clear the composer only when the turn actually resolved — a blocked or
370 // failed send keeps the player's crafted 160 characters for another try.
371 const resolved = await gameStore.sendMessage(messageText, target, { stake: stakeArmed.value })
372 if (resolved) {
373 stakeArmed.value = false
374 if (messageInputRef.value) messageInputRef.value.message = ''
375 }
377 
378// The header reset arms a confirm when a run is in progress (and auto-disarms);
379// performReset is the single unified reset path — the end screen's Play Again
380// lands here too, so every reset clears the figure input and composer alike.
381const resetArmed = ref(false)
382let resetArmTimer: ReturnType<typeof setTimeout> | null = null
383 
384const handleResetGame = () => {
385 // One tap only when there is truly nothing to lose: no resolved turns AND no
386 // words or contact in progress (a typed first dispatch is work too).
387 const composerDirty = !!messageInputRef.value?.message?.trim() || !!figure.value.trim()
388 if (gameStore.timelineEvents.length === 0 && !composerDirty) {
389 performReset()
390 return
391 }
392 resetArmed.value = true
393 if (resetArmTimer) clearTimeout(resetArmTimer)
394 resetArmTimer = setTimeout(() => { resetArmed.value = false }, 3500)
396 
397function disarmReset() {
398 resetArmed.value = false
399 if (resetArmTimer) clearTimeout(resetArmTimer)
401 
402function performReset() {
403 disarmReset()
404 stakeArmed.value = false
405 gameStore.resetGame()
406 figure.value = ''
407 if (messageInputRef.value) messageInputRef.value.message = ''
408 focusFigure()
410 
411onUnmounted(() => { if (resetArmTimer) clearTimeout(resetArmTimer) })
412 
413useHead({
414 title: 'Revisionist — Rewrite History',
415 meta: [
416 { name: 'description', content: 'Send 160-character messages to anyone in history. A D20 and an AI decide how the timeline bends.' }
417 ]
418})
419</script>
420 
421<style scoped>
422/* Phone tab bar — letterpress, not chrome: a hairline-topped strip, the active tab
423 marked by the one accent and a terracotta top rule. ≥44px tall for the thumb. */
424.mobile-tab {
425 display: flex;
426 flex-direction: column;
427 align-items: center;
428 justify-content: center;
429 gap: 2px;
430 padding: 8px 0 9px;
431 color: var(--rv-faint);
432 border-top: 2px solid transparent;
433 transition: color var(--rv-dur-1) var(--rv-ease), border-color var(--rv-dur-1) var(--rv-ease);
435.mobile-tab .rv-label { color: inherit; }
436.mobile-tab.is-active {
437 color: var(--rv-accent);
438 border-top-color: var(--rv-accent);
440 
441/* The armed last stand — a quiet accent wash, not a flashing alarm. */
442.stake-armed {
443 border-color: var(--rv-accent);
444 background: color-mix(in srgb, var(--rv-accent) 7%, transparent);
446</style>

The resolved equation gains the ⏳ step between the wager and the stake.

components/MessageHistory.vue · 188 lines
components/MessageHistory.vue188 lines · Vue
⋯ 143 lines hidden (lines 1–143)
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. -->
48 <div v-if="message.craft && (message.craft !== 'sound' || message.craftReason)" data-testid="craft-verdict" class="text-[11px] mb-1.5">
49 <span class="rv-faint">✒ your dispatch · </span><span class="font-semibold" :class="craftClass(message.craft)">{{ CRAFT_LABEL[message.craft] }}</span><span v-if="message.craftReason" class="rv-faint">{{ message.craftReason }}</span>
50 </div>
51 
52 <p data-testid="character-message" class="rv-fg text-sm mb-2">{{ message.text }}</p>
53 
54 <div v-if="message.characterAction" data-testid="character-action" class="text-xs rv-muted border-l rv-line pl-2 mb-2">
55 <span class="rv-faint">acts · </span><span class="italic">{{ message.characterAction }}</span>
56 </div>
57 
58 <div v-if="message.timelineImpact" data-testid="timeline-impact" class="text-xs rv-muted border-l rv-line pl-2 mb-1">
59 <span class="rv-faint">ripple · </span>{{ message.timelineImpact }}
60 <span v-if="message.progressChange !== undefined" data-testid="progress-change"
61 class="rv-mono font-bold ml-1" :class="deltaText(message.progressChange)">
62 {{ message.progressChange > 0 ? '+' : '' }}{{ message.progressChange }}%
63 </span>
64 <!-- The wager's work, shown as an equation: base ⚡tier (⚑ staked) → final. -->
65 <span v-if="showEquation(message)" data-testid="swing-equation" class="rv-mono rv-faint ml-1 whitespace-nowrap">({{ equationText(message) }})</span>
66 </div>
67 
68 <p class="text-[11px] rv-faint rv-mono" data-testid="message-timestamp">{{ formatTimestamp(message.timestamp) }}</p>
69 </div>
70 </li>
71 
72 <!-- Loading indicator -->
73 <li v-if="gameStore.isLoading" data-testid="loading-indicator" class="py-3 flex items-center gap-2 rv-muted text-sm">
74 <span class="rv-spinner" aria-hidden="true" />
75 {{ activeFigure || 'The past' }} is weighing your words…
76 </li>
77 </ol>
78 </div>
79</template>
80 
81<script setup lang="ts">
82/**
83 * MessageHistory — the conversation thread with the active figure, as hairline rows
84 * led by an outcome dot. Figure-aware labels; each figure keeps an independent thread.
85 */
86import { computed } from 'vue'
87import { useGameStore, type Message } from '~/stores/game'
88import { ANACHRONISM_LABEL } from '~/server/utils/anachronism'
89import { CHAIN_LABEL } from '~/utils/causal-chain'
90import { CRAFT_LABEL, type Craft } from '~/utils/craft'
91 
92const gameStore = useGameStore()
93 
94const activeFigure = computed(() => gameStore.activeFigureName)
95const thread = computed(() => gameStore.conversationWith(gameStore.activeFigureName))
96 
97const formatTimestamp = (timestamp: Date): string => {
98 return new Intl.DateTimeFormat('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(timestamp)
100 
101function outcomeDot(outcome?: string): string {
102 switch (outcome) {
103 case 'Critical Success':
104 case 'Success': return 'rv-dot--ok'
105 case 'Failure':
106 case 'Critical Failure': return 'rv-dot--bad'
107 default: return 'rv-dot--mut'
108 }
110function outcomeText(outcome?: string): string {
111 switch (outcome) {
112 case 'Critical Success':
113 case 'Success': return 'rv-ok'
114 case 'Failure':
115 case 'Critical Failure': return 'rv-bad'
116 default: return 'rv-muted'
117 }
119function deltaText(change?: number): string {
120 if (change === undefined || change === 0) return 'rv-muted'
121 return change > 0 ? 'rv-ok' : 'rv-bad'
123 
124function craftClass(craft?: Craft): string {
125 switch (craft) {
126 case 'masterful':
127 case 'sharp': return 'rv-ok'
128 case 'vague':
129 case 'reckless': return 'rv-warn'
130 default: return 'rv-muted'
131 }
133 
134// The swing equation appears only when something actually transformed the base
135// swing (wager amplification and/or the stake) — an in-period unstaked turn
136// keeps its single clean number.
137function showEquation(m: Message): boolean {
138 return m.baseProgressChange !== undefined
139 && m.progressChange !== undefined
140 && m.baseProgressChange !== m.progressChange
142function signed(n: number): string {
143 return `${n > 0 ? '+' : ''}${n}`
145function equationText(m: Message): string {
146 const parts = [signed(m.baseProgressChange!)]
147 if (m.anachronism && m.anachronism !== 'in-period') {
148 const pips = m.anachronism === 'impossible' ? '⚡⚡⚡' : m.anachronism === 'far-ahead' ? '⚡⚡' : '⚡'
149 parts.push(`${pips} ${ANACHRONISM_LABEL[m.anachronism].toLowerCase()}`)
150 }
151 if (m.causalChain && m.causalChain.tier !== 'at-hinge') {
152 parts.push(`${CHAIN_LABEL[m.causalChain.tier].toLowerCase()}`)
153 }
154 if (m.staked) parts.push('⚑ staked')
155 return `${parts.join(' ')}${signed(m.progressChange!)}%`
⋯ 33 lines hidden (lines 156–188)
157</script>
158 
159<style scoped>
160/* Phone keeps a fixed scroll box (the Story tab is one panel); the 340px cap and
161 md+ release live as utilities on the element (a scoped rule would out-specify the
162 Tailwind md: override). From md up the thread grows into the panel's own scroll. */
163.message-history-container {
164 min-height: 120px;
166 
167/* Staged reveal: a resolved turn writes itself in — the figure speaks, then acts,
168 then the ripple through history lands. Elements stay in the DOM (opacity only). */
169[data-testid="character-message"],
170[data-testid="character-action"],
171[data-testid="timeline-impact"] {
172 animation: reveal-up 0.45s var(--rv-ease) both;
174[data-testid="character-message"] { animation-delay: 0.08s; }
175[data-testid="character-action"] { animation-delay: 0.30s; }
176[data-testid="timeline-impact"] { animation-delay: 0.55s; }
177 
178@keyframes reveal-up {
179 from { opacity: 0; transform: translateY(8px); }
180 to { opacity: 1; transform: none; }
182 
183@media (prefers-reduced-motion: reduce) {
184 [data-testid="character-message"],
185 [data-testid="character-action"],
186 [data-testid="timeline-impact"] { animation: none; }
188</style>

Verification

427 unit tests: the decay curve (bridge-free, exponential, floor, monotonic), the Leonardo-seed vs Caesar-blast calibration cases, chaining-compounds, the server decay applied end-to-end, and the in-store chip read. Clean typecheck, 12/12 Playwright e2e.

And the end-to-end eval (the deterministic dial composing with the real model's swings): a lone far blast diffuses to mean 3.0 vs a hinge contact's 22.0, and a planted foothold lifts the same move — stepping stones, not blasts.

The calibration cases pinned: a real-but-modest far seed, a near-dead lone blast, and the compounding that rewards a chain.

tests/unit/utils/causal-chain.spec.ts · 97 lines
tests/unit/utils/causal-chain.spec.ts97 lines · TypeScript
⋯ 45 lines hidden (lines 1–45)
1import { describe, it, expect } from 'vitest'
2import {
3 nearestFootholdGap,
4 chainDecay,
5 chainTier,
6 chainStatus,
7 decayForChain,
8 CHAIN_FLOOR,
9 BRIDGE_FREE
10} from '~/utils/causal-chain'
11 
12/**
13 * The causal-chain dial: history bends by stepping stones, not blasts. These
14 * pin the curve and the two anchor cases the mechanic is calibrated to — a
15 * worthwhile far seed (Leonardo) vs a near-dead lone blast (Caesar) — and the
16 * compounding that rewards building a chain forward.
17 */
18describe('nearestFootholdGap', () => {
19 it('takes the nearest of several footholds, across the BC/AD line', () => {
20 expect(nearestFootholdGap(1650, [1900, 1500])).toBe(150) // the 1500 stone, not the 1900 objective
21 expect(nearestFootholdGap(-50, [1960])).toBe(2010) // 50 BC → AD 1960
22 })
23 
24 it('no-ops (null) when there is no contact year or no foothold', () => {
25 expect(nearestFootholdGap(null, [1900])).toBeNull()
26 expect(nearestFootholdGap(1900, [])).toBeNull()
27 expect(nearestFootholdGap(1900, [null, undefined])).toBeNull()
28 })
29})
30 
31describe('chainDecay', () => {
32 it('is full power within the bridge-free radius', () => {
33 expect(chainDecay(0)).toBe(1)
34 expect(chainDecay(BRIDGE_FREE)).toBe(1)
35 })
36 
37 it('decays beyond it, monotonically, and never below the floor', () => {
38 const near = chainDecay(200)
39 const mid = chainDecay(700)
40 const far = chainDecay(5000)
41 expect(near).toBeGreaterThan(mid)
42 expect(mid).toBeGreaterThan(far)
43 expect(far).toBe(CHAIN_FLOOR) // floored, never zero — diffuse, not blocked
44 expect(near).toBeLessThan(1)
45 })
46 
47 it('no-ops to 1 when the gap is null', () => {
48 expect(chainDecay(null)).toBe(1)
49 })
50})
51 
52describe('chainTier', () => {
53 it('buckets factors into legible tiers', () => {
54 expect(chainTier(1)).toBe('at-hinge')
55 expect(chainTier(0.7)).toBe('bridged')
56 expect(chainTier(0.4)).toBe('reaching')
57 expect(chainTier(CHAIN_FLOOR)).toBe('diffuse')
58 })
59})
60 
61describe('the calibration cases', () => {
62 it('Leonardo (1500) opening at the Moon-by-1900 objective is a real but modest seed', () => {
63 const s = chainStatus(1500, [1900]) // only foothold is the objective
64 expect(s).not.toBeNull()
65 expect(s!.gap).toBe(400)
66 expect(s!.factor).toBeGreaterThan(0.5) // worthwhile
67 expect(s!.factor).toBeLessThan(0.65) // but clearly diluted
68 expect(s!.tier).toBe('bridged')
69 })
70 
71 it('Caesar (50 BC) to change 1960 is near-dead — a sliver even on a crit', () => {
72 const s = chainStatus(-50, [1960])
73 expect(s!.tier).toBe('diffuse')
⋯ 24 lines hidden (lines 74–97)
74 expect(s!.factor).toBe(CHAIN_FLOOR)
75 expect(decayForChain(45, s!.factor)).toBeLessThan(7) // a critical success lands a sliver
76 })
77 
78 it('chaining compounds: a landed stone lifts the next move', () => {
79 const lone = chainStatus(1650, [1900])! // no chain yet
80 const bridged = chainStatus(1650, [1900, 1500])! // a stone planted at 1500
81 expect(bridged.factor).toBeGreaterThan(lone.factor)
82 expect(bridged.tier).toBe('at-hinge')
83 })
84 
85 it('no-ops for ungrounded / anchorless play', () => {
86 expect(chainStatus(null, [1900])).toBeNull()
87 expect(chainStatus(1500, [])).toBeNull()
88 })
89})
90 
91describe('decayForChain', () => {
92 it('shrinks the magnitude both ways; factor 1 is a no-op', () => {
93 expect(decayForChain(20, 1)).toBe(20)
94 expect(decayForChain(20, 0.5)).toBe(10)
95 expect(decayForChain(-30, 0.2)).toBe(-6) // a far backfire also just fizzles
96 })
97})