What this PR is

One file: docs/design/game-settings-layer.md. Issue #90 is a design exploration, not an implementation — the deliverable is a recommendation, not code, a schema, or UI. This guide exists so a reviewer can do the one thing that matters for a design note: check that it's grounded in the real code. Each claim the note makes about the run economy is shown here beside the source it rests on.

The recommendation, in one breath: a lean v1 of three difficulty presets (Story · Standard · Iron), two run-shape dials (messages per run, distinct-figures cap), and a deterministic seed plumbed in. Everything else deferred. The note's spine is below.

The v1 cut, lead-with-the-answer.

The note's TL;DR · 353 lines
The note's TL;DR353 lines · Markdown
⋯ 8 lines hidden (lines 1–8)
1# Game Settings Layer — difficulty, run shape, constraints
2 
3> **Design exploration for [#90](https://github.com/mseeks/revisionist/issues/90).** This is a
4> recommendation, not an implementation. No `RunSettings` schema, no settings UI, no
5> retuning of the live curve — just the design space, a recommended v1, and the
6> implementation issues this note should spawn. For what is true in the code today,
7> read [`../../CLAUDE.md`](../../CLAUDE.md); for forward intent, [`../ROADMAP.md`](../ROADMAP.md).
8 
9## TL;DR — the recommendation
10 
11Ship a **lean v1**: three difficulty **presets** as the primary surface, **two run-shape
12dials** exposed directly, and a **deterministic seed** plumbed in (no UI yet). Defer
13everything else.
14 
15| v1 (ship) | v2 (next) | Deferred / never |
16|---|---|---|
17| **Difficulty presets:** Story · Standard · Iron | Per-dial **Custom** overrides | Daily challenge, score normalization |
18| **Messages per run** (run length) | Timeline-window constraints (era clamp, chronological-only) | Message length (it's core identity — leave fixed) |
19| **Distinct-figures cap** (incl. single-figure) | Sandbox / "anywhen" stance (its own slice) | Paid-gating (note the question; don't decide) |
20| **Seed** (plumbing only — basis for sharing/replay) | Repeat-contact + grounded-only policies | — |
⋯ 333 lines hidden (lines 21–353)
21 
22Four load-bearing decisions, each argued below:
23 
241. **Presets bundle the dials; they are not independent sliders.** The run economy is a
25 tuned system. A preset must move the band economy *with* message count and win
26 threshold, or the win rate breaks.
272. **`RunSettings` is a small serializable bundle, chosen at mission-select**, stored in
28 the run, validated server-side. Design it serializable from day one so a shared run
29 (`#88`/`#89`) can carry its rules + seed.
303. **Safety never moves.** Difficulty can make the *game* harder or easier; it can never
31 relax the server-authoritative safety floor (`#72` deceased-only, `#71` objective
32 bounds, the swing clamp, moderation). Those stay closed-enum and server-enforced.
334. **Every preset needs eval coverage** so a retune can't silently regress the EV
34 corridor the economy rests on.
35 
36## Today: one fixed configuration
37 
38The game ships as a single, well-tuned configuration. Every rule is a named constant —
39already centralized, just not player-facing. The cross-module ones live in
40[`utils/game-config.ts`](../../utils/game-config.ts); the rest sit per-module by design.
41 
42| Constant | Value | File | Controls |
43|---|---|---|---|
44| `TOTAL_MESSAGES` | `5` | `utils/game-config.ts` | dispatches allotted per run |
45| `MAX_PROGRESS` | `100` | `utils/game-config.ts` | win threshold (progress is clamped 0..100) |
46| `MAX_PROGRESS_SWING` | `50` | `utils/game-config.ts` | the per-turn fuse (hard clamp on a resolved swing) |
47| `MAX_MESSAGE_CHARS` | `160` | `utils/game-config.ts` | chars per dispatch |
48| `SWING_BANDS` | CF −35..−15 · F −15..−5 · N 0..+12 · S +15..+30 · CS +30..+45 | `utils/swing-bands.ts` | the D20 band → progress table (the run economy) |
49| dice faces | CF 1–2 · F 3–8 · N 9–12 · S 13–18 · CS 19–20 (2/6/4/6/2) | `utils/dice.ts` | the natural-roll distribution |
50| `STAKE_MULTIPLIER` / `STAKED_SWING_CAP` | `2` / `100` | `utils/swing-bands.ts` | the last-stand wager on a final dispatch |
51| `CRAFT_MODIFIER` / `CRAFT_GAIN_FACTOR` | ±2 roll / ×0.15–1.45 gain | `utils/craft.ts` | the Message Judge's two channels |
52| `MOMENTUM_MAX` / `MOMENTUM_STEP` | `4` / `0.1` (cap ×1.4) | `utils/momentum.ts` | the run-level gains amplifier |
53| `BRIDGE_FREE` / `SCALE` / `CHAIN_FLOOR` | `75` / `550` / `0.12` | `utils/causal-chain.ts` | causal-chain decay |
54| `GAIN_FACTOR` / `LOSS_FACTOR` / `KNEE` | ×1–1.6 / ×1–2 / `40` | `server/utils/anachronism.ts` | the future-knowledge wager |
55| `SAFELY_HISTORICAL_BEFORE_YEAR` | (the floor) | `server/utils/historical-floor.ts` | the safely-dead / safely-historical line (`#71`/`#72`) |
56 
57`utils/game-config.ts` is the natural seam for a settings layer — but its own header says
58only *cross-module* rules belong there; many dials are intentionally local. Part of the
59work is deciding **which constants get promoted into a player-facing `RunSettings`** and
60which stay internal.
61 
62## The core risk: the economy is a tuned system, not free dials
63 
64This is the whole reason these aren't already settings, and the reason a difficulty
65"level" must be a **bundle** rather than a row of independent sliders.
66 
67**The EV corridor.** With the documented face distribution (2/6/4/6/2 over 20) and the
68band midpoints, the expected swing for *sound, unaided* play (craft modifier 0, no wager,
69no chain, no momentum) is:
70 
71```
72P(CF)=0.10 P(F)=0.30 P(N)=0.20 P(S)=0.30 P(CS)=0.10
73mid: −25 −10 +6 +22.5 +37.5
74EV = .10(−25) + .30(−10) + .20(6) + .30(22.5) + .10(37.5) ≈ +6.2 / turn
75```
76 
77That matches the tuning intent stated in `utils/swing-bands.ts` ("roughly +6/turn" for
78sound play). Over `TOTAL_MESSAGES = 5` turns that is ≈ **+31 of 100** — an *honorable
79loss*. The gap to 100 is what craft, a calibrated anachronism wager, and momentum are
80meant to close, so victory is earned rather than drawn from a lottery.
81 
82**The pipeline that produces a turn's swing** (`callTimelineAI`,
83`server/utils/openai.ts`), in order:
84 
851. Roll a D20; **craft modifier (±2)** shifts the roll (clamped 1–20) → this picks the band.
862. Model proposes a swing; it is **clamped into the rolled band** (`SWING_BANDS`).
873. **Anachronism amplifier** (`amplifyForAnachronism`) widens both signs, asymmetric
88 (gains ×1.15/1.35/1.6, losses ×1.25/1.6/2), soft knee above ±40, re-clamped to ±50.
894. **Causal-chain decay** (`decayForChain`) diffuses both signs toward zero by distance
90 from the nearest foothold (floor 0.12).
915. **Gains-only** multipliers: positive swing × `CRAFT_GAIN_FACTOR` × `momentumFactor`.
92 Losses are never scaled — skill buys reach, never immunity.
936. **Clamp to ±`MAX_PROGRESS_SWING`** (the per-turn fuse).
947. On a final *staked* dispatch only: `applyStake` doubles, clamp ±100.
95 
96**Why the coupling bites.** The corridor is calibrated against `TOTAL_MESSAGES` *and*
97`MAX_PROGRESS` together:
98 
99- Cut messages 5 → 3 and unaided play yields ≈ +18, while the per-turn band ceiling is
100 +45 (`BAND_CEILING`). The win now demands ≈ +33/turn average — near-impossible inside
101 the bands. The run is "harder" by accident, not design.
102- Raise the win threshold 100 → 150 and the same thing happens from the other side.
103- Widen the per-turn fuse and the stake's drama collapses (every turn can already swing
104 the meter).
105 
106So a difficulty preset cannot change message count or win threshold *and leave the bands
107alone*. The bands, the message count, and the threshold move **together**, as one tuned
108bundle, validated by eval. This single fact drives the entire design: **presets, not
109sliders.**
110 
111The honest framing for a preset: it sets a *target* unaided-finish ratio (today ≈ 0.31 of
112the win bar) and a target aided EV, then derives the dials to hit them. There is one
113genuinely clean exception, and it points at the simplest KISS move: **message count
114re-prices the run while leaving every per-turn artifact byte-identical** (bands, dice,
115legend, prompt calibration all unchanged). The pragmatic call is to let message count *be*
116a difficulty axis — Story = more messages, Iron = fewer — and accept that the corridor
117ratio moves with it, rather than re-scaling the bands to hold the ratio constant. Document
118the shift as the intended knob.
119 
120### Gotchas the implementation must handle
121 
122Making these constants settings exposes seams that are invisible today because the values
123happen to line up:
124 
125- **Coincidentally-equal literals must become derived.** `STAKED_SWING_CAP` is a hardcoded
126 `100` (`utils/swing-bands.ts`), *not* a reference to `MAX_PROGRESS`. They are equal by
127 intent (a staked throw sweeps the whole meter) but would silently diverge the moment the
128 win threshold becomes a setting. Same risk anywhere a tuning value re-derives a
129 game-config number by hand.
130- **Re-typed eval constants would desync.** The ±3 valence window and the 160-char cap are
131 re-typed inside the eval fixtures, not imported. A preset touching them desyncs the evals
132 from the game silently. Promote them to imports first.
133- **`BAND_CEILING ≤ MAX_PROGRESS_SWING` is a pinned invariant** (`swing-bands.spec.ts`). Any
134 preset that reshapes the bands has to re-check it, or the amplifier loses its headroom.
135 
136## What gets promoted vs stays internal
137 
138A clean split keeps the settings surface small and the safety/tuning invariants intact.
139 
140**Promote into `RunSettings` (player-facing, per run):**
141 
142- `messagesPerRun` (← `TOTAL_MESSAGES`) — the cleanest standalone run-shape knob.
143- `winThreshold` (← `MAX_PROGRESS`) — only via presets (it re-tunes the corridor).
144- `distinctFiguresCap` — a *new* constraint (none today); 1 = single-thread challenge.
145- `difficulty` — a preset id that *bundles* the tuning dials below.
146- `seed` — deterministic dice; plumbing now, surfaced later for sharing/replay.
147 
148**Keep internal, scaled only *through* a preset (never a raw slider):** the band table,
149the dice reshaping, `CRAFT_*`, `MOMENTUM_*`, the anachronism factors, the chain decay,
150the stake. These are the tuning dials; exposing them raw is how a player breaks the
151curve. A v2 "Custom" mode can open a curated subset behind an advanced panel.
152 
153**Keep internal and immovable (safety):** `isDeceased` / `SAFELY_HISTORICAL_BEFORE_YEAR`
154(`#72`), the objective bounds (`#71`), the in-code band clamp, and moderation
155(`REVISIONIST_MODERATION`). Difficulty does not get a vote here.
156 
157**Leave fixed:** `MAX_MESSAGE_CHARS = 160`. The 160-char discipline is core identity and
158an eval'd Character-layer invariant; making it a dial dilutes the game's signature
159constraint for little variety.
160 
161## The candidate catalog, triaged
162 
163The issue's brainstorm (groups A–G), cut to a v1 verdict. The full rationale for the
164"economy" rows is the section above.
165 
166| Candidate | Maps to | Verdict | Why |
167|---|---|---|---|
168| Messages per run | `TOTAL_MESSAGES` | **v1 dial** | cleanest run-shape lever; couples to the corridor, so its retune rides the preset |
169| Distinct-figures cap | new constraint | **v1 dial** | adds real variety (single-figure run), no economy coupling, server-checkable |
170| Difficulty preset | bundles the tuning dials | **v1 (primary)** | the only safe way to move the coupled economy |
171| Win threshold | `MAX_PROGRESS` | **v1, preset-only** | moves the corridor — must ride a preset, never a free slider |
172| Per-turn fuse | `MAX_PROGRESS_SWING` | **internal** | a safety clamp + the stake's basis; not a player knob |
173| Roll variance / band reshape | dice + bands | **internal (preset-scaled)** | the corridor itself |
174| Craft weight / anachronism / decay / momentum / stake | the tuning dials | **internal (preset-scaled)** | exposing raw breaks tuning |
175| Timeline-window constraints (era clamp, chronological-only) | new, atop the when-slider | **v2** | strong variety, but needs its own UX + eval; lifetime bound already exists |
176| Sandbox / "anywhen" | drop lifetime + safely-dead bounds | **deferred (own slice)** | collides with the safety floor; a deliberate separate stance, not a difficulty knob |
177| Repeat-contact / grounded-only | figure policy | **v2** | grounded-only is already the floor (`#73`); repeat-contact is a light add |
178| Safely-dead cutoff relax | `#72` floor | **never (as difficulty)** | safety, server-authoritative — cannot be a difficulty lever |
179| Objective source / steering / anchor visibility / reroll cap | `#71`, objective compose | **v2** | ties to objective work, not the core economy |
180| Assist (tutorial, scaffolding, archive access) | `#60`, UI | **folds into presets** | Story shows scaffolding; Iron hides it + caps research |
181| Seed | new | **v1 (plumbing)** | the basis for fair sharing/replay; cheap to land early |
182| Daily challenge / speedrun / score normalization | meta modes | **deferred** | depend on seed + sharing; design after the bundle exists |
183 
184## The v1 proposal: three presets
185 
186Three is the right number: an on-ramp, the tuned default, and a brutal mode — from one
187engine. The dial values below are **illustrative targets to be validated by eval**, not
188final tuning (the issue forbids re-tuning the live curve here; this just shows *where*
189each preset would re-tune it).
190 
191| Dimension | **Story** (on-ramp) | **Standard** (today) | **Iron** (veteran) |
192|---|---|---|---|
193| Target unaided EV/turn | higher (~+9) | ~+6 (today) | lower (~+4) |
194| Messages per run | 6–7 | **5** | 4 |
195| Win threshold | 100 | **100** | 100 (decisive feel via tighter economy, not a higher bar) |
196| Band economy | gentler losses, fatter Success | **today** | harsher losses, leaner gains |
197| Anachronism penalty | softened | **today** | harsher losses |
198| Causal-chain decay | gentler | **today** | steeper (chaining matters more) |
199| Last stand (stake) | on | **on** | **off** (no safety net) |
200| Scaffolding (band table, pace hint, ⚡/⏳ chips) | all shown | **shown** | hidden |
201| Archive (research) | unlimited | **unlimited** | capped or disabled |
202| Guided first-run (`#60`) | on | default | off |
203 
204Standard *is* the current configuration — shipping presets must not change today's game.
205Story and Iron are symmetric moves outward from it, each a coherent bundle rather than a
206pile of toggles. Note that "harder" on Iron comes mostly from a leaner economy and a
207removed safety net, **not** from raising the win bar — keeping the meter legible.
208 
209## Surface, persistence, and travel
210 
211**Presets over a wall of sliders.** The mission-select briefing
212([`components/MissionSelect.vue`](../../components/MissionSelect.vue)) gains one row: a
213three-way difficulty chip plus the two run-shape dials (messages, figure cap). That is
214the entire v1 surface. A "Custom" disclosure (per-dial overrides) is explicitly v2.
215 
216**Where settings live.** A run starts when the player commits an objective
217(`chooseObjective` in `stores/game.ts`; a null `currentObjective` is what surfaces the
218briefing). `RunSettings` is authored in the same briefing — a sibling fieldset to the
219existing era/theme steer dials, closed enums, committed only on Begin — threaded through
220`chooseObjective` into **one new `runSettings` state field cleared by `resetGame`**, and
221read by `sendMessage` and the pure dials. Because it carries only pre-run choices (no
222dates, no server `runId`, none of the staleness/account plumbing), it round-trips through
223JSON or a URL param cleanly — which is exactly what makes it the right unit to travel with
224a run. It must work for **anonymous** players (the default path today). A saved profile
225default is a later tie to accounts (`#83`), layered on top — the per-run choice is the
226primitive.
227 
228**Settings travel with a run.** There is **no run snapshot, share, or seed concept in the
229code today** — a run is entirely live and ephemeral. That makes this greenfield, and it
230argues for designing `RunSettings` as a **small serializable bundle from the start**, next
231to a `seed`. For shared-run "beat this" (`#88`) and replay (`#89`) to be *fair*, the
232snapshot has to carry both the rules and the seed; otherwise two players aren't playing
233the same game. The seed is the one piece worth plumbing in v1 even before daily/seeded
234modes ship, because retrofitting determinism later is far more invasive than reserving the
235field now (the dice live behind `rollD20` in `utils/dice.ts` — a single, swappable source).
236 
237## Safety stays server-authoritative
238 
239Several knobs are server-enforced and closed-enum on purpose: the deceased-only contact
240floor (`isDeceased`, `server/utils/figure-grounding.ts`, fail-closed — a figure with no
241confirmed death is never contactable), the objective bounds (`#71`), the in-code clamp of
242the engine's swing into the rolled band, and moderation. A settings layer **must not
243become a client bypass.** Concretely:
244 
245- `RunSettings` is **validated and clamped server-side**, to an allowed range per field —
246 the client's chosen values are untrusted input, like every other field. This is the
247 pattern already in place: `send-message.post.ts` and `research.post.ts` **re-ground the
248 figure name** against the untrusted payload rather than trusting a client claim of "this
249 person is contactable."
250- Difficulty scales the *game* (harder/easier), never the *safety floor*. There is no
251 difficulty that lets you message a living person or dodge moderation. A Sandbox / "anywhen"
252 stance that *did* relax the lifetime + deceased-only bounds (its own deferred slice, not a
253 difficulty knob) would flip a **server-read** flag, never a client toggle the POST handler
254 honors — and moderation (`REVISIONIST_MODERATION`) is never surfaced to players at all;
255 `off` is an operator/calibration lever only.
256- The closed enums stay closed. A preset id is one of a fixed set; an out-of-range message
257 count or figure cap is rejected, not honored.
258 
259## Evals: a preset can't silently break the curve
260 
261The tuning expectations already live as evals, and a new difficulty knob has to extend
262them or it can regress the curve invisibly:
263 
264- [`evals/bakeoff.eval.ts`](../../evals/bakeoff.eval.ts) scores **the NEUTRAL-band EV
265 corridor the run economy is tuned around** — the exact invariant a preset's retune
266 threatens.
267- [`evals/behavior.eval.ts`](../../evals/behavior.eval.ts) checks the model-behavior
268 invariants the mechanics rest on (band steering, `sharp ≥ vague` craft ordering,
269 anachronism accuracy).
270- [`evals/causal-chain.eval.ts`](../../evals/causal-chain.eval.ts) confirms stepping
271 stones beat blasts end-to-end; [`evals/judge-tune.eval.ts`](../../evals/judge-tune.eval.ts)
272 pins craft-grade anchoring (which feeds the economy).
273 
274But the existing coverage has two gaps a preset would fall straight through, and naming
275them is half the value of this exploration:
276 
2771. **The corridor eval guards the wrong number.** It scores `baseProgressChange` — the swing
278 *before* the anachronism / causal-chain / craft-gain / momentum multipliers. So the four
279 downstream multipliers are an **unguarded realized-EV surface**: a "hard" preset that
280 halved `CRAFT_GAIN_FACTOR` would change the EV the player actually experiences while the
281 corridor eval stayed green. The `[4,9]` corridor is a real invariant, but it is *not* the
282 EV the run is won or lost on.
2832. **The corridor bounds are hardcoded literals, not a function of the preset.** If Story
284 legitimately targets a higher neutral EV and Iron a lower one, today's absolute `[4,9]`
285 assertion would flag the *intended* presets as misses. The corridor eval has to become
286 **preset-parameterized** — fed each preset's target band.
287 
288**Requirement for the implementation:** each shipped preset needs (a) a parameterized
289pre-multiplier corridor assertion fed its own target, and (b) a **realized-EV** assertion
290that composes the full pipeline (so a multiplier change can't move the curve unseen) and a
291plausible win rate at sound + calibrated play. The pure dials (bands, decay, momentum,
292craft) are already unit-tested in isolation; the new surface to cover is *the composition
293under each preset*.
294 
295## Scope & monetization
296 
297v1 is deliberately small: presets + two dials + seed plumbing. The open question worth
298**noting, not deciding here**: whether any setting (e.g. seeded/daily competitive modes,
299or an Iron leaderboard) is paid-gated. The accounts/paywall seam already exists (`#83`,
300the runs economy); this note flags the question and leaves the call to the monetization
301track.
302 
303## Recommended v1, restated
304 
305- **Surface:** three difficulty presets (Story · Standard · Iron) + two run-shape dials
306 (messages per run, distinct-figures cap), chosen at mission-select. Custom per-dial
307 overrides deferred to v2.
308- **Model:** a small serializable `RunSettings` bundle (`difficulty`, `messagesPerRun`,
309 `distinctFiguresCap`, `seed`), validated + clamped server-side, stored in the run,
310 anonymous-friendly, profile-default later (`#83`).
311- **Economy:** each preset re-tunes the bundle (message count + band economy + the wager /
312 decay / momentum / stake) **together**, with Standard == today's game unchanged.
313- **Travel:** plumb a deterministic `seed` now; carry `RunSettings` + `seed` in the run
314 snapshot when sharing/replay (`#88`/`#89`) lands.
315- **Guardrails:** safety floor untouched and server-authoritative; an EV-corridor eval per
316 preset.
317 
318## Implementation issues this note spawns
319 
320Ready-to-file, in dependency order:
321 
3221. **`RunSettings` bundle + server validation + seed plumbing.** Define the serializable
323 type (`difficulty`, `messagesPerRun`, `distinctFiguresCap`, `seed`), thread it through
324 the store run-state and `sendMessage`, validate + clamp server-side, and route the dice
325 through a seeded source behind `rollD20`. No UI beyond a default. *Foundation for the
326 rest.*
3272. **Difficulty presets + economy retune + eval coverage.** Implement Story/Standard/Iron
328 as preset bundles that scale the internal tuning dials together; Standard reproduces
329 today's curve exactly. Make the corridor eval **preset-parameterized** and add a
330 **realized-EV** assertion (full pipeline, not just `baseProgressChange`) per preset.
331 First promote the re-typed eval constants (±3 valence, 160-char) to imports and derive
332 `STAKED_SWING_CAP` from the win threshold so nothing diverges silently. *Depends on 1.*
3333. **Run-shape dials at mission-select.** Surface `messagesPerRun` and
334 `distinctFiguresCap` (incl. the single-figure challenge) in `MissionSelect.vue`, with
335 the figure cap enforced server-side. *Depends on 1; pairs with 2 for the message-count
336 retune.*
3374. **Settings + seed travel with a shared run.** Carry `RunSettings` + `seed` in the run
338 snapshot so a shared/replayed run is the same game. *Depends on 1 and on `#88`/`#89`.*
339 
340## Explicitly out of scope (per #90)
341 
342No implementation, no final `RunSettings` schema, no settings UI here. No commitment to the
343final set — this is divergent brainstorming plus a recommendation. Not a re-tuning of the
344existing curve; this only identifies *where* each difficulty would re-tune it.
345 
346---
347 
348*Related:* [#83](https://github.com/mseeks/revisionist/issues/83) (accounts — saved
349defaults) · [#88](https://github.com/mseeks/revisionist/issues/88) /
350[#89](https://github.com/mseeks/revisionist/issues/89) (sharing & replay — why a run
351should carry its settings + seed) · [#71](https://github.com/mseeks/revisionist/issues/71)
352(objective steering) · [#72](https://github.com/mseeks/revisionist/issues/72) (safely-dead
353floor) · [#60](https://github.com/mseeks/revisionist/issues/60) (guided first-run).

The run economy is a tuned system, not free dials

This is the load-bearing claim of the whole note, and the reason a difficulty 'level' has to be a bundle rather than a row of independent sliders. The cross-module constants are small and already centralized — they're just not player-facing yet.

TOTAL_MESSAGES, MAX_PROGRESS (win bar), MAX_PROGRESS_SWING (the per-turn fuse).

The config seam · 31 lines
The config seam31 lines · TypeScript
⋯ 8 lines hidden (lines 1–8)
1/**
2 * Shared game-rule constants — named once so the store, the server validation,
3 * the post-game summary, and the input UI can't silently drift apart. Each was
4 * surfaced as a re-typed literal by the duplicated-constant loop. (Per-module
5 * rules that live in a single file — the rate-limit window, the dice bands —
6 * stay where they are used; only the genuinely cross-module ones live here.)
7 */
8 
9/** Messages allotted per run. */
10export const TOTAL_MESSAGES = 5
11 
12/** Hard cap on a single message's length, in characters. */
13export const MAX_MESSAGE_CHARS = 160
14 
15/**
16 * Hard safety cap on a single resolved turn's progress swing, in percentage
17 * points (±). The Timeline Engine's base swing is clamped to this and the
18 * anachronism amplifier re-clamps to it. Deliberately a touch WIDER than the
19 * instructed band ceiling (`BAND_CEILING` in `utils/swing-bands.ts`, the range
20 * the prompt + schema state to the model), so the amplifier has real headroom
21 * above the bands; the band table is the tuning surface, this is the fuse.
22 */
23export const MAX_PROGRESS_SWING = 50
24 
25/**
26 * The objective-progress ceiling, in percentage points: a run's progress is clamped
27 * to 0..MAX_PROGRESS and victory is reaching it. Cross-module — the store gates and
28 * clamps against it, and /api/chronicle sanitizes the client-supplied progress to it
29 * before the Chronicler narrates — so it lives here rather than being re-typed.
30 */
31export const MAX_PROGRESS = 100

The D20 outcome → progress-swing table, single-sourced; BAND_CEILING derives from it (= 45).

The band table — the run economy · 70 lines
The band table — the run economy70 lines · TypeScript
⋯ 23 lines hidden (lines 1–23)
1import { DiceOutcome } from '~/utils/dice'
2 
3/**
4 * The D20 band → progress-swing table, named once. The Timeline Engine's prompt
5 * renders its calibration lines FROM this table, the response schema states the
6 * same ceiling, and the UI's roll legend reads it too — so the numbers the model
7 * is told, the numbers the code enforces, and the numbers the player is shown
8 * can't silently drift apart. A rebalance is one edit, here.
9 *
10 * Tuning intent (the run economy): five messages must reach 100. With sound play
11 * the expected swing is roughly +6/turn — losing, but honorably; craft (the
12 * Message Judge's roll modifier) and a well-calibrated anachronism wager push a
13 * thoughtful run to roughly +12..16/turn, where victory is earned rather than
14 * drawn from a lottery. Dice still dominate any single turn; judgment decides
15 * the campaign.
16 */
17export interface SwingBand {
18 /** Most negative end of the band's instructed range, in progress points. */
19 min: number
20 /** Most positive end of the band's instructed range, in progress points. */
21 max: number
23 
24export const SWING_BANDS: Record<DiceOutcome, SwingBand> = {
25 [DiceOutcome.CRITICAL_SUCCESS]: { min: 30, max: 45 },
26 [DiceOutcome.SUCCESS]: { min: 15, max: 30 },
27 [DiceOutcome.NEUTRAL]: { min: 0, max: 12 },
28 [DiceOutcome.FAILURE]: { min: -15, max: -5 },
29 [DiceOutcome.CRITICAL_FAILURE]: { min: -35, max: -15 }
⋯ 7 lines hidden (lines 31–37)
31 
32/**
33 * The largest base magnitude any band allows — the integer range stated to the
34 * model (±45). Derived, not re-typed; the hard safety clamp in game-config
35 * (±MAX_PROGRESS_SWING) stays slightly wider so the anachronism amplifier has
36 * headroom above the bands.
37 */
38export const BAND_CEILING = Math.max(
39 ...Object.values(SWING_BANDS).flatMap(b => [Math.abs(b.min), Math.abs(b.max)])
⋯ 30 lines hidden (lines 41–70)
41 
42/**
43 * The neutral window for the Timeline Engine's valence sign-guard: a resolved swing
44 * whose magnitude exceeds this must wear the color of its sign (a "positive" badge on
45 * a clear loss reads as arbitrary), while a smaller swing keeps the model's shading.
46 * Applied once in callTimelineAI on the pre-stake swing and re-asserted in
47 * send-message.post.ts on a doubled (staked) swing — named once so the two can't drift.
48 */
49export const VALENCE_SIGN_GUARD_MIN = 3
50 
51/**
52 * The last stand: when the FINAL dispatch of a run can no longer win inside the
53 * normal fuse (the gap exceeds MAX_PROGRESS_SWING — the store's `canStake`),
54 * the player may STAKE THE TIMELINE — the resolved swing doubles, both ways,
55 * and escapes the per-turn cap up to the full meter. Structurally this means no
56 * run is ever mathematically dead while a message remains. Deliberately NOT
57 * offered from winnable positions: doubling never lowers win odds on a final
58 * throw, so an unrestricted stake would be a dominance trap, not a decision.
59 * The doubling itself is applied server-side after amplification.
60 */
61export const STAKE_MULTIPLIER = 2
62export const STAKED_SWING_CAP = 100
63 
64/** One-line hover hint for the ⚑ stake badge — the last-stand wager. */
65export const STAKE_HINT = 'the last stand, only on your final dispatch — doubles the swing, both ways'
66 
67export function applyStake(progressChange: number): number {
68 const staked = Math.round(progressChange * STAKE_MULTIPLIER)
69 return Math.max(-STAKED_SWING_CAP, Math.min(STAKED_SWING_CAP, staked))

STAKED_SWING_CAP is a hardcoded 100, not a reference to MAX_PROGRESS — a seam the note flags.

The last stand (context) · 70 lines
The last stand (context)70 lines · TypeScript
⋯ 50 lines hidden (lines 1–50)
1import { DiceOutcome } from '~/utils/dice'
2 
3/**
4 * The D20 band → progress-swing table, named once. The Timeline Engine's prompt
5 * renders its calibration lines FROM this table, the response schema states the
6 * same ceiling, and the UI's roll legend reads it too — so the numbers the model
7 * is told, the numbers the code enforces, and the numbers the player is shown
8 * can't silently drift apart. A rebalance is one edit, here.
9 *
10 * Tuning intent (the run economy): five messages must reach 100. With sound play
11 * the expected swing is roughly +6/turn — losing, but honorably; craft (the
12 * Message Judge's roll modifier) and a well-calibrated anachronism wager push a
13 * thoughtful run to roughly +12..16/turn, where victory is earned rather than
14 * drawn from a lottery. Dice still dominate any single turn; judgment decides
15 * the campaign.
16 */
17export interface SwingBand {
18 /** Most negative end of the band's instructed range, in progress points. */
19 min: number
20 /** Most positive end of the band's instructed range, in progress points. */
21 max: number
23 
24export const SWING_BANDS: Record<DiceOutcome, SwingBand> = {
25 [DiceOutcome.CRITICAL_SUCCESS]: { min: 30, max: 45 },
26 [DiceOutcome.SUCCESS]: { min: 15, max: 30 },
27 [DiceOutcome.NEUTRAL]: { min: 0, max: 12 },
28 [DiceOutcome.FAILURE]: { min: -15, max: -5 },
29 [DiceOutcome.CRITICAL_FAILURE]: { min: -35, max: -15 }
31 
32/**
33 * The largest base magnitude any band allows — the integer range stated to the
34 * model (±45). Derived, not re-typed; the hard safety clamp in game-config
35 * (±MAX_PROGRESS_SWING) stays slightly wider so the anachronism amplifier has
36 * headroom above the bands.
37 */
38export const BAND_CEILING = Math.max(
39 ...Object.values(SWING_BANDS).flatMap(b => [Math.abs(b.min), Math.abs(b.max)])
41 
42/**
43 * The neutral window for the Timeline Engine's valence sign-guard: a resolved swing
44 * whose magnitude exceeds this must wear the color of its sign (a "positive" badge on
45 * a clear loss reads as arbitrary), while a smaller swing keeps the model's shading.
46 * Applied once in callTimelineAI on the pre-stake swing and re-asserted in
47 * send-message.post.ts on a doubled (staked) swing — named once so the two can't drift.
48 */
49export const VALENCE_SIGN_GUARD_MIN = 3
50 
51/**
52 * The last stand: when the FINAL dispatch of a run can no longer win inside the
53 * normal fuse (the gap exceeds MAX_PROGRESS_SWING — the store's `canStake`),
54 * the player may STAKE THE TIMELINE — the resolved swing doubles, both ways,
55 * and escapes the per-turn cap up to the full meter. Structurally this means no
56 * run is ever mathematically dead while a message remains. Deliberately NOT
57 * offered from winnable positions: doubling never lowers win odds on a final
58 * throw, so an unrestricted stake would be a dominance trap, not a decision.
59 * The doubling itself is applied server-side after amplification.
60 */
61export const STAKE_MULTIPLIER = 2
62export const STAKED_SWING_CAP = 100
63 
64/** One-line hover hint for the ⚑ stake badge — the last-stand wager. */
65export const STAKE_HINT = 'the last stand, only on your final dispatch — doubles the swing, both ways'
66 
67export function applyStake(progressChange: number): number {
68 const staked = Math.round(progressChange * STAKE_MULTIPLIER)
69 return Math.max(-STAKED_SWING_CAP, Math.min(STAKED_SWING_CAP, staked))

The swing pipeline — exactly the order the note claims

The note describes how one turn's swing is built. Here is that pipeline in callTimelineAI, verbatim. Read it top to bottom: rolled band → anachronism amplify → causal decay → gains-only craft × momentum → clamp to the ±50 fuse. (The stake doubling, on a final staked dispatch only, is applied later, server-side.)

The single place the economy resolves; the note's pipeline section maps 1:1 to this.

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

Why presets, not sliders

The corridor is tuned against TOTAL_MESSAGES and MAX_PROGRESS together. Cut messages 5 → 3 and unaided play yields ≈ +18 while the per-turn band ceiling is +45 — the win now demands ≈ +33/turn, near-impossible inside the bands. So a preset cannot move message count or the win bar and leave the bands alone; they move as one bundle. That single fact drives the entire design.

The note's coupling argument · 353 lines
The note's coupling argument353 lines · Markdown
⋯ 95 lines hidden (lines 1–95)
1# Game Settings Layer — difficulty, run shape, constraints
2 
3> **Design exploration for [#90](https://github.com/mseeks/revisionist/issues/90).** This is a
4> recommendation, not an implementation. No `RunSettings` schema, no settings UI, no
5> retuning of the live curve — just the design space, a recommended v1, and the
6> implementation issues this note should spawn. For what is true in the code today,
7> read [`../../CLAUDE.md`](../../CLAUDE.md); for forward intent, [`../ROADMAP.md`](../ROADMAP.md).
8 
9## TL;DR — the recommendation
10 
11Ship a **lean v1**: three difficulty **presets** as the primary surface, **two run-shape
12dials** exposed directly, and a **deterministic seed** plumbed in (no UI yet). Defer
13everything else.
14 
15| v1 (ship) | v2 (next) | Deferred / never |
16|---|---|---|
17| **Difficulty presets:** Story · Standard · Iron | Per-dial **Custom** overrides | Daily challenge, score normalization |
18| **Messages per run** (run length) | Timeline-window constraints (era clamp, chronological-only) | Message length (it's core identity — leave fixed) |
19| **Distinct-figures cap** (incl. single-figure) | Sandbox / "anywhen" stance (its own slice) | Paid-gating (note the question; don't decide) |
20| **Seed** (plumbing only — basis for sharing/replay) | Repeat-contact + grounded-only policies | — |
21 
22Four load-bearing decisions, each argued below:
23 
241. **Presets bundle the dials; they are not independent sliders.** The run economy is a
25 tuned system. A preset must move the band economy *with* message count and win
26 threshold, or the win rate breaks.
272. **`RunSettings` is a small serializable bundle, chosen at mission-select**, stored in
28 the run, validated server-side. Design it serializable from day one so a shared run
29 (`#88`/`#89`) can carry its rules + seed.
303. **Safety never moves.** Difficulty can make the *game* harder or easier; it can never
31 relax the server-authoritative safety floor (`#72` deceased-only, `#71` objective
32 bounds, the swing clamp, moderation). Those stay closed-enum and server-enforced.
334. **Every preset needs eval coverage** so a retune can't silently regress the EV
34 corridor the economy rests on.
35 
36## Today: one fixed configuration
37 
38The game ships as a single, well-tuned configuration. Every rule is a named constant —
39already centralized, just not player-facing. The cross-module ones live in
40[`utils/game-config.ts`](../../utils/game-config.ts); the rest sit per-module by design.
41 
42| Constant | Value | File | Controls |
43|---|---|---|---|
44| `TOTAL_MESSAGES` | `5` | `utils/game-config.ts` | dispatches allotted per run |
45| `MAX_PROGRESS` | `100` | `utils/game-config.ts` | win threshold (progress is clamped 0..100) |
46| `MAX_PROGRESS_SWING` | `50` | `utils/game-config.ts` | the per-turn fuse (hard clamp on a resolved swing) |
47| `MAX_MESSAGE_CHARS` | `160` | `utils/game-config.ts` | chars per dispatch |
48| `SWING_BANDS` | CF −35..−15 · F −15..−5 · N 0..+12 · S +15..+30 · CS +30..+45 | `utils/swing-bands.ts` | the D20 band → progress table (the run economy) |
49| dice faces | CF 1–2 · F 3–8 · N 9–12 · S 13–18 · CS 19–20 (2/6/4/6/2) | `utils/dice.ts` | the natural-roll distribution |
50| `STAKE_MULTIPLIER` / `STAKED_SWING_CAP` | `2` / `100` | `utils/swing-bands.ts` | the last-stand wager on a final dispatch |
51| `CRAFT_MODIFIER` / `CRAFT_GAIN_FACTOR` | ±2 roll / ×0.15–1.45 gain | `utils/craft.ts` | the Message Judge's two channels |
52| `MOMENTUM_MAX` / `MOMENTUM_STEP` | `4` / `0.1` (cap ×1.4) | `utils/momentum.ts` | the run-level gains amplifier |
53| `BRIDGE_FREE` / `SCALE` / `CHAIN_FLOOR` | `75` / `550` / `0.12` | `utils/causal-chain.ts` | causal-chain decay |
54| `GAIN_FACTOR` / `LOSS_FACTOR` / `KNEE` | ×1–1.6 / ×1–2 / `40` | `server/utils/anachronism.ts` | the future-knowledge wager |
55| `SAFELY_HISTORICAL_BEFORE_YEAR` | (the floor) | `server/utils/historical-floor.ts` | the safely-dead / safely-historical line (`#71`/`#72`) |
56 
57`utils/game-config.ts` is the natural seam for a settings layer — but its own header says
58only *cross-module* rules belong there; many dials are intentionally local. Part of the
59work is deciding **which constants get promoted into a player-facing `RunSettings`** and
60which stay internal.
61 
62## The core risk: the economy is a tuned system, not free dials
63 
64This is the whole reason these aren't already settings, and the reason a difficulty
65"level" must be a **bundle** rather than a row of independent sliders.
66 
67**The EV corridor.** With the documented face distribution (2/6/4/6/2 over 20) and the
68band midpoints, the expected swing for *sound, unaided* play (craft modifier 0, no wager,
69no chain, no momentum) is:
70 
71```
72P(CF)=0.10 P(F)=0.30 P(N)=0.20 P(S)=0.30 P(CS)=0.10
73mid: −25 −10 +6 +22.5 +37.5
74EV = .10(−25) + .30(−10) + .20(6) + .30(22.5) + .10(37.5) ≈ +6.2 / turn
75```
76 
77That matches the tuning intent stated in `utils/swing-bands.ts` ("roughly +6/turn" for
78sound play). Over `TOTAL_MESSAGES = 5` turns that is ≈ **+31 of 100** — an *honorable
79loss*. The gap to 100 is what craft, a calibrated anachronism wager, and momentum are
80meant to close, so victory is earned rather than drawn from a lottery.
81 
82**The pipeline that produces a turn's swing** (`callTimelineAI`,
83`server/utils/openai.ts`), in order:
84 
851. Roll a D20; **craft modifier (±2)** shifts the roll (clamped 1–20) → this picks the band.
862. Model proposes a swing; it is **clamped into the rolled band** (`SWING_BANDS`).
873. **Anachronism amplifier** (`amplifyForAnachronism`) widens both signs, asymmetric
88 (gains ×1.15/1.35/1.6, losses ×1.25/1.6/2), soft knee above ±40, re-clamped to ±50.
894. **Causal-chain decay** (`decayForChain`) diffuses both signs toward zero by distance
90 from the nearest foothold (floor 0.12).
915. **Gains-only** multipliers: positive swing × `CRAFT_GAIN_FACTOR` × `momentumFactor`.
92 Losses are never scaled — skill buys reach, never immunity.
936. **Clamp to ±`MAX_PROGRESS_SWING`** (the per-turn fuse).
947. On a final *staked* dispatch only: `applyStake` doubles, clamp ±100.
95 
96**Why the coupling bites.** The corridor is calibrated against `TOTAL_MESSAGES` *and*
97`MAX_PROGRESS` together:
98 
99- Cut messages 5 → 3 and unaided play yields ≈ +18, while the per-turn band ceiling is
100 +45 (`BAND_CEILING`). The win now demands ≈ +33/turn average — near-impossible inside
101 the bands. The run is "harder" by accident, not design.
102- Raise the win threshold 100 → 150 and the same thing happens from the other side.
103- Widen the per-turn fuse and the stake's drama collapses (every turn can already swing
104 the meter).
105 
106So a difficulty preset cannot change message count or win threshold *and leave the bands
107alone*. The bands, the message count, and the threshold move **together**, as one tuned
108bundle, validated by eval. This single fact drives the entire design: **presets, not
109sliders.**
110 
111The honest framing for a preset: it sets a *target* unaided-finish ratio (today ≈ 0.31 of
112the win bar) and a target aided EV, then derives the dials to hit them. There is one
113genuinely clean exception, and it points at the simplest KISS move: **message count
114re-prices the run while leaving every per-turn artifact byte-identical** (bands, dice,
115legend, prompt calibration all unchanged). The pragmatic call is to let message count *be*
116a difficulty axis — Story = more messages, Iron = fewer — and accept that the corridor
117ratio moves with it, rather than re-scaling the bands to hold the ratio constant. Document
118the shift as the intended knob.
⋯ 235 lines hidden (lines 119–353)
119 
120### Gotchas the implementation must handle
121 
122Making these constants settings exposes seams that are invisible today because the values
123happen to line up:
124 
125- **Coincidentally-equal literals must become derived.** `STAKED_SWING_CAP` is a hardcoded
126 `100` (`utils/swing-bands.ts`), *not* a reference to `MAX_PROGRESS`. They are equal by
127 intent (a staked throw sweeps the whole meter) but would silently diverge the moment the
128 win threshold becomes a setting. Same risk anywhere a tuning value re-derives a
129 game-config number by hand.
130- **Re-typed eval constants would desync.** The ±3 valence window and the 160-char cap are
131 re-typed inside the eval fixtures, not imported. A preset touching them desyncs the evals
132 from the game silently. Promote them to imports first.
133- **`BAND_CEILING ≤ MAX_PROGRESS_SWING` is a pinned invariant** (`swing-bands.spec.ts`). Any
134 preset that reshapes the bands has to re-check it, or the amplifier loses its headroom.
135 
136## What gets promoted vs stays internal
137 
138A clean split keeps the settings surface small and the safety/tuning invariants intact.
139 
140**Promote into `RunSettings` (player-facing, per run):**
141 
142- `messagesPerRun` (← `TOTAL_MESSAGES`) — the cleanest standalone run-shape knob.
143- `winThreshold` (← `MAX_PROGRESS`) — only via presets (it re-tunes the corridor).
144- `distinctFiguresCap` — a *new* constraint (none today); 1 = single-thread challenge.
145- `difficulty` — a preset id that *bundles* the tuning dials below.
146- `seed` — deterministic dice; plumbing now, surfaced later for sharing/replay.
147 
148**Keep internal, scaled only *through* a preset (never a raw slider):** the band table,
149the dice reshaping, `CRAFT_*`, `MOMENTUM_*`, the anachronism factors, the chain decay,
150the stake. These are the tuning dials; exposing them raw is how a player breaks the
151curve. A v2 "Custom" mode can open a curated subset behind an advanced panel.
152 
153**Keep internal and immovable (safety):** `isDeceased` / `SAFELY_HISTORICAL_BEFORE_YEAR`
154(`#72`), the objective bounds (`#71`), the in-code band clamp, and moderation
155(`REVISIONIST_MODERATION`). Difficulty does not get a vote here.
156 
157**Leave fixed:** `MAX_MESSAGE_CHARS = 160`. The 160-char discipline is core identity and
158an eval'd Character-layer invariant; making it a dial dilutes the game's signature
159constraint for little variety.
160 
161## The candidate catalog, triaged
162 
163The issue's brainstorm (groups A–G), cut to a v1 verdict. The full rationale for the
164"economy" rows is the section above.
165 
166| Candidate | Maps to | Verdict | Why |
167|---|---|---|---|
168| Messages per run | `TOTAL_MESSAGES` | **v1 dial** | cleanest run-shape lever; couples to the corridor, so its retune rides the preset |
169| Distinct-figures cap | new constraint | **v1 dial** | adds real variety (single-figure run), no economy coupling, server-checkable |
170| Difficulty preset | bundles the tuning dials | **v1 (primary)** | the only safe way to move the coupled economy |
171| Win threshold | `MAX_PROGRESS` | **v1, preset-only** | moves the corridor — must ride a preset, never a free slider |
172| Per-turn fuse | `MAX_PROGRESS_SWING` | **internal** | a safety clamp + the stake's basis; not a player knob |
173| Roll variance / band reshape | dice + bands | **internal (preset-scaled)** | the corridor itself |
174| Craft weight / anachronism / decay / momentum / stake | the tuning dials | **internal (preset-scaled)** | exposing raw breaks tuning |
175| Timeline-window constraints (era clamp, chronological-only) | new, atop the when-slider | **v2** | strong variety, but needs its own UX + eval; lifetime bound already exists |
176| Sandbox / "anywhen" | drop lifetime + safely-dead bounds | **deferred (own slice)** | collides with the safety floor; a deliberate separate stance, not a difficulty knob |
177| Repeat-contact / grounded-only | figure policy | **v2** | grounded-only is already the floor (`#73`); repeat-contact is a light add |
178| Safely-dead cutoff relax | `#72` floor | **never (as difficulty)** | safety, server-authoritative — cannot be a difficulty lever |
179| Objective source / steering / anchor visibility / reroll cap | `#71`, objective compose | **v2** | ties to objective work, not the core economy |
180| Assist (tutorial, scaffolding, archive access) | `#60`, UI | **folds into presets** | Story shows scaffolding; Iron hides it + caps research |
181| Seed | new | **v1 (plumbing)** | the basis for fair sharing/replay; cheap to land early |
182| Daily challenge / speedrun / score normalization | meta modes | **deferred** | depend on seed + sharing; design after the bundle exists |
183 
184## The v1 proposal: three presets
185 
186Three is the right number: an on-ramp, the tuned default, and a brutal mode — from one
187engine. The dial values below are **illustrative targets to be validated by eval**, not
188final tuning (the issue forbids re-tuning the live curve here; this just shows *where*
189each preset would re-tune it).
190 
191| Dimension | **Story** (on-ramp) | **Standard** (today) | **Iron** (veteran) |
192|---|---|---|---|
193| Target unaided EV/turn | higher (~+9) | ~+6 (today) | lower (~+4) |
194| Messages per run | 6–7 | **5** | 4 |
195| Win threshold | 100 | **100** | 100 (decisive feel via tighter economy, not a higher bar) |
196| Band economy | gentler losses, fatter Success | **today** | harsher losses, leaner gains |
197| Anachronism penalty | softened | **today** | harsher losses |
198| Causal-chain decay | gentler | **today** | steeper (chaining matters more) |
199| Last stand (stake) | on | **on** | **off** (no safety net) |
200| Scaffolding (band table, pace hint, ⚡/⏳ chips) | all shown | **shown** | hidden |
201| Archive (research) | unlimited | **unlimited** | capped or disabled |
202| Guided first-run (`#60`) | on | default | off |
203 
204Standard *is* the current configuration — shipping presets must not change today's game.
205Story and Iron are symmetric moves outward from it, each a coherent bundle rather than a
206pile of toggles. Note that "harder" on Iron comes mostly from a leaner economy and a
207removed safety net, **not** from raising the win bar — keeping the meter legible.
208 
209## Surface, persistence, and travel
210 
211**Presets over a wall of sliders.** The mission-select briefing
212([`components/MissionSelect.vue`](../../components/MissionSelect.vue)) gains one row: a
213three-way difficulty chip plus the two run-shape dials (messages, figure cap). That is
214the entire v1 surface. A "Custom" disclosure (per-dial overrides) is explicitly v2.
215 
216**Where settings live.** A run starts when the player commits an objective
217(`chooseObjective` in `stores/game.ts`; a null `currentObjective` is what surfaces the
218briefing). `RunSettings` is authored in the same briefing — a sibling fieldset to the
219existing era/theme steer dials, closed enums, committed only on Begin — threaded through
220`chooseObjective` into **one new `runSettings` state field cleared by `resetGame`**, and
221read by `sendMessage` and the pure dials. Because it carries only pre-run choices (no
222dates, no server `runId`, none of the staleness/account plumbing), it round-trips through
223JSON or a URL param cleanly — which is exactly what makes it the right unit to travel with
224a run. It must work for **anonymous** players (the default path today). A saved profile
225default is a later tie to accounts (`#83`), layered on top — the per-run choice is the
226primitive.
227 
228**Settings travel with a run.** There is **no run snapshot, share, or seed concept in the
229code today** — a run is entirely live and ephemeral. That makes this greenfield, and it
230argues for designing `RunSettings` as a **small serializable bundle from the start**, next
231to a `seed`. For shared-run "beat this" (`#88`) and replay (`#89`) to be *fair*, the
232snapshot has to carry both the rules and the seed; otherwise two players aren't playing
233the same game. The seed is the one piece worth plumbing in v1 even before daily/seeded
234modes ship, because retrofitting determinism later is far more invasive than reserving the
235field now (the dice live behind `rollD20` in `utils/dice.ts` — a single, swappable source).
236 
237## Safety stays server-authoritative
238 
239Several knobs are server-enforced and closed-enum on purpose: the deceased-only contact
240floor (`isDeceased`, `server/utils/figure-grounding.ts`, fail-closed — a figure with no
241confirmed death is never contactable), the objective bounds (`#71`), the in-code clamp of
242the engine's swing into the rolled band, and moderation. A settings layer **must not
243become a client bypass.** Concretely:
244 
245- `RunSettings` is **validated and clamped server-side**, to an allowed range per field —
246 the client's chosen values are untrusted input, like every other field. This is the
247 pattern already in place: `send-message.post.ts` and `research.post.ts` **re-ground the
248 figure name** against the untrusted payload rather than trusting a client claim of "this
249 person is contactable."
250- Difficulty scales the *game* (harder/easier), never the *safety floor*. There is no
251 difficulty that lets you message a living person or dodge moderation. A Sandbox / "anywhen"
252 stance that *did* relax the lifetime + deceased-only bounds (its own deferred slice, not a
253 difficulty knob) would flip a **server-read** flag, never a client toggle the POST handler
254 honors — and moderation (`REVISIONIST_MODERATION`) is never surfaced to players at all;
255 `off` is an operator/calibration lever only.
256- The closed enums stay closed. A preset id is one of a fixed set; an out-of-range message
257 count or figure cap is rejected, not honored.
258 
259## Evals: a preset can't silently break the curve
260 
261The tuning expectations already live as evals, and a new difficulty knob has to extend
262them or it can regress the curve invisibly:
263 
264- [`evals/bakeoff.eval.ts`](../../evals/bakeoff.eval.ts) scores **the NEUTRAL-band EV
265 corridor the run economy is tuned around** — the exact invariant a preset's retune
266 threatens.
267- [`evals/behavior.eval.ts`](../../evals/behavior.eval.ts) checks the model-behavior
268 invariants the mechanics rest on (band steering, `sharp ≥ vague` craft ordering,
269 anachronism accuracy).
270- [`evals/causal-chain.eval.ts`](../../evals/causal-chain.eval.ts) confirms stepping
271 stones beat blasts end-to-end; [`evals/judge-tune.eval.ts`](../../evals/judge-tune.eval.ts)
272 pins craft-grade anchoring (which feeds the economy).
273 
274But the existing coverage has two gaps a preset would fall straight through, and naming
275them is half the value of this exploration:
276 
2771. **The corridor eval guards the wrong number.** It scores `baseProgressChange` — the swing
278 *before* the anachronism / causal-chain / craft-gain / momentum multipliers. So the four
279 downstream multipliers are an **unguarded realized-EV surface**: a "hard" preset that
280 halved `CRAFT_GAIN_FACTOR` would change the EV the player actually experiences while the
281 corridor eval stayed green. The `[4,9]` corridor is a real invariant, but it is *not* the
282 EV the run is won or lost on.
2832. **The corridor bounds are hardcoded literals, not a function of the preset.** If Story
284 legitimately targets a higher neutral EV and Iron a lower one, today's absolute `[4,9]`
285 assertion would flag the *intended* presets as misses. The corridor eval has to become
286 **preset-parameterized** — fed each preset's target band.
287 
288**Requirement for the implementation:** each shipped preset needs (a) a parameterized
289pre-multiplier corridor assertion fed its own target, and (b) a **realized-EV** assertion
290that composes the full pipeline (so a multiplier change can't move the curve unseen) and a
291plausible win rate at sound + calibrated play. The pure dials (bands, decay, momentum,
292craft) are already unit-tested in isolation; the new surface to cover is *the composition
293under each preset*.
294 
295## Scope & monetization
296 
297v1 is deliberately small: presets + two dials + seed plumbing. The open question worth
298**noting, not deciding here**: whether any setting (e.g. seeded/daily competitive modes,
299or an Iron leaderboard) is paid-gated. The accounts/paywall seam already exists (`#83`,
300the runs economy); this note flags the question and leaves the call to the monetization
301track.
302 
303## Recommended v1, restated
304 
305- **Surface:** three difficulty presets (Story · Standard · Iron) + two run-shape dials
306 (messages per run, distinct-figures cap), chosen at mission-select. Custom per-dial
307 overrides deferred to v2.
308- **Model:** a small serializable `RunSettings` bundle (`difficulty`, `messagesPerRun`,
309 `distinctFiguresCap`, `seed`), validated + clamped server-side, stored in the run,
310 anonymous-friendly, profile-default later (`#83`).
311- **Economy:** each preset re-tunes the bundle (message count + band economy + the wager /
312 decay / momentum / stake) **together**, with Standard == today's game unchanged.
313- **Travel:** plumb a deterministic `seed` now; carry `RunSettings` + `seed` in the run
314 snapshot when sharing/replay (`#88`/`#89`) lands.
315- **Guardrails:** safety floor untouched and server-authoritative; an EV-corridor eval per
316 preset.
317 
318## Implementation issues this note spawns
319 
320Ready-to-file, in dependency order:
321 
3221. **`RunSettings` bundle + server validation + seed plumbing.** Define the serializable
323 type (`difficulty`, `messagesPerRun`, `distinctFiguresCap`, `seed`), thread it through
324 the store run-state and `sendMessage`, validate + clamp server-side, and route the dice
325 through a seeded source behind `rollD20`. No UI beyond a default. *Foundation for the
326 rest.*
3272. **Difficulty presets + economy retune + eval coverage.** Implement Story/Standard/Iron
328 as preset bundles that scale the internal tuning dials together; Standard reproduces
329 today's curve exactly. Make the corridor eval **preset-parameterized** and add a
330 **realized-EV** assertion (full pipeline, not just `baseProgressChange`) per preset.
331 First promote the re-typed eval constants (±3 valence, 160-char) to imports and derive
332 `STAKED_SWING_CAP` from the win threshold so nothing diverges silently. *Depends on 1.*
3333. **Run-shape dials at mission-select.** Surface `messagesPerRun` and
334 `distinctFiguresCap` (incl. the single-figure challenge) in `MissionSelect.vue`, with
335 the figure cap enforced server-side. *Depends on 1; pairs with 2 for the message-count
336 retune.*
3374. **Settings + seed travel with a shared run.** Carry `RunSettings` + `seed` in the run
338 snapshot so a shared/replayed run is the same game. *Depends on 1 and on `#88`/`#89`.*
339 
340## Explicitly out of scope (per #90)
341 
342No implementation, no final `RunSettings` schema, no settings UI here. No commitment to the
343final set — this is divergent brainstorming plus a recommendation. Not a re-tuning of the
344existing curve; this only identifies *where* each difficulty would re-tune it.
345 
346---
347 
348*Related:* [#83](https://github.com/mseeks/revisionist/issues/83) (accounts — saved
349defaults) · [#88](https://github.com/mseeks/revisionist/issues/88) /
350[#89](https://github.com/mseeks/revisionist/issues/89) (sharing & replay — why a run
351should carry its settings + seed) · [#71](https://github.com/mseeks/revisionist/issues/71)
352(objective steering) · [#72](https://github.com/mseeks/revisionist/issues/72) (safely-dead
353floor) · [#60](https://github.com/mseeks/revisionist/issues/60) (guided first-run).

Safety stays server-authoritative

A settings layer must never become a client bypass. The deceased-only contact floor (#72) is the clearest case: it's fail-closed and server-enforced. Difficulty can scale the game; it can never relax this.

A figure with no confirmed death is never contactable. Re-checked server-side against the untrusted payload in the send/research handlers.

isDeceased — fail-closed · 325 lines
isDeceased — fail-closed325 lines · TypeScript
⋯ 59 lines hidden (lines 1–59)
1/**
2 * Real-world grounding for historical figures — the backbone of the "timeline RPG"
3 * direction (see docs/ROADMAP.md). Resolves a free-form name to solid facts so the
4 * game can show who someone was, validate *when* you may contact them, and feed the
5 * role-play real dates.
6 *
7 * Pipeline (the wiki APIs are open + key-less):
8 * 1. Wikipedia REST summary — the reliable resolver. It follows redirects and
9 * picks the notable article, giving a canonical title, a one-line description,
10 * a blurb, a portrait, and the Wikidata item id. (Wikidata search alone is NOT
11 * reliable: "Cleopatra" resolves to "female given name", "Genghis Khan" to an
12 * Iron Maiden song.)
13 * 2. Wikidata, looked up by that item id (title as fallback) — structured birth
14 * (P569) / death (P570), taking the best-ranked claim that actually carries a
15 * date, with sub-year precision surfaced honestly as `circa`.
16 * 3. If the record still has no birth year, a small AI estimator bridges the gap
17 * with circa dates (issue #31) — so a real, resolvable figure keeps their
18 * "when" slider even when Wikidata can't date them.
19 *
20 * Anything unresolved returns `{ resolved: false }` (with `transient` set when the
21 * lookup couldn't complete). Contact callers now BLOCK an ungrounded name (#73) —
22 * only a real, documented figure can be reached — while a transient miss is a retry.
23 *
24 * Cache honesty: only definitive answers — a real Wikidata reply (dated or not,
25 * with the estimator genuinely consulted), a genuine no-such-article miss, a
26 * disambiguation page — keep the full TTL. A transient failure anywhere on the
27 * path (a rate-limit 429, a timeout, an estimator outage) gets a short TTL, so
28 * one blip never poisons a figure for a day.
29 */
30import { estimateLifespan } from './lifespan-estimator'
31import { SAFELY_HISTORICAL_BEFORE_YEAR } from './historical-floor'
32 
33/** A year on the timeline. `signed` is AD-positive / BCE-negative for comparison. */
34export interface FigureYear {
35 year: number
36 bce: boolean
37 signed: number
38 display: string
39 /** Approximate: sub-year Wikidata precision (decade/century/…) or an AI estimate. */
40 circa?: boolean
42 
43export interface GroundedFigure {
44 name: string
45 resolved: boolean
46 description?: string
47 extract?: string
48 born?: FigureYear
49 died?: FigureYear
50 living?: boolean
51 thumbnail?: string
52 wikiUrl?: string
53 /** The lookup couldn't be completed (rate-limit / timeout / outage), so an
54 * unresolved result is "we don't know yet", not "no such person". The contact
55 * gate treats this as a retry rather than a definitive miss — and never as a
56 * free-form pass (#72 / #73). */
57 transient?: boolean
59 
60/**
61 * The deceased-only contact floor (#72): a figure may be reached only once we can
62 * confirm they have died. A resolved figure with no death year is living (or not
63 * datable enough to prove otherwise) and is never contactable — fail closed. An
64 * unresolved name is not deceased here either (the free-form path is removed in #73).
65 * The canonical predicate, shared by the suggester and the send-message gate; the
66 * client store inlines the same rule (it can't import this server-only module).
67 */
68export function isDeceased(figure: GroundedFigure): boolean {
69 return figure.resolved && !!figure.died
⋯ 255 lines hidden (lines 71–325)
71 
72/**
73 * The one header for every Wikipedia / Wikidata call the server makes — grounding,
74 * the name autocomplete, and the suggester's lookups all share it (issue #58).
75 * Wikimedia's User-Agent policy wants the client identified plus a reachable CONTACT
76 * so an operator can be reached about problematic traffic; an agent without one gets
77 * throttled harder, and rate-limit blips are the dominant real-world failure for the
78 * autocomplete. A mailto is that contact — the deployed site is auth-gated and the
79 * repo private, so neither would reach a human. Exported so every caller sends the
80 * identical compliant string instead of drifting per-feature copies.
81 */
82export const WIKI_HEADERS = {
83 accept: 'application/json',
84 'User-Agent': 'Revisionist/1.0 (timeline RPG; mailto:matthew@mseeks.me)'
86 
87const FULL_TTL_MS = 24 * 60 * 60 * 1000
88const TRANSIENT_TTL_MS = 10 * 60 * 1000
89const WIKIDATA_RETRY_DELAY_MS = 400
90const cache = new Map<string, { value: GroundedFigure; expires: number }>()
91 
92/** Builds a `FigureYear`; the display string owns the "c." honesty marker. */
93export function makeFigureYear(year: number, bce: boolean, circa = false): FigureYear {
94 const base = bce ? `${year} BC` : `${year}`
95 return {
96 year,
97 bce,
98 signed: bce ? -year : year,
99 display: circa ? `c. ${base}` : base,
100 ...(circa ? { circa: true } : {})
101 }
103 
104/**
105 * Parses a Wikidata time literal (e.g. "+1856-07-10T..", "-0069-01-13T..") to a year.
106 * Precision 9 means year-level; anything below (8 decade, 7 century, 6 millennium)
107 * still carries a representative year in the literal, but only approximately —
108 * those parse as `circa` rather than being dropped or displayed as exact.
109 */
110export function parseWikidataYear(time?: string, precision?: number): FigureYear | undefined {
111 if (!time) return undefined
112 const m = /^([+-])0*(\d+)-/.exec(time)
113 if (!m) return undefined
114 const year = parseInt(m[2], 10)
115 if (!year) return undefined
116 const bce = m[1] === '-'
117 return makeFigureYear(year, bce, precision != null && precision < 9)
119 
120/** The slice of the Wikipedia REST summary we actually read. */
121interface WikiSummary {
122 type?: string
123 title?: string
124 description?: string
125 extract?: string
126 thumbnail?: { source?: string }
127 content_urls?: { desktop?: { page?: string } }
128 wikibase_item?: string
130 
131/** The slice of a Wikidata date claim we actually read. */
132interface WikidataClaim {
133 rank?: string
134 mainsnak?: {
135 snaktype?: string
136 datavalue?: { value?: { time?: string; precision?: number } }
137 }
139 
140/** The slice of the Wikidata `wbgetentities` response we actually read. */
141interface WikidataEntities {
142 entities?: Record<string, { claims?: Record<string, WikidataClaim[]> }>
144 
145/**
146 * The best date a claim list actually carries. Wikidata routinely stacks several
147 * birth/death claims (scholarly guesses, deprecated values, "unknown value"
148 * placeholders) and the community-preferred one is often NOT first — Moses, Jesus
149 * and Confucius all keep theirs at index 2. So: only claims whose snak carries a
150 * real time (somevalue/novalue snaks don't), never deprecated, preferred rank first.
151 */
152export function pickClaimYear(claims?: WikidataClaim[]): FigureYear | undefined {
153 const usable = (claims ?? []).filter(c =>
154 c.rank !== 'deprecated' &&
155 c.mainsnak?.snaktype === 'value' &&
156 c.mainsnak.datavalue?.value?.time
157 )
158 const best = usable.find(c => c.rank === 'preferred') ?? usable[0]
159 const value = best?.mainsnak?.datavalue?.value
160 return parseWikidataYear(value?.time, value?.precision)
162 
163/**
164 * A fetch that tells a definitive miss from a transient one: 404 means the thing
165 * genuinely isn't there (cacheable at full TTL), anything else — 429, 5xx, timeout,
166 * network error — is weather to retry soon, not remember for a day.
167 */
168interface FetchOutcome<T> {
169 data: T | null
170 transient: boolean
172 
173async function fetchJson<T>(url: string): Promise<FetchOutcome<T>> {
174 try {
175 const res = await fetch(url, { headers: WIKI_HEADERS, signal: AbortSignal.timeout(6000) })
176 if (!res.ok) return { data: null, transient: res.status !== 404 }
177 return { data: (await res.json()) as T, transient: false }
178 } catch {
179 return { data: null, transient: true }
180 }
182 
183interface WikidataDates {
184 born?: FigureYear
185 died?: FigureYear
186 /** True when we never got a real answer (so the result must not cache long). */
187 transient: boolean
189 
190/**
191 * Birth/death from Wikidata — by item id when the summary supplied one (immune to
192 * title-matching quirks), by sitelink title otherwise. Rate-limit blips are the
193 * dominant way dates go missing in practice (a 429 body carries no `entities`), so
194 * a no-entities response gets one spaced retry before being declared transient.
195 */
196async function wikidataDates(qid: string | undefined, title: string): Promise<WikidataDates> {
197 const subject = qid
198 ? `ids=${encodeURIComponent(qid)}`
199 : `sites=enwiki&titles=${encodeURIComponent(title)}&normalize=1`
200 const url = `https://www.wikidata.org/w/api.php?action=wbgetentities&${subject}&props=claims&languages=en&format=json&origin=*`
201 let outcome = await fetchJson<WikidataEntities>(url)
202 if (!outcome.data?.entities) {
203 await new Promise(resolve => setTimeout(resolve, WIKIDATA_RETRY_DELAY_MS))
204 outcome = await fetchJson<WikidataEntities>(url)
205 }
206 const entities = outcome.data?.entities
207 if (!entities) return { transient: true }
208 const id = Object.keys(entities)[0]
209 const claims = id ? entities[id]?.claims : undefined
210 return { born: pickClaimYear(claims?.P569), died: pickClaimYear(claims?.P570), transient: false }
212 
213/**
214 * Resolves a name to grounded facts (cached). Never throws; returns
215 * `{ resolved: false }` when the figure can't be confidently identified.
216 */
217export async function groundFigure(rawName: string): Promise<GroundedFigure> {
218 const name = (rawName || '').trim()
219 if (!name) return { name: '', resolved: false }
220 
221 const key = name.toLowerCase()
222 const hit = cache.get(key)
223 if (hit && hit.expires > Date.now()) return hit.value
224 
225 const { value, ttlMs } = await resolve(name)
226 cache.set(key, { value, expires: Date.now() + ttlMs })
227 return value
229 
230/**
231 * Keep a URL only if it's a plain http(s) link — drop `javascript:` / `data:` /
232 * relative values. These come from a remote Wikipedia response and end up bound to
233 * `:href` / `:src` in the figure dossier; Vue escapes the value but NOT the scheme,
234 * so a spoofed `javascript:` URL would execute on click. (Surfaced by the
235 * unsafe-render loop.) Exported for the figure-search route, which binds Wikipedia
236 * thumbnails the same way.
237 */
238export function safeUrl(url?: string): string | undefined {
239 if (!url) return undefined
240 try {
241 const { protocol } = new URL(url)
242 return protocol === 'https:' || protocol === 'http:' ? url : undefined
243 } catch {
244 return undefined
245 }
247 
248interface Resolution {
249 value: GroundedFigure
250 ttlMs: number
252 
253/** A purely AI-estimated death is only trusted as "historical" (and the figure
254 * contactable, #72) when it predates this year — a recent estimated death is the
255 * least trustworthy signal and could mark a living person dead. Documented deaths
256 * are exempt (they're facts). The same safely-historical line the objective bounds
257 * reuse (#71), sourced once from historical-floor so the two never drift. */
258const ESTIMATED_DEATH_HISTORICAL_BEFORE = SAFELY_HISTORICAL_BEFORE_YEAR
259 
260async function resolve(name: string): Promise<Resolution> {
261 const summary = await fetchJson<WikiSummary>(
262 `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(name)}`
263 )
264 
265 // No article, or an ambiguous disambiguation page → can't pin a person.
266 const article = summary.data
267 if (!article || article.type === 'disambiguation' || !article.title) {
268 return {
269 value: { name, resolved: false, transient: summary.transient },
270 ttlMs: summary.transient ? TRANSIENT_TTL_MS : FULL_TTL_MS
271 }
272 }
273 
274 const title: string = article.title
275 
276 const dates = await wikidataDates(article.wikibase_item, title)
277 let born = dates.born
278 let died = dates.died
279 let bridgeTransient = false
280 
281 // The AI bridge (issue #31): the record knows WHO they are but not WHEN — either
282 // Wikidata genuinely has no usable birth claim, or the lookup is down right now.
283 // Estimated years arrive marked circa, so the dossier and prompts stay honest.
284 // A recorded death is law: the estimate only fills what the record lacks, and an
285 // estimate that contradicts the record (born after the documented death) is
286 // dropped whole rather than shipped as an incoherent lifespan.
287 if (!born) {
288 const bridge = await estimateLifespan(title, article.description, article.extract, died?.display)
289 bridgeTransient = bridge.transient
290 if (bridge.estimate) {
291 const estBorn = makeFigureYear(bridge.estimate.born.year, bridge.estimate.born.bce, true)
292 if (!died || estBorn.signed <= died.signed) {
293 born = estBorn
294 // A purely AI-ESTIMATED death is trusted as "deceased" (the floor in
295 // #72) only when it's clearly historical. An LLM guess of a RECENT
296 // death is exactly what could mark a living person contactable, so we
297 // don't promote it — the figure stays `living` and is blocked. A
298 // DOCUMENTED death (already in `died`) is a fact and is kept as-is.
299 const estDied = bridge.estimate.died
300 ? makeFigureYear(bridge.estimate.died.year, bridge.estimate.died.bce, true)
301 : undefined
302 died = died ?? (estDied && estDied.signed <= ESTIMATED_DEATH_HISTORICAL_BEFORE ? estDied : undefined)
303 }
304 }
305 }
306 
307 return {
308 value: {
309 name: title,
310 resolved: true,
311 description: article.description || undefined,
312 extract: article.extract || undefined,
313 born,
314 died,
315 living: !!born && !died,
316 thumbnail: safeUrl(article.thumbnail?.source),
317 wikiUrl: safeUrl(article.content_urls?.desktop?.page),
318 transient: dates.transient || bridgeTransient
319 },
320 // Anything short of a definitive answer keeps the short TTL: a transient
321 // Wikidata miss (even when the estimator papered over it — the next lookup
322 // should get a shot at the real dates) and an estimator outage alike.
323 ttlMs: dates.transient || bridgeTransient ? TRANSIENT_TTL_MS : FULL_TTL_MS
324 }

The eval gap the exploration surfaced

The sharpest finding, and one a reviewer should verify in source. The EV-corridor eval scores baseProgressChange — the swing before the anachronism / causal-chain / craft-gain / momentum multipliers. So those four downstream multipliers are an unguarded realized-EV surface: a 'hard' preset that halved CRAFT_GAIN_FACTOR would change the EV the player actually experiences while this eval stayed green. And the bound is a hardcoded literal.

Line 110 means over r.baseProgressChange (pre-multiplier); line 123 hardcodes >= 4 && <= 9.

The corridor assertion · 372 lines
The corridor assertion372 lines · TypeScript
⋯ 107 lines hidden (lines 1–107)
1/**
2 * The provider bake-off (GTM dispatch 1) — the same fixtures driven down BOTH
3 * lanes of the per-task routing seam, scored per layer:
4 *
5 * Timeline — band→swing ordering, the NEUTRAL-band EV corridor the run
6 * economy is tuned around, valence↔sign, anachronism accuracy.
7 * Judge — calibration against the labeled craft ladders (ordinal
8 * agreement + hard fairness violations), the Haiku gate.
9 * Character — dice-band differential, 160-char discipline, era-blindness.
10 * Chronicler— blind pairwise prose (position-debiased, dual-graded), plus a
11 * ledger-consistency gate; the epilogue judged separately.
12 * Cost — real usage per lane from the gateway's telemetry observer.
13 *
14 * Run with: `npm run eval:bakeoff` (needs OPENAI_API_KEY + ANTHROPIC_API_KEY).
15 * Results land in evals/results/ as JSON alongside the printed scorecard.
16 * Lane passes are sequential; REVISIONIST_AI_LANE flips the whole seam per pass.
17 */
18import { describe, it, afterAll } from 'vitest'
19import { mkdirSync, writeFileSync } from 'node:fs'
20import { resolve } from 'node:path'
21import { DiceOutcome } from '~/utils/dice'
22import { callCharacterAI, callTimelineAI, callChroniclerAI, callJudgeAI } from '~/server/utils/openai'
23import { setAiCallObserver, type AiCallTelemetry } from '~/server/utils/ai-gateway'
24import { CRAFT_MODIFIER } from '~/utils/craft'
25import { RUN, record, recordedRows, sample, gradeAll, prefer, mean, printScorecard } from './harness'
26import {
27 ROLL_BY_BAND,
28 TIMELINE_FIXED,
29 ANACHRONISM_INPERIOD,
30 ANACHRONISM_FUTURE,
31 CHARACTER,
32 OBJECTIVE
33} from './fixtures'
34import { JUDGE_LADDERS, EPILOGUE_LEDGER, CHRONICLE_CRITERIA } from './bakeoff-fixtures'
35 
36const LANES = ['anthropic', 'openai'] as const
37type Lane = (typeof LANES)[number]
38 
39/** Samples per cell — sized for a decision, not a census; paired across lanes. */
40const N_TIMELINE = 10
41const N_ANACHRONISM = 8
42const N_JUDGE = 2
43const N_CHARACTER = 8
44const K_CHRONICLE = 5
45 
46/** Anthropic list prices per MTok (verified 2026-06); OpenAI reported as tokens. */
47const ANTHROPIC_PRICES: Record<string, { input: number; output: number }> = {
48 'claude-haiku-4-5': { input: 1, output: 5 },
49 'claude-sonnet-4-6': { input: 3, output: 15 },
50 'claude-opus-4-8': { input: 5, output: 25 }
52 
53interface UsageCell { calls: number; input: number; output: number }
54const usageByLane: Record<Lane, Record<string, UsageCell>> = { anthropic: {}, openai: {} }
55let currentLane: Lane = 'anthropic'
56 
57function collect(t: AiCallTelemetry): void {
58 if (!t.usage) return
59 const key = `${t.task}:${t.model}`
60 const cell = (usageByLane[currentLane][key] ??= { calls: 0, input: 0, output: 0 })
61 cell.calls++
62 cell.input += t.usage.inputTokens
63 cell.output += t.usage.outputTokens
65 
66function enterLane(lane: Lane): void {
67 currentLane = lane
68 process.env.REVISIONIST_AI_LANE = lane
69 setAiCallObserver(collect)
71 
72/** Everything the JSON snapshot carries, accumulated across the it-blocks. */
73const results: Record<string, unknown> = { lanes: {} }
74function laneResults(lane: Lane): Record<string, unknown> {
75 const lanes = results.lanes as Record<string, Record<string, unknown>>
76 return (lanes[lane] ??= {})
78 
79const chronicles: Record<Lane, { mid: string[]; epilogue: string[] }> = {
80 anthropic: { mid: [], epilogue: [] },
81 openai: { mid: [], epilogue: [] }
83 
84afterAll(() => {
85 setAiCallObserver(null)
86 delete process.env.REVISIONIST_AI_LANE
87 printScorecard()
88})
89 
90function skip(layer: string, invariant: string): void {
91 record({ layer, invariant, result: 'skipped (needs both keys)', status: 'skipped' })
93 
94for (const lane of LANES) {
95 describe(`${lane} lane`, () => {
96 it('timeline: bands, corridor, anachronism', async () => {
97 if (!RUN) {
98 skip(`TL·${lane}`, 'band ordering / corridor / anachronism')
99 return
100 }
101 enterLane(lane)
102 
103 const bandMeans: number[] = []
104 let vOK = 0
105 let vTotal = 0
106 for (const band of ROLL_BY_BAND) {
107 const res = await sample(N_TIMELINE, () =>
108 callTimelineAI({ ...TIMELINE_FIXED, diceRoll: band.roll, diceOutcome: band.outcome }))
109 const ripples = res.flatMap(r => (r.success && r.ripple ? [r.ripple] : []))
110 bandMeans.push(Math.round(mean(ripples.map(r => r.baseProgressChange)) * 10) / 10)
111 for (const r of ripples) {
112 vTotal++
113 const s = r.progressChange
114 if ((r.valence === 'positive' && s > 0) || (r.valence === 'negative' && s < 0) || (r.valence === 'neutral' && Math.abs(s) <= 3)) vOK++
115 }
116 }
117 let ordered = 0
118 for (let i = 1; i < bandMeans.length; i++) if (bandMeans[i] >= bandMeans[i - 1]) ordered++
119 // The run economy is tuned around ~+6/turn for sound unaided play —
120 // the NEUTRAL band (0..+12) is the anchor; a lane whose neutral mean
121 // drifts out of [4, 9] silently rebalances the whole game.
122 const neutralMean = bandMeans[2]
123 const corridorOK = neutralMean >= 4 && neutralMean <= 9
124 
⋯ 248 lines hidden (lines 125–372)
125 const inP = await sample(N_ANACHRONISM, () =>
126 callTimelineAI({ ...ANACHRONISM_INPERIOD, diceRoll: 12, diceOutcome: DiceOutcome.NEUTRAL }))
127 const fut = await sample(N_ANACHRONISM, () =>
128 callTimelineAI({ ...ANACHRONISM_FUTURE, diceRoll: 12, diceOutcome: DiceOutcome.NEUTRAL }))
129 const inLevels = inP.flatMap(r => (r.success && r.ripple ? [r.ripple.anachronism] : []))
130 const futLevels = fut.flatMap(r => (r.success && r.ripple ? [r.ripple.anachronism] : []))
131 const inOK = inLevels.filter(a => a === 'in-period' || a === 'ahead').length
132 const futOK = futLevels.filter(a => a === 'far-ahead' || a === 'impossible').length
133 
134 laneResults(lane).timeline = {
135 bandMeans, ordered, neutralMean, corridorOK,
136 valence: { ok: vOK, total: vTotal },
137 anachronism: { inOK, inTotal: inLevels.length, futOK, futTotal: futLevels.length }
138 }
139 record({
140 layer: `TL·${lane}`,
141 invariant: 'band means + ordering',
142 result: vTotal === 0 ? 'no samples' : `[${bandMeans.join(', ')}] · ${ordered}/4 ordered`,
143 status: vTotal === 0 ? 'error' : ordered >= 4 ? 'ok' : 'soft-miss'
144 })
145 record({
146 layer: `TL·${lane}`,
147 invariant: 'neutral EV corridor [4..9]',
148 result: `mean ${neutralMean}`,
149 status: vTotal === 0 ? 'error' : corridorOK ? 'ok' : 'soft-miss'
150 })
151 record({
152 layer: `TL·${lane}`,
153 invariant: 'valence↔sign',
154 result: `${vOK}/${vTotal}`,
155 status: vTotal > 0 && vOK / vTotal >= 0.9 ? 'ok' : 'soft-miss'
156 })
157 record({
158 layer: `TL·${lane}`,
159 invariant: 'anachronism accuracy',
160 result: `in-period ${inOK}/${inLevels.length} · future ${futOK}/${futLevels.length}`,
161 status: inLevels.length + futLevels.length > 0 && (inOK + futOK) / (inLevels.length + futLevels.length) >= 0.75 ? 'ok' : 'soft-miss'
162 })
163 })
164 
165 it('judge: calibration ladders', async () => {
166 if (!RUN) {
167 skip(`JD·${lane}`, 'ladder calibration')
168 return
169 }
170 enterLane(lane)
171 
172 let orderedPairs = 0
173 let totalPairs = 0
174 let hardViolations = 0
175 const ladderMeans: number[][] = []
176 for (const ladder of JUDGE_LADDERS) {
177 const rungMeans: number[] = []
178 for (const dispatch of ladder.rungs) {
179 const res = await sample(N_JUDGE, () =>
180 callJudgeAI({ objective: ladder.objective, figureName: ladder.figureName, when: ladder.when, userMessage: dispatch }))
181 const mods = res.flatMap(r => (r.success && r.judge ? [CRAFT_MODIFIER[r.judge.craft]] : []))
182 rungMeans.push(mean(mods))
183 // Fairness floor: the bottom two rungs must never read masterful
184 // (+2), the top rung must never read reckless (-2).
185 const rungIndex = ladder.rungs.indexOf(dispatch)
186 if (rungIndex >= 2 && mods.some(m => m >= 2)) hardViolations++
187 if (rungIndex === 0 && mods.some(m => m <= -2)) hardViolations++
188 }
189 ladderMeans.push(rungMeans)
190 for (let i = 0; i < rungMeans.length; i++) {
191 for (let j = i + 1; j < rungMeans.length; j++) {
192 totalPairs++
193 if (rungMeans[i] >= rungMeans[j]) orderedPairs++
194 }
195 }
196 }
197 
198 laneResults(lane).judge = { ladderMeans, orderedPairs, totalPairs, hardViolations }
199 record({
200 layer: `JD·${lane}`,
201 invariant: 'ladder ordinal agreement',
202 result: `${orderedPairs}/${totalPairs} pairs ordered`,
203 status: totalPairs > 0 && orderedPairs / totalPairs >= 0.9 ? 'ok' : 'soft-miss'
204 })
205 record({
206 layer: `JD·${lane}`,
207 invariant: 'hard violations',
208 result: `${hardViolations}`,
209 status: hardViolations === 0 ? 'ok' : 'soft-miss'
210 })
211 })
212 
213 it('character: differential, discipline, era-blindness', async () => {
214 if (!RUN) {
215 skip(`CH·${lane}`, 'differential / 160 / era')
216 return
217 }
218 enterLane(lane)
219 
220 const succ = await sample(N_CHARACTER, () =>
221 callCharacterAI(CHARACTER.figureName, CHARACTER.nudgeMessage, [], DiceOutcome.CRITICAL_SUCCESS, CHARACTER.grounding))
222 const fail = await sample(N_CHARACTER, () =>
223 callCharacterAI(CHARACTER.figureName, CHARACTER.nudgeMessage, [], DiceOutcome.CRITICAL_FAILURE, CHARACTER.grounding))
224 const succR = succ.flatMap(r => (r.success && r.characterResponse ? [r.characterResponse] : []))
225 const failR = fail.flatMap(r => (r.success && r.characterResponse ? [r.characterResponse] : []))
226 
227 const q = `Does the figure decisively and favorably act toward this goal — "${CHARACTER.nudgeGoal}"? Answer yes only if they clearly move to do it.`
228 const sG = await gradeAll(q, succR.map(a => a.action))
229 const fG = await gradeAll(q, failR.map(a => a.action))
230 const sRate = sG.graded ? sG.yes / sG.graded : 0
231 const fRate = fG.graded ? fG.yes / fG.graded : 0
232 
233 const all = [...succR, ...failR]
234 const within = all.filter(a => a.message.length <= 160).length
235 
236 const future = await sample(N_CHARACTER, () =>
237 callCharacterAI(CHARACTER.figureName, CHARACTER.futureMessage, [], DiceOutcome.NEUTRAL, CHARACTER.grounding))
238 const futureReplies = future.flatMap(r => (r.success && r.characterResponse ? [r.characterResponse.message] : []))
239 const eraQ = 'Does this reply treat events beyond the speaker’s own lifetime as established fact, or acknowledge knowing the future / being in a game? Answer yes if it breaks the historical illusion.'
240 const eraG = await gradeAll(eraQ, futureReplies)
241 
242 laneResults(lane).character = {
243 actToward: { success: sG, fail: fG },
244 len160: { within, total: all.length },
245 eraBreaks: eraG
246 }
247 record({
248 layer: `CH·${lane}`,
249 invariant: 'band→reaction differential',
250 result: `crit-succ ${sG.yes}/${sG.graded} · crit-fail ${fG.yes}/${fG.graded} (${sG.contested + fG.contested} contested)`,
251 status: sG.graded && fG.graded ? (sRate > fRate ? 'ok' : 'soft-miss') : 'error'
252 })
253 record({
254 layer: `CH·${lane}`,
255 invariant: 'reply ≤160 chars',
256 result: `${within}/${all.length}`,
257 status: all.length > 0 && within / all.length >= 0.8 ? 'ok' : 'soft-miss'
258 })
259 record({
260 layer: `CH·${lane}`,
261 invariant: 'no future-as-fact',
262 result: `${eraG.graded - eraG.yes}/${eraG.graded} stay in-era (${eraG.contested} contested)`,
263 status: eraG.graded > 0 && eraG.yes / eraG.graded <= 0.25 ? 'ok' : 'soft-miss'
264 })
265 })
266 
267 it('chronicler: generate tellings (judged cross-lane later)', async () => {
268 if (!RUN) {
269 skip(`CR·${lane}`, 'tellings generated')
270 return
271 }
272 enterLane(lane)
273 
274 const mid = await sample(K_CHRONICLE, () =>
275 callChroniclerAI({ objective: OBJECTIVE, timeline: EPILOGUE_LEDGER.slice(0, 3), status: 'playing', progress: 55 }))
276 const epi = await sample(K_CHRONICLE, () =>
277 callChroniclerAI({ objective: OBJECTIVE, timeline: EPILOGUE_LEDGER, status: 'victory', progress: 100 }))
278 chronicles[lane].mid = mid.flatMap(r => (r.success && r.chronicle ? [`${r.chronicle.title}\n${r.chronicle.paragraphs.join('\n')}`] : []))
279 chronicles[lane].epilogue = epi.flatMap(r => (r.success && r.chronicle ? [`${r.chronicle.title}\n${r.chronicle.paragraphs.join('\n')}`] : []))
280 
281 const facts = 'These facts are TRUE: (1) Caesar spared the library from fire; (2) Cleopatra endowed it; (3) Octavian later spared Alexandria and endowed the Museion. Does the narrative CONTRADICT any of these (e.g. the library burns or is lost)? Answer yes if it contradicts.'
282 const g = await gradeAll(facts, [...chronicles[lane].mid, ...chronicles[lane].epilogue])
283 
284 laneResults(lane).chronicler = { consistency: g, midCount: chronicles[lane].mid.length, epilogueCount: chronicles[lane].epilogue.length }
285 record({
286 layer: `CR·${lane}`,
287 invariant: 'ledger consistency',
288 result: `${g.graded - g.yes}/${g.graded} consistent (${g.contested} contested)`,
289 status: g.graded > 0 && g.yes / g.graded <= 0.25 ? 'ok' : 'soft-miss'
290 })
291 })
292 })
294 
295describe('cross-lane', () => {
296 it('chronicle pairwise + cost + snapshot', async () => {
297 if (!RUN) {
298 skip('XL', 'pairwise / cost / snapshot')
299 return
300 }
301 setAiCallObserver(null)
302 
303 // Blind pairwise: pair lane outputs by index, both kinds. Votes are
304 // position-debiased and dual-graded inside prefer().
305 async function pairwise(kind: 'mid' | 'epilogue'): Promise<{ anthropic: number; openai: number }> {
306 const a = chronicles.anthropic[kind]
307 const o = chronicles.openai[kind]
308 const pairs = Math.min(a.length, o.length)
309 let aVotes = 0
310 let oVotes = 0
311 for (let i = 0; i < pairs; i++) {
312 const v = await prefer(CHRONICLE_CRITERIA, a[i], o[i])
313 aVotes += v.a
314 oVotes += v.b
315 }
316 return { anthropic: aVotes, openai: oVotes }
317 }
318 
319 const midVotes = await pairwise('mid')
320 const epiVotes = await pairwise('epilogue')
321 results.pairwise = { mid: midVotes, epilogue: epiVotes }
322 record({
323 layer: 'XL',
324 invariant: 'chronicle pairwise (mid-run)',
325 result: `anthropic ${midVotes.anthropic} · openai ${midVotes.openai}`,
326 status: 'ok'
327 })
328 record({
329 layer: 'XL',
330 invariant: 'epilogue pairwise',
331 result: `anthropic ${epiVotes.anthropic} · openai ${epiVotes.openai}`,
332 status: 'ok'
333 })
334 
335 // Cost from real usage. Anthropic priced from the verified list; the
336 // OpenAI lane is reported in raw tokens (its prices live outside the repo).
337 for (const lane of LANES) {
338 const cells = usageByLane[lane]
339 let dollars = 0
340 let input = 0
341 let output = 0
342 let calls = 0
343 for (const [key, cell] of Object.entries(cells)) {
344 input += cell.input
345 output += cell.output
346 calls += cell.calls
347 const model = key.split(':')[1]
348 const price = ANTHROPIC_PRICES[model]
349 if (price) dollars += (cell.input * price.input + cell.output * price.output) / 1_000_000
350 }
351 laneResults(lane).usage = { calls, input, output, dollars: lane === 'anthropic' ? Math.round(dollars * 10000) / 10000 : null, byTask: cells }
352 record({
353 layer: 'COST',
354 invariant: `${lane} battery usage`,
355 result: `${calls} calls · ${input} in · ${output} out${lane === 'anthropic' ? ` · $${dollars.toFixed(4)}` : ''}`,
356 status: 'ok'
357 })
358 }
359 
360 results.rows = recordedRows()
361 results.meta = {
362 date: new Date().toISOString(),
363 samples: { N_TIMELINE, N_ANACHRONISM, N_JUDGE, N_CHARACTER, K_CHRONICLE },
364 graders: 'dual (gpt-5.4 + claude-sonnet-4-6), agreement-gated, position-debiased pairwise'
365 }
366 const dir = resolve(process.cwd(), 'evals/results')
367 mkdirSync(dir, { recursive: true })
368 const file = resolve(dir, `bakeoff-${new Date().toISOString().slice(0, 10)}.json`)
369 writeFileSync(file, JSON.stringify(results, null, 2))
370 process.stdout.write(`\nbake-off snapshot → ${file}\n`)
371 })
372})

What the note spawns

A design exploration earns its keep by ending in concrete, ordered work. The note closes with four implementation issues in dependency order — the RunSettings foundation first, then presets + eval coverage, the mission-select dials, and settings-travel-with-a-run.

Implementation issues, in dependency order · 353 lines
Implementation issues, in dependency order353 lines · Markdown
⋯ 317 lines hidden (lines 1–317)
1# Game Settings Layer — difficulty, run shape, constraints
2 
3> **Design exploration for [#90](https://github.com/mseeks/revisionist/issues/90).** This is a
4> recommendation, not an implementation. No `RunSettings` schema, no settings UI, no
5> retuning of the live curve — just the design space, a recommended v1, and the
6> implementation issues this note should spawn. For what is true in the code today,
7> read [`../../CLAUDE.md`](../../CLAUDE.md); for forward intent, [`../ROADMAP.md`](../ROADMAP.md).
8 
9## TL;DR — the recommendation
10 
11Ship a **lean v1**: three difficulty **presets** as the primary surface, **two run-shape
12dials** exposed directly, and a **deterministic seed** plumbed in (no UI yet). Defer
13everything else.
14 
15| v1 (ship) | v2 (next) | Deferred / never |
16|---|---|---|
17| **Difficulty presets:** Story · Standard · Iron | Per-dial **Custom** overrides | Daily challenge, score normalization |
18| **Messages per run** (run length) | Timeline-window constraints (era clamp, chronological-only) | Message length (it's core identity — leave fixed) |
19| **Distinct-figures cap** (incl. single-figure) | Sandbox / "anywhen" stance (its own slice) | Paid-gating (note the question; don't decide) |
20| **Seed** (plumbing only — basis for sharing/replay) | Repeat-contact + grounded-only policies | — |
21 
22Four load-bearing decisions, each argued below:
23 
241. **Presets bundle the dials; they are not independent sliders.** The run economy is a
25 tuned system. A preset must move the band economy *with* message count and win
26 threshold, or the win rate breaks.
272. **`RunSettings` is a small serializable bundle, chosen at mission-select**, stored in
28 the run, validated server-side. Design it serializable from day one so a shared run
29 (`#88`/`#89`) can carry its rules + seed.
303. **Safety never moves.** Difficulty can make the *game* harder or easier; it can never
31 relax the server-authoritative safety floor (`#72` deceased-only, `#71` objective
32 bounds, the swing clamp, moderation). Those stay closed-enum and server-enforced.
334. **Every preset needs eval coverage** so a retune can't silently regress the EV
34 corridor the economy rests on.
35 
36## Today: one fixed configuration
37 
38The game ships as a single, well-tuned configuration. Every rule is a named constant —
39already centralized, just not player-facing. The cross-module ones live in
40[`utils/game-config.ts`](../../utils/game-config.ts); the rest sit per-module by design.
41 
42| Constant | Value | File | Controls |
43|---|---|---|---|
44| `TOTAL_MESSAGES` | `5` | `utils/game-config.ts` | dispatches allotted per run |
45| `MAX_PROGRESS` | `100` | `utils/game-config.ts` | win threshold (progress is clamped 0..100) |
46| `MAX_PROGRESS_SWING` | `50` | `utils/game-config.ts` | the per-turn fuse (hard clamp on a resolved swing) |
47| `MAX_MESSAGE_CHARS` | `160` | `utils/game-config.ts` | chars per dispatch |
48| `SWING_BANDS` | CF −35..−15 · F −15..−5 · N 0..+12 · S +15..+30 · CS +30..+45 | `utils/swing-bands.ts` | the D20 band → progress table (the run economy) |
49| dice faces | CF 1–2 · F 3–8 · N 9–12 · S 13–18 · CS 19–20 (2/6/4/6/2) | `utils/dice.ts` | the natural-roll distribution |
50| `STAKE_MULTIPLIER` / `STAKED_SWING_CAP` | `2` / `100` | `utils/swing-bands.ts` | the last-stand wager on a final dispatch |
51| `CRAFT_MODIFIER` / `CRAFT_GAIN_FACTOR` | ±2 roll / ×0.15–1.45 gain | `utils/craft.ts` | the Message Judge's two channels |
52| `MOMENTUM_MAX` / `MOMENTUM_STEP` | `4` / `0.1` (cap ×1.4) | `utils/momentum.ts` | the run-level gains amplifier |
53| `BRIDGE_FREE` / `SCALE` / `CHAIN_FLOOR` | `75` / `550` / `0.12` | `utils/causal-chain.ts` | causal-chain decay |
54| `GAIN_FACTOR` / `LOSS_FACTOR` / `KNEE` | ×1–1.6 / ×1–2 / `40` | `server/utils/anachronism.ts` | the future-knowledge wager |
55| `SAFELY_HISTORICAL_BEFORE_YEAR` | (the floor) | `server/utils/historical-floor.ts` | the safely-dead / safely-historical line (`#71`/`#72`) |
56 
57`utils/game-config.ts` is the natural seam for a settings layer — but its own header says
58only *cross-module* rules belong there; many dials are intentionally local. Part of the
59work is deciding **which constants get promoted into a player-facing `RunSettings`** and
60which stay internal.
61 
62## The core risk: the economy is a tuned system, not free dials
63 
64This is the whole reason these aren't already settings, and the reason a difficulty
65"level" must be a **bundle** rather than a row of independent sliders.
66 
67**The EV corridor.** With the documented face distribution (2/6/4/6/2 over 20) and the
68band midpoints, the expected swing for *sound, unaided* play (craft modifier 0, no wager,
69no chain, no momentum) is:
70 
71```
72P(CF)=0.10 P(F)=0.30 P(N)=0.20 P(S)=0.30 P(CS)=0.10
73mid: −25 −10 +6 +22.5 +37.5
74EV = .10(−25) + .30(−10) + .20(6) + .30(22.5) + .10(37.5) ≈ +6.2 / turn
75```
76 
77That matches the tuning intent stated in `utils/swing-bands.ts` ("roughly +6/turn" for
78sound play). Over `TOTAL_MESSAGES = 5` turns that is ≈ **+31 of 100** — an *honorable
79loss*. The gap to 100 is what craft, a calibrated anachronism wager, and momentum are
80meant to close, so victory is earned rather than drawn from a lottery.
81 
82**The pipeline that produces a turn's swing** (`callTimelineAI`,
83`server/utils/openai.ts`), in order:
84 
851. Roll a D20; **craft modifier (±2)** shifts the roll (clamped 1–20) → this picks the band.
862. Model proposes a swing; it is **clamped into the rolled band** (`SWING_BANDS`).
873. **Anachronism amplifier** (`amplifyForAnachronism`) widens both signs, asymmetric
88 (gains ×1.15/1.35/1.6, losses ×1.25/1.6/2), soft knee above ±40, re-clamped to ±50.
894. **Causal-chain decay** (`decayForChain`) diffuses both signs toward zero by distance
90 from the nearest foothold (floor 0.12).
915. **Gains-only** multipliers: positive swing × `CRAFT_GAIN_FACTOR` × `momentumFactor`.
92 Losses are never scaled — skill buys reach, never immunity.
936. **Clamp to ±`MAX_PROGRESS_SWING`** (the per-turn fuse).
947. On a final *staked* dispatch only: `applyStake` doubles, clamp ±100.
95 
96**Why the coupling bites.** The corridor is calibrated against `TOTAL_MESSAGES` *and*
97`MAX_PROGRESS` together:
98 
99- Cut messages 5 → 3 and unaided play yields ≈ +18, while the per-turn band ceiling is
100 +45 (`BAND_CEILING`). The win now demands ≈ +33/turn average — near-impossible inside
101 the bands. The run is "harder" by accident, not design.
102- Raise the win threshold 100 → 150 and the same thing happens from the other side.
103- Widen the per-turn fuse and the stake's drama collapses (every turn can already swing
104 the meter).
105 
106So a difficulty preset cannot change message count or win threshold *and leave the bands
107alone*. The bands, the message count, and the threshold move **together**, as one tuned
108bundle, validated by eval. This single fact drives the entire design: **presets, not
109sliders.**
110 
111The honest framing for a preset: it sets a *target* unaided-finish ratio (today ≈ 0.31 of
112the win bar) and a target aided EV, then derives the dials to hit them. There is one
113genuinely clean exception, and it points at the simplest KISS move: **message count
114re-prices the run while leaving every per-turn artifact byte-identical** (bands, dice,
115legend, prompt calibration all unchanged). The pragmatic call is to let message count *be*
116a difficulty axis — Story = more messages, Iron = fewer — and accept that the corridor
117ratio moves with it, rather than re-scaling the bands to hold the ratio constant. Document
118the shift as the intended knob.
119 
120### Gotchas the implementation must handle
121 
122Making these constants settings exposes seams that are invisible today because the values
123happen to line up:
124 
125- **Coincidentally-equal literals must become derived.** `STAKED_SWING_CAP` is a hardcoded
126 `100` (`utils/swing-bands.ts`), *not* a reference to `MAX_PROGRESS`. They are equal by
127 intent (a staked throw sweeps the whole meter) but would silently diverge the moment the
128 win threshold becomes a setting. Same risk anywhere a tuning value re-derives a
129 game-config number by hand.
130- **Re-typed eval constants would desync.** The ±3 valence window and the 160-char cap are
131 re-typed inside the eval fixtures, not imported. A preset touching them desyncs the evals
132 from the game silently. Promote them to imports first.
133- **`BAND_CEILING ≤ MAX_PROGRESS_SWING` is a pinned invariant** (`swing-bands.spec.ts`). Any
134 preset that reshapes the bands has to re-check it, or the amplifier loses its headroom.
135 
136## What gets promoted vs stays internal
137 
138A clean split keeps the settings surface small and the safety/tuning invariants intact.
139 
140**Promote into `RunSettings` (player-facing, per run):**
141 
142- `messagesPerRun` (← `TOTAL_MESSAGES`) — the cleanest standalone run-shape knob.
143- `winThreshold` (← `MAX_PROGRESS`) — only via presets (it re-tunes the corridor).
144- `distinctFiguresCap` — a *new* constraint (none today); 1 = single-thread challenge.
145- `difficulty` — a preset id that *bundles* the tuning dials below.
146- `seed` — deterministic dice; plumbing now, surfaced later for sharing/replay.
147 
148**Keep internal, scaled only *through* a preset (never a raw slider):** the band table,
149the dice reshaping, `CRAFT_*`, `MOMENTUM_*`, the anachronism factors, the chain decay,
150the stake. These are the tuning dials; exposing them raw is how a player breaks the
151curve. A v2 "Custom" mode can open a curated subset behind an advanced panel.
152 
153**Keep internal and immovable (safety):** `isDeceased` / `SAFELY_HISTORICAL_BEFORE_YEAR`
154(`#72`), the objective bounds (`#71`), the in-code band clamp, and moderation
155(`REVISIONIST_MODERATION`). Difficulty does not get a vote here.
156 
157**Leave fixed:** `MAX_MESSAGE_CHARS = 160`. The 160-char discipline is core identity and
158an eval'd Character-layer invariant; making it a dial dilutes the game's signature
159constraint for little variety.
160 
161## The candidate catalog, triaged
162 
163The issue's brainstorm (groups A–G), cut to a v1 verdict. The full rationale for the
164"economy" rows is the section above.
165 
166| Candidate | Maps to | Verdict | Why |
167|---|---|---|---|
168| Messages per run | `TOTAL_MESSAGES` | **v1 dial** | cleanest run-shape lever; couples to the corridor, so its retune rides the preset |
169| Distinct-figures cap | new constraint | **v1 dial** | adds real variety (single-figure run), no economy coupling, server-checkable |
170| Difficulty preset | bundles the tuning dials | **v1 (primary)** | the only safe way to move the coupled economy |
171| Win threshold | `MAX_PROGRESS` | **v1, preset-only** | moves the corridor — must ride a preset, never a free slider |
172| Per-turn fuse | `MAX_PROGRESS_SWING` | **internal** | a safety clamp + the stake's basis; not a player knob |
173| Roll variance / band reshape | dice + bands | **internal (preset-scaled)** | the corridor itself |
174| Craft weight / anachronism / decay / momentum / stake | the tuning dials | **internal (preset-scaled)** | exposing raw breaks tuning |
175| Timeline-window constraints (era clamp, chronological-only) | new, atop the when-slider | **v2** | strong variety, but needs its own UX + eval; lifetime bound already exists |
176| Sandbox / "anywhen" | drop lifetime + safely-dead bounds | **deferred (own slice)** | collides with the safety floor; a deliberate separate stance, not a difficulty knob |
177| Repeat-contact / grounded-only | figure policy | **v2** | grounded-only is already the floor (`#73`); repeat-contact is a light add |
178| Safely-dead cutoff relax | `#72` floor | **never (as difficulty)** | safety, server-authoritative — cannot be a difficulty lever |
179| Objective source / steering / anchor visibility / reroll cap | `#71`, objective compose | **v2** | ties to objective work, not the core economy |
180| Assist (tutorial, scaffolding, archive access) | `#60`, UI | **folds into presets** | Story shows scaffolding; Iron hides it + caps research |
181| Seed | new | **v1 (plumbing)** | the basis for fair sharing/replay; cheap to land early |
182| Daily challenge / speedrun / score normalization | meta modes | **deferred** | depend on seed + sharing; design after the bundle exists |
183 
184## The v1 proposal: three presets
185 
186Three is the right number: an on-ramp, the tuned default, and a brutal mode — from one
187engine. The dial values below are **illustrative targets to be validated by eval**, not
188final tuning (the issue forbids re-tuning the live curve here; this just shows *where*
189each preset would re-tune it).
190 
191| Dimension | **Story** (on-ramp) | **Standard** (today) | **Iron** (veteran) |
192|---|---|---|---|
193| Target unaided EV/turn | higher (~+9) | ~+6 (today) | lower (~+4) |
194| Messages per run | 6–7 | **5** | 4 |
195| Win threshold | 100 | **100** | 100 (decisive feel via tighter economy, not a higher bar) |
196| Band economy | gentler losses, fatter Success | **today** | harsher losses, leaner gains |
197| Anachronism penalty | softened | **today** | harsher losses |
198| Causal-chain decay | gentler | **today** | steeper (chaining matters more) |
199| Last stand (stake) | on | **on** | **off** (no safety net) |
200| Scaffolding (band table, pace hint, ⚡/⏳ chips) | all shown | **shown** | hidden |
201| Archive (research) | unlimited | **unlimited** | capped or disabled |
202| Guided first-run (`#60`) | on | default | off |
203 
204Standard *is* the current configuration — shipping presets must not change today's game.
205Story and Iron are symmetric moves outward from it, each a coherent bundle rather than a
206pile of toggles. Note that "harder" on Iron comes mostly from a leaner economy and a
207removed safety net, **not** from raising the win bar — keeping the meter legible.
208 
209## Surface, persistence, and travel
210 
211**Presets over a wall of sliders.** The mission-select briefing
212([`components/MissionSelect.vue`](../../components/MissionSelect.vue)) gains one row: a
213three-way difficulty chip plus the two run-shape dials (messages, figure cap). That is
214the entire v1 surface. A "Custom" disclosure (per-dial overrides) is explicitly v2.
215 
216**Where settings live.** A run starts when the player commits an objective
217(`chooseObjective` in `stores/game.ts`; a null `currentObjective` is what surfaces the
218briefing). `RunSettings` is authored in the same briefing — a sibling fieldset to the
219existing era/theme steer dials, closed enums, committed only on Begin — threaded through
220`chooseObjective` into **one new `runSettings` state field cleared by `resetGame`**, and
221read by `sendMessage` and the pure dials. Because it carries only pre-run choices (no
222dates, no server `runId`, none of the staleness/account plumbing), it round-trips through
223JSON or a URL param cleanly — which is exactly what makes it the right unit to travel with
224a run. It must work for **anonymous** players (the default path today). A saved profile
225default is a later tie to accounts (`#83`), layered on top — the per-run choice is the
226primitive.
227 
228**Settings travel with a run.** There is **no run snapshot, share, or seed concept in the
229code today** — a run is entirely live and ephemeral. That makes this greenfield, and it
230argues for designing `RunSettings` as a **small serializable bundle from the start**, next
231to a `seed`. For shared-run "beat this" (`#88`) and replay (`#89`) to be *fair*, the
232snapshot has to carry both the rules and the seed; otherwise two players aren't playing
233the same game. The seed is the one piece worth plumbing in v1 even before daily/seeded
234modes ship, because retrofitting determinism later is far more invasive than reserving the
235field now (the dice live behind `rollD20` in `utils/dice.ts` — a single, swappable source).
236 
237## Safety stays server-authoritative
238 
239Several knobs are server-enforced and closed-enum on purpose: the deceased-only contact
240floor (`isDeceased`, `server/utils/figure-grounding.ts`, fail-closed — a figure with no
241confirmed death is never contactable), the objective bounds (`#71`), the in-code clamp of
242the engine's swing into the rolled band, and moderation. A settings layer **must not
243become a client bypass.** Concretely:
244 
245- `RunSettings` is **validated and clamped server-side**, to an allowed range per field —
246 the client's chosen values are untrusted input, like every other field. This is the
247 pattern already in place: `send-message.post.ts` and `research.post.ts` **re-ground the
248 figure name** against the untrusted payload rather than trusting a client claim of "this
249 person is contactable."
250- Difficulty scales the *game* (harder/easier), never the *safety floor*. There is no
251 difficulty that lets you message a living person or dodge moderation. A Sandbox / "anywhen"
252 stance that *did* relax the lifetime + deceased-only bounds (its own deferred slice, not a
253 difficulty knob) would flip a **server-read** flag, never a client toggle the POST handler
254 honors — and moderation (`REVISIONIST_MODERATION`) is never surfaced to players at all;
255 `off` is an operator/calibration lever only.
256- The closed enums stay closed. A preset id is one of a fixed set; an out-of-range message
257 count or figure cap is rejected, not honored.
258 
259## Evals: a preset can't silently break the curve
260 
261The tuning expectations already live as evals, and a new difficulty knob has to extend
262them or it can regress the curve invisibly:
263 
264- [`evals/bakeoff.eval.ts`](../../evals/bakeoff.eval.ts) scores **the NEUTRAL-band EV
265 corridor the run economy is tuned around** — the exact invariant a preset's retune
266 threatens.
267- [`evals/behavior.eval.ts`](../../evals/behavior.eval.ts) checks the model-behavior
268 invariants the mechanics rest on (band steering, `sharp ≥ vague` craft ordering,
269 anachronism accuracy).
270- [`evals/causal-chain.eval.ts`](../../evals/causal-chain.eval.ts) confirms stepping
271 stones beat blasts end-to-end; [`evals/judge-tune.eval.ts`](../../evals/judge-tune.eval.ts)
272 pins craft-grade anchoring (which feeds the economy).
273 
274But the existing coverage has two gaps a preset would fall straight through, and naming
275them is half the value of this exploration:
276 
2771. **The corridor eval guards the wrong number.** It scores `baseProgressChange` — the swing
278 *before* the anachronism / causal-chain / craft-gain / momentum multipliers. So the four
279 downstream multipliers are an **unguarded realized-EV surface**: a "hard" preset that
280 halved `CRAFT_GAIN_FACTOR` would change the EV the player actually experiences while the
281 corridor eval stayed green. The `[4,9]` corridor is a real invariant, but it is *not* the
282 EV the run is won or lost on.
2832. **The corridor bounds are hardcoded literals, not a function of the preset.** If Story
284 legitimately targets a higher neutral EV and Iron a lower one, today's absolute `[4,9]`
285 assertion would flag the *intended* presets as misses. The corridor eval has to become
286 **preset-parameterized** — fed each preset's target band.
287 
288**Requirement for the implementation:** each shipped preset needs (a) a parameterized
289pre-multiplier corridor assertion fed its own target, and (b) a **realized-EV** assertion
290that composes the full pipeline (so a multiplier change can't move the curve unseen) and a
291plausible win rate at sound + calibrated play. The pure dials (bands, decay, momentum,
292craft) are already unit-tested in isolation; the new surface to cover is *the composition
293under each preset*.
294 
295## Scope & monetization
296 
297v1 is deliberately small: presets + two dials + seed plumbing. The open question worth
298**noting, not deciding here**: whether any setting (e.g. seeded/daily competitive modes,
299or an Iron leaderboard) is paid-gated. The accounts/paywall seam already exists (`#83`,
300the runs economy); this note flags the question and leaves the call to the monetization
301track.
302 
303## Recommended v1, restated
304 
305- **Surface:** three difficulty presets (Story · Standard · Iron) + two run-shape dials
306 (messages per run, distinct-figures cap), chosen at mission-select. Custom per-dial
307 overrides deferred to v2.
308- **Model:** a small serializable `RunSettings` bundle (`difficulty`, `messagesPerRun`,
309 `distinctFiguresCap`, `seed`), validated + clamped server-side, stored in the run,
310 anonymous-friendly, profile-default later (`#83`).
311- **Economy:** each preset re-tunes the bundle (message count + band economy + the wager /
312 decay / momentum / stake) **together**, with Standard == today's game unchanged.
313- **Travel:** plumb a deterministic `seed` now; carry `RunSettings` + `seed` in the run
314 snapshot when sharing/replay (`#88`/`#89`) lands.
315- **Guardrails:** safety floor untouched and server-authoritative; an EV-corridor eval per
316 preset.
317 
318## Implementation issues this note spawns
319 
320Ready-to-file, in dependency order:
321 
3221. **`RunSettings` bundle + server validation + seed plumbing.** Define the serializable
323 type (`difficulty`, `messagesPerRun`, `distinctFiguresCap`, `seed`), thread it through
324 the store run-state and `sendMessage`, validate + clamp server-side, and route the dice
325 through a seeded source behind `rollD20`. No UI beyond a default. *Foundation for the
326 rest.*
3272. **Difficulty presets + economy retune + eval coverage.** Implement Story/Standard/Iron
328 as preset bundles that scale the internal tuning dials together; Standard reproduces
329 today's curve exactly. Make the corridor eval **preset-parameterized** and add a
330 **realized-EV** assertion (full pipeline, not just `baseProgressChange`) per preset.
331 First promote the re-typed eval constants (±3 valence, 160-char) to imports and derive
332 `STAKED_SWING_CAP` from the win threshold so nothing diverges silently. *Depends on 1.*
3333. **Run-shape dials at mission-select.** Surface `messagesPerRun` and
334 `distinctFiguresCap` (incl. the single-figure challenge) in `MissionSelect.vue`, with
335 the figure cap enforced server-side. *Depends on 1; pairs with 2 for the message-count
336 retune.*
3374. **Settings + seed travel with a shared run.** Carry `RunSettings` + `seed` in the run
338 snapshot so a shared/replayed run is the same game. *Depends on 1 and on `#88`/`#89`.*
⋯ 15 lines hidden (lines 339–353)
339 
340## Explicitly out of scope (per #90)
341 
342No implementation, no final `RunSettings` schema, no settings UI here. No commitment to the
343final set — this is divergent brainstorming plus a recommendation. Not a re-tuning of the
344existing curve; this only identifies *where* each difficulty would re-tune it.
345 
346---
347 
348*Related:* [#83](https://github.com/mseeks/revisionist/issues/83) (accounts — saved
349defaults) · [#88](https://github.com/mseeks/revisionist/issues/88) /
350[#89](https://github.com/mseeks/revisionist/issues/89) (sharing & replay — why a run
351should carry its settings + seed) · [#71](https://github.com/mseeks/revisionist/issues/71)
352(objective steering) · [#72](https://github.com/mseeks/revisionist/issues/72) (safely-dead
353floor) · [#60](https://github.com/mseeks/revisionist/issues/60) (guided first-run).