What changed, and why

Inspecting a stage in this game used to mean playing a real winning run by hand: pick an objective, write five real messages, hope the dice cooperate, and pay for plus wait on four AI layers every turn. This PR adds a dev-only panel that jumps straight to any stage and forces any outcome — driven by the real mechanics, not a faked UI.

The design splits cleanly in two. State jumps mutate the store directly to land on victory, defeat, a populated mid-game, or the stake offer, so the real end screen, Spine, and Chronicle render with no run and no server call. Forced turns fire the real sendMessage pipeline with only the four AI layers replaced by scripted output: the dice bands, anachronism amplifier, causal-chain decay, momentum, the progress clamp, win/lose, and the Chronicle all run for real.

The whole thing is gated so it never reaches players, and it deliberately does not bypass the charged-run paywall. The sections below walk the seam first (where the determinism is injected), then the safety gate, then the two surfaces, and finally how the fixture shape is shared with the e2e tests so the two can't drift.

The fixture lane: one branch at the gateway

Every model call in the game goes through one transport, completeStructured, routed per task. The dev lane adds a single branch at the very top: if a fixture is installed for this task, return its canned JSON and skip the provider entirely. Because it short-circuits before routeFor, the real lanes below are untouched, and the telemetry line still records the call honestly — with no usage, so no cost is ever attributed (the "no spend" guarantee).

The fixture check sits above the real dispatch; no usage → no cost recorded.

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

The lane union gains a 'fixture' member purely so that telemetry line is typed. routeFor never returns it (the routing table is still anthropic/openai only) — the gateway is the only place the fixture lane is ever selected.

server/utils/ai-routing.ts · 182 lines
server/utils/ai-routing.ts182 lines · TypeScript
⋯ 36 lines hidden (lines 1–36)
1/**
2 * The per-task model routing table — the provider seam the go-to-market plan
3 * calls for (dispatch 1's bake-off lives behind this file). Every AI task names
4 * its lane and its full parameter payload here, because valid parameter sets
5 * differ by model family:
6 *
7 * - Haiku 4.5 rejects `effort` and has no adaptive thinking — omit both.
8 * - Sonnet 4.6 defaults `effort` to HIGH — always set it explicitly (this game
9 * wants reflexes, not deliberation). `temperature` is accepted.
10 * - Opus 4.8 rejects `temperature`/`top_p`/`top_k` — steer tone via prompts.
11 * Thinking is adaptive-only; omitting `thinking` runs without thinking.
12 *
13 * Lane defaults were decided by the two-lane bake-off plus the prompt-tuning
14 * campaign (evals/bakeoff.eval.ts, evals/prompt-tune.eval.ts; snapshots +
15 * verdict in evals/results/) — quality-first, with cost a close second. All
16 * ten tasks run Anthropic: the prose tellings initially lost the blind
17 * pairwise 19-0 under the shared prompt, and the Claude-voice rewrite
18 * (buildChroniclePromptClaude) won them back on Sonnet. The OpenAI lane stays
19 * fully alive behind REVISIONIST_AI_LANE so a forced reverse migration is a
20 * config change, not a rewrite (the GTM's platform-risk counter).
21 */
22 
23export type AiTask =
24 | 'judge'
25 | 'character'
26 | 'timeline'
27 | 'chronicler'
28 | 'epilogue'
29 | 'archivist-study'
30 | 'archive-lookup'
31 | 'objective'
32 | 'objective-check'
33 | 'suggester'
34 | 'lifespan'
35 | 'sentinel'
36 
37/** 'fixture' is the dev-only deterministic lane (issue #105): a scripted structured
38 * output, no model call. Never selected by `routeFor` (the table is anthropic/openai
39 * only) — the gateway short-circuits to it when a dev fixture is installed, behind
40 * the dev gate. It exists in the union so telemetry can label a fixtured call. */
41export type AiLane = 'anthropic' | 'openai' | 'fixture'
⋯ 141 lines hidden (lines 42–182)
42 
43export interface AnthropicParams {
44 model: string
45 maxTokens: number
46 /** Omit on Opus-tier models — the API rejects sampling params there. */
47 temperature?: number
48 /** Omit on Haiku 4.5 — the API rejects the effort parameter there.
49 * 'xhigh' exists on Opus 4.7+ and Fable 5 only. */
50 effort?: 'low' | 'medium' | 'high' | 'xhigh'
51 /** 'adaptive' turns thinking on (billed as output); omitted = no thinking. */
52 thinking?: 'adaptive'
54 
55export interface OpenAiParams {
56 model: string
57 /** gpt-5.5 bills hidden reasoning inside this cap — keep generous headroom. */
58 maxTokens: number
59 reasoningEffort?: 'low' | 'medium' | 'high'
61 
62export interface TaskRoute {
63 lane: AiLane
64 anthropic: AnthropicParams
65 openai: OpenAiParams
67 
68/** The OpenAI lane's flagship — one place, like the old exported MODEL constant. */
69const GPT = 'gpt-5.5'
70const OPENAI_DEFAULTS: OpenAiParams = { model: GPT, maxTokens: 1200, reasoningEffort: 'low' }
71 
72/**
73 * With thinking off, Anthropic's max_tokens is TRUE output budget — no hidden
74 * reasoning billed inside it — so caps are sized to real output shapes instead
75 * of the old padded 1200.
76 */
77export const AI_ROUTES: Record<AiTask, TaskRoute> = {
78 // A rubric classifier in the critical path of every dispatch (it runs before
79 // the die). Haiku for speed; the calibration eval is its gate.
80 judge: {
81 lane: 'anthropic',
82 anthropic: { model: 'claude-haiku-4-5', maxTokens: 300, temperature: 0 },
83 openai: OPENAI_DEFAULTS
84 },
85 // The player converses with this output every turn — prose is product.
86 character: {
87 lane: 'anthropic',
88 anthropic: { model: 'claude-sonnet-4-6', maxTokens: 700, temperature: 0.9, effort: 'low' },
89 openai: OPENAI_DEFAULTS
90 },
91 // The rules-critical judgment layer; code clamps swings into the rolled band,
92 // the model places within band and rates anachronism.
93 timeline: {
94 lane: 'anthropic',
95 anthropic: { model: 'claude-sonnet-4-6', maxTokens: 600, temperature: 0.3, effort: 'medium' },
96 openai: OPENAI_DEFAULTS
97 },
98 // Re-narrates the whole timeline each turn. The original bake-off lost this
99 // slot 9-0 under the shared prompt; the Claude-voice rewrite (V2's
100 // transmission-chain bar, buildChroniclePromptClaude) won it back 8-5 in
101 // blind pairwise on Sonnet at ~1/4 the prior cost-per-telling — and the
102 // incumbent lane's quota outage made the flip availability-driven too.
103 // Higher-N confirmation pending (evals/results/2026-06-11-verdict.md).
104 chronicler: {
105 lane: 'anthropic',
106 anthropic: { model: 'claude-sonnet-4-6', maxTokens: 2000, temperature: 1, effort: 'high' },
107 openai: OPENAI_DEFAULTS
108 },
109 // The final telling — the share artifact and the thing people pay for. The
110 // Claude-voice V1 prompt on SONNET beat the incumbent 16-3 across two
111 // dual-graded rounds (7-3, then 9-0 at N=12) — decisively confirmed, and
112 // cheaper than the Opus/Fable candidates it outscored. Sonnet over Opus is
113 // what the data said, twice; Fable won too but at 4x Sonnet's price for no
114 // measured gain over it.
115 epilogue: {
116 lane: 'anthropic',
117 anthropic: { model: 'claude-sonnet-4-6', maxTokens: 2000, temperature: 1, effort: 'high' },
118 openai: OPENAI_DEFAULTS
119 },
120 // Feeds the player's wager calibration — accuracy matters.
121 'archivist-study': {
122 lane: 'anthropic',
123 anthropic: { model: 'claude-sonnet-4-6', maxTokens: 800, temperature: 0.5, effort: 'low' },
124 openai: OPENAI_DEFAULTS
125 },
126 // Factual micro-answers with a known-since stamp.
127 'archive-lookup': {
128 lane: 'anthropic',
129 anthropic: { model: 'claude-haiku-4-5', maxTokens: 500, temperature: 0.3 },
130 openai: OPENAI_DEFAULTS
131 },
132 // 0-1 calls per run; the objective steers every layer's valence.
133 objective: {
134 lane: 'anthropic',
135 anthropic: { model: 'claude-sonnet-4-6', maxTokens: 600, temperature: 1, effort: 'low' },
136 openai: OPENAI_DEFAULTS
137 },
138 // Gates a freshly composed objective on the safely-historical bounds (#71): a
139 // cheap, temp-0 classifier (the figure-floor pattern) that catches the
140 // living-people / current-events framings a year bound alone can't. Haiku,
141 // off the critical generation path — at most twice per composition.
142 'objective-check': {
143 lane: 'anthropic',
144 anthropic: { model: 'claude-haiku-4-5', maxTokens: 200, temperature: 0 },
145 openai: OPENAI_DEFAULTS
146 },
147 // Tool-using agent verified against free grounding, curated floor beneath it.
148 // maxTokens here is per ROUND of the agent loop.
149 suggester: {
150 lane: 'anthropic',
151 anthropic: { model: 'claude-haiku-4-5', maxTokens: 1500, temperature: 0.7 },
152 openai: { model: GPT, maxTokens: 2500 }
153 },
154 // A dates classifier with hard validation guards behind it (issue #31 bridge).
155 lifespan: {
156 lane: 'anthropic',
157 anthropic: { model: 'claude-haiku-4-5', maxTokens: 300, temperature: 0 },
158 openai: OPENAI_DEFAULTS
159 },
160 // The safety Sentinel — classifies a whole exchange against the verbatim
161 // Usage Policy (server/utils/usage-policy.ts). Haiku at temp 0 for a
162 // consistent, cheap classifier, exactly the tier Anthropic's content-
163 // moderation guide recommends. Runs once per user action, never on the
164 // critical generation path's tokens.
165 sentinel: {
166 lane: 'anthropic',
167 anthropic: { model: 'claude-haiku-4-5', maxTokens: 400, temperature: 0 },
168 openai: OPENAI_DEFAULTS
169 }
171 
172/**
173 * Resolves a task to its lane + parameters. REVISIONIST_AI_LANE overrides the
174 * whole table ('openai' | 'anthropic') — the emergency lever, and how the eval
175 * matrix runs identical fixtures down each lane.
176 */
177export function routeFor(task: AiTask): { lane: AiLane; route: TaskRoute } {
178 const route = AI_ROUTES[task]
179 const override = process.env.REVISIONIST_AI_LANE
180 const lane = override === 'openai' || override === 'anthropic' ? override : route.lane
181 return { lane, route }

The registry and the dev gate

What feeds that branch is a tiny process-global registry, set once per forced turn. This mirrors the codebase's existing global-state seams (setAiCallObserver, the REVISIONIST_AI_LANE lever): dev is a single operator, so a per-request store buys nothing, and one set covers both /api/send-message and the separate /api/chronicle request that follows.

devModeEnabled() is the single source of truth for the gate — a dev build, or an explicit staging opt-in. It is the only thing standing between this code and a production player, so it is deliberately strict: the env var must be exactly true.

The gate, then the registry: set behind the gate, read by the gateway and the dice seam.

server/utils/dev-fixtures.ts · 61 lines
server/utils/dev-fixtures.ts61 lines · TypeScript
⋯ 20 lines hidden (lines 1–20)
1/**
2 * Developer-mode deterministic AI fixtures (issue #105).
3 *
4 * When dev mode is on, the dev panel scripts the four AI layers (Judge, Character,
5 * Timeline, Chronicler/epilogue) and the die, so a turn runs the ENTIRE real
6 * pipeline below the seam — dice math, swing bands, the anachronism amplifier,
7 * causal-chain decay, momentum, the progress clamp, win/lose, and the living
8 * Chronicle — with no model calls: no keys, no spend.
9 *
10 * The registry is process-global mutable state, like `setAiCallObserver` and the
11 * `REVISIONIST_AI_LANE` lever: dev is a single operator, and the gate keeps it
12 * inert in production. It is populated ONLY through the dev-gated `/api/dev/fixtures`
13 * route (`devModeEnabled()` there), so in a prod build nothing can set it — every
14 * AI call then falls through to the real lane, untouched.
15 *
16 * Crucially this is NOT a paywall bypass: it only fakes generation. A forced turn
17 * still goes through the real `/api/send-message`, which still enforces `runIsActive`
18 * (the charged-run gate), so dev mode never grants a free *real* play.
19 */
20import type { AiTask } from './ai-routing'
21 
22/**
23 * Is dev mode enabled? True in a Nuxt dev build (`import.meta.dev`), or behind an
24 * explicit opt-in for a deployed staging preview (`REVISIONIST_DEV_MODE=true`).
25 * Never true in a normal production build — the single source of truth both the
26 * fixture route and the dice seam gate on.
27 */
28export function devModeEnabled(): boolean {
29 return import.meta.dev || process.env.REVISIONIST_DEV_MODE === 'true'
31 
32interface DevFixtureState {
33 /** The scripted JSON object each AI task returns (the consumer JSON-stringifies). */
34 scripts: Partial<Record<AiTask, unknown>>
⋯ 4 lines hidden (lines 35–38)
35 /** The forced natural d20 (1–20) for the turn, or undefined for a real roll. */
36 roll?: number
38 
39let state: DevFixtureState | null = null
40 
41/** Install (or, with null, clear) the active fixture script. Called only by the
42 * dev-gated route. */
43export function setDevFixtures(next: DevFixtureState | null): void {
44 state = next
46 
47/** The scripted output for a task, or undefined when none is set (→ the task runs
48 * on its real lane). */
49export function devFixtureFor(task: AiTask): unknown | undefined {
50 return state?.scripts[task]
52 
53/** The forced natural roll for a dev turn, or undefined (→ a real `rollD20()`). */
54export function devForcedRoll(): number | undefined {
55 return state?.roll
57 
58/** Whether any fixture script is currently installed. */
59export function devFixturesActive(): boolean {
60 return state !== null

The dev-gated route

The panel arms a turn by POSTing a TurnFixture here. The handler is the registry's only writer. It hard-gates on devModeEnabled() first — in production this route behaves as if it doesn't exist — then turns the fixture into the four layers' scripted outputs (both prose tasks share the one telling) plus a forced die.

404 unless dev; then install the per-task scripts + the forced roll, or clear.

server/api/dev/fixtures.post.ts · 44 lines
server/api/dev/fixtures.post.ts44 lines · TypeScript
⋯ 15 lines hidden (lines 1–15)
1/**
2 * Dev-only endpoint that arms (or clears) the deterministic AI fixtures for the
3 * next turn(s) — issue #105. The dev panel POSTs a `TurnFixture`; the server turns
4 * it into the four AI layers' scripted outputs plus a forced die, so a subsequent
5 * real `/api/send-message` (and its Chronicle refresh) runs the whole pipeline with
6 * no model calls.
7 *
8 * Hard-gated on `devModeEnabled()`: in a production build this route 404s, so the
9 * fixture registry can never be populated there. It fakes only AI OUTPUT — the real
10 * `/api/send-message` still enforces the charged-run paywall — so it is not a
11 * free-play vector.
12 */
13import { setDevFixtures, devModeEnabled } from '~/server/utils/dev-fixtures'
14import { turnFixtureScripts, forcedRollFor, type TurnFixture } from '~/utils/fixtures/turn-fixture'
15 
16export default defineEventHandler(async (event) => {
17 // Not even present in production — same posture as a route that doesn't exist.
18 if (!devModeEnabled()) {
19 throw createError({ statusCode: 404, statusMessage: 'Not Found' })
20 }
21 
22 const body = await readBody(event)
23 
24 // Clear: disarm the fixtures so real AI runs again.
25 if (body?.clear === true) {
26 setDevFixtures(null)
27 return { ok: true, cleared: true }
28 }
29 
30 const fixture = (body?.fixture ?? {}) as TurnFixture
31 const s = turnFixtureScripts(fixture)
32 setDevFixtures({
33 // Both prose tasks (mid-run + the terminal epilogue) share the one telling.
34 scripts: {
35 judge: s.judge,
36 character: s.character,
37 timeline: s.timeline,
38 chronicler: s.chronicle,
39 epilogue: s.chronicle
40 },
41 roll: forcedRollFor(fixture)
42 })
43 return { ok: true }
44})

Forcing the die, inside the real turn

The dice are the one part of a turn that is not behind the AI seam — send-message rolls them directly. So determinism needs a second, equally-gated hook right at the roll. When dev mode is on and a roll is armed, it stands in for rollD20(); the craft modifier still applies on top, and everything after (band selection, the amplifiers, the clamp) is the untouched real path.

A gated forced roll replaces rollD20(); the rest of the turn is unchanged.

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

The gate is read here too, not just at the route, so even a populated registry can never force a roll in a prod build. The dependency is lazily imported alongside the turn's other server utils.

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

The shared fixture shape

One description of a scripted turn feeds two seams that must agree: the dev lane (which turns it into per-task AI outputs) and the e2e route-mock harness (which turns it into a /api/send-message wire response). TurnFixture is that one shape — pure, type-only dependencies, so it imports safely from client, server, and the Playwright transform alike. Every field is optional; a bare {} is a sensible "sound success".

The knob type: figure, judge, dice, timeline, chronicle — overlapping fields drive both seams.

utils/fixtures/turn-fixture.ts · 138 lines
utils/fixtures/turn-fixture.ts138 lines · TypeScript
⋯ 32 lines hidden (lines 1–32)
1/**
2 * The shared turn-fixture shape (issue #105 — developer mode).
3 *
4 * One pure description of a scripted turn, read by two seams that must never drift:
5 *
6 * - The dev-mode fixture AI lane (`server/utils/dev-fixtures.ts`), which turns this
7 * into the per-task structured outputs the four AI layers would have generated —
8 * so a forced turn runs the REAL pipeline (dice, swing bands, anachronism, causal
9 * chain, momentum, the progress clamp, win/lose, the Chronicle) with no model
10 * calls: no keys, no spend.
11 * - The e2e route-mock harness (`tests/e2e/support/game.ts`), which turns the same
12 * fields into a `/api/send-message` wire response.
13 *
14 * Pure + dependency-light on purpose: only type imports (all erased at build), so it
15 * is safe to import from the client bundle, the server, and the Playwright transform
16 * alike. Every field is optional; a bare `{}` yields a sensible "sound success" turn.
17 */
18import type { Craft } from '~/utils/craft'
19import type { Continuity } from '~/utils/continuity'
20import type { Anachronism } from '~/server/utils/anachronism'
21 
22/** Timeline valence — the same closed set the Timeline Engine returns. Inlined to
23 * keep this module free of a store/server runtime dependency. */
24export type Valence = 'positive' | 'negative' | 'neutral'
25 
26/**
27 * A scripted turn. The dev lane reads the AI-seam fields (craft, continuity, the
28 * figure reply, the ripple, the chronicle) and the forced roll; the e2e harness
29 * reads the wire fields (diceRoll, diceOutcome, …). Overlapping fields (figureName,
30 * era, headline, detail, progressChange, valence, anachronism, craft, staked) mean
31 * the same knobs drive both seams.
32 */
33export interface TurnFixture {
34 // --- the figure (Character layer / wire `figure` + `characterResponse`) ---
35 figureName?: string
36 era?: string
37 /** The figure's short factual descriptor (Character `persona`; wire `descriptor`). */
38 persona?: string
39 descriptor?: string
40 /** The figure's in-character reply. */
41 message?: string
42 /** The concrete thing the figure decides to do. */
43 action?: string
44 
45 // --- the Judge (craft + continuity) ---
46 craft?: Craft
47 craftReason?: string
48 /** Whether the dispatch builds on the run's thread — drives the momentum meter. */
49 continuity?: Continuity
50 
51 // --- the dice ---
52 /** The natural d20 (1–20) the dev lane forces, before the craft modifier. The
53 * effective roll = clamp(roll + CRAFT_MODIFIER[craft], 1, 20), banded normally. */
54 roll?: number
55 /** Wire-only echoes the e2e harness uses for its mocked response. */
56 diceRoll?: number
57 diceOutcome?: string
58 naturalRoll?: number
59 rollModifier?: number
60 
61 // --- the Timeline Engine (the ripple) ---
62 headline?: string
63 detail?: string
64 /** The engine's base swing. The server clamps it into the ROLLED band, then the
65 * real anachronism amplifier, causal-chain decay, and craft/momentum gains run. */
66 progressChange?: number
67 baseProgressChange?: number
68 valence?: Valence
69 anachronism?: Anachronism
70 /** True when this turn is the staked last stand (the wire echo). */
71 staked?: boolean
72 
73 // --- the Chronicler (the prose telling / epilogue) ---
74 chronicleTitle?: string
75 chronicleParagraphs?: string[]
77 
78/** The per-AI-task structured outputs a `TurnFixture` scripts — each is the raw
⋯ 60 lines hidden (lines 79–138)
79 * object that task's consumer in `server/utils/openai.ts` will `JSON.parse`. */
80export interface TurnFixtureScripts {
81 judge: { craft: Craft; continuity: Continuity; reason: string }
82 character: { message: string; action: string; era: string; persona: string }
83 timeline: {
84 headline: string
85 detail: string
86 progressChange: number
87 valence: Valence
88 anachronism: Anachronism
89 }
90 /** Shared by the `chronicler` (mid-run) and `epilogue` (terminal) tasks. */
91 chronicle: { title: string; paragraphs: string[] }
93 
94/**
95 * Builds the four AI layers' outputs from a fixture, filling every required field so
96 * each consumer's validation passes and parses cleanly. Defaults describe a plain,
97 * in-period "sound" turn — callers override only what a scenario needs.
98 */
99export function turnFixtureScripts(f: TurnFixture = {}): TurnFixtureScripts {
100 const era = f.era ?? 'Alexandria, 48 BC'
101 return {
102 judge: {
103 craft: f.craft ?? 'sound',
104 continuity: f.continuity ?? 'neutral',
105 reason: f.craftReason ?? 'A clear, period-fluent ask.'
106 },
107 character: {
108 message: f.message ?? 'A striking notion — I shall weigh it.',
109 action: f.action ?? 'The figure sets the idea in motion.',
110 era,
111 persona: f.persona ?? f.descriptor ?? 'A figure of history'
112 },
113 timeline: {
114 headline: f.headline ?? 'History bends toward the aim',
115 detail: f.detail ?? 'The intervention ripples outward and the world shifts.',
116 // 20 sits inside the Success band; the server re-clamps to whatever band
117 // the (forced) roll actually lands in, so this is just a sane default.
118 progressChange: f.progressChange ?? 20,
119 valence: f.valence ?? 'positive',
120 anachronism: f.anachronism ?? 'in-period'
121 },
122 chronicle: {
123 title: f.chronicleTitle ?? 'The Account So Far',
124 paragraphs: f.chronicleParagraphs ?? [
125 'A stranger\'s words have bent the timeline.',
126 'The old certainties waver, and a new course takes shape.'
127 ]
128 }
129 }
131 
132/** The forced natural d20 a `TurnFixture` injects at the dice seam, or undefined to
133 * let the real `rollD20()` run. Prefers `roll`, then the wire `naturalRoll`. */
134export function forcedRollFor(f: TurnFixture = {}): number | undefined {
135 if (typeof f.roll === 'number') return f.roll
136 if (typeof f.naturalRoll === 'number') return f.naturalRoll
137 return undefined

turnFixtureScripts fills every required field so each consumer in server/utils/openai.ts parses cleanly (closed-enum craft/continuity/valence/ anachronism, a non-empty chronicle). forcedRollFor extracts the die.

utils/fixtures/turn-fixture.ts · 138 lines
utils/fixtures/turn-fixture.ts138 lines · TypeScript
⋯ 98 lines hidden (lines 1–98)
1/**
2 * The shared turn-fixture shape (issue #105 — developer mode).
3 *
4 * One pure description of a scripted turn, read by two seams that must never drift:
5 *
6 * - The dev-mode fixture AI lane (`server/utils/dev-fixtures.ts`), which turns this
7 * into the per-task structured outputs the four AI layers would have generated —
8 * so a forced turn runs the REAL pipeline (dice, swing bands, anachronism, causal
9 * chain, momentum, the progress clamp, win/lose, the Chronicle) with no model
10 * calls: no keys, no spend.
11 * - The e2e route-mock harness (`tests/e2e/support/game.ts`), which turns the same
12 * fields into a `/api/send-message` wire response.
13 *
14 * Pure + dependency-light on purpose: only type imports (all erased at build), so it
15 * is safe to import from the client bundle, the server, and the Playwright transform
16 * alike. Every field is optional; a bare `{}` yields a sensible "sound success" turn.
17 */
18import type { Craft } from '~/utils/craft'
19import type { Continuity } from '~/utils/continuity'
20import type { Anachronism } from '~/server/utils/anachronism'
21 
22/** Timeline valence — the same closed set the Timeline Engine returns. Inlined to
23 * keep this module free of a store/server runtime dependency. */
24export type Valence = 'positive' | 'negative' | 'neutral'
25 
26/**
27 * A scripted turn. The dev lane reads the AI-seam fields (craft, continuity, the
28 * figure reply, the ripple, the chronicle) and the forced roll; the e2e harness
29 * reads the wire fields (diceRoll, diceOutcome, …). Overlapping fields (figureName,
30 * era, headline, detail, progressChange, valence, anachronism, craft, staked) mean
31 * the same knobs drive both seams.
32 */
33export interface TurnFixture {
34 // --- the figure (Character layer / wire `figure` + `characterResponse`) ---
35 figureName?: string
36 era?: string
37 /** The figure's short factual descriptor (Character `persona`; wire `descriptor`). */
38 persona?: string
39 descriptor?: string
40 /** The figure's in-character reply. */
41 message?: string
42 /** The concrete thing the figure decides to do. */
43 action?: string
44 
45 // --- the Judge (craft + continuity) ---
46 craft?: Craft
47 craftReason?: string
48 /** Whether the dispatch builds on the run's thread — drives the momentum meter. */
49 continuity?: Continuity
50 
51 // --- the dice ---
52 /** The natural d20 (1–20) the dev lane forces, before the craft modifier. The
53 * effective roll = clamp(roll + CRAFT_MODIFIER[craft], 1, 20), banded normally. */
54 roll?: number
55 /** Wire-only echoes the e2e harness uses for its mocked response. */
56 diceRoll?: number
57 diceOutcome?: string
58 naturalRoll?: number
59 rollModifier?: number
60 
61 // --- the Timeline Engine (the ripple) ---
62 headline?: string
63 detail?: string
64 /** The engine's base swing. The server clamps it into the ROLLED band, then the
65 * real anachronism amplifier, causal-chain decay, and craft/momentum gains run. */
66 progressChange?: number
67 baseProgressChange?: number
68 valence?: Valence
69 anachronism?: Anachronism
70 /** True when this turn is the staked last stand (the wire echo). */
71 staked?: boolean
72 
73 // --- the Chronicler (the prose telling / epilogue) ---
74 chronicleTitle?: string
75 chronicleParagraphs?: string[]
77 
78/** The per-AI-task structured outputs a `TurnFixture` scripts — each is the raw
79 * object that task's consumer in `server/utils/openai.ts` will `JSON.parse`. */
80export interface TurnFixtureScripts {
81 judge: { craft: Craft; continuity: Continuity; reason: string }
82 character: { message: string; action: string; era: string; persona: string }
83 timeline: {
84 headline: string
85 detail: string
86 progressChange: number
87 valence: Valence
88 anachronism: Anachronism
89 }
90 /** Shared by the `chronicler` (mid-run) and `epilogue` (terminal) tasks. */
91 chronicle: { title: string; paragraphs: string[] }
93 
94/**
95 * Builds the four AI layers' outputs from a fixture, filling every required field so
96 * each consumer's validation passes and parses cleanly. Defaults describe a plain,
97 * in-period "sound" turn — callers override only what a scenario needs.
98 */
99export function turnFixtureScripts(f: TurnFixture = {}): TurnFixtureScripts {
100 const era = f.era ?? 'Alexandria, 48 BC'
101 return {
102 judge: {
103 craft: f.craft ?? 'sound',
104 continuity: f.continuity ?? 'neutral',
105 reason: f.craftReason ?? 'A clear, period-fluent ask.'
106 },
107 character: {
108 message: f.message ?? 'A striking notion — I shall weigh it.',
109 action: f.action ?? 'The figure sets the idea in motion.',
110 era,
111 persona: f.persona ?? f.descriptor ?? 'A figure of history'
112 },
113 timeline: {
114 headline: f.headline ?? 'History bends toward the aim',
115 detail: f.detail ?? 'The intervention ripples outward and the world shifts.',
116 // 20 sits inside the Success band; the server re-clamps to whatever band
117 // the (forced) roll actually lands in, so this is just a sane default.
118 progressChange: f.progressChange ?? 20,
119 valence: f.valence ?? 'positive',
120 anachronism: f.anachronism ?? 'in-period'
121 },
122 chronicle: {
123 title: f.chronicleTitle ?? 'The Account So Far',
124 paragraphs: f.chronicleParagraphs ?? [
125 'A stranger\'s words have bent the timeline.',
126 'The old certainties waver, and a new course takes shape.'
127 ]
128 }
129 }
131 
132/** The forced natural d20 a `TurnFixture` injects at the dice seam, or undefined to
133 * let the real `rollD20()` run. Prefers `roll`, then the wire `naturalRoll`. */
134export function forcedRollFor(f: TurnFixture = {}): number | undefined {
135 if (typeof f.roll === 'number') return f.roll
136 if (typeof f.naturalRoll === 'number') return f.naturalRoll
137 return undefined

The e2e harness now imports that type and aliases its own RippleFixture to it, so the two can't drift. The import is type-only — erased at build — so it adds no runtime coupling to the Playwright run; the existing wire builder reads the same field names unchanged.

tests/e2e/support/game.ts · 271 lines
tests/e2e/support/game.ts271 lines · TypeScript
1import type { Page } from '@playwright/test'
2import { expect } from '@nuxt/test-utils/playwright'
3import type { TurnFixture } from '../../../utils/fixtures/turn-fixture'
4 
5/**
6 * Shared e2e helpers. The game is AI-backed, so real runs would be slow, costly,
7 * and non-deterministic — every spec mocks the two API routes (`page.route`) and
8 * drives the real UI flow against fixed responses. No OpenAI key is needed.
9 */
10 
11/**
12 * A scripted turn. Aliased to the canonical `TurnFixture` (issue #105) so the e2e
13 * mocks and dev mode's fixture lane share ONE shape and can't drift — the import is
14 * type-only (erased at build), so it adds no runtime coupling to the Playwright run.
15 */
16export type RippleFixture = TurnFixture
⋯ 255 lines hidden (lines 17–271)
17 
18/** Builds a /api/send-message payload in the exact shape the game store consumes. */
19function sendMessageResponse(f: RippleFixture = {}) {
20 const progressChange = f.progressChange ?? 20
21 const era = f.era ?? 'Alexandria, 48 BC'
22 const diceRoll = f.diceRoll ?? 14
23 return {
24 success: true,
25 message: 'Timeline updated',
26 data: {
27 userMessage: 'mocked',
28 figure: {
29 name: f.figureName ?? 'Cleopatra',
30 era,
31 descriptor: f.descriptor ?? 'Queen of Egypt'
32 },
33 diceRoll,
34 diceOutcome: f.diceOutcome ?? 'Success',
35 // Judge-of-craft fields: the default is an untilted die (sound, ±0),
36 // so specs that predate the judge keep their exact dice displays.
37 naturalRoll: f.naturalRoll ?? diceRoll,
38 rollModifier: f.rollModifier ?? 0,
39 craft: f.craft ?? 'sound',
40 craftReason: f.craftReason ?? '',
41 staked: f.staked ?? false,
42 characterResponse: {
43 message: f.message ?? 'A bold notion — I shall weigh it.',
44 action: f.action ?? 'Cleopatra dispatches envoys to Rome a decade early'
45 },
46 timeline: {
47 headline: f.headline ?? 'Envoys redraw the map of power',
48 detail: f.detail ?? 'Egypt courts Rome ahead of schedule and alliances shift.',
49 era,
50 progressChange,
51 // Defaults to the final swing (an in-period, unamplified turn), so
52 // the reveal's equation only appears when a fixture asks for it.
53 baseProgressChange: f.baseProgressChange ?? progressChange,
54 valence: f.valence ?? (progressChange > 0 ? 'positive' : progressChange < 0 ? 'negative' : 'neutral'),
55 anachronism: f.anachronism ?? 'in-period'
56 },
57 error: null
58 }
59 }
61 
62/** Routes /api/send-message to a fixed ripple (or a per-call function for variation). */
63export async function mockSendMessage(page: Page, fixture: RippleFixture | (() => RippleFixture) = {}) {
64 await page.route('**/api/send-message', async (route) => {
65 const f = typeof fixture === 'function' ? fixture() : fixture
66 // Echo back the figure the player actually addressed (the store registers
67 // contacts from the response), unless the fixture pins one explicitly.
68 let requested: string | undefined
69 try {
70 requested = (route.request().postDataJSON() as { figureName?: string })?.figureName
71 } catch {
72 /* no/!json body */
73 }
74 await route.fulfill({
75 status: 200,
76 contentType: 'application/json',
77 body: JSON.stringify(sendMessageResponse({ ...f, figureName: f.figureName ?? requested }))
78 })
79 })
81 
82/** Routes /api/objective to a fixed, freshly "composed" objective. */
83export async function mockObjective(
84 page: Page,
85 objective: { title: string; description: string; era: string; icon: string }
86) {
87 await page.route('**/api/objective', async (route) => {
88 await route.fulfill({
89 status: 200,
90 contentType: 'application/json',
91 body: JSON.stringify({ success: true, objective })
92 })
93 })
95 
96/** A resolved, DECEASED figure — the default contact fixture. #73 requires a
97 * grounded, deceased figure to send, so a deceased default keeps the happy-path
98 * specs sending without each re-registering grounding. (Cleopatra, 69–30 BC.) */
99const RESOLVED_FIGURE = {
100 resolved: true,
101 name: 'Cleopatra',
102 description: 'Queen of Egypt',
103 born: { signed: -69, display: '69 BC' },
104 died: { signed: -30, display: '30 BC' },
105 living: false
107 
108/**
109 * Routes /api/figure (the grounding lookup FigurePicker fires on selection) to a
110 * fixed result. Defaults to a resolved, deceased figure (RESOLVED_FIGURE) so the
111 * contact is sendable under the #73 require-grounding gate without every spec
112 * re-registering. Pass `{ resolved: false }` to exercise the unresolved/blocked path.
113 *
114 * NOTE: the route pattern must NOT swallow /api/figure-search (the autocomplete),
115 * which has its own mock below — hence the exact-or-query matcher.
116 */
117export async function mockFigure(page: Page, figure: Record<string, unknown> = RESOLVED_FIGURE) {
118 await page.route(/\/api\/figure(\?|$)/, async (route) => {
119 await route.fulfill({
120 status: 200,
121 contentType: 'application/json',
122 body: JSON.stringify({ name: '', resolved: false, ...figure })
123 })
124 })
126 
127/**
128 * Routes /api/figure-search (the name-autocomplete combobox, fired while typing)
129 * to a fixed result set — default empty, so existing specs type without a
130 * dropdown appearing and never touch live Wikipedia.
131 */
132export async function mockFigureSearch(
133 page: Page,
134 results: Array<{ name: string; description?: string; thumbnail?: string }> = []
135) {
136 await page.route('**/api/figure-search*', async (route) => {
137 await route.fulfill({
138 status: 200,
139 contentType: 'application/json',
140 body: JSON.stringify({ results })
141 })
142 })
144 
145const DEFAULT_SUGGESTIONS = [
146 { name: 'Wilhelm II', reason: 'German Emperor who could restrain Vienna before the crisis spread.', lifespan: '1859 – 1941' },
147 { name: 'Edward Grey', reason: "Britain's foreign secretary — early mediation could cool the crisis.", lifespan: '1862 – 1933' },
148 { name: 'Nicholas II', reason: 'Russian Emperor; limiting mobilization could avert escalation.', lifespan: '1868 – 1918' }
150 
151/**
152 * Routes /api/suggestions (the tool-using agent FigurePicker loads on mount) to a
153 * fixed set, so specs never trigger the live agent. Registered inside startCuratedRun
154 * because the load fires as the board mounts.
155 */
156async function mockSuggestions(page: Page, suggestions = DEFAULT_SUGGESTIONS) {
157 await page.route('**/api/suggestions', async (route) => {
158 await route.fulfill({
159 status: 200,
160 contentType: 'application/json',
161 body: JSON.stringify({ success: true, suggestions, fallback: false })
162 })
163 })
165 
166/**
167 * Routes /api/chronicle (the non-blocking living-chronicle refresh fired after each
168 * resolved turn) to a fixed telling, so specs stay offline + deterministic. The call
169 * is fire-and-forget, so most specs never assert on it — but it must be mocked or it
170 * would hit the live network mid-run.
171 */
172export async function mockChronicle(
173 page: Page,
174 chronicle: { title: string; paragraphs: string[] } = {
175 title: 'The Account So Far',
176 paragraphs: ["History bends under a stranger's words.", 'The old certainties waver.']
177 }
178) {
179 await page.route('**/api/chronicle', async (route) => {
180 await route.fulfill({
181 status: 200,
182 contentType: 'application/json',
183 body: JSON.stringify({ success: true, chronicle })
184 })
185 })
187 
188/**
189 * Routes /api/research (the Archivist study, fired when the player clicks "Study
190 * them") to a fixed brief, so specs stay offline + deterministic.
191 */
192export async function mockResearch(
193 page: Page,
194 study = {
195 atThisMoment: 'At the height of their powers.',
196 grasp: ['the state of their art'],
197 reaching: ['the next problem before them'],
198 cannotYetKnow: 'what the centuries after them would discover'
199 }
200) {
201 await page.route('**/api/research', async (route) => {
202 await route.fulfill({
203 status: 200,
204 contentType: 'application/json',
205 body: JSON.stringify({ success: true, study })
206 })
207 })
209 
210/** Routes /api/lookup (the Archive's freeform topic lookup) to a fixed result. */
211export async function mockLookup(
212 page: Page,
213 lookup = {
214 topic: 'Gunpowder',
215 summary: 'An explosive mix of saltpeter, charcoal, and sulfur.',
216 requires: ['saltpeter', 'charcoal', 'sulfur'],
217 knownSince: 'China, ~9th century'
218 }
219) {
220 await page.route('**/api/lookup', async (route) => {
221 await route.fulfill({
222 status: 200,
223 contentType: 'application/json',
224 body: JSON.stringify({ success: true, lookup })
225 })
226 })
228 
229/** From the mission briefing, choose the first curated objective and begin the run. */
230export async function startCuratedRun(page: Page) {
231 // FigurePicker grounds whoever you select and loads suggestions on mount; default
232 // both to deterministic, offline fixtures. (A later mockFigure call wins, since
233 // route handlers run last-registered-first; suggestions load on mount, so they're
234 // mocked here, before the board appears.) The chronicle refresh fires after the
235 // first turn resolves, and the Archive study fires on demand — mock both here too.
236 await mockFigure(page)
237 await mockFigureSearch(page)
238 await mockSuggestions(page)
239 await mockChronicle(page)
240 await mockResearch(page)
241 await mockLookup(page)
242 await expect(page.locator('[data-testid="mission-select"]')).toBeVisible()
243 await page.locator('[data-testid="objective-option"]').first().click()
244 await page.locator('[data-testid="begin-button"]').click()
245 // The board is now live.
246 await expect(page.locator('[data-testid="message-input"]')).toBeVisible()
248 
249/** Picks a figure by typing into the contact input (decoupled from the now
250 * objective-relevant suggestion chips). */
251export async function selectFigure(page: Page, name: string) {
252 await page.locator('[data-testid="figure-picker"]').getByRole('combobox').fill(name)
254 
255/**
256 * The send button. Located by its ARIA name rather than data-testid: the testid is
257 * a fallthrough attr on the <SendButton> wrapper and doesn't reach the rendered
258 * <button>, but the static aria-label does (and survives the "Sending…"/"Please
259 * wait…" label changes, since aria-label fixes the accessible name).
260 */
261export function sendButton(page: Page) {
262 return page.getByRole('button', { name: 'Send message' })
264 
265/** Fills the composer and sends, asserting the button is ready first. */
266export async function fillAndSend(page: Page, text: string) {
267 await page.locator('[data-testid="message-input"] textarea').fill(text)
268 const send = sendButton(page)
269 await expect(send).toBeEnabled()
270 await send.click()

Surface one: state jumps

All dev logic lives in a composable, never in the production store — state jumps use $patch plus existing actions, so stores/game.ts is untouched. A jump lands a stage the way a resolved turn would. jumpToVictory seeds a few ledger entries and a chronicle, sets progress to 100 with messages to spare, and flips the status — so the real EndGameScreen, the Spine, and the efficiency rating all render.

Victory in one action: seed the ledger, set the knobs, flip the status.

composables/useDevTools.ts · 290 lines
composables/useDevTools.ts290 lines · TypeScript
⋯ 125 lines hidden (lines 1–125)
1/**
2 * Developer-mode orchestration (issue #105).
3 *
4 * Two surfaces, one composable:
5 *
6 * 1. STATE JUMPS (client-only) — land on a stage to inspect it (victory, defeat, a
7 * populated mid-game, the stake offer) and set knobs (progress / remaining /
8 * momentum) directly. These never call the server, never charge a run, and never
9 * spend: they drive the store the same way a resolved turn would.
10 *
11 * 2. FORCED TURNS (the faithful path) — fire the REAL `sendMessage` pipeline with
12 * the AI seam scripted (via `/api/dev/fixtures`) and the die forced, so the swing
13 * bands, anachronism amplifier, causal-chain decay, momentum, progress clamp,
14 * win/lose, Spine append, and Chronicle all run for real, deterministically, with
15 * no keys and no model spend. The turn still goes through the charged-run paywall
16 * (one free dev run backs unlimited forced turns — the server has no per-run
17 * message cap, and this panel keeps `remainingMessages` topped), so dev mode is
18 * not a free-play bypass.
19 *
20 * All dev logic lives here (and in the gated server seam), never in the production
21 * store: state jumps use `$patch` + existing actions, so `stores/game.ts` is untouched.
22 */
23import { computed } from 'vue'
24import { useGameStore } from '~/stores/game'
25import { listCuratedObjectives, type GameObjective } from '~/server/utils/objectives'
26import { DiceOutcome } from '~/utils/dice'
27import type { Craft } from '~/utils/craft'
28import type { Continuity } from '~/utils/continuity'
29import type { Anachronism } from '~/server/utils/anachronism'
30import type { GroundedFigure } from '~/server/utils/figure-grounding'
31import type { TurnFixture } from '~/utils/fixtures/turn-fixture'
32 
33/** A real, deceased figure so the server's own grounding gate resolves (it re-grounds
34 * the name against Wikipedia; that needs no key and costs nothing). Cleopatra, 69–30 BC. */
35const DEV_FIGURE: GroundedFigure = {
36 name: 'Cleopatra',
37 resolved: true,
38 description: 'Queen of Egypt, last of the Ptolemaic dynasty',
39 born: { year: 69, bce: true, signed: -69, display: '69 BC' },
40 died: { year: 30, bce: true, signed: -30, display: '30 BC' },
41 living: false
43const DEV_FIGURE_ERA = 'Alexandria, 48 BC'
44const DEV_FIGURE_WHEN = -40
45 
46/** Forced-die + base-swing per target band (natural d20; the craft modifier still
47 * applies on top, and the server re-clamps the swing into the effective rolled band). */
48const OUTCOME_PRESET: Record<DiceOutcome, { roll: number; progressChange: number }> = {
49 [DiceOutcome.CRITICAL_SUCCESS]: { roll: 20, progressChange: 45 },
50 [DiceOutcome.SUCCESS]: { roll: 16, progressChange: 30 },
51 [DiceOutcome.NEUTRAL]: { roll: 10, progressChange: 8 },
52 [DiceOutcome.FAILURE]: { roll: 5, progressChange: -15 },
53 [DiceOutcome.CRITICAL_FAILURE]: { roll: 1, progressChange: -35 }
55 
56/** A few sample ledger entries to populate the Spine / end screen for inspection. */
57const SAMPLE_LEDGER = [
58 { headline: 'Envoys redraw the map of power', detail: 'Egypt courts Rome ahead of schedule and the alliances shift.', diceRoll: 16, diceOutcome: DiceOutcome.SUCCESS, progressChange: 22, craft: 'sharp' as Craft, whenSigned: -44 },
59 { headline: 'The great library is spared the flames', detail: 'Scrolls that history lost are copied and sent abroad for safekeeping.', diceRoll: 11, diceOutcome: DiceOutcome.NEUTRAL, progressChange: 8, craft: 'sound' as Craft, whenSigned: -42 },
60 { headline: 'A rival faction takes offense', detail: 'The senate bristles at Alexandria\'s new confidence, and knives are sharpened.', diceRoll: 4, diceOutcome: DiceOutcome.FAILURE, progressChange: -9, craft: 'vague' as Craft, whenSigned: -40 }
62 
63export interface ForceTurnOptions {
64 craft?: Craft
65 continuity?: Continuity
66 anachronism?: Anachronism
67 staked?: boolean
69 
70export function useDevTools() {
71 const gameStore = useGameStore()
72 
73 /** The dev objective — anchored near Cleopatra so the causal chain reads strong. */
74 function devObjective(): GameObjective {
75 const all = listCuratedObjectives()
76 return all.find(o => o.anchorYear === -48) ?? all[0]
77 }
78 
79 /** Set the current objective WITHOUT charging a run (for offline inspection). */
80 function ensureObjective() {
81 if (!gameStore.currentObjective) {
82 gameStore.$patch({ currentObjective: devObjective() })
83 }
84 }
85 
86 /** Point the composer at a known, grounded, deceased figure — no /api/figure call. */
87 function autoFillFigure() {
88 gameStore.registerFigure({ name: DEV_FIGURE.name, era: DEV_FIGURE_ERA, descriptor: 'Queen of Egypt' })
89 gameStore.setActiveFigure(DEV_FIGURE.name)
90 gameStore.$patch({
91 figureGrounding: DEV_FIGURE,
92 contactWhen: DEV_FIGURE_WHEN,
93 contactMoment: null,
94 groundingLoading: false
95 })
96 }
97 
98 /** Seed up to `n` sample changes onto the Spine (skips if the ledger already has any). */
99 function seedLedger(n: number) {
100 if (gameStore.timelineEvents.length) return
101 SAMPLE_LEDGER.slice(0, n).forEach(s =>
102 gameStore.addTimelineEvent({
103 figureName: DEV_FIGURE.name,
104 era: DEV_FIGURE_ERA,
105 headline: s.headline,
106 detail: s.detail,
107 diceRoll: s.diceRoll,
108 diceOutcome: s.diceOutcome,
109 progressChange: s.progressChange,
110 craft: s.craft,
111 whenSigned: s.whenSigned
112 })
113 )
114 }
115 
116 const DEV_CHRONICLE = {
117 title: 'A History Being Rewritten',
118 paragraphs: [
119 'In this telling, a stranger\'s dispatches have bent the course of events.',
120 'What was settled now wavers, and the world leans toward an unfamiliar shape.'
121 ]
122 }
123 
124 // ---------- state jumps (client-only) ----------
125 
126 function jumpToVictory() {
127 ensureObjective()
128 seedLedger(3)
129 gameStore.$patch({
130 objectiveProgress: 100,
131 remainingMessages: 2,
132 momentum: 3,
133 gameStatus: 'victory',
134 chronicle: { title: 'A World Remade', paragraphs: DEV_CHRONICLE.paragraphs },
135 epiloguePending: false
136 })
137 }
138 
139 function jumpToDefeat() {
140 ensureObjective()
⋯ 150 lines hidden (lines 141–290)
141 seedLedger(2)
142 gameStore.$patch({
143 objectiveProgress: 38,
144 remainingMessages: 0,
145 momentum: 0,
146 gameStatus: 'defeat',
147 chronicle: { title: 'The Tide Held', paragraphs: DEV_CHRONICLE.paragraphs },
148 epiloguePending: false
149 })
150 }
151 
152 function seedMidGame() {
153 ensureObjective()
154 autoFillFigure()
155 seedLedger(2)
156 gameStore.$patch({
157 objectiveProgress: 36,
158 remainingMessages: 3,
159 momentum: 2,
160 gameStatus: 'playing',
161 chronicle: DEV_CHRONICLE
162 })
163 }
164 
165 /** Final message, win out of normal reach → `canStake` becomes true and the
166 * inline stake control renders for inspection. */
167 function setupStakeOffer() {
168 ensureObjective()
169 autoFillFigure()
170 seedLedger(1)
171 gameStore.$patch({
172 objectiveProgress: 20,
173 remainingMessages: 1,
174 momentum: 1,
175 gameStatus: 'playing',
176 chronicle: DEV_CHRONICLE
177 })
178 }
179 
180 /** Back to the mission-select briefing (a full reset). */
181 function toMissionSelect() {
182 gameStore.resetGame()
183 }
184 
185 // ---------- knob setters (client-only) ----------
186 
187 const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, Math.round(n)))
188 function setProgress(n: number) { gameStore.$patch({ objectiveProgress: clamp(n, 0, 100) }) }
189 function setRemaining(n: number) { gameStore.$patch({ remainingMessages: clamp(n, 0, 5) }) }
190 function setMomentum(n: number) { gameStore.$patch({ momentum: clamp(n, 0, 4) }) }
191 
192 // ---------- the fixture AI lane (server-gated) ----------
193 
194 async function armFixtures(fixture: TurnFixture): Promise<void> {
195 await $fetch('/api/dev/fixtures', { method: 'POST', body: { fixture } })
196 }
197 async function clearFixtures(): Promise<void> {
198 await $fetch('/api/dev/fixtures', { method: 'POST', body: { clear: true } })
199 }
200 
201 // ---------- forced turns (the real pipeline) ----------
202 
203 /** Commit a charged dev run + an objective + a figure, so forced turns can fire.
204 * Returns false (and leaves a store error) if the device is out of runs. */
205 async function startDevRun(): Promise<boolean> {
206 const ok = await gameStore.chooseObjective(devObjective())
207 if (!ok) return false
208 autoFillFigure()
209 gameStore.$patch({ remainingMessages: 5, objectiveProgress: 0, momentum: 0, timelineEvents: [] })
210 return true
211 }
212 
213 /**
214 * Fire one forced turn through the real `sendMessage`. Scripts the four AI layers
215 * + the die for the chosen band, then sends — the swing, progress, Spine, momentum,
216 * and Chronicle all resolve for real. Ensures a charged run first.
217 */
218 async function forceTurn(outcome: DiceOutcome, opts: ForceTurnOptions = {}): Promise<boolean> {
219 if (!gameStore.runId && !(await startDevRun())) return false
220 const preset = OUTCOME_PRESET[outcome]
221 const fixture: TurnFixture = {
222 figureName: DEV_FIGURE.name,
223 era: DEV_FIGURE_ERA,
224 persona: 'Queen of Egypt',
225 message: 'A bold notion — I shall set it in motion.',
226 action: 'Cleopatra acts decisively on the idea.',
227 craft: opts.craft ?? 'sound',
228 craftReason: 'A dev-forced craft grade.',
229 continuity: opts.continuity ?? 'neutral',
230 roll: preset.roll,
231 headline: `${outcome}: the timeline answers`,
232 detail: 'A dev-forced ripple, resolved through the real mechanics.',
233 progressChange: preset.progressChange,
234 valence: preset.progressChange > 0 ? 'positive' : preset.progressChange < 0 ? 'negative' : 'neutral',
235 anachronism: opts.anachronism ?? 'in-period',
236 chronicleTitle: DEV_CHRONICLE.title,
237 chronicleParagraphs: DEV_CHRONICLE.paragraphs
238 }
239 // Let rapid dev clicks through (skip the 1s cooldown) and keep the run playable.
240 gameStore.$patch({ isRateLimited: false, lastMessageTime: null })
241 if (!opts.staked) {
242 if (gameStore.gameStatus !== 'playing') gameStore.$patch({ gameStatus: 'playing' })
243 if (gameStore.remainingMessages <= 0) gameStore.$patch({ remainingMessages: 5 })
244 }
245 await armFixtures(fixture)
246 return gameStore.sendMessage('A dev-forced dispatch to history.', DEV_FIGURE.name, { stake: opts.staked })
247 }
248 
249 /** Set up the stake offer, then fire the final dispatch staked (real doubling). */
250 async function forceStakedTurn(outcome: DiceOutcome = DiceOutcome.SUCCESS, opts: ForceTurnOptions = {}): Promise<boolean> {
251 if (!gameStore.runId && !(await startDevRun())) return false
252 setupStakeOffer()
253 return forceTurn(outcome, { ...opts, staked: true })
254 }
255 
256 // ---------- the live inspector ----------
257 
258 const inspector = computed(() => {
259 const lastAi = [...gameStore.messageHistory].reverse().find(m => m.sender === 'ai' && m.diceRoll != null)
260 const mod = lastAi?.rollModifier
261 const rollLine = lastAi
262 ? `${lastAi.naturalRoll ?? lastAi.diceRoll}${mod ? (mod > 0 ? `+${mod}` : `${mod}`) : ''}${lastAi.diceRoll} · ${lastAi.diceOutcome}`
263 : '—'
264 return {
265 gameStatus: gameStore.gameStatus,
266 objectiveProgress: gameStore.objectiveProgress,
267 remainingMessages: gameStore.remainingMessages,
268 momentum: gameStore.momentum,
269 runEpoch: gameStore.runEpoch,
270 ledgerCount: gameStore.timelineEvents.length,
271 lastRoll: rollLine,
272 runId: gameStore.runId ? `${gameStore.runId.slice(0, 8)}` : '(none)',
273 canStake: gameStore.canStake,
274 isLoading: gameStore.isLoading
275 }
276 })
277 
278 return {
279 inspector,
280 // stage jumps
281 jumpToVictory, jumpToDefeat, seedMidGame, setupStakeOffer, toMissionSelect,
282 // knobs
283 setProgress, setRemaining, setMomentum,
284 // setup
285 autoFillFigure, startDevRun,
286 // forced turns + fixtures
287 forceTurn, forceStakedTurn, armFixtures, clearFixtures,
288 DiceOutcome
289 }

The stake offer is the fiddly edge case: it appears only on the final message when a win is out of normal reach. The recipe sets exactly that — one message left, progress low enough that the gap exceeds the per-turn swing — so the store's real canStake getter turns true and the inline stake control renders.

composables/useDevTools.ts · 290 lines
composables/useDevTools.ts290 lines · TypeScript
⋯ 166 lines hidden (lines 1–166)
1/**
2 * Developer-mode orchestration (issue #105).
3 *
4 * Two surfaces, one composable:
5 *
6 * 1. STATE JUMPS (client-only) — land on a stage to inspect it (victory, defeat, a
7 * populated mid-game, the stake offer) and set knobs (progress / remaining /
8 * momentum) directly. These never call the server, never charge a run, and never
9 * spend: they drive the store the same way a resolved turn would.
10 *
11 * 2. FORCED TURNS (the faithful path) — fire the REAL `sendMessage` pipeline with
12 * the AI seam scripted (via `/api/dev/fixtures`) and the die forced, so the swing
13 * bands, anachronism amplifier, causal-chain decay, momentum, progress clamp,
14 * win/lose, Spine append, and Chronicle all run for real, deterministically, with
15 * no keys and no model spend. The turn still goes through the charged-run paywall
16 * (one free dev run backs unlimited forced turns — the server has no per-run
17 * message cap, and this panel keeps `remainingMessages` topped), so dev mode is
18 * not a free-play bypass.
19 *
20 * All dev logic lives here (and in the gated server seam), never in the production
21 * store: state jumps use `$patch` + existing actions, so `stores/game.ts` is untouched.
22 */
23import { computed } from 'vue'
24import { useGameStore } from '~/stores/game'
25import { listCuratedObjectives, type GameObjective } from '~/server/utils/objectives'
26import { DiceOutcome } from '~/utils/dice'
27import type { Craft } from '~/utils/craft'
28import type { Continuity } from '~/utils/continuity'
29import type { Anachronism } from '~/server/utils/anachronism'
30import type { GroundedFigure } from '~/server/utils/figure-grounding'
31import type { TurnFixture } from '~/utils/fixtures/turn-fixture'
32 
33/** A real, deceased figure so the server's own grounding gate resolves (it re-grounds
34 * the name against Wikipedia; that needs no key and costs nothing). Cleopatra, 69–30 BC. */
35const DEV_FIGURE: GroundedFigure = {
36 name: 'Cleopatra',
37 resolved: true,
38 description: 'Queen of Egypt, last of the Ptolemaic dynasty',
39 born: { year: 69, bce: true, signed: -69, display: '69 BC' },
40 died: { year: 30, bce: true, signed: -30, display: '30 BC' },
41 living: false
43const DEV_FIGURE_ERA = 'Alexandria, 48 BC'
44const DEV_FIGURE_WHEN = -40
45 
46/** Forced-die + base-swing per target band (natural d20; the craft modifier still
47 * applies on top, and the server re-clamps the swing into the effective rolled band). */
48const OUTCOME_PRESET: Record<DiceOutcome, { roll: number; progressChange: number }> = {
49 [DiceOutcome.CRITICAL_SUCCESS]: { roll: 20, progressChange: 45 },
50 [DiceOutcome.SUCCESS]: { roll: 16, progressChange: 30 },
51 [DiceOutcome.NEUTRAL]: { roll: 10, progressChange: 8 },
52 [DiceOutcome.FAILURE]: { roll: 5, progressChange: -15 },
53 [DiceOutcome.CRITICAL_FAILURE]: { roll: 1, progressChange: -35 }
55 
56/** A few sample ledger entries to populate the Spine / end screen for inspection. */
57const SAMPLE_LEDGER = [
58 { headline: 'Envoys redraw the map of power', detail: 'Egypt courts Rome ahead of schedule and the alliances shift.', diceRoll: 16, diceOutcome: DiceOutcome.SUCCESS, progressChange: 22, craft: 'sharp' as Craft, whenSigned: -44 },
59 { headline: 'The great library is spared the flames', detail: 'Scrolls that history lost are copied and sent abroad for safekeeping.', diceRoll: 11, diceOutcome: DiceOutcome.NEUTRAL, progressChange: 8, craft: 'sound' as Craft, whenSigned: -42 },
60 { headline: 'A rival faction takes offense', detail: 'The senate bristles at Alexandria\'s new confidence, and knives are sharpened.', diceRoll: 4, diceOutcome: DiceOutcome.FAILURE, progressChange: -9, craft: 'vague' as Craft, whenSigned: -40 }
62 
63export interface ForceTurnOptions {
64 craft?: Craft
65 continuity?: Continuity
66 anachronism?: Anachronism
67 staked?: boolean
69 
70export function useDevTools() {
71 const gameStore = useGameStore()
72 
73 /** The dev objective — anchored near Cleopatra so the causal chain reads strong. */
74 function devObjective(): GameObjective {
75 const all = listCuratedObjectives()
76 return all.find(o => o.anchorYear === -48) ?? all[0]
77 }
78 
79 /** Set the current objective WITHOUT charging a run (for offline inspection). */
80 function ensureObjective() {
81 if (!gameStore.currentObjective) {
82 gameStore.$patch({ currentObjective: devObjective() })
83 }
84 }
85 
86 /** Point the composer at a known, grounded, deceased figure — no /api/figure call. */
87 function autoFillFigure() {
88 gameStore.registerFigure({ name: DEV_FIGURE.name, era: DEV_FIGURE_ERA, descriptor: 'Queen of Egypt' })
89 gameStore.setActiveFigure(DEV_FIGURE.name)
90 gameStore.$patch({
91 figureGrounding: DEV_FIGURE,
92 contactWhen: DEV_FIGURE_WHEN,
93 contactMoment: null,
94 groundingLoading: false
95 })
96 }
97 
98 /** Seed up to `n` sample changes onto the Spine (skips if the ledger already has any). */
99 function seedLedger(n: number) {
100 if (gameStore.timelineEvents.length) return
101 SAMPLE_LEDGER.slice(0, n).forEach(s =>
102 gameStore.addTimelineEvent({
103 figureName: DEV_FIGURE.name,
104 era: DEV_FIGURE_ERA,
105 headline: s.headline,
106 detail: s.detail,
107 diceRoll: s.diceRoll,
108 diceOutcome: s.diceOutcome,
109 progressChange: s.progressChange,
110 craft: s.craft,
111 whenSigned: s.whenSigned
112 })
113 )
114 }
115 
116 const DEV_CHRONICLE = {
117 title: 'A History Being Rewritten',
118 paragraphs: [
119 'In this telling, a stranger\'s dispatches have bent the course of events.',
120 'What was settled now wavers, and the world leans toward an unfamiliar shape.'
121 ]
122 }
123 
124 // ---------- state jumps (client-only) ----------
125 
126 function jumpToVictory() {
127 ensureObjective()
128 seedLedger(3)
129 gameStore.$patch({
130 objectiveProgress: 100,
131 remainingMessages: 2,
132 momentum: 3,
133 gameStatus: 'victory',
134 chronicle: { title: 'A World Remade', paragraphs: DEV_CHRONICLE.paragraphs },
135 epiloguePending: false
136 })
137 }
138 
139 function jumpToDefeat() {
140 ensureObjective()
141 seedLedger(2)
142 gameStore.$patch({
143 objectiveProgress: 38,
144 remainingMessages: 0,
145 momentum: 0,
146 gameStatus: 'defeat',
147 chronicle: { title: 'The Tide Held', paragraphs: DEV_CHRONICLE.paragraphs },
148 epiloguePending: false
149 })
150 }
151 
152 function seedMidGame() {
153 ensureObjective()
154 autoFillFigure()
155 seedLedger(2)
156 gameStore.$patch({
157 objectiveProgress: 36,
158 remainingMessages: 3,
159 momentum: 2,
160 gameStatus: 'playing',
161 chronicle: DEV_CHRONICLE
162 })
163 }
164 
165 /** Final message, win out of normal reach → `canStake` becomes true and the
166 * inline stake control renders for inspection. */
167 function setupStakeOffer() {
168 ensureObjective()
169 autoFillFigure()
170 seedLedger(1)
171 gameStore.$patch({
172 objectiveProgress: 20,
173 remainingMessages: 1,
174 momentum: 1,
175 gameStatus: 'playing',
176 chronicle: DEV_CHRONICLE
177 })
178 }
179 
180 /** Back to the mission-select briefing (a full reset). */
181 function toMissionSelect() {
⋯ 109 lines hidden (lines 182–290)
182 gameStore.resetGame()
183 }
184 
185 // ---------- knob setters (client-only) ----------
186 
187 const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, Math.round(n)))
188 function setProgress(n: number) { gameStore.$patch({ objectiveProgress: clamp(n, 0, 100) }) }
189 function setRemaining(n: number) { gameStore.$patch({ remainingMessages: clamp(n, 0, 5) }) }
190 function setMomentum(n: number) { gameStore.$patch({ momentum: clamp(n, 0, 4) }) }
191 
192 // ---------- the fixture AI lane (server-gated) ----------
193 
194 async function armFixtures(fixture: TurnFixture): Promise<void> {
195 await $fetch('/api/dev/fixtures', { method: 'POST', body: { fixture } })
196 }
197 async function clearFixtures(): Promise<void> {
198 await $fetch('/api/dev/fixtures', { method: 'POST', body: { clear: true } })
199 }
200 
201 // ---------- forced turns (the real pipeline) ----------
202 
203 /** Commit a charged dev run + an objective + a figure, so forced turns can fire.
204 * Returns false (and leaves a store error) if the device is out of runs. */
205 async function startDevRun(): Promise<boolean> {
206 const ok = await gameStore.chooseObjective(devObjective())
207 if (!ok) return false
208 autoFillFigure()
209 gameStore.$patch({ remainingMessages: 5, objectiveProgress: 0, momentum: 0, timelineEvents: [] })
210 return true
211 }
212 
213 /**
214 * Fire one forced turn through the real `sendMessage`. Scripts the four AI layers
215 * + the die for the chosen band, then sends — the swing, progress, Spine, momentum,
216 * and Chronicle all resolve for real. Ensures a charged run first.
217 */
218 async function forceTurn(outcome: DiceOutcome, opts: ForceTurnOptions = {}): Promise<boolean> {
219 if (!gameStore.runId && !(await startDevRun())) return false
220 const preset = OUTCOME_PRESET[outcome]
221 const fixture: TurnFixture = {
222 figureName: DEV_FIGURE.name,
223 era: DEV_FIGURE_ERA,
224 persona: 'Queen of Egypt',
225 message: 'A bold notion — I shall set it in motion.',
226 action: 'Cleopatra acts decisively on the idea.',
227 craft: opts.craft ?? 'sound',
228 craftReason: 'A dev-forced craft grade.',
229 continuity: opts.continuity ?? 'neutral',
230 roll: preset.roll,
231 headline: `${outcome}: the timeline answers`,
232 detail: 'A dev-forced ripple, resolved through the real mechanics.',
233 progressChange: preset.progressChange,
234 valence: preset.progressChange > 0 ? 'positive' : preset.progressChange < 0 ? 'negative' : 'neutral',
235 anachronism: opts.anachronism ?? 'in-period',
236 chronicleTitle: DEV_CHRONICLE.title,
237 chronicleParagraphs: DEV_CHRONICLE.paragraphs
238 }
239 // Let rapid dev clicks through (skip the 1s cooldown) and keep the run playable.
240 gameStore.$patch({ isRateLimited: false, lastMessageTime: null })
241 if (!opts.staked) {
242 if (gameStore.gameStatus !== 'playing') gameStore.$patch({ gameStatus: 'playing' })
243 if (gameStore.remainingMessages <= 0) gameStore.$patch({ remainingMessages: 5 })
244 }
245 await armFixtures(fixture)
246 return gameStore.sendMessage('A dev-forced dispatch to history.', DEV_FIGURE.name, { stake: opts.staked })
247 }
248 
249 /** Set up the stake offer, then fire the final dispatch staked (real doubling). */
250 async function forceStakedTurn(outcome: DiceOutcome = DiceOutcome.SUCCESS, opts: ForceTurnOptions = {}): Promise<boolean> {
251 if (!gameStore.runId && !(await startDevRun())) return false
252 setupStakeOffer()
253 return forceTurn(outcome, { ...opts, staked: true })
254 }
255 
256 // ---------- the live inspector ----------
257 
258 const inspector = computed(() => {
259 const lastAi = [...gameStore.messageHistory].reverse().find(m => m.sender === 'ai' && m.diceRoll != null)
260 const mod = lastAi?.rollModifier
261 const rollLine = lastAi
262 ? `${lastAi.naturalRoll ?? lastAi.diceRoll}${mod ? (mod > 0 ? `+${mod}` : `${mod}`) : ''}${lastAi.diceRoll} · ${lastAi.diceOutcome}`
263 : '—'
264 return {
265 gameStatus: gameStore.gameStatus,
266 objectiveProgress: gameStore.objectiveProgress,
267 remainingMessages: gameStore.remainingMessages,
268 momentum: gameStore.momentum,
269 runEpoch: gameStore.runEpoch,
270 ledgerCount: gameStore.timelineEvents.length,
271 lastRoll: rollLine,
272 runId: gameStore.runId ? `${gameStore.runId.slice(0, 8)}` : '(none)',
273 canStake: gameStore.canStake,
274 isLoading: gameStore.isLoading
275 }
276 })
277 
278 return {
279 inspector,
280 // stage jumps
281 jumpToVictory, jumpToDefeat, seedMidGame, setupStakeOffer, toMissionSelect,
282 // knobs
283 setProgress, setRemaining, setMomentum,
284 // setup
285 autoFillFigure, startDevRun,
286 // forced turns + fixtures
287 forceTurn, forceStakedTurn, armFixtures, clearFixtures,
288 DiceOutcome
289 }

Surface two: forced turns

A forced turn is the faithful path. It ensures a charged run exists (the normal chooseObjective flow — a real charge, no bypass), arms the fixture for the chosen band, then calls the real sendMessage. The swing, progress, Spine, momentum, and Chronicle all resolve for real; only the four AI layers are scripted.

Ensure a run, arm the fixture (incl. the forced roll), fire the real sendMessage.

composables/useDevTools.ts · 290 lines
composables/useDevTools.ts290 lines · TypeScript
⋯ 217 lines hidden (lines 1–217)
1/**
2 * Developer-mode orchestration (issue #105).
3 *
4 * Two surfaces, one composable:
5 *
6 * 1. STATE JUMPS (client-only) — land on a stage to inspect it (victory, defeat, a
7 * populated mid-game, the stake offer) and set knobs (progress / remaining /
8 * momentum) directly. These never call the server, never charge a run, and never
9 * spend: they drive the store the same way a resolved turn would.
10 *
11 * 2. FORCED TURNS (the faithful path) — fire the REAL `sendMessage` pipeline with
12 * the AI seam scripted (via `/api/dev/fixtures`) and the die forced, so the swing
13 * bands, anachronism amplifier, causal-chain decay, momentum, progress clamp,
14 * win/lose, Spine append, and Chronicle all run for real, deterministically, with
15 * no keys and no model spend. The turn still goes through the charged-run paywall
16 * (one free dev run backs unlimited forced turns — the server has no per-run
17 * message cap, and this panel keeps `remainingMessages` topped), so dev mode is
18 * not a free-play bypass.
19 *
20 * All dev logic lives here (and in the gated server seam), never in the production
21 * store: state jumps use `$patch` + existing actions, so `stores/game.ts` is untouched.
22 */
23import { computed } from 'vue'
24import { useGameStore } from '~/stores/game'
25import { listCuratedObjectives, type GameObjective } from '~/server/utils/objectives'
26import { DiceOutcome } from '~/utils/dice'
27import type { Craft } from '~/utils/craft'
28import type { Continuity } from '~/utils/continuity'
29import type { Anachronism } from '~/server/utils/anachronism'
30import type { GroundedFigure } from '~/server/utils/figure-grounding'
31import type { TurnFixture } from '~/utils/fixtures/turn-fixture'
32 
33/** A real, deceased figure so the server's own grounding gate resolves (it re-grounds
34 * the name against Wikipedia; that needs no key and costs nothing). Cleopatra, 69–30 BC. */
35const DEV_FIGURE: GroundedFigure = {
36 name: 'Cleopatra',
37 resolved: true,
38 description: 'Queen of Egypt, last of the Ptolemaic dynasty',
39 born: { year: 69, bce: true, signed: -69, display: '69 BC' },
40 died: { year: 30, bce: true, signed: -30, display: '30 BC' },
41 living: false
43const DEV_FIGURE_ERA = 'Alexandria, 48 BC'
44const DEV_FIGURE_WHEN = -40
45 
46/** Forced-die + base-swing per target band (natural d20; the craft modifier still
47 * applies on top, and the server re-clamps the swing into the effective rolled band). */
48const OUTCOME_PRESET: Record<DiceOutcome, { roll: number; progressChange: number }> = {
49 [DiceOutcome.CRITICAL_SUCCESS]: { roll: 20, progressChange: 45 },
50 [DiceOutcome.SUCCESS]: { roll: 16, progressChange: 30 },
51 [DiceOutcome.NEUTRAL]: { roll: 10, progressChange: 8 },
52 [DiceOutcome.FAILURE]: { roll: 5, progressChange: -15 },
53 [DiceOutcome.CRITICAL_FAILURE]: { roll: 1, progressChange: -35 }
55 
56/** A few sample ledger entries to populate the Spine / end screen for inspection. */
57const SAMPLE_LEDGER = [
58 { headline: 'Envoys redraw the map of power', detail: 'Egypt courts Rome ahead of schedule and the alliances shift.', diceRoll: 16, diceOutcome: DiceOutcome.SUCCESS, progressChange: 22, craft: 'sharp' as Craft, whenSigned: -44 },
59 { headline: 'The great library is spared the flames', detail: 'Scrolls that history lost are copied and sent abroad for safekeeping.', diceRoll: 11, diceOutcome: DiceOutcome.NEUTRAL, progressChange: 8, craft: 'sound' as Craft, whenSigned: -42 },
60 { headline: 'A rival faction takes offense', detail: 'The senate bristles at Alexandria\'s new confidence, and knives are sharpened.', diceRoll: 4, diceOutcome: DiceOutcome.FAILURE, progressChange: -9, craft: 'vague' as Craft, whenSigned: -40 }
62 
63export interface ForceTurnOptions {
64 craft?: Craft
65 continuity?: Continuity
66 anachronism?: Anachronism
67 staked?: boolean
69 
70export function useDevTools() {
71 const gameStore = useGameStore()
72 
73 /** The dev objective — anchored near Cleopatra so the causal chain reads strong. */
74 function devObjective(): GameObjective {
75 const all = listCuratedObjectives()
76 return all.find(o => o.anchorYear === -48) ?? all[0]
77 }
78 
79 /** Set the current objective WITHOUT charging a run (for offline inspection). */
80 function ensureObjective() {
81 if (!gameStore.currentObjective) {
82 gameStore.$patch({ currentObjective: devObjective() })
83 }
84 }
85 
86 /** Point the composer at a known, grounded, deceased figure — no /api/figure call. */
87 function autoFillFigure() {
88 gameStore.registerFigure({ name: DEV_FIGURE.name, era: DEV_FIGURE_ERA, descriptor: 'Queen of Egypt' })
89 gameStore.setActiveFigure(DEV_FIGURE.name)
90 gameStore.$patch({
91 figureGrounding: DEV_FIGURE,
92 contactWhen: DEV_FIGURE_WHEN,
93 contactMoment: null,
94 groundingLoading: false
95 })
96 }
97 
98 /** Seed up to `n` sample changes onto the Spine (skips if the ledger already has any). */
99 function seedLedger(n: number) {
100 if (gameStore.timelineEvents.length) return
101 SAMPLE_LEDGER.slice(0, n).forEach(s =>
102 gameStore.addTimelineEvent({
103 figureName: DEV_FIGURE.name,
104 era: DEV_FIGURE_ERA,
105 headline: s.headline,
106 detail: s.detail,
107 diceRoll: s.diceRoll,
108 diceOutcome: s.diceOutcome,
109 progressChange: s.progressChange,
110 craft: s.craft,
111 whenSigned: s.whenSigned
112 })
113 )
114 }
115 
116 const DEV_CHRONICLE = {
117 title: 'A History Being Rewritten',
118 paragraphs: [
119 'In this telling, a stranger\'s dispatches have bent the course of events.',
120 'What was settled now wavers, and the world leans toward an unfamiliar shape.'
121 ]
122 }
123 
124 // ---------- state jumps (client-only) ----------
125 
126 function jumpToVictory() {
127 ensureObjective()
128 seedLedger(3)
129 gameStore.$patch({
130 objectiveProgress: 100,
131 remainingMessages: 2,
132 momentum: 3,
133 gameStatus: 'victory',
134 chronicle: { title: 'A World Remade', paragraphs: DEV_CHRONICLE.paragraphs },
135 epiloguePending: false
136 })
137 }
138 
139 function jumpToDefeat() {
140 ensureObjective()
141 seedLedger(2)
142 gameStore.$patch({
143 objectiveProgress: 38,
144 remainingMessages: 0,
145 momentum: 0,
146 gameStatus: 'defeat',
147 chronicle: { title: 'The Tide Held', paragraphs: DEV_CHRONICLE.paragraphs },
148 epiloguePending: false
149 })
150 }
151 
152 function seedMidGame() {
153 ensureObjective()
154 autoFillFigure()
155 seedLedger(2)
156 gameStore.$patch({
157 objectiveProgress: 36,
158 remainingMessages: 3,
159 momentum: 2,
160 gameStatus: 'playing',
161 chronicle: DEV_CHRONICLE
162 })
163 }
164 
165 /** Final message, win out of normal reach → `canStake` becomes true and the
166 * inline stake control renders for inspection. */
167 function setupStakeOffer() {
168 ensureObjective()
169 autoFillFigure()
170 seedLedger(1)
171 gameStore.$patch({
172 objectiveProgress: 20,
173 remainingMessages: 1,
174 momentum: 1,
175 gameStatus: 'playing',
176 chronicle: DEV_CHRONICLE
177 })
178 }
179 
180 /** Back to the mission-select briefing (a full reset). */
181 function toMissionSelect() {
182 gameStore.resetGame()
183 }
184 
185 // ---------- knob setters (client-only) ----------
186 
187 const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, Math.round(n)))
188 function setProgress(n: number) { gameStore.$patch({ objectiveProgress: clamp(n, 0, 100) }) }
189 function setRemaining(n: number) { gameStore.$patch({ remainingMessages: clamp(n, 0, 5) }) }
190 function setMomentum(n: number) { gameStore.$patch({ momentum: clamp(n, 0, 4) }) }
191 
192 // ---------- the fixture AI lane (server-gated) ----------
193 
194 async function armFixtures(fixture: TurnFixture): Promise<void> {
195 await $fetch('/api/dev/fixtures', { method: 'POST', body: { fixture } })
196 }
197 async function clearFixtures(): Promise<void> {
198 await $fetch('/api/dev/fixtures', { method: 'POST', body: { clear: true } })
199 }
200 
201 // ---------- forced turns (the real pipeline) ----------
202 
203 /** Commit a charged dev run + an objective + a figure, so forced turns can fire.
204 * Returns false (and leaves a store error) if the device is out of runs. */
205 async function startDevRun(): Promise<boolean> {
206 const ok = await gameStore.chooseObjective(devObjective())
207 if (!ok) return false
208 autoFillFigure()
209 gameStore.$patch({ remainingMessages: 5, objectiveProgress: 0, momentum: 0, timelineEvents: [] })
210 return true
211 }
212 
213 /**
214 * Fire one forced turn through the real `sendMessage`. Scripts the four AI layers
215 * + the die for the chosen band, then sends — the swing, progress, Spine, momentum,
216 * and Chronicle all resolve for real. Ensures a charged run first.
217 */
218 async function forceTurn(outcome: DiceOutcome, opts: ForceTurnOptions = {}): Promise<boolean> {
219 if (!gameStore.runId && !(await startDevRun())) return false
220 const preset = OUTCOME_PRESET[outcome]
221 const fixture: TurnFixture = {
222 figureName: DEV_FIGURE.name,
223 era: DEV_FIGURE_ERA,
224 persona: 'Queen of Egypt',
225 message: 'A bold notion — I shall set it in motion.',
226 action: 'Cleopatra acts decisively on the idea.',
227 craft: opts.craft ?? 'sound',
228 craftReason: 'A dev-forced craft grade.',
229 continuity: opts.continuity ?? 'neutral',
230 roll: preset.roll,
231 headline: `${outcome}: the timeline answers`,
232 detail: 'A dev-forced ripple, resolved through the real mechanics.',
233 progressChange: preset.progressChange,
234 valence: preset.progressChange > 0 ? 'positive' : preset.progressChange < 0 ? 'negative' : 'neutral',
235 anachronism: opts.anachronism ?? 'in-period',
236 chronicleTitle: DEV_CHRONICLE.title,
237 chronicleParagraphs: DEV_CHRONICLE.paragraphs
238 }
239 // Let rapid dev clicks through (skip the 1s cooldown) and keep the run playable.
240 gameStore.$patch({ isRateLimited: false, lastMessageTime: null })
241 if (!opts.staked) {
242 if (gameStore.gameStatus !== 'playing') gameStore.$patch({ gameStatus: 'playing' })
243 if (gameStore.remainingMessages <= 0) gameStore.$patch({ remainingMessages: 5 })
244 }
245 await armFixtures(fixture)
246 return gameStore.sendMessage('A dev-forced dispatch to history.', DEV_FIGURE.name, { stake: opts.staked })
247 }
248 
249 /** Set up the stake offer, then fire the final dispatch staked (real doubling). */
250 async function forceStakedTurn(outcome: DiceOutcome = DiceOutcome.SUCCESS, opts: ForceTurnOptions = {}): Promise<boolean> {
251 if (!gameStore.runId && !(await startDevRun())) return false
252 setupStakeOffer()
253 return forceTurn(outcome, { ...opts, staked: true })
254 }
255 
256 // ---------- the live inspector ----------
⋯ 34 lines hidden (lines 257–290)
257 
258 const inspector = computed(() => {
259 const lastAi = [...gameStore.messageHistory].reverse().find(m => m.sender === 'ai' && m.diceRoll != null)
260 const mod = lastAi?.rollModifier
261 const rollLine = lastAi
262 ? `${lastAi.naturalRoll ?? lastAi.diceRoll}${mod ? (mod > 0 ? `+${mod}` : `${mod}`) : ''}${lastAi.diceRoll} · ${lastAi.diceOutcome}`
263 : '—'
264 return {
265 gameStatus: gameStore.gameStatus,
266 objectiveProgress: gameStore.objectiveProgress,
267 remainingMessages: gameStore.remainingMessages,
268 momentum: gameStore.momentum,
269 runEpoch: gameStore.runEpoch,
270 ledgerCount: gameStore.timelineEvents.length,
271 lastRoll: rollLine,
272 runId: gameStore.runId ? `${gameStore.runId.slice(0, 8)}` : '(none)',
273 canStake: gameStore.canStake,
274 isLoading: gameStore.isLoading
275 }
276 })
277 
278 return {
279 inspector,
280 // stage jumps
281 jumpToVictory, jumpToDefeat, seedMidGame, setupStakeOffer, toMissionSelect,
282 // knobs
283 setProgress, setRemaining, setMomentum,
284 // setup
285 autoFillFigure, startDevRun,
286 // forced turns + fixtures
287 forceTurn, forceStakedTurn, armFixtures, clearFixtures,
288 DiceOutcome
289 }

armFixtures is the client half of the seam: it POSTs the TurnFixture to the dev-gated route before the turn fires.

composables/useDevTools.ts · 290 lines
composables/useDevTools.ts290 lines · TypeScript
⋯ 193 lines hidden (lines 1–193)
1/**
2 * Developer-mode orchestration (issue #105).
3 *
4 * Two surfaces, one composable:
5 *
6 * 1. STATE JUMPS (client-only) — land on a stage to inspect it (victory, defeat, a
7 * populated mid-game, the stake offer) and set knobs (progress / remaining /
8 * momentum) directly. These never call the server, never charge a run, and never
9 * spend: they drive the store the same way a resolved turn would.
10 *
11 * 2. FORCED TURNS (the faithful path) — fire the REAL `sendMessage` pipeline with
12 * the AI seam scripted (via `/api/dev/fixtures`) and the die forced, so the swing
13 * bands, anachronism amplifier, causal-chain decay, momentum, progress clamp,
14 * win/lose, Spine append, and Chronicle all run for real, deterministically, with
15 * no keys and no model spend. The turn still goes through the charged-run paywall
16 * (one free dev run backs unlimited forced turns — the server has no per-run
17 * message cap, and this panel keeps `remainingMessages` topped), so dev mode is
18 * not a free-play bypass.
19 *
20 * All dev logic lives here (and in the gated server seam), never in the production
21 * store: state jumps use `$patch` + existing actions, so `stores/game.ts` is untouched.
22 */
23import { computed } from 'vue'
24import { useGameStore } from '~/stores/game'
25import { listCuratedObjectives, type GameObjective } from '~/server/utils/objectives'
26import { DiceOutcome } from '~/utils/dice'
27import type { Craft } from '~/utils/craft'
28import type { Continuity } from '~/utils/continuity'
29import type { Anachronism } from '~/server/utils/anachronism'
30import type { GroundedFigure } from '~/server/utils/figure-grounding'
31import type { TurnFixture } from '~/utils/fixtures/turn-fixture'
32 
33/** A real, deceased figure so the server's own grounding gate resolves (it re-grounds
34 * the name against Wikipedia; that needs no key and costs nothing). Cleopatra, 69–30 BC. */
35const DEV_FIGURE: GroundedFigure = {
36 name: 'Cleopatra',
37 resolved: true,
38 description: 'Queen of Egypt, last of the Ptolemaic dynasty',
39 born: { year: 69, bce: true, signed: -69, display: '69 BC' },
40 died: { year: 30, bce: true, signed: -30, display: '30 BC' },
41 living: false
43const DEV_FIGURE_ERA = 'Alexandria, 48 BC'
44const DEV_FIGURE_WHEN = -40
45 
46/** Forced-die + base-swing per target band (natural d20; the craft modifier still
47 * applies on top, and the server re-clamps the swing into the effective rolled band). */
48const OUTCOME_PRESET: Record<DiceOutcome, { roll: number; progressChange: number }> = {
49 [DiceOutcome.CRITICAL_SUCCESS]: { roll: 20, progressChange: 45 },
50 [DiceOutcome.SUCCESS]: { roll: 16, progressChange: 30 },
51 [DiceOutcome.NEUTRAL]: { roll: 10, progressChange: 8 },
52 [DiceOutcome.FAILURE]: { roll: 5, progressChange: -15 },
53 [DiceOutcome.CRITICAL_FAILURE]: { roll: 1, progressChange: -35 }
55 
56/** A few sample ledger entries to populate the Spine / end screen for inspection. */
57const SAMPLE_LEDGER = [
58 { headline: 'Envoys redraw the map of power', detail: 'Egypt courts Rome ahead of schedule and the alliances shift.', diceRoll: 16, diceOutcome: DiceOutcome.SUCCESS, progressChange: 22, craft: 'sharp' as Craft, whenSigned: -44 },
59 { headline: 'The great library is spared the flames', detail: 'Scrolls that history lost are copied and sent abroad for safekeeping.', diceRoll: 11, diceOutcome: DiceOutcome.NEUTRAL, progressChange: 8, craft: 'sound' as Craft, whenSigned: -42 },
60 { headline: 'A rival faction takes offense', detail: 'The senate bristles at Alexandria\'s new confidence, and knives are sharpened.', diceRoll: 4, diceOutcome: DiceOutcome.FAILURE, progressChange: -9, craft: 'vague' as Craft, whenSigned: -40 }
62 
63export interface ForceTurnOptions {
64 craft?: Craft
65 continuity?: Continuity
66 anachronism?: Anachronism
67 staked?: boolean
69 
70export function useDevTools() {
71 const gameStore = useGameStore()
72 
73 /** The dev objective — anchored near Cleopatra so the causal chain reads strong. */
74 function devObjective(): GameObjective {
75 const all = listCuratedObjectives()
76 return all.find(o => o.anchorYear === -48) ?? all[0]
77 }
78 
79 /** Set the current objective WITHOUT charging a run (for offline inspection). */
80 function ensureObjective() {
81 if (!gameStore.currentObjective) {
82 gameStore.$patch({ currentObjective: devObjective() })
83 }
84 }
85 
86 /** Point the composer at a known, grounded, deceased figure — no /api/figure call. */
87 function autoFillFigure() {
88 gameStore.registerFigure({ name: DEV_FIGURE.name, era: DEV_FIGURE_ERA, descriptor: 'Queen of Egypt' })
89 gameStore.setActiveFigure(DEV_FIGURE.name)
90 gameStore.$patch({
91 figureGrounding: DEV_FIGURE,
92 contactWhen: DEV_FIGURE_WHEN,
93 contactMoment: null,
94 groundingLoading: false
95 })
96 }
97 
98 /** Seed up to `n` sample changes onto the Spine (skips if the ledger already has any). */
99 function seedLedger(n: number) {
100 if (gameStore.timelineEvents.length) return
101 SAMPLE_LEDGER.slice(0, n).forEach(s =>
102 gameStore.addTimelineEvent({
103 figureName: DEV_FIGURE.name,
104 era: DEV_FIGURE_ERA,
105 headline: s.headline,
106 detail: s.detail,
107 diceRoll: s.diceRoll,
108 diceOutcome: s.diceOutcome,
109 progressChange: s.progressChange,
110 craft: s.craft,
111 whenSigned: s.whenSigned
112 })
113 )
114 }
115 
116 const DEV_CHRONICLE = {
117 title: 'A History Being Rewritten',
118 paragraphs: [
119 'In this telling, a stranger\'s dispatches have bent the course of events.',
120 'What was settled now wavers, and the world leans toward an unfamiliar shape.'
121 ]
122 }
123 
124 // ---------- state jumps (client-only) ----------
125 
126 function jumpToVictory() {
127 ensureObjective()
128 seedLedger(3)
129 gameStore.$patch({
130 objectiveProgress: 100,
131 remainingMessages: 2,
132 momentum: 3,
133 gameStatus: 'victory',
134 chronicle: { title: 'A World Remade', paragraphs: DEV_CHRONICLE.paragraphs },
135 epiloguePending: false
136 })
137 }
138 
139 function jumpToDefeat() {
140 ensureObjective()
141 seedLedger(2)
142 gameStore.$patch({
143 objectiveProgress: 38,
144 remainingMessages: 0,
145 momentum: 0,
146 gameStatus: 'defeat',
147 chronicle: { title: 'The Tide Held', paragraphs: DEV_CHRONICLE.paragraphs },
148 epiloguePending: false
149 })
150 }
151 
152 function seedMidGame() {
153 ensureObjective()
154 autoFillFigure()
155 seedLedger(2)
156 gameStore.$patch({
157 objectiveProgress: 36,
158 remainingMessages: 3,
159 momentum: 2,
160 gameStatus: 'playing',
161 chronicle: DEV_CHRONICLE
162 })
163 }
164 
165 /** Final message, win out of normal reach → `canStake` becomes true and the
166 * inline stake control renders for inspection. */
167 function setupStakeOffer() {
168 ensureObjective()
169 autoFillFigure()
170 seedLedger(1)
171 gameStore.$patch({
172 objectiveProgress: 20,
173 remainingMessages: 1,
174 momentum: 1,
175 gameStatus: 'playing',
176 chronicle: DEV_CHRONICLE
177 })
178 }
179 
180 /** Back to the mission-select briefing (a full reset). */
181 function toMissionSelect() {
182 gameStore.resetGame()
183 }
184 
185 // ---------- knob setters (client-only) ----------
186 
187 const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, Math.round(n)))
188 function setProgress(n: number) { gameStore.$patch({ objectiveProgress: clamp(n, 0, 100) }) }
189 function setRemaining(n: number) { gameStore.$patch({ remainingMessages: clamp(n, 0, 5) }) }
190 function setMomentum(n: number) { gameStore.$patch({ momentum: clamp(n, 0, 4) }) }
191 
192 // ---------- the fixture AI lane (server-gated) ----------
193 
194 async function armFixtures(fixture: TurnFixture): Promise<void> {
195 await $fetch('/api/dev/fixtures', { method: 'POST', body: { fixture } })
196 }
197 async function clearFixtures(): Promise<void> {
198 await $fetch('/api/dev/fixtures', { method: 'POST', body: { clear: true } })
199 }
200 
201 // ---------- forced turns (the real pipeline) ----------
202 
203 /** Commit a charged dev run + an objective + a figure, so forced turns can fire.
⋯ 87 lines hidden (lines 204–290)
204 * Returns false (and leaves a store error) if the device is out of runs. */
205 async function startDevRun(): Promise<boolean> {
206 const ok = await gameStore.chooseObjective(devObjective())
207 if (!ok) return false
208 autoFillFigure()
209 gameStore.$patch({ remainingMessages: 5, objectiveProgress: 0, momentum: 0, timelineEvents: [] })
210 return true
211 }
212 
213 /**
214 * Fire one forced turn through the real `sendMessage`. Scripts the four AI layers
215 * + the die for the chosen band, then sends — the swing, progress, Spine, momentum,
216 * and Chronicle all resolve for real. Ensures a charged run first.
217 */
218 async function forceTurn(outcome: DiceOutcome, opts: ForceTurnOptions = {}): Promise<boolean> {
219 if (!gameStore.runId && !(await startDevRun())) return false
220 const preset = OUTCOME_PRESET[outcome]
221 const fixture: TurnFixture = {
222 figureName: DEV_FIGURE.name,
223 era: DEV_FIGURE_ERA,
224 persona: 'Queen of Egypt',
225 message: 'A bold notion — I shall set it in motion.',
226 action: 'Cleopatra acts decisively on the idea.',
227 craft: opts.craft ?? 'sound',
228 craftReason: 'A dev-forced craft grade.',
229 continuity: opts.continuity ?? 'neutral',
230 roll: preset.roll,
231 headline: `${outcome}: the timeline answers`,
232 detail: 'A dev-forced ripple, resolved through the real mechanics.',
233 progressChange: preset.progressChange,
234 valence: preset.progressChange > 0 ? 'positive' : preset.progressChange < 0 ? 'negative' : 'neutral',
235 anachronism: opts.anachronism ?? 'in-period',
236 chronicleTitle: DEV_CHRONICLE.title,
237 chronicleParagraphs: DEV_CHRONICLE.paragraphs
238 }
239 // Let rapid dev clicks through (skip the 1s cooldown) and keep the run playable.
240 gameStore.$patch({ isRateLimited: false, lastMessageTime: null })
241 if (!opts.staked) {
242 if (gameStore.gameStatus !== 'playing') gameStore.$patch({ gameStatus: 'playing' })
243 if (gameStore.remainingMessages <= 0) gameStore.$patch({ remainingMessages: 5 })
244 }
245 await armFixtures(fixture)
246 return gameStore.sendMessage('A dev-forced dispatch to history.', DEV_FIGURE.name, { stake: opts.staked })
247 }
248 
249 /** Set up the stake offer, then fire the final dispatch staked (real doubling). */
250 async function forceStakedTurn(outcome: DiceOutcome = DiceOutcome.SUCCESS, opts: ForceTurnOptions = {}): Promise<boolean> {
251 if (!gameStore.runId && !(await startDevRun())) return false
252 setupStakeOffer()
253 return forceTurn(outcome, { ...opts, staked: true })
254 }
255 
256 // ---------- the live inspector ----------
257 
258 const inspector = computed(() => {
259 const lastAi = [...gameStore.messageHistory].reverse().find(m => m.sender === 'ai' && m.diceRoll != null)
260 const mod = lastAi?.rollModifier
261 const rollLine = lastAi
262 ? `${lastAi.naturalRoll ?? lastAi.diceRoll}${mod ? (mod > 0 ? `+${mod}` : `${mod}`) : ''}${lastAi.diceRoll} · ${lastAi.diceOutcome}`
263 : '—'
264 return {
265 gameStatus: gameStore.gameStatus,
266 objectiveProgress: gameStore.objectiveProgress,
267 remainingMessages: gameStore.remainingMessages,
268 momentum: gameStore.momentum,
269 runEpoch: gameStore.runEpoch,
270 ledgerCount: gameStore.timelineEvents.length,
271 lastRoll: rollLine,
272 runId: gameStore.runId ? `${gameStore.runId.slice(0, 8)}` : '(none)',
273 canStake: gameStore.canStake,
274 isLoading: gameStore.isLoading
275 }
276 })
277 
278 return {
279 inspector,
280 // stage jumps
281 jumpToVictory, jumpToDefeat, seedMidGame, setupStakeOffer, toMissionSelect,
282 // knobs
283 setProgress, setRemaining, setMomentum,
284 // setup
285 autoFillFigure, startDevRun,
286 // forced turns + fixtures
287 forceTurn, forceStakedTurn, armFixtures, clearFixtures,
288 DiceOutcome
289 }

The panel, the mount, and the gate

The panel is just the surface over that composable: an inspector readout, the stage jumps, the knobs, and the forced-turn controls. It self-gates in its script — the same import.meta.dev || config.public.devMode condition — so in a normal prod build the whole component tree-shakes away.

The client gate mirrors the server's devModeEnabled().

components/DevPanel.vue · 289 lines
components/DevPanel.vue289 lines · Vue
⋯ 103 lines hidden (lines 1–103)
1<template>
2 <div v-if="enabled" class="dev-root" data-testid="dev-panel">
3 <!-- Collapsed: a small floating tab. Deliberately garish so it's never mistaken
4 for player chrome — it ships only in dev / staging, never to players. -->
5 <button v-if="!open" type="button" class="dev-fab" data-testid="dev-panel-toggle"
6 title="Developer mode (issue #105)" @click="open = true">🛠 DEV</button>
7 
8 <section v-else class="dev-card" aria-label="Developer mode">
9 <header class="dev-head">
10 <span class="dev-title">🛠 Dev mode</span>
11 <button type="button" class="dev-x" aria-label="Close dev panel" @click="open = false"></button>
12 </header>
13 
14 <!-- Live inspector -->
15 <div class="dev-grid" data-testid="dev-inspector">
16 <span>status</span><b>{{ inspector.gameStatus }}</b>
17 <span>progress</span><b>{{ inspector.objectiveProgress }}%</b>
18 <span>messages</span><b>{{ inspector.remainingMessages }}</b>
19 <span>momentum</span><b>{{ inspector.momentum }}</b>
20 <span>ledger</span><b>{{ inspector.ledgerCount }}</b>
21 <span>epoch</span><b>{{ inspector.runEpoch }}</b>
22 <span>run</span><b>{{ inspector.runId }}</b>
23 <span>canStake</span><b>{{ inspector.canStake ? 'yes' : 'no' }}</b>
24 <span>last roll</span><b class="dev-roll">{{ inspector.lastRoll }}</b>
25 </div>
26 
27 <!-- Stage jumps (client-only, no run, no spend) -->
28 <p class="dev-legend">Jump to stage</p>
29 <div class="dev-row">
30 <button type="button" @click="dev.jumpToVictory()">Victory</button>
31 <button type="button" @click="dev.jumpToDefeat()">Defeat</button>
32 <button type="button" @click="dev.seedMidGame()">Mid-game</button>
33 <button type="button" @click="dev.setupStakeOffer()">Stake offer</button>
34 <button type="button" @click="dev.toMissionSelect()">Mission select</button>
35 </div>
36 
37 <!-- Direct knobs -->
38 <p class="dev-legend">Set knobs</p>
39 <div class="dev-knobs">
40 <label>progress <input v-model.number="progress" type="number" min="0" max="100"
41 @change="dev.setProgress(progress)"></label>
42 <label>messages <input v-model.number="remaining" type="number" min="0" max="5"
43 @change="dev.setRemaining(remaining)"></label>
44 <label>momentum <input v-model.number="momentum" type="number" min="0" max="4"
45 @change="dev.setMomentum(momentum)"></label>
46 </div>
47 
48 <!-- Forced turns through the REAL pipeline (deterministic AI seam + die) -->
49 <p class="dev-legend">Force a turn <small>(real pipeline · charged run · no spend)</small></p>
50 <div class="dev-tunables">
51 <label>craft
52 <select v-model="craft">
53 <option v-for="c in CRAFT_LEVELS" :key="c" :value="c">{{ c }}</option>
54 </select>
55 </label>
56 <label>continuity
57 <select v-model="continuity">
58 <option v-for="c in CONTINUITY_LEVELS" :key="c" :value="c">{{ c }}</option>
59 </select>
60 </label>
61 <label>anachronism
62 <select v-model="anachronism">
63 <option v-for="a in ANACHRONISM_LEVELS" :key="a" :value="a">{{ a }}</option>
64 </select>
65 </label>
66 </div>
67 <div class="dev-row">
68 <button type="button" :disabled="busy" @click="fire(dev.DiceOutcome.CRITICAL_SUCCESS)">Crit success</button>
69 <button type="button" :disabled="busy" @click="fire(dev.DiceOutcome.SUCCESS)">Success</button>
70 <button type="button" :disabled="busy" @click="fire(dev.DiceOutcome.NEUTRAL)">Neutral</button>
71 <button type="button" :disabled="busy" @click="fire(dev.DiceOutcome.FAILURE)">Failure</button>
72 <button type="button" :disabled="busy" @click="fire(dev.DiceOutcome.CRITICAL_FAILURE)">Crit fail</button>
73 </div>
74 <div class="dev-row">
75 <button type="button" :disabled="busy" @click="fireStaked()">⚑ Staked turn</button>
76 <button type="button" :disabled="busy" @click="run('start')">Start dev run</button>
77 <button type="button" @click="dev.clearFixtures()">Clear AI fixtures</button>
78 </div>
79 
80 <p v-if="note" class="dev-note" data-testid="dev-note">{{ note }}</p>
81 </section>
82 </div>
83</template>
84 
85<script setup lang="ts">
86/**
87 * The developer-mode panel (issue #105). A floating, client-only overlay that drives
88 * the game store: jump to any stage, set knobs, and fire forced turns through the real
89 * `sendMessage` pipeline with the AI seam scripted deterministically (no keys, no
90 * spend). All logic lives in `useDevTools`; this is just the surface.
91 *
92 * Gated on `import.meta.dev || $config.public.devMode` — tree-shaken out of a normal
93 * production build, optionally enabled on a deployed staging preview. Never shipped to
94 * players. Mounted once in pages/play.vue inside <ClientOnly>, floating above the
95 * end-screen takeover (z-60 > z-50).
96 */
97import { ref } from 'vue'
98import { useDevTools } from '~/composables/useDevTools'
99import { CRAFT_LEVELS } from '~/utils/craft'
100import { CONTINUITY_LEVELS } from '~/utils/continuity'
101import { ANACHRONISM_LEVELS } from '~/server/utils/anachronism'
102import type { Craft } from '~/utils/craft'
103import type { Continuity } from '~/utils/continuity'
104import type { Anachronism } from '~/server/utils/anachronism'
105import type { DiceOutcome } from '~/utils/dice'
106 
107const config = useRuntimeConfig()
108const enabled = import.meta.dev || config.public.devMode === true
109 
110const dev = useDevTools()
111const { inspector } = dev
112 
113const open = ref(false)
114const busy = ref(false)
115const note = ref('')
116 
⋯ 173 lines hidden (lines 117–289)
117const progress = ref(50)
118const remaining = ref(3)
119const momentum = ref(0)
120const craft = ref<Craft>('sound')
121const continuity = ref<Continuity>('neutral')
122const anachronism = ref<Anachronism>('in-period')
123 
124async function fire(outcome: DiceOutcome) {
125 busy.value = true
126 note.value = ''
127 try {
128 const ok = await dev.forceTurn(outcome, { craft: craft.value, continuity: continuity.value, anachronism: anachronism.value })
129 note.value = ok ? `Fired ${outcome}.` : 'Turn did not resolve (out of runs? see the board).'
130 } finally {
131 busy.value = false
132 }
134 
135async function fireStaked() {
136 busy.value = true
137 note.value = ''
138 try {
139 const ok = await dev.forceStakedTurn(dev.DiceOutcome.SUCCESS, { craft: craft.value, continuity: continuity.value, anachronism: anachronism.value })
140 note.value = ok ? 'Fired a staked turn.' : 'Staked turn did not resolve (out of runs?).'
141 } finally {
142 busy.value = false
143 }
145 
146async function run(_: 'start') {
147 busy.value = true
148 note.value = ''
149 try {
150 const ok = await dev.startDevRun()
151 note.value = ok ? 'Dev run started (objective + figure set).' : 'Could not start a run (out of free runs — restart the dev server).'
152 } finally {
153 busy.value = false
154 }
156</script>
157 
158<style scoped>
159.dev-root {
160 position: fixed;
161 right: 12px;
162 bottom: 12px;
163 z-index: 60;
164 font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
165 font-size: 11px;
167.dev-fab {
168 background: #1b1033;
169 color: #ffd166;
170 border: 1px solid #ffd166;
171 border-radius: 8px;
172 padding: 6px 10px;
173 cursor: pointer;
174 box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
175 letter-spacing: 0.05em;
177.dev-card {
178 width: 280px;
179 max-height: 80vh;
180 overflow-y: auto;
181 background: rgba(20, 14, 38, 0.97);
182 color: #e8e0ff;
183 border: 1px solid #ffd166;
184 border-radius: 10px;
185 padding: 10px 12px;
186 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
188.dev-head {
189 display: flex;
190 align-items: center;
191 justify-content: space-between;
192 margin-bottom: 8px;
194.dev-title {
195 color: #ffd166;
196 font-weight: 700;
197 letter-spacing: 0.05em;
199.dev-x {
200 background: none;
201 border: none;
202 color: #e8e0ff;
203 cursor: pointer;
204 font-size: 13px;
206.dev-grid {
207 display: grid;
208 grid-template-columns: auto 1fr;
209 gap: 1px 8px;
210 background: rgba(0, 0, 0, 0.25);
211 border-radius: 6px;
212 padding: 6px 8px;
213 margin-bottom: 8px;
215.dev-grid span {
216 color: #a99fd0;
218.dev-grid b {
219 text-align: right;
221.dev-roll {
222 font-size: 10px;
224.dev-legend {
225 color: #ffd166;
226 margin: 8px 0 4px;
227 text-transform: uppercase;
228 letter-spacing: 0.06em;
229 font-size: 10px;
231.dev-legend small {
232 text-transform: none;
233 letter-spacing: 0;
234 color: #a99fd0;
236.dev-row {
237 display: flex;
238 flex-wrap: wrap;
239 gap: 4px;
240 margin-bottom: 4px;
242.dev-row button {
243 flex: 1 1 auto;
244 background: #2a1d4d;
245 color: #e8e0ff;
246 border: 1px solid #4a3a78;
247 border-radius: 6px;
248 padding: 5px 7px;
249 cursor: pointer;
250 white-space: nowrap;
252.dev-row button:hover:not(:disabled) {
253 border-color: #ffd166;
255.dev-row button:disabled {
256 opacity: 0.5;
257 cursor: progress;
259.dev-knobs,
260.dev-tunables {
261 display: flex;
262 flex-wrap: wrap;
263 gap: 6px;
264 margin-bottom: 4px;
266.dev-knobs label,
267.dev-tunables label {
268 display: flex;
269 flex-direction: column;
270 gap: 2px;
271 color: #a99fd0;
272 flex: 1 1 30%;
274.dev-knobs input,
275.dev-tunables select {
276 background: #140e26;
277 color: #e8e0ff;
278 border: 1px solid #4a3a78;
279 border-radius: 5px;
280 padding: 3px 5px;
281 font-family: inherit;
282 font-size: 11px;
284.dev-note {
285 margin-top: 8px;
286 color: #9be39b;
287 font-size: 10px;
289</style>

It mounts once on the play page, inside <ClientOnly>, beside the existing coach-mark overlay — so it floats across every stage.

pages/play.vue · 617 lines
pages/play.vue617 lines · Vue
⋯ 275 lines hidden (lines 1–275)
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 <!-- One mis-tap must not erase five AI-resolved turns: with a run in
19 progress the reset arms a tiny inline confirm (auto-reverts); an
20 untouched run still resets in one tap. -->
21 <span v-if="resetArmed" class="flex items-center gap-1.5">
22 <span class="text-[11px] rv-warn">abandon this history?</span>
23 <button type="button" data-testid="reset-confirm" class="rv-tap rv-hover-fg text-sm leading-none rv-warn"
24 aria-label="Yes — abandon this history" @click="performReset"></button>
25 <button type="button" data-testid="reset-cancel" class="rv-tap rv-hover-fg text-sm leading-none"
26 aria-label="Keep playing" @click="disarmReset"></button>
27 </span>
28 <button v-else type="button" data-testid="reset-button" class="rv-tap rv-hover-fg text-base leading-none"
29 title="New timeline" aria-label="New timeline" @click="handleResetGame"></button>
30 </div>
31 
32 <!-- Runs gauge — always visible (briefing + board), and the entry to the
33 account popover / the run-packs modal. -->
34 <RunsBalance class="shrink-0" :class="gameStore.currentObjective ? '' : 'ml-auto'" />
35 
36 <!-- Sound on/off — beside the dark toggle, persisted the same way. The svg is
37 decorative (aria-hidden); the button carries the state-reflecting name. -->
38 <button type="button" data-testid="mute-toggle" class="rv-tap rv-hover-fg leading-none shrink-0"
39 :aria-label="audio.muted ? 'Unmute sound effects' : 'Mute sound effects'"
40 :title="audio.muted ? 'Sound off' : 'Sound on'" :aria-pressed="!audio.muted" @click="toggleMute">
41 <svg width="17" height="17" viewBox="0 0 24 24" fill="none" aria-hidden="true">
42 <path d="M4 9h3l5-4v14l-5-4H4z" fill="currentColor" stroke="currentColor"
43 stroke-width="1.6" stroke-linejoin="round" />
44 <template v-if="!audio.muted">
45 <path d="M16.5 8.5a5 5 0 0 1 0 7" stroke="currentColor" stroke-width="1.8"
46 stroke-linecap="round" />
47 <path d="M19 6a8.5 8.5 0 0 1 0 12" stroke="currentColor" stroke-width="1.8"
48 stroke-linecap="round" />
49 </template>
50 <template v-else>
51 <path d="M16.5 9.5l5 5m0-5l-5 5" stroke="currentColor" stroke-width="1.8"
52 stroke-linecap="round" />
53 </template>
54 </svg>
55 </button>
56 
57 <button type="button" class="rv-tap rv-hover-fg text-base leading-none shrink-0"
58 :aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'" @click="toggleDark"></button>
59 </div>
60 </header>
61 
62 <!-- Post-checkout banner: the missing feedback after returning from Stripe — a
63 credited confirmation, or a gentle "no charge" on cancel. Self-dismisses. -->
64 <div v-if="gameStore.purchaseNotice" data-testid="purchase-notice" role="status" aria-live="polite"
65 class="border-b rv-line px-5 py-2.5 text-sm flex items-center gap-2"
66 :class="gameStore.purchaseNotice === 'success' ? 'rv-tint' : ''">
67 <span aria-hidden="true">{{ gameStore.purchaseNotice === 'success' ? '✓' : '○' }}</span>
68 <span class="rv-fg">
69 <template v-if="gameStore.purchaseNotice === 'success'">
70 Payment received — your runs have been added to your balance.
71 </template>
72 <template v-else>Checkout canceled — you weren't charged.</template>
73 </span>
74 <button type="button" data-testid="purchase-notice-close" class="rv-tap rv-hover-fg ml-auto shrink-0"
75 aria-label="Dismiss" @click="gameStore.clearPurchaseNotice()"></button>
76 </div>
77 
78 <!-- Waiting mode: a thin indeterminate sweep while a turn resolves — the whole
79 board reads as "history is being rewritten," in concert with the shaking die. -->
80 <div v-if="gameStore.isLoading" class="rv-loading-bar" role="status">
81 <span class="sr-only">Resolving your move — history is being rewritten…</span>
82 </div>
83 
84 <!-- At lg the board owns the viewport and scrolls inside its own panes, so main is
85 clipped (lg:overflow-hidden). The mission briefing has no such inner scroll, so
86 on a short viewport that clipping hid the Begin button under the footer — give
87 the briefing a scrollable main instead. -->
88 <main class="px-5 py-6 pb-24 md:pb-6 flex-1"
89 :class="gameStore.currentObjective ? 'lg:min-h-0 lg:py-0 lg:overflow-hidden' : 'lg:overflow-y-auto'">
90 <!-- Briefing: choose (or compose) an objective before the run begins -->
91 <MissionSelect v-if="!gameStore.currentObjective" />
92 
93 <template v-else>
94 <!-- The board, only while the run is live — once it ends, the end-screen
95 takeover fully replaces it (no board bleeding under the verdict).
96 On phones the three zones become one-at-a-time panels driven by the
97 bottom tab bar; from md up every zone is shown together as before. -->
98 <template v-if="gameStore.gameStatus === 'playing'">
99 <!-- THE WORKSPACE — at lg the board splits into two panes that fit the viewport:
100 a scrolling read-model (roll · shift · Spine · Chronicle · Thread) on the
101 left, and the act-model (the compose dock) pinned on the right, so the move
102 is never buried below the read. Below lg this wrapper is inert and the zones
103 fall back to the stacked page (md) / phone tab panels (sm). -->
104 <div class="rv-fade-in lg:h-full lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(360px,440px)] lg:grid-rows-1">
105 <!-- LEFT pane — the read-model. At lg it's a flex column the height of the
106 pane: the board/Spine on top at its natural height, then the story zone
107 flexes to fill the rest, so the Chronicle & Thread are full-height panels
108 (each scrolls inside itself) rather than a single scrolling stack. -->
109 <div class="lg:min-h-0 lg:min-w-0 lg:flex lg:flex-col lg:overflow-hidden lg:pr-6 lg:py-6">
110 <!-- ZONE 1 — the board state: the roll + the shift, then the Spine -->
111 <section class="rv-panel space-y-4 lg:space-y-6 lg:flex-none" :class="[{ 'rv-dim': isFirstTurn }, mobileTab === 'board' ? '' : 'hidden', 'md:block']">
112 <div class="grid sm:grid-cols-2 gap-4">
113 <div class="sm:border-r rv-line sm:pr-6 py-1">
114 <span class="rv-label mb-2 block">Dice of fate</span>
115 <DiceRoller />
116 <!-- The bands, disclosed: the player can always see how fate maps to
117 swing — the same table the Timeline Engine is instructed with. -->
118 <details data-testid="roll-legend" class="mt-2 text-[11px]">
119 <summary class="rv-faint cursor-pointer select-none hover:underline">how fate is weighed</summary>
120 <ul class="mt-1.5 space-y-0.5 rv-mono rv-muted">
121 <li v-for="row in rollLegend" :key="row.label" class="flex justify-between gap-3">
122 <span>{{ row.range }} · {{ row.label }}</span><span :class="row.cls">{{ row.swing }}</span>
123 </li>
124 </ul>
125 <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>
126 </details>
127 </div>
128 <div class="sm:pl-2 flex flex-col justify-center gap-3">
129 <ProgressTracker />
130 <MomentumMeter />
131 </div>
132 </div>
133 <TimelineLedger />
134 </section>
135 
136 <!-- ZONE 2 — the story: the living chronicle · the conversation thread. At lg
137 this flexes to fill the rest of the pane and lays the two out side by side
138 as equal full-height columns (md keeps the two-up grid in the page flow). -->
139 <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']">
140 <Chronicle :force-open="isMd" />
141 <!-- Thread: a <div> from md up (see Chronicle for why a <details> can't fill the
142 column height), a collapsible <details> on phones. -->
143 <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">
144 <summary class="rv-summary" :class="{ 'rv-summary--static': isMd }">
145 <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>
146 </summary>
147 <div class="pt-2 lg:flex-1 lg:min-h-0 lg:overflow-y-auto">
148 <MessageHistory data-testid="message-history" />
149 </div>
150 </component>
151 </section>
152 </div><!-- /LEFT pane -->
153 
154 <!-- RIGHT pane — the act-model, pinned beside the read column at lg -->
155 <div class="lg:min-h-0 lg:overflow-y-auto lg:border-l rv-line lg:pl-6 lg:py-6">
156 <!-- ZONE 3 — your turn: the compose dock (the page-stack divider is dropped at
157 lg, where it's a column of its own, not a section below the read). -->
158 <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']">
159 <div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
160 <span class="rv-label">Send a message into the past</span>
161 <!-- The wager, read BEFORE the roll: when the Archive has stamped a
162 known-since and a contact year is chosen, the chip translates the
163 reach into the same ⚡ tiers the spine uses. Otherwise the general
164 principle is stated — on the first turn too, where it teaches most. -->
165 <span v-if="wagerRead" data-testid="wager-chip" class="text-[11px]"
166 :class="wagerRead.tier === 'in-period' ? 'rv-muted' : 'rv-warn'">
167 <span aria-hidden="true">{{ wagerRead.pips }}</span> {{ wagerRead.text }}
168 </span>
169 <span v-else data-testid="wager-line" 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>
170 <!-- The chain, read BEFORE the roll: how far this moment lands from your
171 nearest foothold (the objective's era or a change you've made). A
172 lone leap into the deep past is diffuse; building a chain forward
173 closes the gap. Hidden at the hinge, where there's nothing to warn. -->
174 <span v-if="chainRead" data-testid="chain-chip" class="text-[11px]"
175 :class="chainRead.tier === 'diffuse' ? 'rv-warn' : 'rv-muted'">
176 <span aria-hidden="true"></span> {{ chainRead.text }}
177 </span>
178 </div>
179 
180 <p v-if="isFirstTurn" class="rv-accent text-sm mb-4">
181 Reach someone in history, write up to 160 characters, and send — one message can bend the whole timeline.
182 </p>
183 
184 <div class="grid md:grid-cols-2 lg:grid-cols-1 gap-x-8 gap-y-6">
185 <div>
186 <span class="rv-label block mb-2"><span class="rv-accent">1</span> · choose who &amp; when</span>
187 <FigurePicker v-model="figure" :disabled="!gameStore.canSendMessage" />
188 </div>
189 
190 <div class="space-y-3 rv-line lg:border-t lg:pt-6">
191 <span class="rv-label block mb-2"><span class="rv-accent">2</span> · write &amp; send</span>
192 <MessageInput ref="messageInputRef" />
193 <!-- The last stand: offered only when the final dispatch can no
194 longer win inside the normal per-turn cap — a true last resort,
195 never a free doubling from a winnable position. -->
196 <div v-if="gameStore.canStake" data-testid="stake-control"
197 class="border rounded-sm px-2.5 py-2 flex items-start gap-2"
198 :class="stakeArmed ? 'stake-armed' : 'rv-line'">
199 <input id="stake-toggle" v-model="stakeArmed" type="checkbox" class="mt-0.5"
200 :style="{ accentColor: 'var(--rv-accent)' }" />
201 <label for="stake-toggle" class="text-[11px] leading-snug cursor-pointer">
202 <span class="rv-warn font-semibold uppercase tracking-wide"><span aria-hidden="true"></span> Stake the timeline</span>
203 <span class="rv-muted block">Your final dispatch cuts twice as deep — both ways — and can move the whole meter. The last stand.</span>
204 </label>
205 </div>
206 <div class="flex items-center gap-3 flex-wrap">
207 <SendButton data-testid="send-button" :input-valid="canSend" @click="handleSendMessage" />
208 <div v-if="gameStore.error" data-testid="error-alert" role="status" aria-live="polite"
209 class="flex items-center gap-2 text-xs flex-1 min-w-0 border-l-2 rv-line pl-2 py-0.5">
210 <span class="rv-label shrink-0">notice</span>
211 <span class="rv-muted truncate">{{ gameStore.error }}</span>
212 <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>
213 </div>
214 <div v-if="gameStore.moderationNotice" data-testid="moderation-alert" role="alert" aria-live="assertive"
215 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">
216 <span class="shrink-0 font-semibold uppercase tracking-wide">⚠ blocked</span>
217 <span class="min-w-0">{{ gameStore.moderationNotice }}</span>
218 <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>
219 </div>
220 </div>
221 <ArchiveLookup />
222 </div>
223 </div>
224 </section>
225 </div><!-- /RIGHT pane -->
226 </div><!-- /workspace -->
227 </template>
228 </template>
229 </main>
230 
231 <!-- Phone-only tab bar: the three zones as fixed panels you switch between, so the
232 board never becomes one infinite scroll and your next move is always one tap
233 away. Hidden from md up (where every zone is shown at once). -->
234 <!-- A labelled nav of view-switch buttons (NOT an ARIA tablist: there's no roving
235 tabindex / arrow-key tabset here, and the zones are simple show/hide regions).
236 The active view is conveyed with aria-current, the honest, complete pattern. -->
237 <nav v-if="gameStore.currentObjective && gameStore.gameStatus === 'playing'"
238 class="md:hidden fixed bottom-0 inset-x-0 z-30 border-t rv-line rv-bg grid grid-cols-3"
239 aria-label="Switch board view">
240 <button v-for="t in mobileTabs" :key="t.id" type="button" :aria-current="mobileTab === t.id ? 'true' : undefined"
241 class="mobile-tab rv-press" :class="{ 'is-active': mobileTab === t.id }" @click="mobileTab = t.id">
242 <span class="text-lg leading-none" aria-hidden="true">{{ t.glyph }}</span>
243 <span class="rv-label flex items-center gap-1">
244 {{ t.label }}
245 <span v-if="t.id === 'compose' && yourMove" class="rv-dot rv-dot--accent" aria-hidden="true" />
246 </span>
247 </button>
248 </nav>
249 
250 <!-- A ledger running-foot: header + footer frame the page as a document.
251 (Hidden on phones, where the tab bar is the foot.) -->
252 <footer class="border-t rv-line px-5 py-3 hidden md:block text-[11px] rv-faint">
253 <div class="flex items-center gap-2">
254 <span class="rv-mono uppercase tracking-wide">Revisionist</span>
255 <span class="rv-hair-c" aria-hidden="true">·</span>
256 <span class="rv-mono">a history you are writing</span>
257 <span class="ml-auto rv-serif italic">the past is not fixed</span>
258 </div>
259 <!-- Persistent compliance line (AI disclosure + fiction + adults-only). -->
260 <p data-testid="footer-disclosure" class="mt-1.5">
261 Figures are AI, not real people · replies are AI-generated fiction, not historical fact · for adults (18+)
262 </p>
263 </footer>
264 
265 <EndGameScreen @play-again="performReset" />
266 
267 <!-- The run-packs store: opened from the header/account or automatically when a
268 commit is refused for being out of runs. Mounted once, here. -->
269 <RunPacksModal />
270 
271 <!-- The guided first run (issue #60): a skippable, non-blocking coach-mark that
272 teaches a brand-new player the core loop in context, then gets out of the
273 way. Client-only — it reads localStorage and positions against live rects. -->
274 <ClientOnly><CoachMark /></ClientOnly>
275 
276 <!-- Developer mode (issue #105): a floating panel to traverse stages and force
277 outcomes through the real mechanics. Self-gates on import.meta.dev /
278 public.devMode — tree-shaken out of a normal production build. -->
279 <ClientOnly><DevPanel /></ClientOnly>
280 </div>
⋯ 337 lines hidden (lines 281–617)
281</template>
282 
283<script setup lang="ts">
284/**
285 * Main game page — Revisionist, "The Spine Console" layout.
286 *
287 * A full-bleed board grouped into three super-zones — board-state (roll + shift +
288 * Spine), story (chronicle · thread), and your-turn (the compose dock) — separated
289 * by space, not chrome. State lives in the store; this only arranges it.
290 */
291import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
292import { useGameStore, formatContactYear } from '~/stores/game'
293import { useCoachingStore } from '~/stores/coaching'
294import { useAudioStore } from '~/stores/audio'
295import { useGameSoundscape } from '~/composables/useGameSoundscape'
296import { SWING_BANDS } from '~/utils/swing-bands'
297import { DiceOutcome, CRIT_FAIL_MAX, FAILURE_MAX, NEUTRAL_MAX, CRIT_SUCCESS_MIN } from '~/utils/dice'
298import { parseKnownSinceYear, wagerTier } from '~/utils/known-since'
299import { CHAIN_HINT } from '~/utils/causal-chain'
300import { ANACHRONISM_LABEL } from '~/server/utils/anachronism'
301import { readAuthReturnParams, hasAuthReturn, finalizeAuthReturn } from '~/utils/auth-return'
302 
303const gameStore = useGameStore()
304 
305// The soundscape (issue #92): this one call wires every game transition to its cue
306// (decoupled — the store stays sound-agnostic) and returns the engine handle for the
307// few UI-driven cues. The audio store holds the persisted mute/volume the header
308// control toggles. Both survive resetGame() untouched.
309const audio = useAudioStore()
310const soundscape = useGameSoundscape()
311 
312// The disclosed band table — rendered from the same constants the Timeline
313// Engine is instructed with, so the legend can't lie.
314const fmtSwing = (b: { min: number; max: number }) =>
315 `${b.min >= 0 ? '+' : ''}${b.min}${b.max >= 0 ? '+' : ''}${b.max}%`
316const rollLegend = [
317 { range: `${CRIT_SUCCESS_MIN}–20`, label: 'Critical Success', swing: fmtSwing(SWING_BANDS[DiceOutcome.CRITICAL_SUCCESS]), cls: 'rv-ok' },
318 { range: `${NEUTRAL_MAX + 1}${CRIT_SUCCESS_MIN - 1}`, label: 'Success', swing: fmtSwing(SWING_BANDS[DiceOutcome.SUCCESS]), cls: 'rv-ok' },
319 { range: `${FAILURE_MAX + 1}${NEUTRAL_MAX}`, label: 'Neutral', swing: fmtSwing(SWING_BANDS[DiceOutcome.NEUTRAL]), cls: 'rv-muted' },
320 { range: `${CRIT_FAIL_MAX + 1}${FAILURE_MAX}`, label: 'Failure', swing: fmtSwing(SWING_BANDS[DiceOutcome.FAILURE]), cls: 'rv-warn' },
321 { range: `1–${CRIT_FAIL_MAX}`, label: 'Critical Failure', swing: fmtSwing(SWING_BANDS[DiceOutcome.CRITICAL_FAILURE]), cls: 'rv-bad' }
323 
324// The pre-send wager chip: Archive known-since × chosen contact year → ⚡ tier.
325const wagerRead = computed(() => {
326 const lookup = gameStore.archiveResult
327 const when = gameStore.contactWhen
328 if (!lookup?.knownSince || when == null) return null
329 const knownYear = parseKnownSinceYear(lookup.knownSince)
330 if (knownYear == null) return null
331 const tier = wagerTier(knownYear, when)
332 const pips = tier === 'impossible' ? '⚡⚡⚡' : tier === 'far-ahead' ? '⚡⚡' : tier === 'ahead' ? '⚡' : '✓'
333 // Hedged copy: this is a year-gap compass, not the engine's verdict.
334 const text = tier === 'in-period'
335 ? `${lookup.topic}: already known by ${formatContactYear(when)} — steady ground`
336 : `${lookup.topic}: reads ≈ ${ANACHRONISM_LABEL[tier].toLowerCase()} for ${formatContactYear(when)} — swings harder, both ways`
337 return { tier, pips, text }
338})
339 
340// The pre-send chain chip: how far the chosen moment lands from the nearest
341// foothold (the objective's era or a prior change). Hidden at the hinge (full
342// force, nothing to flag); otherwise it reads the dilution + how to close it.
343const chainRead = computed(() => {
344 const s = gameStore.causalChainRead
345 if (!s || s.tier === 'at-hinge') return null
346 return { tier: s.tier, text: CHAIN_HINT[s.tier] }
347})
348 
349// The last stand, armed by the player on their final dispatch. Arming it is the
350// wager committed — a tension swell marks the doubling.
351const stakeArmed = ref(false)
352watch(stakeArmed, (armed) => { if (armed) soundscape.play('stake') })
353 
354const figure = ref('')
355watch(figure, (name) => gameStore.setActiveFigure((name || '').trim()))
356 
357const messageInputRef = ref()
358const messageInputValid = computed(() => messageInputRef.value?.isValid ?? false)
359const canSend = computed(() => messageInputValid.value && figure.value.trim().length > 0 && gameStore.canContact)
360 
361// The very first turn: the read-model is empty, so quiet it and lead the eye to the dock.
362const isFirstTurn = computed(() => gameStore.timelineEvents.length === 0)
363 
364// Phone tab bar: the three zones as switchable panels (md+ shows them all at once, so
365// this state is inert there). The run opens on the move; the instant a turn is rolling
366// we flip to the Board so the die + shift + spine land as the payoff beat, then the
367// player taps back to Compose. A nudge dot marks Compose when it's their move.
368type MobileTab = 'board' | 'story' | 'compose'
369const mobileTabs = [
370 { id: 'board', glyph: '🎲', label: 'Board' },
371 { id: 'story', glyph: '📖', label: 'Story' },
372 { id: 'compose', glyph: '✎', label: 'Compose' }
373] as const
374const mobileTab = ref<MobileTab>('compose')
375const yourMove = computed(() => gameStore.canSendMessage && !gameStore.isLoading && mobileTab.value !== 'compose')
376watch(() => gameStore.isLoading, (loading) => { if (loading) mobileTab.value = 'board' })
377watch(() => gameStore.currentObjective, (obj) => { if (obj) mobileTab.value = 'compose' })
378 
379// Thread rail (below md): open by default (the figure's replies are the game's most
380// charming content — collapsed-by-default made them too easy to miss). A fold is the
381// player's explicit choice now, so nothing force-reopens it. From md up it's forced
382// open as a full panel beside the Chronicle (isMd), so its toggle is inert there —
383// guard the handler so the native toggle event can't flip our own state.
384const threadOpen = ref(true)
385function onThreadToggle(e: Event) { if (!isMd.value) threadOpen.value = (e.target as HTMLDetailsElement).open }
386 
387// When a run begins, drop the cursor straight into the "who" field.
388function focusFigure() {
389 nextTick(() => (document.querySelector('[data-testid="figure-input"]') as HTMLElement | null)?.focus())
391watch(() => gameStore.currentObjective, (obj) => { if (obj) focusFigure() })
392 
393// Lock body scroll while the end-screen takeover is up (so the board can't peek).
394watch(() => gameStore.gameStatus, (s) => {
395 if (typeof document === 'undefined') return
396 document.body.style.overflow = (s === 'victory' || s === 'defeat') ? 'hidden' : ''
397})
398 
399// md+ breakpoint — from md up every zone is shown together, so the Chronicle is
400// forced open as the prose payoff rather than a tap-to-open rail (below md it stays
401// a collapsible rail in the Story tab); tracked reactively.
402const isMd = ref(false)
403let mdMql: MediaQueryList | null = null
404function syncMd() { isMd.value = mdMql?.matches ?? false }
405onMounted(() => {
406 mdMql = window.matchMedia('(min-width: 768px)')
407 syncMd()
408 mdMql.addEventListener('change', syncMd)
409})
410onUnmounted(() => { mdMql?.removeEventListener('change', syncMd) })
411 
412// Load the device's run balance for the header gauge, and handle the return from
413// Stripe Checkout: ?purchase=success|cancel drives the banner + a balance refresh,
414// then the query is stripped so a later refresh can't re-fire it.
415const route = useRoute()
416const router = useRouter()
417const supabase = useSupabaseClient()
418onMounted(async () => {
419 // A magic-link / email-change sign-in lands back here carrying an artifact to
420 // consume (?code= the Supabase client auto-exchanges; ?token_hash= we verify;
421 // ?error= means the link failed). On success the anonymous account upgrades in
422 // place — same id, runs carried over — so re-read the balance (the account view
423 // is server-derived) and strip the query so a reload can't re-fire a spent link.
424 const authReturn = readAuthReturnParams(route.query)
425 if (hasAuthReturn(authReturn)) {
426 const result = await finalizeAuthReturn(authReturn, {
427 settle: async () => {
428 const { data } = await supabase.auth.getSession()
429 return Boolean(data.session && !data.session.user.is_anonymous)
430 },
431 verifyOtp: (params) => supabase.auth.verifyOtp(params)
432 })
433 if (result.status === 'error') {
434 console.warn('[auth] sign-in link could not be finalized:', authReturn.error, authReturn.errorDescription)
435 }
436 await gameStore.loadBalance()
437 router.replace({ query: {} })
438 return
439 }
440 
441 const purchase = route.query.purchase
442 if (purchase === 'success' || purchase === 'cancel') {
443 await gameStore.notePurchaseReturn(purchase)
444 router.replace({ query: {} })
445 // The webhook credits asynchronously and can lag Stripe's redirect, so the
446 // first read may predate the credit. Poll briefly so the header gauge
447 // converges to the credited total without a manual reload — stopping as soon
448 // as it changes, or after a few tries (the credit is guaranteed by the
449 // webhook + its retries regardless). The banner asserts no specific count.
450 if (purchase === 'success') {
451 const before = gameStore.runsRemaining
452 for (let i = 0; i < 4; i++) {
453 await new Promise((r) => setTimeout(r, 1500))
454 await gameStore.loadBalance()
455 if (gameStore.runsRemaining !== before) break
456 }
457 }
458 } else {
459 await gameStore.loadBalance()
460 }
461})
462 
463// Dark-mode toggle via @nuxtjs/color-mode (bundled with @nuxt/ui): persists across
464// reloads, respects the OS preference, and ships a no-flash inline script — replacing
465// the old manual classList toggle that did none of those.
466const colorMode = useColorMode()
467const isDark = computed(() => colorMode.value === 'dark')
468function toggleDark() {
469 colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
471 
472// Sound on/off (issue #92), persisted like the dark toggle. The click is itself a
473// user gesture, so unmuting unlocks the audio context here and chirps a tick to
474// confirm sound is live; muting falls silent (the cue would be inaudible anyway).
475function toggleMute() {
476 audio.toggleMute()
477 if (!audio.muted) {
478 soundscape.unlock()
479 soundscape.play('uiTick')
480 }
482 
483const handleSendMessage = async () => {
484 if (!messageInputRef.value) return
485 const messageText = messageInputRef.value.message?.trim()
486 const target = figure.value.trim()
487 if (!messageText || !messageInputRef.value.isValid || !target) return
488 
489 // Clear the composer only when the turn actually resolved — a blocked or
490 // failed send keeps the player's crafted 160 characters for another try.
491 const resolved = await gameStore.sendMessage(messageText, target, { stake: stakeArmed.value })
492 if (resolved) {
493 stakeArmed.value = false
494 if (messageInputRef.value) messageInputRef.value.message = ''
495 }
497 
498// The header reset arms a confirm when a run is in progress (and auto-disarms);
499// performReset is the single unified reset path — the end screen's Play Again
500// lands here too, so every reset clears the figure input and composer alike.
501const resetArmed = ref(false)
502let resetArmTimer: ReturnType<typeof setTimeout> | null = null
503 
504const handleResetGame = () => {
505 // One tap only when there is truly nothing to lose: no resolved turns AND no
506 // words or contact in progress (a typed first dispatch is work too).
507 const composerDirty = !!messageInputRef.value?.message?.trim() || !!figure.value.trim()
508 if (gameStore.timelineEvents.length === 0 && !composerDirty) {
509 performReset()
510 return
511 }
512 resetArmed.value = true
513 if (resetArmTimer) clearTimeout(resetArmTimer)
514 resetArmTimer = setTimeout(() => { resetArmed.value = false }, 3500)
516 
517function disarmReset() {
518 resetArmed.value = false
519 if (resetArmTimer) clearTimeout(resetArmTimer)
521 
522function performReset() {
523 disarmReset()
524 stakeArmed.value = false
525 gameStore.resetGame()
526 figure.value = ''
527 if (messageInputRef.value) messageInputRef.value.message = ''
528 focusFigure()
530 
531onUnmounted(() => { if (resetArmTimer) clearTimeout(resetArmTimer) })
532 
533// ── Guided first run (issue #60) ──────────────────────────────────────────
534// A teaching layer for a brand-new player's first game. This page is the single
535// orchestration site: it reads the game and drives the coaching store one way,
536// so the store stays game-agnostic and survives resetGame() untouched. The board
537// never blocks — the coach-mark only points; the player plays through it.
538const coaching = useCoachingStore()
539const autoStartCtx = () => ({
540 hasObjective: !!gameStore.currentObjective,
541 isPlaying: gameStore.gameStatus === 'playing',
542 isFirstTurn: gameStore.timelineEvents.length === 0
543})
544onMounted(() => {
545 audio.hydrate()
546 coaching.hydrate()
547 coaching.maybeAutoStart(autoStartCtx())
548})
549// A run beginning cues a brand-new player's first beat; tearing the board down
550// (New timeline) ends a tour left running rather than stranding it over an empty board.
551watch(() => gameStore.currentObjective, (obj) => {
552 if (obj) coaching.maybeAutoStart(autoStartCtx())
553 else coaching.stop()
554})
555// Replays can begin mid-run, so the reveal + overstay beats key off turns resolved
556// SINCE the tour started, not absolute counts (a fresh first run baselines at 0).
557const tourBaseTurns = ref(0)
558watch(() => coaching.isRunning, (running) => {
559 if (running) tourBaseTurns.value = gameStore.timelineEvents.length
560})
561// Auto-advance on the real action. Picking who & when clears the first beat; the
562// reading beats (the dispatch, the wager) advance on the card's own Next; the
563// send beat waits for an actual resolved turn below.
564watch(
565 () => !!gameStore.figureGrounding?.resolved && gameStore.contactWhen != null,
566 (ready) => { if (ready) coaching.advanceFrom('who-when') }
568// The first turn resolved since the tour began is the payoff beat; advanceTo is
569// monotonic, so it also fast-forwards a player who sent before pressing Next on the
570// readers. One turn further and the loop has plainly landed, so a tour still running
571// has overstayed — it bows out on its own (the engaged reader pressed Done already).
572watch(() => gameStore.timelineEvents.length, (n) => {
573 if (!coaching.isRunning) return
574 if (n >= tourBaseTurns.value + 2) coaching.complete()
575 else if (n > tourBaseTurns.value) coaching.advanceTo('roll-swing')
576})
577// On each new beat, bring its host control into view on phones (md+ shows every
578// zone at once, so this is inert there). Only on a real step change — never yanks
579// a player who tapped elsewhere mid-beat back.
580watch(() => coaching.activeStep?.hostTab, (tab) => {
581 if (tab && !isMd.value) mobileTab.value = tab
582})
583 
584useHead({
585 title: 'Revisionist — Rewrite History',
586 meta: [
587 { name: 'description', content: 'Send 160-character messages to anyone in history. A D20 and an AI decide how the timeline bends.' }
588 ]
589})
590</script>
591 
592<style scoped>
593/* Phone tab bar — letterpress, not chrome: a hairline-topped strip, the active tab
594 marked by the one accent and a terracotta top rule. ≥44px tall for the thumb. */
595.mobile-tab {
596 display: flex;
597 flex-direction: column;
598 align-items: center;
599 justify-content: center;
600 gap: 2px;
601 padding: 8px 0 9px;
602 color: var(--rv-faint);
603 border-top: 2px solid transparent;
604 transition: color var(--rv-dur-1) var(--rv-ease), border-color var(--rv-dur-1) var(--rv-ease);
606.mobile-tab .rv-label { color: inherit; }
607.mobile-tab.is-active {
608 color: var(--rv-accent);
609 border-top-color: var(--rv-accent);
611 
612/* The armed last stand — a quiet accent wash, not a flashing alarm. */
613.stake-armed {
614 border-color: var(--rv-accent);
615 background: color-mix(in srgb, var(--rv-accent) 7%, transparent);
617</style>

The staging opt-in is the first runtimeConfig.public entry: one env var, REVISIONIST_DEV_MODE, read by both the client panel (via config.public.devMode) and the server gate (via process.env), so the two never disagree.

nuxt.config.ts · 81 lines
nuxt.config.ts81 lines · TypeScript
⋯ 63 lines hidden (lines 1–63)
1/**
2 * Nuxt 3 Configuration
3 * https://nuxt.com/docs/api/configuration/nuxt-config
4 */
5export default defineNuxtConfig({
6 // Ensure compatibility with modern Nuxt features
7 compatibilityDate: '2025-05-15',
8 
9 // Enable devtools for development
10 devtools: { enabled: true },
11 
12 // Register required modules
13 modules: ['@nuxt/ui', '@pinia/nuxt', '@nuxtjs/supabase'],
14 
15 // Supabase Auth (accounts). Anonymous-first: every visitor gets an anonymous
16 // user so the balance keys on a real user id from the first second; signing in
17 // with a magic link upgrades that same user in place (balance carries over).
18 // redirect:false — we DON'T gate play behind sign-in (anonymous play is the
19 // free-trial hook); buying is what requires an account. It also means the module
20 // registers no /confirm callback route, so the magic-link return is finalized in
21 // pages/play.vue (see utils/auth-return.ts). For that return to arrive, the
22 // Supabase project's Auth → URL Configuration → Redirect URLs must allowlist the
23 // deployed origin's /play (e.g. https://<origin>/play); otherwise Supabase falls
24 // back to the Site URL and the link never reaches the handler. The client key is
25 // the PUBLISHABLE key (safe to ship); the service key stays server-only in the
26 // balance store. Placeholders keep `nuxt build` working in CI without secrets;
27 // the real values come from NUXT_PUBLIC_SUPABASE_URL / _KEY at runtime.
28 supabase: {
29 url: process.env.SUPABASE_URL || 'https://placeholder.supabase.co',
30 key: process.env.SUPABASE_PUBLISHABLE_KEY || 'sb_publishable_placeholder',
31 redirect: false,
32 // We don't use generated DB types (the balance store talks to Supabase with the
33 // service client directly); disable the lookup so the module doesn't warn.
34 types: false
35 },
36 
37 // Global CSS imports
38 css: ['~/assets/css/main.css'],
39 
40 // Color mode (via @nuxtjs/color-mode, bundled with @nuxt/ui). classSuffix '' so the
41 // class on <html> is exactly `dark` / `light` — what the .dark token block and
42 // Tailwind's dark: variant key off. Defaulting to `system` respects the OS pref, and
43 // the module's inline no-flash script + cookie persistence replace the old manual
44 // classList toggle (which didn't persist and could FOUC).
45 colorMode: {
46 classSuffix: '',
47 preference: 'system',
48 fallback: 'light'
49 },
50 
51 // Runtime configuration
52 runtimeConfig: {
53 // Private keys (only available on server-side)
54 openaiApiKey: process.env.OPENAI_API_KEY,
55 anthropicApiKey: process.env.ANTHROPIC_API_KEY,
56 // Supabase — the run store (server-side only). Absent → in-memory dev store.
57 supabaseUrl: process.env.SUPABASE_URL,
58 supabaseSecretKey: process.env.SUPABASE_SECRET_KEY,
59 // Stripe — run packs (server-side only). Absent → checkout returns 503.
60 stripeSecretKey: process.env.STRIPE_SECRET_KEY,
61 stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
62 // Canonical site origin for Stripe redirect URLs (never the client Host header).
63 siteUrl: process.env.REVISIONIST_BASE_URL,
64 // Public keys (exposed to client-side)
65 public: {
66 // Developer mode (issue #105). A dev build always has it (`import.meta.dev`);
67 // this flag additionally opts a DEPLOYED staging preview in. Never set in
68 // production. The server gates the fixture lane on the same env var, so the
69 // client panel and the server seam agree on one source of truth.
70 devMode: process.env.REVISIONIST_DEV_MODE === 'true'
71 }
72 },
⋯ 9 lines hidden (lines 73–81)
73 
74 // The standalone agents/ tooling package has its own deps + tsconfig; keep it
75 // out of the app's type program so `nuxt typecheck` never compiles the loops.
76 typescript: {
77 tsConfig: {
78 exclude: ['../agents']
79 }
80 }
81})

Verification

The full unit suite stays green at 872 tests (19 new), and nuxt typecheck is clean. The new tests pin the load-bearing pieces: the dev gate (the prod-bypass guard), the gateway fixture branch including its no-cost telemetry, the shared fixture shape, and the state-jump recipes.

Beyond the suite, the real pipeline was driven end to end against a booted dev server with no API keys: a forced crit-success landed +45 through the real bands with the real causal-chain read; a forced failure with impossible anachronism widened −15 to −30 via the real loss amplifier; and the Chronicle resolved from the same armed fixture. The panel renders, and the victory and stake-offer jumps land the real stages.