What changed, and why

The go-to-market plan sells runs — one playthrough is a bounded bundle of roughly 20–26 AI calls — and prices packs at 3–5x the p95 cost per run. That number can't be computed today: the AI gateway already logs one line per call with token counts, but nothing ties a call to a run, and nothing turns tokens into dollars.

This PR adds both, and only those. A run id is minted on the client and rides every AI request as a header; the server binds it per request and the gateway stamps it onto each log line along with the call's computed cost. Group the lines by run id, sum the cost, and you have cost-per-run. No database, no new dependency — the change is telemetry enrichment plus the thread that carries the id from the browser to the log line.

The run id, bound per request

The id has to reach the gateway's emit(), which sits several wrapper calls below each route handler. Threading it through every signature would be noisy, so it travels in request-scoped storage instead. AsyncLocalStorage gives each Nitro request its own context: bind the id once and every awaited call in that request can read it, with no leakage across requests.

Bind with enterWith (the middleware), read with currentRunId (the gateway).

server/utils/run-context.ts · 30 lines
server/utils/run-context.ts30 lines · TypeScript
⋯ 15 lines hidden (lines 1–15)
1/**
2 * Request-scoped run context — the thread that ties every AI call in one game
3 * run to a single id, so the cost telemetry (ai-gateway's `[ai]` line) groups
4 * per run and yields cost-per-run, the number dispatch 1 prices packs against.
5 *
6 * Set once per request by the run-context middleware (from the `x-run-id`
7 * header) and read deep inside the gateway's emit() — so the id never has to be
8 * threaded through every wrapper signature between the route and the model call.
9 *
10 * AsyncLocalStorage is the request-local store: each Nitro request runs in its
11 * own async context, so `enterWith` in the middleware propagates to every
12 * awaited model call within that request and never leaks across requests.
13 */
14import { AsyncLocalStorage } from 'node:async_hooks'
15 
16export interface RunContext {
17 runId?: string
19 
20const storage = new AsyncLocalStorage<RunContext>()
21 
22/** Bind the run context to the remainder of the current request (middleware). */
23export function setRunContext(ctx: RunContext): void {
24 storage.enterWith(ctx)
26 
27/** The current request's run id, or undefined outside a run-scoped request. */
28export function currentRunId(): string | undefined {
29 return storage.getStore()?.runId

A middleware binds it once per request, reading the x-run-id header. The id is untrusted client input that lands in a log line, so it is stripped to id-safe characters and length-bounded before use. Requests with no header (static assets, the dashboard) simply leave the context unset.

Header in, sanitized, bound for the rest of the request.

server/middleware/run-context.ts · 17 lines
server/middleware/run-context.ts17 lines · TypeScript
⋯ 12 lines hidden (lines 1–12)
1/**
2 * Stamps each request with its game run id (the `x-run-id` header the client
3 * sends on every AI call) so the gateway's cost telemetry groups per run.
4 *
5 * Header-based on purpose: it works uniformly across the GET and POST AI routes
6 * without consuming the body, and is harmless on requests that carry no id
7 * (static assets, the dashboard) — they simply leave the context unset. The id
8 * is untrusted client input that lands in a log line, so it is bounded and
9 * stripped to id-safe characters before use.
10 */
11import { setRunContext } from '~/server/utils/run-context'
12 
13export default defineEventHandler((event) => {
14 const raw = getHeader(event, 'x-run-id')
15 const runId = raw?.replace(/[^a-zA-Z0-9-]/g, '').slice(0, 64)
16 if (runId) setRunContext({ runId })
17})

The rate card, and per-call cost

Cost needs a price per model. The rate card is one constant — USD per million tokens, per model — and costUsd applies it. An unpriced model returns undefined rather than a guess, so a missing rate shows up as uncounted instead of silently low.

The rate card and the cost function; note the per-lane input handling.

server/utils/cost.ts · 51 lines
server/utils/cost.ts51 lines · TypeScript
⋯ 25 lines hidden (lines 1–25)
1/**
2 * The rate card — USD per million tokens, per model — and the per-call cost it
3 * implies. This is the GTM cost instrument's price half: ai-gateway stamps every
4 * `[ai]` line with the dollar cost computed here, so grouping by run id yields
5 * cost-per-run (the p95 that dispatch 1 prices packs at 3-5x against).
6 *
7 * Rates are list prices as of 2026-06 — the ONE number to keep fresh; verify
8 * against current provider pricing before pricing packs. Cache reads bill at
9 * ~0.1x input on Anthropic. The token conventions differ by lane: Anthropic's
10 * input_tokens EXCLUDES cached tokens, OpenAI's prompt_tokens INCLUDES them, so
11 * on the OpenAI lane the cached subset is subtracted out of input to avoid
12 * billing it twice (see ai-gateway's usage mapping).
13 */
14import type { AiLane } from './ai-routing'
15import type { AiUsage } from './ai-gateway'
16 
17/** USD per 1,000,000 tokens. */
18interface Rate {
19 input: number
20 output: number
21 cacheRead: number
23 
24const PER_MILLION = 1_000_000
25 
26const RATES: Record<string, Rate> = {
27 'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1 },
28 'claude-sonnet-4-6': { input: 3, output: 15, cacheRead: 0.3 },
29 'claude-opus-4-8': { input: 15, output: 75, cacheRead: 1.5 },
30 'gpt-5.5': { input: 1.25, output: 10, cacheRead: 0.125 }
32 
33/**
34 * The dollar cost of one call, or undefined for a model with no rate — better an
35 * absent number than a guessed one (a missing line shows up as uncounted rather
36 * than silently low). Rounded to the microdollar; per-call costs are fractions
37 * of a cent and the run total is summed downstream.
38 */
39export function costUsd(model: string, usage: AiUsage, lane: AiLane): number | undefined {
40 const rate = RATES[model]
41 if (!rate) return undefined
42 // OpenAI reports cached tokens inside input; Anthropic reports them apart.
43 const uncachedInput =
44 lane === 'openai' ? Math.max(0, usage.inputTokens - usage.cacheReadTokens) : usage.inputTokens
45 const usd =
46 (uncachedInput * rate.input +
47 usage.cacheReadTokens * rate.cacheRead +
48 usage.outputTokens * rate.output) /
49 PER_MILLION
50 return Math.round(usd * PER_MILLION) / PER_MILLION
⋯ 1 line hidden (lines 51–51)

Stamping the telemetry line

The gateway is the single transport for every structured model call, so one edit to its emit() reaches all of them. Enrichment happens in one place: both the success and failure call sites get the run id, and the cost rides only on calls that reported usage. The eval-harness observer sees the same enriched line the log does.

Two new optional telemetry fields; emit() fills them once, centrally.

server/utils/ai-gateway.ts · 239 lines
server/utils/ai-gateway.ts239 lines · TypeScript
⋯ 75 lines hidden (lines 1–75)
1/**
2 * The AI gateway — one transport for every structured model call, dispatched
3 * per task by the routing table (ai-routing.ts). Callers keep their own prompts,
4 * schemas, parsing, guards, and never-throw envelopes; the gateway only decides
5 * WHICH provider runs the request and HOW (model + parameters), and emits the
6 * cost telemetry the go-to-market plan's dispatch 1 requires.
7 *
8 * Telemetry: every call logs one structured line (task, lane, model, tokens
9 * in/out, latency, ok) — intentional production output, suppressed under vitest.
10 * The eval harness subscribes via setAiCallObserver to price bake-off lanes.
11 */
12import OpenAI from 'openai'
13import Anthropic from '@anthropic-ai/sdk'
14import { routeFor, type AiTask, type AiLane } from './ai-routing'
15import { currentRunId } from './run-context'
16import { costUsd } from './cost'
17 
18let openaiClient: OpenAI | null = null
19let anthropicClient: Anthropic | null = null
20 
21/** Reads a runtime-config key, falling back to raw env outside a Nuxt context
22 * (the eval harness exercises these utils directly in node). */
23function configuredKey(name: 'openaiApiKey' | 'anthropicApiKey', envName: string): string | undefined {
24 let key: string | undefined
25 try {
26 key = useRuntimeConfig()[name] as string | undefined
27 } catch {
28 key = undefined
29 }
30 return key || process.env[envName]
32 
33export function getOpenAIClient(): OpenAI {
34 if (!openaiClient) {
35 const apiKey = configuredKey('openaiApiKey', 'OPENAI_API_KEY')
36 if (!apiKey) throw new Error('OpenAI API key is not configured')
37 openaiClient = new OpenAI({
38 apiKey,
39 // Fail fast rather than hang on the SDK's 10-minute default if a
40 // call stalls (e.g. a bad key or network hiccup).
41 timeout: 30_000,
42 maxRetries: 1
43 })
44 }
45 return openaiClient
47 
48export function getAnthropicClient(): Anthropic {
49 if (!anthropicClient) {
50 const apiKey = configuredKey('anthropicApiKey', 'ANTHROPIC_API_KEY')
51 if (!apiKey) throw new Error('Anthropic API key is not configured')
52 anthropicClient = new Anthropic({
53 apiKey,
54 // The epilogue's adaptive thinking can run long; everything else is
55 // seconds. 60s bounds the worst case without hanging a turn forever.
56 timeout: 60_000,
57 maxRetries: 1
58 })
59 }
60 return anthropicClient
62 
63export interface AiUsage {
64 inputTokens: number
65 outputTokens: number
66 cacheReadTokens: number
68 
69export interface AiCallTelemetry {
70 task: AiTask
71 lane: AiLane
72 model: string
73 ms: number
74 ok: boolean
75 usage?: AiUsage
76 /** The game run this call belongs to (from the request's x-run-id); absent
77 * outside a run-scoped request. The key that groups calls into a run. */
78 runId?: string
79 /** The call's dollar cost from the rate card; absent when usage is missing
80 * (a failed call) or the model has no listed rate. */
81 usd?: number
⋯ 9 lines hidden (lines 83–91)
83 
84type AiCallObserver = (t: AiCallTelemetry) => void
85let observer: AiCallObserver | null = null
86 
87/** The eval harness (and future dashboards) subscribe here; null to clear. */
88export function setAiCallObserver(fn: AiCallObserver | null): void {
89 observer = fn
91 
92function emit(t: AiCallTelemetry): void {
93 // Enrich once, here, so both the success and failure call sites get the run
94 // id, and so the observer (the eval harness) sees the same priced line the
95 // log does. usd needs usage, so it rides only on calls that reported tokens.
96 const enriched: AiCallTelemetry = {
97 ...t,
98 runId: t.runId ?? currentRunId(),
99 usd: t.usd ?? (t.usage ? costUsd(t.model, t.usage, t.lane) : undefined)
100 }
101 observer?.(enriched)
102 // One greppable line per call — the GTM's "structured log line is enough to
103 // start". Intentional production telemetry; quiet under the test runner.
104 if (!process.env.VITEST) {
105 console.info(`[ai] ${JSON.stringify(enriched)}`)
106 }
⋯ 132 lines hidden (lines 108–239)
108 
109export interface ChatTurn {
110 role: 'user' | 'assistant'
111 content: string
113 
114/**
115 * Thrown when Claude's own safety classifiers decline a request mid-flight
116 * (Claude 4 returns stop_reason "refusal"). It is NOT an infra error and NOT an
117 * empty answer — it's a model-side block, so callers surface it as a moderation
118 * block, not a "try again" refund. (See Anthropic's streaming-refusals guide.)
119 */
120export class ModelRefusalError extends Error {
121 constructor() {
122 super('The model declined this request (safety refusal).')
123 this.name = 'ModelRefusalError'
124 }
126 
127export interface StructuredRequest {
128 task: AiTask
129 /** The built prompt. Single-shot tasks send it as the whole request; with
130 * `turns` it becomes the system prompt over the conversation. */
131 system: string
132 turns?: ChatTurn[]
133 /** OpenAI requires a schema name; Anthropic ignores it. */
134 schemaName: string
135 schema: Record<string, unknown>
137 
138/**
139 * Runs one structured completion on the task's routed lane and returns the raw
140 * JSON text (trimmed), or null on an empty answer. Throws on transport errors —
141 * callers own their never-throw envelopes, exactly as before the seam.
142 */
143export async function completeStructured(req: StructuredRequest): Promise<string | null> {
144 const { lane, route } = routeFor(req.task)
145 const model = lane === 'anthropic' ? route.anthropic.model : route.openai.model
146 const started = Date.now()
147 try {
148 const result = lane === 'anthropic' ? await viaAnthropic(req) : await viaOpenAI(req)
149 emit({ task: req.task, lane, model, ms: Date.now() - started, ok: true, usage: result.usage })
150 return result.content
151 } catch (error) {
152 emit({ task: req.task, lane, model, ms: Date.now() - started, ok: false })
153 throw error
154 }
156 
157interface LaneResult {
158 content: string | null
159 usage?: AiUsage
161 
162async function viaOpenAI(req: StructuredRequest): Promise<LaneResult> {
163 const { route } = routeFor(req.task)
164 const params = route.openai
165 const client = getOpenAIClient()
166 const completion = await client.chat.completions.create({
167 model: params.model,
168 messages: [
169 { role: 'system' as const, content: req.system },
170 ...(req.turns ?? [])
171 ],
172 // gpt-5.5 is a reasoning model: omit temperature, keep effort low, leave
173 // headroom for hidden reasoning tokens inside the output cap.
174 ...(params.reasoningEffort ? { reasoning_effort: params.reasoningEffort } : {}),
175 max_completion_tokens: params.maxTokens,
176 response_format: {
177 type: 'json_schema',
178 json_schema: { name: req.schemaName, strict: true, schema: req.schema }
179 }
180 })
181 const usage = completion.usage
182 ? {
183 inputTokens: completion.usage.prompt_tokens ?? 0,
184 outputTokens: completion.usage.completion_tokens ?? 0,
185 cacheReadTokens: completion.usage.prompt_tokens_details?.cached_tokens ?? 0
186 }
187 : undefined
188 return { content: completion.choices?.[0]?.message?.content?.trim() || null, usage }
190 
191async function viaAnthropic(req: StructuredRequest): Promise<LaneResult> {
192 const { route } = routeFor(req.task)
193 const params = route.anthropic
194 const client = getAnthropicClient()
195 
196 // Anthropic requires the first message to be a user turn. Single-shot tasks
197 // send the whole built prompt AS the user message (no system param) — for
198 // one-off structured generation the two are equivalent, and it avoids a
199 // contentless "Proceed." turn. Conversational tasks keep system + turns.
200 const turns = req.turns ?? []
201 const system = turns.length ? req.system : undefined
202 const messages: Anthropic.MessageParam[] = turns.length
203 ? (turns[0].role === 'user' ? turns : [{ role: 'user' as const, content: '(The conversation resumes.)' }, ...turns])
204 : [{ role: 'user' as const, content: req.system }]
205 
206 const response = await client.messages.create({
207 model: params.model,
208 max_tokens: params.maxTokens,
209 ...(system ? { system } : {}),
210 messages,
211 // Thinking rejects non-default sampling params — temperature only rides
212 // on thinking-off calls (the family rule the routing table can't express).
213 ...(params.temperature !== undefined && params.thinking !== 'adaptive' ? { temperature: params.temperature } : {}),
214 ...(params.thinking === 'adaptive' ? { thinking: { type: 'adaptive' as const } } : {}),
215 output_config: {
216 ...(params.effort ? { effort: params.effort } : {}),
217 format: { type: 'json_schema' as const, schema: req.schema }
218 }
219 })
220 
221 // Claude's streaming classifiers can decline mid-flight (stop_reason
222 // "refusal"); that's a model-side block, surfaced distinctly so callers
223 // don't mistake it for an empty/infra response. Cast: the union may predate
224 // this SDK's type for the value.
225 if ((response.stop_reason as string) === 'refusal') {
226 throw new ModelRefusalError()
227 }
228 
229 // Thinking blocks (epilogue) precede the text block; take the text.
230 const text = response.content.find(
231 (b): b is Anthropic.TextBlock => b.type === 'text'
232 )?.text?.trim()
233 const usage: AiUsage = {
234 inputTokens: response.usage?.input_tokens ?? 0,
235 outputTokens: response.usage?.output_tokens ?? 0,
236 cacheReadTokens: response.usage?.cache_read_input_tokens ?? 0
237 }
238 return { content: text || null, usage }

Carrying the id from the client

The browser owns the run boundary, so it mints the id. aiHeaders lazily creates one on a run's first server call and returns it as x-run-id, preserving any base headers; generateRunId prefers crypto.randomUUID and falls back for the rare insecure context. All seven AI fetches in the store route through aiHeaders, and resetGame clears the id so each run gets a fresh one.

Mint (generateRunId), tag (aiHeaders), and reset (resetGame) — the run's lifecycle.

stores/game.ts · 1025 lines
stores/game.ts1025 lines · TypeScript
⋯ 167 lines hidden (lines 1–167)
1import { defineStore } from 'pinia'
2import type { GameObjective } from '~/server/utils/objective-generator'
3import type { GroundedFigure } from '~/server/utils/figure-grounding'
4import { formatContactMoment, type ContactMoment } from '~/utils/contact-moment'
5import type { FigureSuggestion } from '~/server/utils/figure-suggester'
6import type { ChronicleEntry } from '~/server/utils/openai'
7import type { FigureStudy, ArchiveLookup } from '~/server/utils/prompt-builder'
8import type { Anachronism } from '~/server/utils/anachronism'
9import { chainStatus, type ChainStatus } from '~/utils/causal-chain'
10import type { Craft } from '~/utils/craft'
11import type { DiceOutcome } from '~/utils/dice'
12import { generateGameSummary, rateEfficiency, type GameSummary } from '~/utils/game-summary'
13import { TOTAL_MESSAGES, MAX_PROGRESS_SWING } from '~/utils/game-config'
14 
15export type Valence = 'positive' | 'negative' | 'neutral'
16 
17/**
18 * A historical figure the player has reached out to. Figures are freeform — the
19 * player can write to anyone, in any era. The AI infers each figure's era and a
20 * short descriptor on first contact, which we cache here for the UI.
21 */
22export interface HistoricalFigure {
23 name: string
24 era: string
25 descriptor: string
27 
28/**
29 * Timeline Ledger entry — a single recorded change to history.
30 *
31 * The ledger is the heart of the game: every resolved turn appends one entry
32 * describing how the world bent, so the player literally watches the timeline
33 * rewrite itself as they play.
34 */
35export interface TimelineEvent {
36 id: string
37 figureName: string
38 era: string
39 headline: string
40 detail: string
41 diceRoll: number
42 diceOutcome: DiceOutcome
43 progressChange: number
44 /** The engine's swing BEFORE the anachronism amplifier — shown so the player
45 * can see exactly what their wager did ("+8 ⚡ far-ahead → +11%"). */
46 baseProgressChange?: number
47 valence: Valence
48 /** How anachronistic the player's nudge was — it widened this swing. */
49 anachronism?: Anachronism
50 /** How far this landed from the nearest foothold — it decayed this swing. */
51 causalChain?: ChainStatus
52 /** The Judge's grade of the dispatch that caused this change. */
53 craft?: Craft
54 /** Signed year of the intervention, when the contact was grounded. */
55 whenSigned?: number
56 /** True when this was the run's staked last stand (doubled swing). */
57 staked?: boolean
58 timestamp: Date
60 
61/**
62 * Message Interface — one line in the conversation with a figure.
63 * Dice / outcome / action / impact / progress are recorded on the AI (figure)
64 * turn, since that is the resolved result of the roll.
65 */
66export interface Message {
67 text: string
68 sender: 'user' | 'ai' | 'system'
69 timestamp: Date
70 figureName?: string
71 /** The effective (craft-tilted) roll — what the bands judged. */
72 diceRoll?: number
73 diceOutcome?: DiceOutcome
74 /** The die as it actually landed, before the craft modifier. */
75 naturalRoll?: number
76 /** The Judge's tilt on the roll (±2..0), and the grade + reason behind it. */
77 rollModifier?: number
78 craft?: Craft
79 craftReason?: string
80 characterAction?: string
81 timelineImpact?: string
82 progressChange?: number
83 /** The pre-amplifier swing + the wager level, for the reveal's equation. */
84 baseProgressChange?: number
85 anachronism?: Anachronism
86 /** The causal-chain decay applied to this swing (for the reveal's equation). */
87 causalChain?: ChainStatus
88 /** True when this turn was the staked last stand. */
89 staked?: boolean
91 
92export type GameStatus = 'playing' | 'victory' | 'defeat'
93 
94/**
95 * Shape returned by `POST /api/send-message` (see `server/api/send-message.post.ts`).
96 * Discriminated by `success`: when `true`, all fields downstream of the dice roll are
97 * guaranteed; when `false`, only `userMessage` + `error` are guaranteed (and the
98 * Character-AI-failure path doesn't even surface a dice roll). The discriminant lets
99 * the consumer destructure without optional chaining or null gymnastics.
100 */
101type SendMessageApiResponse =
102 | {
103 success: true
104 message: string
105 data: {
106 userMessage: string
107 figure: { name: string; era: string; descriptor: string }
108 diceRoll: number
109 diceOutcome: DiceOutcome
110 // Judge-of-craft fields (optional so older mocks/fixtures stay valid;
111 // the live server always sends them).
112 naturalRoll?: number
113 rollModifier?: number
114 craft?: Craft
115 craftReason?: string
116 staked?: boolean
117 characterResponse: { message: string; action: string }
118 timeline: {
119 headline: string
120 detail: string
121 era: string
122 progressChange: number
123 baseProgressChange?: number
124 valence: Valence
125 anachronism?: Anachronism
126 causalChain?: ChainStatus
127 }
128 error: null
129 }
130 }
131 | {
132 success: false
133 message: string
134 data: {
135 userMessage: string
136 diceRoll?: number
137 diceOutcome?: DiceOutcome
138 characterResponse?: { message: string; action: string }
139 error: string
140 /** A content-moderation block (not an infra failure): the dispatch was
141 * refused by the detector, the Sentinel, or the model itself. */
142 blocked?: boolean
143 moderationReason?: string
144 }
145 }
146 
147const RATE_LIMIT_MS = 1000 // message cooldown window, in ms
148const MAX_PROGRESS = 100 // objective progress is clamped to 0..MAX_PROGRESS
149 
150function valenceOf(progressChange: number): Valence {
151 if (progressChange > 0) return 'positive'
152 if (progressChange < 0) return 'negative'
153 return 'neutral'
155 
156/** Formats a signed timeline year (AD positive, BCE negative) for display. */
157export function formatContactYear(signed: number): string {
158 return signed < 0 ? `${-signed} BC` : `${signed}`
160 
161/** Human lifespan line for a grounded figure, e.g. "69 BC – 30 BC" or "1942 – present". */
162function lifespanText(g: GroundedFigure): string | undefined {
163 if (!g.born) return undefined
164 if (g.died) return `${g.born.display}${g.died.display}`
165 return g.living ? `${g.born.display} – present` : g.born.display
167 
168/** A fresh run id. Used to tag every server call in one run so the gateway's
169 * cost telemetry groups per run (the GTM cost instrument). randomUUID needs a
170 * secure context (https / localhost — both our cases); the fallback covers the
171 * rare insecure one. Not security-sensitive: it only groups log lines. */
172function generateRunId(): string {
173 try {
174 return crypto.randomUUID()
175 } catch {
176 return `run-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
177 }
⋯ 284 lines hidden (lines 179–462)
179 
180export type ContactLiveness = 'ok' | 'before-birth' | 'after-death' | 'unknown'
181 
182/**
183 * Game Store — core state for a session of freeform timeline editing.
184 */
185export const useGameStore = defineStore('game', {
186 state: () => ({
187 remainingMessages: TOTAL_MESSAGES,
188 messageHistory: [] as Message[],
189 gameStatus: 'playing' as GameStatus,
190 isLoading: false,
191 error: null as string | null,
192 lastMessageTime: null as number | null,
193 isRateLimited: false,
194 // Staleness plumbing, not run state. runEpoch increments on every reset so an
195 // async result from a dead run can never write into a fresh one; the seq
196 // counters order overlapping requests of the same kind so only the latest
197 // lands (an earlier chronicle rewrite must not overwrite a later one).
198 // Deliberately NOT restored by resetGame — they must survive it to work.
199 runEpoch: 0,
200 chronicleSeq: 0,
201 groundingSeq: 0,
202 // The current run's id — lazily minted on the run's first server call and
203 // cleared by resetGame so each run gets a fresh one. Tags every AI call
204 // (via the x-run-id header) so the gateway's cost telemetry groups per
205 // run: the GTM cost instrument.
206 runId: null as string | null,
207 currentObjective: null as GameObjective | null,
208 objectiveProgress: 0,
209 timelineEvents: [] as TimelineEvent[],
210 figures: [] as HistoricalFigure[],
211 activeFigureName: '' as string,
212 // Grounding for the active contact: real facts + the chosen year to reach them.
213 figureGrounding: null as GroundedFigure | null,
214 groundingLoading: false,
215 contactWhen: null as number | null,
216 /** Optional sub-year refinement of contactWhen — display/prompt flavor
217 * only; every mechanic keeps doing arithmetic on the YEAR (issue #32). */
218 contactMoment: null as ContactMoment | null,
219 // Era-relevant figure suggestions for the current objective (the on-ramp).
220 figureSuggestions: [] as FigureSuggestion[],
221 suggestionsLoading: false,
222 suggestionsFor: '' as string,
223 // The living Chronicle (Layer 3): a prose telling of the altered timeline,
224 // rewritten each turn. Non-blocking — see refreshChronicle.
225 chronicle: null as ChronicleEntry | null,
226 chronicleLoading: false,
227 // The Archive (prototype): an objective-blind brief on the active figure, so
228 // the player can research who they're reaching without leaving the game. The
229 // brief is moment-specific, so it's cached against the figure AND the year.
230 figureStudy: null as FigureStudy | null,
231 studyLoading: false,
232 studyFor: '' as string,
233 studyWhen: null as number | null,
234 // Archive topic lookup (the "yellow" layer): concrete domain facts on demand.
235 archiveResult: null as ArchiveLookup | null,
236 lookupLoading: false,
237 // A content-moderation block — distinct from `error` (an infra hiccup) so
238 // the UI shows an honest, visibly different banner. One per surface that
239 // takes untrusted input into a model: the dispatch/turn, the Archive lookup,
240 // the Archivist study, and the figure-suggestions step.
241 moderationNotice: null as string | null,
242 archiveNotice: null as string | null,
243 studyNotice: null as string | null,
244 suggestionsNotice: null as string | null
245 }),
246 
247 getters: {
248 /**
249 * Can the player send right now? (messages left, still playing, not
250 * mid-request, not rate-limited)
251 */
252 canSendMessage(): boolean {
253 return this.remainingMessages > 0 &&
254 this.gameStatus === 'playing' &&
255 !this.isLoading &&
256 !this.isRateLimited
257 },
258 
259 /**
260 * The conversation thread with a single figure (their turns + the
261 * player's turns addressed to them). Each figure keeps a coherent,
262 * independent thread.
263 */
264 conversationWith(): (name: string) => Message[] {
265 return (name: string) => this.messageHistory.filter(
266 m => m.figureName === name && m.sender !== 'system'
267 )
268 },
269 
270 /** Total progress, net of setbacks, the player has clawed back. */
271 gameSummary(): GameSummary {
272 return generateGameSummary(this)
273 },
274 
275 /**
276 * Whether the chosen `when` falls within the active figure's lifetime.
277 * 'unknown' (permissive) whenever we lack solid dates — we never block a
278 * figure we couldn't ground, only one we know you can't reach yet/anymore.
279 */
280 contactLiveness(): ContactLiveness {
281 const g = this.figureGrounding
282 if (!g || !g.resolved || !g.born || this.contactWhen == null) return 'unknown'
283 if (this.contactWhen < g.born.signed) return 'before-birth'
284 const latest = g.died ? g.died.signed : new Date().getFullYear()
285 if (this.contactWhen > latest) return 'after-death'
286 return 'ok'
287 },
288 
289 /** Can the chosen contact + when actually be reached? */
290 canContact(): boolean {
291 return this.contactLiveness === 'ok' || this.contactLiveness === 'unknown'
292 },
293 
294 /**
295 * The last stand is offered ONLY when the final dispatch can no longer win
296 * inside the normal per-turn fuse — from any nearer position an unstaked
297 * throw can still land it, and offering the (strictly win-probability-
298 * increasing) doubling there would be a dominance trap, not a decision.
299 */
300 canStake(): boolean {
301 return this.remainingMessages === 1 &&
302 this.gameStatus === 'playing' &&
303 (MAX_PROGRESS - this.objectiveProgress) > MAX_PROGRESS_SWING
304 },
305 
306 /**
307 * The active figure's age at the chosen `when` — so the player never has to
308 * do lifetime math in their head. Null when we lack a birth year or a chosen
309 * year (unresolved / free-form contact). Corrects for the missing year zero
310 * when a life spans the BC→AD boundary (1 BC → AD 1 is one year, not two).
311 */
312 contactAge(): number | null {
313 const g = this.figureGrounding
314 if (!g?.resolved || !g.born || this.contactWhen == null) return null
315 const bornSigned = g.born.signed
316 const whenSigned = this.contactWhen
317 if (whenSigned < bornSigned) return null // before birth — no meaningful age
318 const crossedZero = bornSigned < 0 && whenSigned > 0 ? 1 : 0
319 return whenSigned - bornSigned - crossedZero
320 },
321 
322 /**
323 * The causal-chain read for the CURRENT contact — the pre-send ⏳ chip's
324 * source, mirroring what the Timeline Engine will compute. Footholds are the
325 * objective's anchor year plus every dated change already on the ledger; the
326 * gap to the nearest one decays the swing. Null (no chip) for ungrounded
327 * contacts or an anchorless objective with no dated changes yet.
328 */
329 causalChainRead(): ChainStatus | null {
330 const footholds = [
331 this.currentObjective?.anchorYear,
332 ...this.timelineEvents.map(e => e.whenSigned)
333 ]
334 return chainStatus(this.contactWhen, footholds)
335 }
336 },
337 
338 actions: {
339 // ---------- message helpers ----------
340 addUserMessage(text: string, figureName?: string) {
341 if (this.gameStatus !== 'playing') return
342 this.messageHistory.push({
343 text,
344 sender: 'user',
345 timestamp: new Date(),
346 figureName
347 })
348 },
349 
350 addAIMessage(text: string, figureName?: string) {
351 this.messageHistory.push({
352 text,
353 sender: 'ai',
354 timestamp: new Date(),
355 figureName
356 })
357 },
358 
359 addAIMessageWithData(messageData: Partial<Message> & { text: string, sender: 'ai' }) {
360 this.messageHistory.push({
361 text: messageData.text,
362 sender: messageData.sender,
363 timestamp: messageData.timestamp || new Date(),
364 figureName: messageData.figureName,
365 diceRoll: messageData.diceRoll,
366 diceOutcome: messageData.diceOutcome,
367 naturalRoll: messageData.naturalRoll,
368 rollModifier: messageData.rollModifier,
369 craft: messageData.craft,
370 craftReason: messageData.craftReason,
371 characterAction: messageData.characterAction,
372 timelineImpact: messageData.timelineImpact,
373 progressChange: messageData.progressChange,
374 baseProgressChange: messageData.baseProgressChange,
375 anachronism: messageData.anachronism,
376 causalChain: messageData.causalChain,
377 staked: messageData.staked
378 })
379 },
380 
381 // ---------- figures ----------
382 registerFigure(figure: HistoricalFigure) {
383 const existing = this.figures.find(f => f.name === figure.name)
384 if (existing) {
385 if (figure.era) existing.era = figure.era
386 if (figure.descriptor) existing.descriptor = figure.descriptor
387 } else {
388 this.figures.push({ ...figure })
389 }
390 },
391 
392 setActiveFigure(name: string) {
393 this.activeFigureName = name
394 },
395 
396 /**
397 * Synchronously clears the dossier the moment the contact NAME changes, so
398 * a send racing the grounding debounce/fetch goes out clean (free-form)
399 * instead of wearing the PREVIOUS figure's facts, year, and liveness gate —
400 * and so the wrong year can never be written into the ledger's whenSigned.
401 */
402 clearGrounding() {
403 this.groundingSeq++ // invalidate any lookup still in flight
404 this.figureGrounding = null
405 this.contactWhen = null
406 this.contactMoment = null
407 this.groundingLoading = false
408 this.figureStudy = null
409 this.studyFor = ''
410 this.studyWhen = null
411 this.studyNotice = null
412 },
413 
414 /**
415 * Resolves the active figure against the grounding service and defaults the
416 * "when" to a point inside any known lifetime. Never throws: an unresolved
417 * figure simply clears grounding, leaving the contact free-form.
418 */
419 async groundActiveFigure(name: string): Promise<void> {
420 const target = (name || '').trim()
421 // A new contact makes any prior Archive study stale.
422 this.figureStudy = null
423 this.studyFor = ''
424 this.studyWhen = null
425 this.studyNotice = null
426 if (!target) {
427 this.groundingSeq++ // invalidate any lookup still in flight
428 this.figureGrounding = null
429 this.contactWhen = null
430 this.contactMoment = null
431 this.groundingLoading = false
432 return
433 }
434 // Out-of-order guard: only the LATEST lookup may land. Without it, a slow
435 // response for the previous name attaches the wrong dossier (and a wrong
436 // figureContext on a fast send) to whatever the player typed next.
437 const epoch = this.runEpoch
438 const seq = ++this.groundingSeq
439 this.groundingLoading = true
440 try {
441 const grounded = await $fetch('/api/figure', { params: { name: target }, headers: this.aiHeaders() }) as GroundedFigure
442 if (epoch !== this.runEpoch || seq !== this.groundingSeq) return
443 this.figureGrounding = grounded
444 this.contactMoment = null
445 if (grounded?.resolved && grounded.born) {
446 const latest = grounded.died ? grounded.died.signed : new Date().getFullYear()
447 this.contactWhen = Math.round((grounded.born.signed + latest) / 2)
448 } else {
449 this.contactWhen = null
450 }
451 } catch (error) {
452 if (epoch !== this.runEpoch || seq !== this.groundingSeq) return
453 console.error('Figure grounding failed:', error)
454 this.figureGrounding = null
455 this.contactWhen = null
456 this.contactMoment = null
457 } finally {
458 if (epoch === this.runEpoch && seq === this.groundingSeq) this.groundingLoading = false
459 }
460 },
461 
462 /** Headers that tag a server call with the current run, so the gateway's
463 * cost telemetry groups every call in this run together (the GTM cost
464 * instrument). Lazily mints the run id on the run's first server call. */
465 aiHeaders(base: Record<string, string> = {}): Record<string, string> {
466 if (!this.runId) this.runId = generateRunId()
467 return { ...base, 'x-run-id': this.runId }
468 },
469 
470 setContactWhen(year: number | null) {
⋯ 525 lines hidden (lines 471–995)
471 // Scrubbing the year keeps any pinned month/day — the pin refines
472 // whichever year is chosen, it doesn't belong to one.
473 this.contactWhen = year
474 },
475 
476 setContactMoment(moment: ContactMoment | null) {
477 this.contactMoment = moment
478 },
479 
480 /**
481 * Studies the active (grounded) figure via the Archivist — an objective-blind
482 * brief on who they are AT THE CHOSEN MOMENT, so the player can research in the
483 * game instead of a browser tab. The brief is moment-specific, so it's cached
484 * against the figure AND the year: move the contact slider and the player can
485 * re-study them at the new moment. Graceful on failure (leaves the prior brief).
486 * Only resolved figures can be studied — an unresolved name has no record.
487 */
488 async studyActiveFigure(): Promise<void> {
489 const g = this.figureGrounding
490 if (!g?.resolved) {
491 this.figureStudy = null
492 return
493 }
494 // Cache hit only when both the figure AND the studied year still match.
495 if (this.studyFor === g.name && this.studyWhen === this.contactWhen && this.figureStudy) return
496 
497 // Capture the moment being studied NOW: the brief describes this figure at
498 // this year. If the slider moves mid-flight, recording the live value would
499 // mislabel the brief and silently suppress the Re-study button.
500 const epoch = this.runEpoch
501 const studiedName = g.name
502 const studiedWhen = this.contactWhen
503 this.studyLoading = true
504 this.studyNotice = null
505 try {
506 const res = await $fetch('/api/research', {
507 method: 'POST',
508 headers: this.aiHeaders({ 'Content-Type': 'application/json' }),
509 body: {
510 figureName: studiedName,
511 when: studiedWhen != null ? formatContactYear(studiedWhen) : undefined,
512 description: g.description,
513 extract: g.extract
514 }
515 }) as { success?: boolean; study?: FigureStudy; blocked?: boolean; error?: string }
516 if (epoch !== this.runEpoch) return
517 // If the contact changed mid-flight, the stale-study clear in
518 // groundActiveFigure already ran — don't resurrect the old brief.
519 if (res?.blocked && this.figureGrounding?.name === studiedName) {
520 this.studyNotice = res.error || 'That study was blocked by content moderation.'
521 } else if (res?.success && res.study && this.figureGrounding?.name === studiedName) {
522 this.figureStudy = res.study
523 this.studyFor = studiedName
524 this.studyWhen = studiedWhen
525 }
526 } catch (error) {
527 if (epoch !== this.runEpoch) return
528 console.error('Failed to study the figure:', error)
529 } finally {
530 if (epoch === this.runEpoch) this.studyLoading = false
531 }
532 },
533 
534 /**
535 * Asks the Archive about a freeform topic (the "yellow" layer) — concrete
536 * domain facts: what it is, what it takes, and when it first became known
537 * (the player's anachronism read). Graceful on failure. Topic-scoped, so it
538 * persists across figure changes within a run.
539 */
540 async askArchive(query: string): Promise<void> {
541 const q = (query || '').trim()
542 if (!q) return
543 const epoch = this.runEpoch
544 this.lookupLoading = true
545 this.archiveNotice = null
546 try {
547 const res = await $fetch('/api/lookup', {
548 method: 'POST',
549 headers: this.aiHeaders({ 'Content-Type': 'application/json' }),
550 body: { query: q }
551 }) as { success?: boolean; lookup?: ArchiveLookup; blocked?: boolean; error?: string }
552 if (epoch !== this.runEpoch) return
553 if (res?.blocked) {
554 // The Archive is the sharpest elicitation vector — a block here is
555 // honest and visible, not a silent empty result.
556 this.archiveNotice = res.error || 'That lookup was blocked by content moderation.'
557 } else if (res?.success && res.lookup) {
558 this.archiveResult = res.lookup
559 }
560 } catch (error) {
561 if (epoch !== this.runEpoch) return
562 console.error('Archive lookup failed:', error)
563 } finally {
564 if (epoch === this.runEpoch) this.lookupLoading = false
565 }
566 },
567 
568 /**
569 * Loads era-relevant figure suggestions for the current objective (the
570 * educational on-ramp). Cached per objective; on any failure it leaves the
571 * list empty so the UI falls back to its generic starters.
572 */
573 async loadSuggestions(): Promise<void> {
574 const objective = this.currentObjective
575 if (!objective) {
576 this.figureSuggestions = []
577 this.suggestionsFor = ''
578 return
579 }
580 if (this.suggestionsFor === objective.title && this.figureSuggestions.length) return
581 
582 const epoch = this.runEpoch
583 this.suggestionsLoading = true
584 this.suggestionsNotice = null
585 try {
586 const res = await $fetch('/api/suggestions', {
587 method: 'POST',
588 headers: this.aiHeaders({ 'Content-Type': 'application/json' }),
589 body: {
590 objective: {
591 title: objective.title,
592 description: objective.description,
593 era: objective.era
594 }
595 }
596 }) as { success?: boolean; suggestions?: FigureSuggestion[]; blocked?: boolean; reason?: string }
597 if (epoch !== this.runEpoch) return
598 // A blocked objective must not masquerade as an empty result — surface it.
599 if (res?.blocked) this.suggestionsNotice = res.reason || 'That objective was blocked by content moderation.'
600 this.figureSuggestions = res?.suggestions ?? []
601 this.suggestionsFor = objective.title
602 } catch (error) {
603 if (epoch !== this.runEpoch) return
604 console.error('Failed to load figure suggestions:', error)
605 this.figureSuggestions = []
606 // Record that this objective WAS asked, even though it failed — the
607 // picker uses suggestionsFor to tell "not yet asked" (skeletons)
608 // from "asked and came up empty" (the honest famous-names fallback).
609 this.suggestionsFor = objective.title
610 } finally {
611 if (epoch === this.runEpoch) this.suggestionsLoading = false
612 }
613 },
614 
615 // ---------- timeline ledger ----------
616 addTimelineEvent(
617 evt: Omit<TimelineEvent, 'id' | 'timestamp' | 'valence'> & { valence?: Valence; timestamp?: Date }
618 ) {
619 this.timelineEvents.push({
620 id: `evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
621 figureName: evt.figureName,
622 era: evt.era,
623 headline: evt.headline,
624 detail: evt.detail,
625 diceRoll: evt.diceRoll,
626 diceOutcome: evt.diceOutcome,
627 progressChange: evt.progressChange,
628 baseProgressChange: evt.baseProgressChange,
629 valence: evt.valence ?? valenceOf(evt.progressChange),
630 anachronism: evt.anachronism,
631 causalChain: evt.causalChain,
632 craft: evt.craft,
633 whenSigned: evt.whenSigned,
634 staked: evt.staked,
635 timestamp: evt.timestamp ?? new Date()
636 })
637 },
638 
639 // ---------- the living chronicle (Layer 3) ----------
640 /**
641 * Re-narrates the Chronicle from the freshly-bent timeline. Fired
642 * non-blocking after a resolved turn (and at game end): the dice/progress
643 * reveal never waits on prose. True to the game's name, the WHOLE account is
644 * rewritten each turn — a later change can re-frame how earlier events read.
645 * Graceful: a failed refresh keeps the prior telling, so the panel never
646 * blanks; an empty timeline clears it (nothing to chronicle yet).
647 */
648 async refreshChronicle(): Promise<void> {
649 if (!this.timelineEvents.length) {
650 this.chronicle = null
651 return
652 }
653 // Sequencing: rewrites overlap (each turn fires one, never awaited), so
654 // only the LATEST issued refresh may land — an earlier telling arriving
655 // late must not regress the account (or worse, the epilogue). The epoch
656 // guard keeps a dead run's telling out of a fresh run entirely.
657 const epoch = this.runEpoch
658 const seq = ++this.chronicleSeq
659 this.chronicleLoading = true
660 try {
661 const res = await $fetch('/api/chronicle', {
662 method: 'POST',
663 headers: this.aiHeaders({ 'Content-Type': 'application/json' }),
664 body: {
665 objective: this.currentObjective,
666 status: this.gameStatus,
667 progress: this.objectiveProgress,
668 timeline: this.timelineEvents.map(e => ({
669 era: e.era,
670 figureName: e.figureName,
671 headline: e.headline,
672 detail: e.detail,
673 progressChange: e.progressChange
674 }))
675 }
676 }) as { success?: boolean; chronicle?: ChronicleEntry }
677 if (epoch !== this.runEpoch || seq !== this.chronicleSeq) return
678 if (res?.success && res.chronicle) this.chronicle = res.chronicle
679 } catch (error) {
680 if (epoch !== this.runEpoch || seq !== this.chronicleSeq) return
681 console.error('Failed to refresh the chronicle:', error)
682 // Keep the prior chronicle — a failed refresh never blanks the panel.
683 } finally {
684 if (epoch === this.runEpoch && seq === this.chronicleSeq) this.chronicleLoading = false
685 }
686 },
687 
688 // ---------- simple setters ----------
689 setLoading(loading: boolean) { this.isLoading = loading },
690 setError(error: string | null) { this.error = error },
691 clearModerationNotice() { this.moderationNotice = null },
692 clearArchiveNotice() { this.archiveNotice = null },
693 
694 // ---------- rate limiting ----------
695 checkRateLimit(): boolean {
696 const now = Date.now()
697 if (this.lastMessageTime && (now - this.lastMessageTime) < RATE_LIMIT_MS) return false
698 return true
699 },
700 
701 setRateLimit() {
702 this.isRateLimited = true
703 const remainingTime = this.lastMessageTime
704 ? Math.max(0, RATE_LIMIT_MS - (Date.now() - this.lastMessageTime))
705 : 0
706 setTimeout(() => { this.isRateLimited = false }, remainingTime || RATE_LIMIT_MS)
707 },
708 
709 // ---------- counter & status ----------
710 decrementMessages() {
711 if (this.remainingMessages > 0) this.remainingMessages--
712 },
713 
714 /**
715 * Rolls back an unresolved turn: refunds the spent message and drops the
716 * dangling user entry, so the counter and history can't drift out of sync.
717 * Used for BOTH failure shapes — a thrown request and a graceful HTTP-200
718 * `success:false` — an infra hiccup must never burn one of the five
719 * dispatches (or, on the last one, convert into an instant unearned defeat).
720 */
721 refundUnresolvedTurn() {
722 if (this.remainingMessages < TOTAL_MESSAGES) this.remainingMessages++
723 for (let i = this.messageHistory.length - 1; i >= 0; i--) {
724 if (this.messageHistory[i].sender === 'user') {
725 this.messageHistory.splice(i, 1)
726 break
727 }
728 }
729 },
730 
731 applyProgress(progressChange: number) {
732 this.objectiveProgress = Math.max(0, Math.min(MAX_PROGRESS, this.objectiveProgress + progressChange))
733 },
734 
735 /**
736 * Resolves win/lose AFTER a turn's progress has been applied.
737 *
738 * Victory the instant the objective hits 100%; defeat only once the
739 * final message is spent without reaching it. This fixes the old
740 * premature-defeat bug, where status flipped on the last decrement
741 * BEFORE that turn's progress had a chance to land.
742 */
743 resolveGameStatus() {
744 if (this.objectiveProgress >= MAX_PROGRESS) {
745 this.gameStatus = 'victory'
746 } else if (this.remainingMessages <= 0) {
747 this.gameStatus = 'defeat'
748 }
749 },
750 
751 // ---------- the turn ----------
752 /**
753 * Sends a 160-char message to a chosen figure and folds the result back
754 * into the world: the figure replies + acts, the dice decide the swing,
755 * and the Timeline Engine records how history bent.
756 *
757 * Returns `true` only when the turn actually RESOLVED (a ledger entry
758 * landed). A blocked or failed send returns `false` so the composer can
759 * keep the player's crafted words instead of wiping them.
760 *
761 * `opts.stake` arms the last stand — honored only when `canStake` holds
762 * (final message, win out of normal reach; the server doubles the resolved
763 * swing, both ways, past the usual cap).
764 */
765 async sendMessage(text: string, figureName?: string, opts?: { stake?: boolean }): Promise<boolean> {
766 const target = (figureName ?? this.activeFigureName ?? '').trim()
767 const stake = opts?.stake === true && this.canStake
768 
769 if (!this.canSendMessage) return false
770 if (!target) {
771 this.setError('Choose who in history to send your message to first.')
772 return false
773 }
774 if (!this.checkRateLimit()) {
775 this.setRateLimit()
776 this.setError('The timeline needs a moment — wait before sending again.')
777 return false
778 }
779 if (!this.canContact) {
780 const who = this.figureGrounding?.name || target
781 this.setError(
782 this.contactLiveness === 'before-birth'
783 ? `${who} isn't born yet in that year — choose a later moment to reach them.`
784 : `${who} has died by that year — choose an earlier moment to reach them.`
785 )
786 return false
787 }
788 
789 this.setError(null)
790 this.moderationNotice = null
791 this.setActiveFigure(target)
792 this.addUserMessage(text, target)
793 this.decrementMessages()
794 this.setLoading(true)
795 this.lastMessageTime = Date.now()
796 
797 // If the run is reset while this request is in flight, every write below
798 // would land in a world that no longer exists — the epoch guard drops the
799 // response (no fold-in, no refund: the new run's counter is not ours).
800 const epoch = this.runEpoch
801 const ledgerBefore = this.timelineEvents.length
802 // Capture the contact year NOW: the slider can move while the request is
803 // in flight, and the ledger must record the year this turn was SENT to.
804 const sentWhen = this.contactWhen
805 const sentMoment = this.contactMoment
806 let resolved = false
807 
808 try {
809 const response = await $fetch<SendMessageApiResponse>('/api/send-message', {
810 method: 'POST',
811 headers: this.aiHeaders({ 'Content-Type': 'application/json' }),
812 body: {
813 message: text,
814 figureName: target,
815 when: sentWhen != null ? formatContactMoment(sentWhen, sentMoment) : undefined,
816 whenSigned: sentWhen ?? undefined,
817 // The pinned moment travels as validated integers; the server
818 // re-derives the display string rather than trusting ours.
819 whenMonth: sentWhen != null ? sentMoment?.month : undefined,
820 whenDay: sentWhen != null ? sentMoment?.day : undefined,
821 stake,
822 figureContext: this.figureGrounding?.resolved
823 ? {
824 description: this.figureGrounding.description,
825 lifespan: lifespanText(this.figureGrounding)
826 }
827 : undefined,
828 objective: this.currentObjective,
829 timeline: this.timelineEvents.map(e => ({
830 era: e.era,
831 figureName: e.figureName,
832 headline: e.headline,
833 detail: e.detail,
834 progressChange: e.progressChange,
835 whenSigned: e.whenSigned
836 })),
837 conversationHistory: this.conversationWith(target)
838 }
839 })
840 
841 if (epoch !== this.runEpoch) return false
842 
843 if (response?.success && response.data?.characterResponse) {
844 const { figure, characterResponse, diceRoll, diceOutcome, timeline } = response.data
845 
846 if (figure?.name) {
847 this.registerFigure({
848 name: figure.name,
849 era: figure.era || '',
850 descriptor: figure.descriptor || ''
851 })
852 }
853 
854 this.addAIMessageWithData({
855 text: characterResponse.message,
856 sender: 'ai',
857 figureName: target,
858 timestamp: new Date(),
859 diceRoll,
860 diceOutcome,
861 naturalRoll: response.data.naturalRoll ?? diceRoll,
862 rollModifier: response.data.rollModifier ?? 0,
863 craft: response.data.craft,
864 craftReason: response.data.craftReason,
865 characterAction: characterResponse.action,
866 timelineImpact: timeline?.detail,
867 progressChange: timeline?.progressChange,
868 baseProgressChange: timeline?.baseProgressChange,
869 anachronism: timeline?.anachronism,
870 causalChain: timeline?.causalChain,
871 staked: response.data.staked
872 })
873 
874 if (timeline) {
875 this.addTimelineEvent({
876 figureName: target,
877 era: timeline.era || figure?.era || (this.currentObjective?.era ?? ''),
878 headline: timeline.headline,
879 detail: timeline.detail,
880 diceRoll,
881 diceOutcome,
882 progressChange: timeline.progressChange ?? 0,
883 baseProgressChange: timeline.baseProgressChange,
884 valence: timeline.valence,
885 anachronism: timeline.anachronism,
886 causalChain: timeline.causalChain,
887 craft: response.data.craft,
888 whenSigned: sentWhen ?? undefined,
889 staked: response.data.staked
890 })
891 // Use !== undefined so a genuine neutral (0%) turn still
892 // registers instead of silently vanishing.
893 if (timeline.progressChange !== undefined) {
894 this.applyProgress(timeline.progressChange)
895 }
896 resolved = true
897 }
898 } else {
899 // A graceful failure (HTTP 200, success:false): an AI layer gave
900 // out mid-turn. No ripple landed, so the dispatch is refunded —
901 // exactly like the thrown path below.
902 // Narrow to the failure variant (its data carries `blocked`).
903 const failed = response && !response.success ? response.data : undefined
904 if (failed?.blocked) {
905 // A content-moderation block, not an infra hiccup — show an
906 // honest, distinct banner (not "try again"). The composer
907 // keeps the player's words (sendMessage returns false).
908 this.moderationNotice = failed.moderationReason || 'That dispatch was blocked by content moderation and cannot be sent.'
909 } else {
910 this.setError(failed?.error || 'History did not answer. Try again.')
911 }
912 this.refundUnresolvedTurn()
913 }
914 } catch (error) {
915 if (epoch !== this.runEpoch) return false
916 console.error('Error sending message:', error)
917 this.setError('The timeline resisted your message. Please try again.')
918 this.refundUnresolvedTurn()
919 } finally {
920 if (epoch === this.runEpoch) {
921 this.setLoading(false)
922 this.resolveGameStatus()
923 }
924 }
925 
926 // The living chronicle re-narrates the world from the new timeline — but
927 // only when this turn actually added a change, and never awaited: the turn
928 // reveal must not wait on prose. By here the status is resolved, so the
929 // final turn's chronicle carries the victory/defeat framing (the epilogue).
930 if (resolved && this.timelineEvents.length > ledgerBefore) {
931 void this.refreshChronicle()
932 }
933 return resolved
934 },
935 
936 /**
937 * Commits a chosen objective and starts the run from a clean slate of
938 * progress. Used by the mission-select screen for both curated picks and
939 * freshly composed ones.
940 */
941 chooseObjective(objective: GameObjective) {
942 this.currentObjective = objective
943 this.objectiveProgress = 0
944 this.error = null
945 },
946 
947 /**
948 * Asks the server to compose a fresh objective with the model, WITHOUT
949 * committing it — the caller previews it, then commits via chooseObjective.
950 * Generation lives server-side because it needs the OpenAI key (the client
951 * has no access to it). Returns null rather than throwing if composing
952 * fails, so the UI can quietly fall back to the curated pool.
953 */
954 async fetchAIObjective(): Promise<GameObjective | null> {
955 try {
956 const res = await $fetch('/api/objective', { method: 'GET', headers: this.aiHeaders() }) as {
957 success?: boolean
958 objective?: GameObjective
959 }
960 return res?.success && res.objective ? res.objective : null
961 } catch (error) {
962 console.error('Failed to compose a fresh objective:', error)
963 return null
964 }
965 },
966 
967 getVictoryEfficiency() {
968 if (this.gameStatus === 'victory') {
969 const messagesSaved = this.remainingMessages
970 const messagesUsed = TOTAL_MESSAGES - this.remainingMessages
971 const efficiencyPercentage = Math.round((messagesSaved / TOTAL_MESSAGES) * 100)
972 
973 const efficiencyRating = rateEfficiency(messagesSaved)
974 
975 return {
976 messagesSaved,
977 messagesUsed,
978 efficiencyPercentage,
979 efficiencyRating,
980 isEarlyVictory: messagesSaved > 0
981 }
982 }
983 return null
984 },
985 
986 resetGame() {
987 // Tear the epoch first: anything still in flight belongs to the old run
988 // and must find no purchase here. (The seq counters deliberately survive.)
989 this.runEpoch++
990 this.remainingMessages = TOTAL_MESSAGES
991 this.messageHistory = []
992 this.gameStatus = 'playing'
993 this.isLoading = false
994 this.error = null
995 this.lastMessageTime = null
996 this.isRateLimited = false
997 // A new run gets a fresh id on its next server call.
998 this.runId = null
999 this.currentObjective = null
⋯ 26 lines hidden (lines 1000–1025)
1000 this.objectiveProgress = 0
1001 this.timelineEvents = []
1002 this.figures = []
1003 this.activeFigureName = ''
1004 this.figureGrounding = null
1005 this.groundingLoading = false
1006 this.contactWhen = null
1007 this.contactMoment = null
1008 this.figureSuggestions = []
1009 this.suggestionsLoading = false
1010 this.suggestionsFor = ''
1011 this.chronicle = null
1012 this.chronicleLoading = false
1013 this.figureStudy = null
1014 this.studyLoading = false
1015 this.studyFor = ''
1016 this.studyWhen = null
1017 this.archiveResult = null
1018 this.lookupLoading = false
1019 this.moderationNotice = null
1020 this.archiveNotice = null
1021 this.studyNotice = null
1022 this.suggestionsNotice = null

How it's verified

The pricing math and the lane convention are unit-tested in cost.spec; the request binding in run-context.spec; the client lifecycle in the store spec. The claim that matters most — that a real call actually emits both fields — is proven against the gateway with a mocked SDK, so the full path (bound context → gateway → enriched line) runs without a live key.

The observer sees a priced, run-tagged line for a real completeStructured call.

tests/unit/server/utils/anthropic-pipeline.spec.ts · 276 lines
tests/unit/server/utils/anthropic-pipeline.spec.ts276 lines · TypeScript
⋯ 231 lines hidden (lines 1–231)
1import { describe, it, expect, vi, beforeEach, beforeAll, afterAll } from 'vitest'
2 
3/**
4 * The Anthropic lane, driven end to end with a mocked SDK: the same band-clamp →
5 * amplify → sign-guard pipeline the OpenAI spec pins, PLUS the gateway's lane
6 * shaping — which parameters each model family receives (Haiku never sees
7 * `effort`, Opus never sees `temperature`), how single-shot vs conversational
8 * requests are framed, and that the telemetry observer sees every call.
9 */
10const createMock = vi.hoisted(() => vi.fn())
11vi.mock('@anthropic-ai/sdk', () => ({
12 // A real `function` (not an arrow): the SUT calls `new Anthropic(...)`, and
13 // vitest only allows construction of function/class mock implementations.
14 default: vi.fn(function () { return { messages: { create: createMock } } })
15}))
16 
17import { callTimelineAI, callJudgeAI, callCharacterAI, callChroniclerAI } from '../../../../server/utils/openai'
18import { setAiCallObserver, type AiCallTelemetry } from '../../../../server/utils/ai-gateway'
19import { setRunContext } from '../../../../server/utils/run-context'
20import { AI_ROUTES } from '../../../../server/utils/ai-routing'
21import { DiceOutcome } from '../../../../utils/dice'
22 
23function modelReturns(payload: unknown) {
24 createMock.mockResolvedValueOnce({
25 content: [{ type: 'text', text: JSON.stringify(payload) }],
26 usage: { input_tokens: 100, output_tokens: 42, cache_read_input_tokens: 0 },
27 stop_reason: 'end_turn'
28 })
30 
31const TURN = {
32 objective: { title: 'Spare the Library', description: 'The scrolls survive.' },
33 figureName: 'Julius Caesar',
34 era: 'Alexandria, 48 BC',
35 userMessage: 'Guard the scrolls.',
36 characterMessage: 'It is done.',
37 characterAction: 'Caesar shields the library.',
38 diceRoll: 16,
39 diceOutcome: DiceOutcome.SUCCESS,
40 timeline: []
42 
43const ripple = (over: Record<string, unknown> = {}) => ({
44 headline: 'The scrolls survive',
45 detail: 'Rome guards wisdom.',
46 progressChange: 20,
47 valence: 'positive',
48 anachronism: 'in-period',
49 ...over
50})
51 
52beforeAll(() => {
53 process.env.ANTHROPIC_API_KEY = 'test-key'
54 process.env.REVISIONIST_AI_LANE = 'anthropic'
55})
56 
57afterAll(() => {
58 delete process.env.REVISIONIST_AI_LANE
59 setAiCallObserver(null)
60})
61 
62beforeEach(() => {
63 createMock.mockReset()
64})
65 
66describe('callTimelineAI on the Anthropic lane — same pipeline, new transport', () => {
67 it('clamps a model overshoot into the rolled band', async () => {
68 modelReturns(ripple({ progressChange: 80, anachronism: 'in-period' }))
69 const res = await callTimelineAI(TURN)
70 expect(res.success).toBe(true)
71 expect(res.ripple?.baseProgressChange).toBe(30) // Success band ceiling
72 expect(res.ripple?.progressChange).toBe(30)
73 })
74 
75 it('amplifies a far-ahead wager and re-colors a contradictory valence', async () => {
76 modelReturns(ripple({ progressChange: -20, anachronism: 'in-period', valence: 'negative' }))
77 const res = await callTimelineAI(TURN)
78 expect(res.ripple?.baseProgressChange).toBe(15) // Success band floor
79 expect(res.ripple?.valence).toBe('positive') // sign-guard re-colors it
80 })
81 
82 it('sends the single-shot prompt as the user message (no contentless turn)', async () => {
83 modelReturns(ripple())
84 await callTimelineAI(TURN)
85 const req = createMock.mock.calls[0][0]
86 expect(req.system).toBeUndefined()
87 expect(req.messages).toHaveLength(1)
88 expect(req.messages[0].role).toBe('user')
89 expect(req.messages[0].content).toContain('Spare the Library')
90 expect(req.output_config.format.type).toBe('json_schema')
91 })
92 
93 it('decays a lone blast far from any foothold (causal chain)', async () => {
94 modelReturns(ripple({ progressChange: 20, anachronism: 'in-period' }))
95 // Caesar at 50 BC (-50) aimed at an objective anchored in AD 1960 — a ~2,000yr
96 // gap with no stones laid: diffuse, a sliver even on a Success.
97 const res = await callTimelineAI({ ...TURN, figureYear: -50, objectiveAnchorYear: 1960 })
98 expect(res.ripple?.baseProgressChange).toBe(20) // the rolled band value is untouched
99 expect(res.ripple?.causalChain?.tier).toBe('diffuse')
100 expect(Math.abs(res.ripple!.progressChange)).toBeLessThan(6)
101 })
102 
103 it('leaves a swing at the hinge at full force', async () => {
104 modelReturns(ripple({ progressChange: 20, anachronism: 'in-period' }))
105 const res = await callTimelineAI({ ...TURN, figureYear: 1960, objectiveAnchorYear: 1960 })
106 expect(res.ripple?.progressChange).toBe(20) // gap 0 → no decay
107 expect(res.ripple?.causalChain?.tier).toBe('at-hinge')
108 })
109 
110 it('no-ops the chain dial for an ungrounded turn (no contact year)', async () => {
111 modelReturns(ripple({ progressChange: 20, anachronism: 'in-period' }))
112 const res = await callTimelineAI({ ...TURN, objectiveAnchorYear: 1960 }) // no figureYear
113 expect(res.ripple?.progressChange).toBe(20)
114 expect(res.ripple?.causalChain).toBeUndefined()
115 })
116})
117 
118describe('the gateway shapes parameters per model family', () => {
119 it('judge (Haiku): temperature yes, effort never, thinking never', async () => {
120 modelReturns({ craft: 'sharp', reason: 'Names the lever.' })
121 const res = await callJudgeAI({ objective: TURN.objective, figureName: 'Caesar', userMessage: 'x' })
122 expect(res.judge?.craft).toBe('sharp')
123 
124 const req = createMock.mock.calls[0][0]
125 expect(req.model).toBe(AI_ROUTES.judge.anthropic.model)
126 expect(req.temperature).toBe(0)
127 expect(req.output_config.effort).toBeUndefined()
128 expect(req.thinking).toBeUndefined()
129 })
130 
131 it('the closed-door cap is code, not vibes: a too-late timing demotes the grade (issue #32)', async () => {
132 const burned = 'the Hindenburg disaster at Lakehurst — May 6, 1937'
133 modelReturns({ event: burned, timing: 'too-late', craft: 'sharp', reason: 'Brilliant wording; the ship already burned.' })
134 const late = await callJudgeAI({ objective: TURN.objective, figureName: 'Pruss', when: 'May 8, 1937', momentRefined: true, userMessage: 'x' })
135 expect(late.judge?.craft).toBe('vague') // capped in code, whatever the model graded
136 
137 modelReturns({ event: burned, timing: 'too-late', craft: 'reckless', reason: 'Unaware its moment passed.' })
138 const reckless = await callJudgeAI({ objective: TURN.objective, figureName: 'Pruss', when: 'May 8, 1937', momentRefined: true, userMessage: 'x' })
139 expect(reckless.judge?.craft).toBe('reckless') // below the cap stays as graded
140 
141 modelReturns({ event: burned, timing: 'in-time', craft: 'sharp', reason: 'Timed to the hinge.' })
142 const onTime = await callJudgeAI({ objective: TURN.objective, figureName: 'Pruss', when: 'May 6, 1937', momentRefined: true, userMessage: 'x' })
143 expect(onTime.judge?.craft).toBe('sharp') // in-time grades ride through
144 
145 // The schema only demands the event+timing pair when a moment is pinned —
146 // event FIRST, so the date is written out before the enum compares it.
147 const lateReq = createMock.mock.calls[0][0]
148 expect(lateReq.output_config.format.schema.required).toContain('event')
149 expect(lateReq.output_config.format.schema.required).toContain('timing')
150 expect(Object.keys(lateReq.output_config.format.schema.properties).slice(0, 2)).toEqual(['event', 'timing'])
151 modelReturns({ craft: 'sound', reason: 'Plain year.' })
152 await callJudgeAI({ objective: TURN.objective, figureName: 'Pruss', when: '1937', userMessage: 'x' })
153 const yearReq = createMock.mock.calls[3][0]
154 expect(yearReq.output_config.format.schema.required).not.toContain('timing')
155 expect(yearReq.output_config.format.schema.required).not.toContain('event')
156 })
157 
158 it('character (Sonnet): system + conversation turns, effort set explicitly', async () => {
159 modelReturns({ message: 'So be it.', action: 'Guards the stacks', era: 'Alexandria, 48 BC', persona: 'Roman consul at war' })
160 const res = await callCharacterAI('Julius Caesar', 'Guard the scrolls.', [], DiceOutcome.SUCCESS)
161 expect(res.success).toBe(true)
162 
163 const req = createMock.mock.calls[0][0]
164 expect(req.system).toContain('Julius Caesar')
165 expect(req.messages[0]).toEqual({ role: 'user', content: 'Guard the scrolls.' })
166 // Sonnet defaults effort to HIGH server-side — the table must always pin it.
167 expect(req.output_config.effort).toBe(AI_ROUTES.character.anthropic.effort)
168 })
169 
170 it('the chronicle speaks the Claude voice on this lane — V1 bar for the epilogue, V2 for mid-run', async () => {
171 modelReturns({ title: 'The Library Stands', paragraphs: ['It stands.'] })
172 await callChroniclerAI({ objective: TURN.objective, timeline: [], status: 'victory', progress: 100 })
173 const epilogueReq = createMock.mock.calls[0][0]
174 expect(epilogueReq.model).toBe(AI_ROUTES.epilogue.anthropic.model)
175 // The pairwise-winning V1 specificity bar, single-shot as the user turn.
176 expect(epilogueReq.messages[0].content).toContain('The bar for every sentence')
177 expect(epilogueReq.output_config.effort).toBe(AI_ROUTES.epilogue.anthropic.effort)
178 
179 modelReturns({ title: 'The Fire Pauses', paragraphs: ['It waits.'] })
180 await callChroniclerAI({ objective: TURN.objective, timeline: [], status: 'playing', progress: 40 })
181 const midReq = createMock.mock.calls[1][0]
182 expect(midReq.model).toBe(AI_ROUTES.chronicler.anthropic.model)
183 // Mid-run carries V2's harder demand (the abstraction ban).
184 expect(midReq.messages[0].content).toContain('are banned')
185 })
186 
187 it('reads the text block even when a thinking block precedes it', async () => {
188 createMock.mockResolvedValueOnce({
189 content: [
190 { type: 'thinking', thinking: '', signature: 'sig' },
191 { type: 'text', text: JSON.stringify({ title: 'After the Fire', paragraphs: ['Done.'] }) }
192 ],
193 usage: { input_tokens: 1, output_tokens: 1 },
194 stop_reason: 'end_turn'
195 })
196 const res = await callChroniclerAI({ objective: TURN.objective, timeline: [], status: 'defeat', progress: 10 })
197 expect(res.chronicle?.title).toBe('After the Fire')
198 })
199})
200 
201describe('telemetry', () => {
202 it('the observer sees task, lane, model, and usage for every call', async () => {
203 const seen: AiCallTelemetry[] = []
204 setAiCallObserver(t => seen.push(t))
205 modelReturns(ripple())
206 await callTimelineAI(TURN)
207 setAiCallObserver(null)
208 
209 expect(seen).toHaveLength(1)
210 expect(seen[0]).toMatchObject({
211 task: 'timeline',
212 lane: 'anthropic',
213 model: AI_ROUTES.timeline.anthropic.model,
214 ok: true,
215 usage: { inputTokens: 100, outputTokens: 42, cacheReadTokens: 0 }
216 })
217 })
218 
219 it('a transport failure still emits (ok: false) and the envelope holds', async () => {
220 const seen: AiCallTelemetry[] = []
221 setAiCallObserver(t => seen.push(t))
222 const quiet = vi.spyOn(console, 'error').mockImplementation(() => {})
223 createMock.mockRejectedValueOnce(new Error('lane down'))
224 const res = await callTimelineAI(TURN)
225 quiet.mockRestore()
226 setAiCallObserver(null)
227 
228 expect(res.success).toBe(false)
229 expect(seen[0]).toMatchObject({ task: 'timeline', ok: false })
230 })
231 
232 it('prices the line from its usage — the cost instrument (GTM dispatch 1)', async () => {
233 const seen: AiCallTelemetry[] = []
234 setAiCallObserver(t => seen.push(t))
235 modelReturns(ripple()) // usage: 100 in / 42 out / 0 cached, timeline → sonnet-4-6
236 await callTimelineAI(TURN)
237 setAiCallObserver(null)
238 // sonnet-4-6 at $3 / $15 per million in/out: (100*3 + 42*15) / 1e6.
239 expect(seen[0].usd).toBe(0.00093)
240 })
241 
242 it('tags the line with the run id bound for the request', async () => {
243 const seen: AiCallTelemetry[] = []
244 setAiCallObserver(t => seen.push(t))
245 setRunContext({ runId: 'run-telemetry-1' })
246 modelReturns(ripple())
247 await callTimelineAI(TURN)
248 setRunContext({}) // clear so the binding can't leak into later tests
249 setAiCallObserver(null)
250 expect(seen[0].runId).toBe('run-telemetry-1')
251 })
252})
⋯ 24 lines hidden (lines 253–276)
253 
254describe('a model-side safety refusal (stop_reason refusal) is a block, not an infra error', () => {
255 function modelRefuses() {
256 createMock.mockResolvedValueOnce({
257 content: [{ type: 'text', text: '' }],
258 usage: { input_tokens: 50, output_tokens: 0, cache_read_input_tokens: 0 },
259 stop_reason: 'refusal'
260 })
261 }
262 
263 it('callCharacterAI surfaces refused:true', async () => {
264 modelRefuses()
265 const res = await callCharacterAI('Fritz Haber', 'Synthesize chlorine gas at scale.', [], DiceOutcome.SUCCESS)
266 expect(res.success).toBe(false)
267 expect(res.refused).toBe(true)
268 })
269 
270 it('callTimelineAI surfaces refused:true', async () => {
271 modelRefuses()
272 const res = await callTimelineAI(TURN)
273 expect(res.success).toBe(false)
274 expect(res.refused).toBe(true)
275 })
276})