What changed, and why
In Everwhen, the Message Judge grades your dispatch and that craft grade bends the turn's swing. Until now it bent the swing one way only. A positive swing was multiplied by CRAFT_GAIN_FACTOR (masterful banks 1.45x, reckless almost nothing); a negative swing was passed through untouched. So a masterful message that rolled a Failure lost exactly as hard as a reckless one. On the ~40% of turns that go badly (Failure + Critical Failure), skill did nothing. Skill was a sword, never a shield.
This change makes craft cut both ways. It adds a CRAFT_LOSS_FACTOR table and applies it to the loss branch, inverted: good craft softens a loss, bad craft deepens it. The whole change is one engine line, one new table, and the coherence work around them: a UI token that now fires on a loss, the tests that pin the new tuning, and the docs/comments that used to say "gains-only".
Two guardrails frame the design. It is a shield, not an eraser (masterful caps the softening at 0.6, so a masterful loss still bites). And it is craft only — momentum, the other gains-only amplifier, stays gains-only. Both are load-bearing, and both are checked below.
The loss factor: an inverted mirror of the gain factor
The new table lives beside the gain table it mirrors. Read the two as a pair: the gain factor descends with quality (masterful 1.45 down to reckless 0.15), the loss factor ascends (masterful 0.6 up to reckless 1.4). A grade that amplifies a win softens a loss, and the reverse. Sound sits at 1.0 on both, so a clean, unremarkable ask neither lifts a gain nor bends a loss.
CRAFT_LOSS_FACTOR — the downside mirror, with the design encoded in the doc.
utils/craft.ts · 84 lines
⋯ 40 lines hidden (lines 1–40)
⋯ 24 lines hidden (lines 61–84)
One line in the engine
The swing is assembled in the Timeline Engine: the rolled band is clamped, widened by the anachronism wager, then decayed by causal distance into decayed. Craft and momentum are the last multipliers before the per-turn fuse. The change is the ternary on the final line — the loss branch was : decayed, and is now : decayed * craftLoss.
The gain branch keeps craft x momentum; the loss branch now multiplies by craftLoss alone.
server/utils/openai.ts · 655 lines
⋯ 251 lines hidden (lines 1–251)
⋯ 389 lines hidden (lines 267–655)
A shield, not an eraser: the EV check
The tuning claim is that skilled play gets a tighter distribution while sloppy play keeps the full brutal variance — and that the shield is modest, not an eraser. A small helper makes that testable: it computes a turn's realized expected value from the band midpoints, routed to the gain or loss factor exactly as the engine routes them, weighted by the face distribution a craft modifier produces.
The realizedEV helper, then the two tests that pin the shield's two edges.
tests/unit/utils/swing-bands.spec.ts · 161 lines
⋯ 5 lines hidden (lines 1–5)
⋯ 74 lines hidden (lines 23–96)
⋯ 31 lines hidden (lines 131–161)
The player sees the shield (composes with #135)
A softened loss is only legible if the player can see why the number moved. The turn's swing renders as an equation — base [tokens] -> final% (the template at line 68) — where each amplifier drops a labelled token. The craft token was gated to gains (progressChange > 0), so on a loss the number would soften with nothing to explain it. The gate now fires on any non-zero swing for a non-sound grade, and the hover hint is sign-aware: it speaks of banking a gain on the upside, and of shielding or deepening a loss on the downside.
The craft token now renders on a loss; the momentum token below it stays gains-only.
components/MessageHistory.vue · 224 lines
⋯ 169 lines hidden (lines 1–169)
⋯ 40 lines hidden (lines 185–224)
So a masterful Failure now reads -15 ✒ masterful -> -9% in the thread — the player watches skill soften the blow. The momentum token directly below keeps its progressChange > 0 gate, matching the engine.
Keeping the codebase honest
The rest of the change is coherence: making sure nothing in the repo still claims craft is gains-only. The load-bearing test is the integration check — a -10 Failure fed through every grade now yields the inverted ladder, and asserts the shield never cancels the loss.
The old test asserted a constant -10 across grades; it now asserts the inverted shield.
tests/unit/server/utils/openai-pipeline.spec.ts · 320 lines
⋯ 156 lines hidden (lines 1–156)
⋯ 148 lines hidden (lines 173–320)
Three more spec files moved with it: craft.spec.ts pins the table, the shield-not-eraser bound (masterful in [0.5, 1)), and the inverted-mirror invariant (a grade is never on the same side of 1 in both tables); MessageHistory.spec.ts now expects the craft token on a loss. The stale prose followed too — the CRAFT_GAIN_FACTOR doc, the engine comment, the UI hint, and two docs/design notes all said "gains-only / losses untouched", and now describe a two-sided craft with momentum still gains-only.