What changed, and why
The game scored history like a slot machine. The Timeline Engine read the rolled band and the figure's action, not the player's message, and craft entered only as a ±2 nudge on the d20. So a junk dispatch on a lucky roll earned as much as a crafted one, and the figure would invent a confident, objective-advancing deed out of pure noise. A run was "the product of die rolls."
This PR makes the player's message a multiplicand, not a footnote:
| Stage | Role |
|---|---|
base swing (d20 band) | the per-turn variance — unchanged drama |
× craft_gain_factor | slice 1 — how good this message is (gains only) |
× momentum_gain_factor | slice 3 — how coherent the arc is (gains only) |
× anachronism · × decay | existing dials |
× stake → clamp | existing last-stand + per-turn fuse |
Three slices, all deterministic and gains-only (they scale the upside, never the downside, so skill buys reach, not immunity). Blast radius: the scoring pipeline, the Judge + Character prompts, the game store, and two UI components. The exploit dies as a side effect of rewarding real play.
The pipeline multiply — where skill enters the math
This is the heart. Inside callTimelineAI, after the band clamp, the anachronism amplifier, and the causal-chain decay have run, the swing is multiplied by craft and momentum — but only when it's a gain. A loss keeps the rolled band's verdict untouched (the decayed > 0 ? … : decayed branch). The product can now exceed the per-turn fuse, and nothing downstream re-clamps an upward push before the post-stake ±100 cap, so this block re-clamps to ±MAX_PROGRESS_SWING itself.
Gains-only craft × momentum, then the mandatory re-clamp to the per-turn fuse.
server/utils/openai.ts · 565 lines
⋯ 240 lines hidden (lines 1–240)
⋯ 313 lines hidden (lines 253–565)
Craft factor & the honest figure
Craft is graded the same as before (sharpness, period-fit, leverage); the change is a second, multiplicative channel that scales the gain the band pays out. The ±2 roll modifier is untouched, and the two compound on purpose — that compounding is how craft decides the campaign.
The gains-only swing factor, anchored at sound=1.0, floored at 0.15 (rails, not walls).
utils/craft.ts · 54 lines
⋯ 24 lines hidden (lines 1–24)
⋯ 10 lines hidden (lines 45–54)
The other half of the slice keeps the figure honest. A contentless dispatch — empty, pure repeats, symbol noise — gives the figure nothing to act on, so even a Critical Success must yield confusion, not a fabricated deed. The check is deliberately conservative and script-aware: any non-Latin-script letter counts as real content (the game invites play in the figure's own tongue), and Latin diacritics are folded before the vowel test so accented play reads as real.
Only obvious noise trips this; borderline junk is left to the craft grade.
utils/substance.ts · 33 lines
⋯ 20 lines hidden (lines 1–20)
⋯ 1 line hidden (lines 33–33)
When contentless, the confusion override REPLACES the band's outcome guide.
server/utils/prompt-builder.ts · 533 lines
⋯ 115 lines hidden (lines 1–115)
⋯ 413 lines hidden (lines 121–533)
A continuity-aware Judge
Slice 2 teaches the Judge to read the run's thread. It already sees the dispatch, the figure, and the objective; now it also gets the figure's prior reply and a digest of recent ledger headlines, and grades a continuity verdict — does this dispatch meet a condition the figure revealed, exploit a change already on the record, or escalate the run's strategy?
Three verdicts; coercion defaults to 'neutral' on a Judge outage or turn 1.
utils/continuity.ts · 20 lines
⋯ 11 lines hidden (lines 1–11)
The verdict is added to the Judge's JSON schema and prompt only when there's a thread to build on, gated on a single shared hasThread. On turn 1 (no prior reply, empty ledger) the prompt and schema stay byte-identical to the pre-feature build, so the existing judge/timing evals are undisturbed — and momentum can't build out of nothing anyway.
The continuity schema field appears only when hasThread; required moves in lockstep.
server/utils/openai.ts · 565 lines
⋯ 335 lines hidden (lines 1–335)
⋯ 217 lines hidden (lines 349–565)
The thread context renders only when present — the turn-1 prompt is unchanged.
server/utils/prompt-builder.ts · 533 lines
⋯ 233 lines hidden (lines 1–233)
⋯ 290 lines hidden (lines 244–533)
Momentum — the persistent skill-state
Per-turn craft is re-rolled every turn; momentum is the part the dice can't randomize away. A run-level meter M (0–4) climbs on a builds that didn't lose, holds a build that rolled a plain Failure, and shatters to 0 on a Critical Failure or a resets. The factor is 1 + 0.1·M, capped 1.4, multiplied alongside craft in the same gains-only block.
The factor (with a non-finite guard) and the update table.
utils/momentum.ts · 52 lines
⋯ 14 lines hidden (lines 1–14)
⋯ 5 lines hidden (lines 30–34)
The round-trip and the visible meter
There's no server run-state today, so momentum is client-authoritative between turns: the store sends the pre-turn value, the server amplifies with it and returns the advanced value, and the client overwrites on ingest — past the epoch-staleness check, with the swing it amplified. Keeping the multiply server-side (inside callTimelineAI) is what lets the eval harness exercise it.
Read + clamp the pre-turn meter; advance it after the turn; return both values.
server/api/send-message.post.ts · 337 lines
⋯ 71 lines hidden (lines 1–71)
⋯ 211 lines hidden (lines 75–285)
⋯ 28 lines hidden (lines 290–317)
⋯ 16 lines hidden (lines 322–337)
Send pre-turn momentum; overwrite from the server at reveal Beat 2.
stores/game.ts · 1274 lines
⋯ 913 lines hidden (lines 1–913)
⋯ 80 lines hidden (lines 917–996)
⋯ 275 lines hidden (lines 1000–1274)
The reward only lands if the player sees it. A MomentumMeter shows four pips and a live "×N.N to gains" readout, fills as the arc builds, and visibly shatters when it breaks — fully gated for reduced motion (the JS timer early-returns and the CSS @media kills the animation). The swing equation gains a ✦ momentum term so the printed math reconciles to the result.
The factor readout and the shatter-on-drop watch (cleared before any reduced-motion return).
components/MomentumMeter.vue · 125 lines
⋯ 37 lines hidden (lines 1–37)
⋯ 14 lines hidden (lines 42–55)
⋯ 53 lines hidden (lines 73–125)
Craft and momentum each show in the equation, gains-only, so +10 → +14% is explained.
components/MessageHistory.vue · 202 lines
⋯ 146 lines hidden (lines 1–146)
⋯ 34 lines hidden (lines 169–202)
Why it's safe to merge
The change is large but bounded. Every new dial is deterministic, gains-only, and fenced by the existing per-turn (±50) and staked (±100) fuses. The losing side of a turn is byte-for-byte unchanged, and so is the turn-1 Judge prompt.