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
⋯ 148 lines hidden (lines 1–148)
⋯ 91 lines hidden (lines 168–258)
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
⋯ 36 lines hidden (lines 1–36)
⋯ 141 lines hidden (lines 42–182)
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
⋯ 20 lines hidden (lines 1–20)
⋯ 4 lines hidden (lines 35–38)
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
⋯ 15 lines hidden (lines 1–15)
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
⋯ 212 lines hidden (lines 1–212)
⋯ 149 lines hidden (lines 224–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
⋯ 123 lines hidden (lines 1–123)
⋯ 245 lines hidden (lines 128–372)
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
⋯ 125 lines hidden (lines 1–125)
⋯ 150 lines hidden (lines 141–290)
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
⋯ 166 lines hidden (lines 1–166)
⋯ 109 lines hidden (lines 182–290)
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
⋯ 217 lines hidden (lines 1–217)
⋯ 34 lines hidden (lines 257–290)
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
⋯ 193 lines hidden (lines 1–193)
⋯ 87 lines hidden (lines 204–290)
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
⋯ 103 lines hidden (lines 1–103)
⋯ 173 lines hidden (lines 117–289)
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
⋯ 275 lines hidden (lines 1–275)
⋯ 337 lines hidden (lines 281–617)
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
⋯ 63 lines hidden (lines 1–63)
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.