What changed, and why
Everwhen grades your writing, then rolls a d20 to decide how far history bends. Across 87 playtests the loudest complaint was "I said the right thing and went backwards" — one cold roll can sink a turn, so skill feels random. The end screen already owned up to single rolls (your best and your worst), but it said nothing about whether the dice ran cold across the whole run. So a well-played loss read as a skill failure.
This PR adds a luck read: one tile, beside best/worst in the "How it played out" row, that averages your natural d20 rolls against their fair 10.5 mean and names the result — the dice ran cold / hot / even. It is pure legibility. No band, multiplier, or threshold that decides a turn is touched; the change only re-tells variance that already happened.
Three files carry it: a baseline constant, the read itself, and the tile.
The honest baseline: a fair die's mean
The read compares against 10.5 — the expected roll of a fair d20, (1 + 20) / 2. That number is the crux of the issue's honesty note: compare against the raw die, because the raw die is the only pure-luck signal. The roll the player watched resolve is the effective roll, which already folds in the craft tilt (±2 from how well they wrote) — and that is skill, not luck.
So the baseline lives beside the other die constants, derived from D20_FACES rather than hardcoded, so it can never drift from the die it describes.
D20_MEAN — the 10.5 baseline, derived from the face count.
utils/dice.ts · 65 lines
⋯ 24 lines hidden (lines 1–24)
⋯ 35 lines hidden (lines 31–65)
Reading the luck — the natural die, banded against the dice table
readLuck is the whole computation, slotted beside findBestRoll / findWorstRoll and folded into the existing GameSummary. It reads each turn's natural roll, averages it, counts how many landed below the median, and bands the average into a one-word verdict. It returns null below two rolls — a lone roll is no "overall" read; that turn is already the screen's "the roll that did it".
The LuckRead type, and the function that fills it.
utils/game-summary.ts · 295 lines
⋯ 38 lines hidden (lines 1–38)
⋯ 49 lines hidden (lines 67–115)
⋯ 156 lines hidden (lines 140–295)
The tile on the end screen
The tile is a near-exact clone of its best/worst siblings: a big mono number over a small label. The number is the average (to one decimal, matching the 10.5 baseline) and borrows the same colour language — cold in the loss red, hot in the win green, even in neutral ink. The compact label carries the core read (avg vs 10.5 · the dice ran cold); the below-median detail rides along in the tooltip. It renders only when gameSummary.luck is non-null (≥ 2 rolls).
The tile markup, and the verdict → colour/phrase/tooltip computeds.
components/EndGameScreen.vue · 338 lines
⋯ 90 lines hidden (lines 1–90)
⋯ 112 lines hidden (lines 101–212)
⋯ 106 lines hidden (lines 233–338)
The tests that pin it
The discriminating cases are the band edges and the natural-vs-effective wiring. Two unit tests pin the cliffs at exactly 8.5 and 12.5 (both read even, since the verdict uses strict < / >) — the likeliest off-by-one to regress. Another proves the read follows the natural die even when the effective roll would say otherwise, at both the util and the rendered-tile layers.
The 8.5 / 12.5 boundary tests and the natural-vs-effective test.
tests/unit/utils/game-summary.spec.ts · 273 lines
⋯ 204 lines hidden (lines 1–204)
⋯ 40 lines hidden (lines 234–273)
Natural 4/6 (avg 5.0, cold) behind effective 9/11 (avg 10, even).