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
utils/dice.ts65 lines · TypeScript
⋯ 24 lines hidden (lines 1–24)
1export enum DiceOutcome {
2 CRITICAL_FAILURE = 'Critical Failure',
3 FAILURE = 'Failure',
4 NEUTRAL = 'Neutral',
5 SUCCESS = 'Success',
6 CRITICAL_SUCCESS = 'Critical Success'
7}
8 
9export interface DiceResult {
10 roll: number
11 outcome: DiceOutcome
13 
14// The D20 outcome bands. The boundaries the post-game summary also classifies
15// against are named + exported so the live rules, the summary, the prompt's
16// calibration lines, and the UI's roll legend can't drift.
17//
18// Neutral is deliberately the NARROWEST band (4 faces): in a five-message run a
19// turn that produces a shrug is a heavy price, so most rolls now commit — toward
20// or against — and a Neutral at least leaks intel (see the character outcome guide).
21export const CRIT_FAIL_MAX = 2 // rolls 1..2 are a Critical Failure
22export const FAILURE_MAX = 8 // rolls 3..8 are a Failure
23export const NEUTRAL_MAX = 12 // rolls 9..12 are Neutral
24export const CRIT_SUCCESS_MIN = 19 // rolls 19..20 are a Critical Success (13..18 Success)
25export const D20_FACES = 20 // the die's face count — the roll range is 1..D20_FACES
26// A fair d20's expected roll — also its median — (1 + 20) / 2 = 10.5. The honest
27// baseline for reading whether a run's dice ran hot or cold: it's the natural die's
28// mean, before any craft tilt (the tilt is skill, not luck). The bands above split a
29// run's average into cold / even / hot — see the luck read in `game-summary.ts`.
30export const D20_MEAN = (1 + D20_FACES) / 2 // 10.5
⋯ 35 lines hidden (lines 31–65)
31 
32/**
33 * Rolls a D20 and returns both the roll value and outcome category.
34 * @returns DiceResult with roll (1-20) and categorized outcome
35 */
36export function rollD20(): DiceResult {
37 const roll = Math.floor(Math.random() * D20_FACES) + 1
38 return { roll, outcome: categorizeRoll(roll) }
40 
41/**
42 * Applies the Message Judge's craft modifier to a natural roll: the effective
43 * roll is clamped to the die's own 1–20 faces, then banded normally. Two clean,
44 * legible consequences fall out of the clamp: a masterful (+2) message can never
45 * critically backfire, and a reckless (−2) one can never critically cascade.
46 */
47export function applyCraftModifier(roll: number, modifier: number): DiceResult {
48 const effective = Math.max(1, Math.min(D20_FACES, roll + modifier))
49 return { roll: effective, outcome: categorizeRoll(effective) }
51 
52/**
53 * Categorizes a dice roll into outcome tier (useful for testing)
54 * @param roll The dice roll value (1-20)
55 * @returns The outcome category
56 */
57export function categorizeRoll(roll: number): DiceOutcome {
58 // Non-finite garbage (NaN, ±Infinity) lands on the indifferent middle.
59 if (!Number.isFinite(roll)) return DiceOutcome.NEUTRAL
60 if (roll <= CRIT_FAIL_MAX) return DiceOutcome.CRITICAL_FAILURE
61 if (roll <= FAILURE_MAX) return DiceOutcome.FAILURE
62 if (roll <= NEUTRAL_MAX) return DiceOutcome.NEUTRAL
63 if (roll < CRIT_SUCCESS_MIN) return DiceOutcome.SUCCESS
64 return DiceOutcome.CRITICAL_SUCCESS // roll >= CRIT_SUCCESS_MIN (19..20)

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
utils/game-summary.ts295 lines · TypeScript
⋯ 38 lines hidden (lines 1–38)
1import type { Message, GameStatus } from '~/stores/game'
2import { type DiceOutcome, CRIT_FAIL_MAX, CRIT_SUCCESS_MIN, FAILURE_MAX, NEUTRAL_MAX, D20_MEAN } from '~/utils/dice'
3import { TOTAL_MESSAGES } from '~/utils/game-config'
4 
5/**
6 * Interface for a critical turning point in the game
7 */
8export interface TurningPoint {
9 text: string
10 diceRoll: number
11 diceOutcome: DiceOutcome
12 progressChange: number
13 timestamp: Date
14 importance: number // Higher numbers indicate more critical moments
16 
17/**
18 * Interface for message efficiency statistics
19 */
20export interface MessageEfficiency {
21 messagesUsed: number
22 messagesSaved: number
23 totalMessages: number
24 efficiencyPercentage: number
25 efficiencyRating: 'Legendary' | 'Excellent' | 'Great' | 'Good' | 'Standard'
27 
28/**
29 * Interface for overall game statistics
30 */
31export interface GameStatistics {
32 averageDiceRoll: number
33 totalProgressGained: number
34 gameOutcome: GameStatus
35 finalProgress: number
36 gameDurationMinutes: number
38 
39/**
40 * A run-level read on how the DICE THEMSELVES ran — the natural d20s, before any
41 * craft tilt — against their fair 10.5 mean. It names a variance-driven loss as cold
42 * dice rather than bad skill (issue #165): the phrasing stays "the dice," never "your
43 * craft." Null until there are at least two rolls, since one roll is no "overall"
44 * read — that turn is already the end screen's "the roll that did it."
45 */
46export interface LuckRead {
47 /** Mean of the run's natural d20 rolls, to one decimal. */
48 averageRoll: number
49 /** A fair d20's expected roll (its mean and median), 10.5 — the honest baseline. */
50 expectedRoll: number
51 /** How many rolls landed below the 10.5 median. */
52 belowMedianCount: number
53 /** How many rolls the read is over (≥ 2). */
54 rollCount: number
55 /** The dice's overall temperature, banded against the live dice table: an average
56 * in the Neutral band reads `even`, below it `cold` (Failure territory), above it
57 * `hot` (Success territory). */
58 verdict: 'cold' | 'even' | 'hot'
60 
61/**
62 * Interface for complete game summary
63 */
64export interface GameSummary {
65 criticalTurningPoints: TurningPoint[]
66 bestRoll: Message | null
⋯ 49 lines hidden (lines 67–115)
67 worstRoll: Message | null
68 luck: LuckRead | null
69 messageEfficiency: MessageEfficiency
70 statistics: GameStatistics
72 
73/**
74 * The structural slice of the game store these helpers need. Lets us avoid
75 * dragging in Pinia's full `Store<…>` type while still catching field-name
76 * typos and `gameStatus`-as-string regressions at compile time. Tests can
77 * pass plain object literals matching this shape, and the store itself
78 * (which has all four fields with these exact types) satisfies it via
79 * structural compatibility at the `generateGameSummary(this)` call site.
80 */
81export interface GameSummarySource {
82 messageHistory: Message[]
83 remainingMessages: number
84 gameStatus: GameStatus
85 objectiveProgress: number
87 
88/**
89 * Generates a comprehensive game summary from the current game store state.
90 *
91 * Every statistic is derived from the *resolved* turns — the AI (figure) messages,
92 * which is where the dice roll, outcome, and timeline progress change for a turn are
93 * recorded (see `addAIMessageWithData`). User messages are only the prompt; they
94 * carry no roll. There is deliberately no placeholder/sample data: a game with no
95 * resolved turns simply produces empty stats rather than inventing numbers.
96 */
97export function generateGameSummary(gameStore: GameSummarySource): GameSummary {
98 const rolledTurns = getRolledTurns(gameStore.messageHistory)
99 
100 return {
101 criticalTurningPoints: generateCriticalTurningPoints(rolledTurns),
102 bestRoll: findBestRoll(rolledTurns),
103 worstRoll: findWorstRoll(rolledTurns),
104 luck: readLuck(rolledTurns),
105 messageEfficiency: calculateMessageEfficiency(gameStore),
106 statistics: calculateGameStatistics(gameStore, rolledTurns)
107 }
109 
110/**
111 * Reads the run's luck from the NATURAL rolls — the raw die, before the craft
112 * modifier — so a player's skill never reads as luck. Falls back to the effective
113 * `diceRoll` for older turns recorded before `naturalRoll` existed (mirrors the
114 * reveal's `naturalRoll ?? diceRoll`). Returns null below two rolls: a single roll
115 * is no "overall" read.
116 *
117 * The verdict reuses the live dice bands so it can never drift from them: an average
118 * inside the Neutral band (rolls 9..12 → the continuous boundaries 8.5..12.5) reads
119 * `even`; below it the dice were landing in Failure territory (`cold`); above it,
120 * Success territory (`hot`). The band is symmetric around the 10.5 mean by
121 * construction (Neutral is centered on it).
122 */
123function readLuck(rolledTurns: Message[]): LuckRead | null {
124 const rolls = rolledTurns
125 .map(msg => msg.naturalRoll ?? msg.diceRoll)
126 .filter((roll): roll is number => roll !== undefined)
127 
128 if (rolls.length < 2) return null
129 
130 const averageRoll = Math.round((rolls.reduce((sum, roll) => sum + roll, 0) / rolls.length) * 10) / 10
131 const belowMedianCount = rolls.filter(roll => roll < D20_MEAN).length
132 
133 const verdict: LuckRead['verdict'] =
134 averageRoll < FAILURE_MAX + 0.5 ? 'cold'
135 : averageRoll > NEUTRAL_MAX + 0.5 ? 'hot'
136 : 'even'
137 
138 return { averageRoll, expectedRoll: D20_MEAN, belowMedianCount, rollCount: rolls.length, verdict }
⋯ 156 lines hidden (lines 140–295)
140 
141/**
142 * Returns the resolved turns: AI/figure messages that carry a real dice roll.
143 * This is the single source of truth for every dice/progress statistic.
144 */
145function getRolledTurns(messageHistory: Message[]): Message[] {
146 return messageHistory.filter(
147 msg => msg.sender === 'ai' && msg.diceRoll !== undefined
148 )
150 
151/**
152 * The most evocative text for a turn is how history actually bent (the timeline
153 * impact); fall back to the figure's reply if no impact was recorded.
154 */
155function turnText(msg: Message): string {
156 return msg.timelineImpact || msg.text
158 
159/**
160 * Identifies critical turning points based on dice outcomes and progress impact
161 */
162function generateCriticalTurningPoints(rolledTurns: Message[]): TurningPoint[] {
163 const turningPoints: TurningPoint[] = []
164 
165 rolledTurns.forEach(msg => {
166 if (msg.diceRoll === undefined || !msg.diceOutcome) return
167 
168 let importance = 0
169 
170 // Critical successes and failures are always turning points
171 if (msg.diceRoll >= CRIT_SUCCESS_MIN) {
172 importance = 100 + (msg.progressChange || 0)
173 } else if (msg.diceRoll <= CRIT_FAIL_MAX) {
174 importance = 90 + Math.abs(msg.progressChange || 0)
175 } else if (Math.abs(msg.progressChange || 0) >= 25) {
176 // High progress impact messages are also turning points
177 importance = 50 + Math.abs(msg.progressChange || 0)
178 }
179 
180 if (importance > 0) {
181 turningPoints.push({
182 text: turnText(msg),
183 diceRoll: msg.diceRoll,
184 diceOutcome: msg.diceOutcome,
185 progressChange: msg.progressChange || 0,
186 timestamp: msg.timestamp,
187 importance
188 })
189 }
190 })
191 
192 // Sort by importance (highest first)
193 return turningPoints.sort((a, b) => b.importance - a.importance)
195 
196/**
197 * Finds the turn with the highest dice roll
198 */
199function findBestRoll(rolledTurns: Message[]): Message | null {
200 if (rolledTurns.length === 0) return null
201 
202 return rolledTurns.reduce((best, msg) => {
203 if (!best || (msg.diceRoll ?? -1) > (best.diceRoll ?? -1)) {
204 return msg
205 }
206 return best
207 })
209 
210/**
211 * Finds the turn with the lowest dice roll
212 */
213function findWorstRoll(rolledTurns: Message[]): Message | null {
214 if (rolledTurns.length === 0) return null
215 
216 return rolledTurns.reduce((worst, msg) => {
217 if (!worst || (msg.diceRoll ?? Infinity) < (worst.diceRoll ?? Infinity)) {
218 return msg
219 }
220 return worst
221 })
223 
224/**
225 * Maps messages-saved to the player-facing efficiency rating. Exported so the
226 * store's victory getter and this summary can't drift on the same four cutoffs
227 * (they used to re-type the thresholds independently).
228 */
229export function rateEfficiency(messagesSaved: number): MessageEfficiency['efficiencyRating'] {
230 if (messagesSaved >= 4) return 'Legendary'
231 if (messagesSaved >= 3) return 'Excellent'
232 if (messagesSaved >= 2) return 'Great'
233 if (messagesSaved >= 1) return 'Good'
234 return 'Standard'
236 
237/**
238 * Calculates message efficiency metrics
239 */
240function calculateMessageEfficiency(gameStore: GameSummarySource): MessageEfficiency {
241 const messagesUsed = TOTAL_MESSAGES - gameStore.remainingMessages
242 const messagesSaved = gameStore.remainingMessages
243 const efficiencyPercentage = Math.round((messagesSaved / TOTAL_MESSAGES) * 100)
244 
245 return {
246 messagesUsed,
247 messagesSaved,
248 totalMessages: TOTAL_MESSAGES,
249 efficiencyPercentage,
250 efficiencyRating: rateEfficiency(messagesSaved)
251 }
253 
254/**
255 * Calculates overall game statistics
256 */
257function calculateGameStatistics(gameStore: GameSummarySource, rolledTurns: Message[]): GameStatistics {
258 const diceRolls = rolledTurns
259 .map(msg => msg.diceRoll)
260 .filter((roll): roll is number => roll !== undefined)
261 
262 const averageDiceRoll = diceRolls.length > 0
263 ? Math.round((diceRolls.reduce((sum, roll) => sum + roll, 0) / diceRolls.length) * 100) / 100
264 : 0
265 
266 const totalProgressGained = rolledTurns
267 .reduce((total, msg) => total + Math.max(0, msg.progressChange || 0), 0)
268 
269 const gameDurationMinutes = calculateGameDuration(rolledTurns)
270 
271 return {
272 averageDiceRoll,
273 totalProgressGained,
274 gameOutcome: gameStore.gameStatus,
275 finalProgress: gameStore.objectiveProgress,
276 gameDurationMinutes
277 }
279 
280/**
281 * Calculates game duration in minutes from first to last resolved turn
282 */
283function calculateGameDuration(rolledTurns: Message[]): number {
284 if (rolledTurns.length <= 1) return 0
285 
286 const sortedMessages = [...rolledTurns].sort((a, b) =>
287 a.timestamp.getTime() - b.timestamp.getTime()
288 )
289 
290 const firstMessage = sortedMessages[0]
291 const lastMessage = sortedMessages[sortedMessages.length - 1]
292 
293 const durationMs = lastMessage.timestamp.getTime() - firstMessage.timestamp.getTime()
294 return Math.round(durationMs / (1000 * 60)) // Convert to minutes

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
components/EndGameScreen.vue338 lines · Vue
⋯ 90 lines hidden (lines 1–90)
1<template>
2 <div v-if="gameStore.gameStatus === 'victory' || gameStore.gameStatus === 'defeat'" ref="dialogRef" data-testid="end-game-screen"
3 role="dialog" aria-modal="true" :aria-labelledby="headingId" @keydown="onKeydown"
4 class="end-screen fixed inset-0 z-50 overflow-y-auto" :class="isVictory ? 'end--win' : 'end--loss'">
5 <div class="max-w-4xl mx-auto px-5 py-8">
6 <!-- Verdict masthead: a struck seal, the verdict, the objective + final %. The
7 win is illuminated (glowing terracotta seal, serif verdict in ink); the loss
8 is somber (a flat, cold seal and a muted verdict) — the two read apart instantly. -->
9 <div class="text-center border-b ew-line pb-6">
10 <div class="verdict-seal" :class="isVictory ? 'is-win' : 'is-loss'" aria-hidden="true">{{ isVictory ? '✦' : '⊘' }}</div>
11 <h2 v-if="isVictory" :id="headingId" ref="headingRef" tabindex="-1" data-testid="victory-message" class="ew-serif text-3xl sm:text-4xl font-bold ew-fg mt-3 outline-none">History Rewritten</h2>
12 <h2 v-else :id="headingId" ref="headingRef" tabindex="-1" data-testid="defeat-message" class="ew-serif text-3xl sm:text-4xl font-bold ew-muted mt-3 outline-none">The Timeline Held</h2>
13 <p class="text-sm mt-2.5 flex items-center justify-center gap-2 flex-wrap">
14 <span class="ew-muted inline-flex items-center gap-1.5">
15 <span aria-hidden="true">{{ gameStore.currentObjective?.icon }}</span>
16 <strong data-testid="objective-title" class="font-semibold">{{ gameStore.currentObjective?.title || 'Historical Mission' }}</strong>
17 </span>
18 <span class="ew-hair-c" aria-hidden="true">·</span>
19 <span class="ew-mono font-bold" :class="isVictory ? 'ew-ok' : 'ew-bad'">
20 <span data-testid="final-progress">{{ gameStore.objectiveProgress }}%</span> rewritten
21 </span>
22 </p>
23 
24 <RemixCreditLine v-if="remixCredit" :credit="remixCredit" class="mt-2.5" />
25 
26 <p v-if="!isVictory" data-testid="defeat-explanation" class="ew-muted text-sm mt-3 max-w-prose mx-auto">
27 Your five messages are spent. What you changed remains — but it was not enough, and the objective slipped away.
28 </p>
29 
30 <div v-if="victoryEfficiency" data-testid="efficiency-display" class="mt-3 ew-mono text-sm">
31 <span class="ew-ok font-semibold">{{ victoryPhrase }}</span>
32 <span class="ew-muted"> · {{ victoryEfficiency.messagesUsed }}/5 used<span v-if="victoryEfficiency.messagesSaved > 0"> · {{ victoryEfficiency.messagesSaved }} saved</span></span>
33 <span v-if="victoryEfficiency.isEarlyVictory" class="ew-ok"> · 🌟 to spare</span>
34 </div>
35 </div>
36 
37 <!-- The Chronicle's final telling — the epilogue. While the terminal telling is
38 still being written (epiloguePending), we show the "final account…" wait, NOT
39 the prior turn's stale, pre-ending telling. When it lands it cross-fades in
40 (mirroring Chronicle.vue's rewrite reveal); a failed final refresh clears
41 pending and falls back to the prior telling, so the panel never hangs. -->
42 <div v-if="chronicle || epiloguePending" data-testid="end-chronicle" class="mt-6">
43 <span class="ew-label ew-label--rule"><span aria-hidden="true">📖</span> The Chronicle</span>
44 <Transition :name="epilogueTransition" mode="out-in">
45 <div v-if="epiloguePending" key="epilogue-loading" data-testid="end-chronicle-loading" class="ew-faint italic text-sm">
46 The chronicler sets down the final account…
47 </div>
48 <article v-else-if="chronicle" key="epilogue-body" data-testid="end-chronicle-body">
49 <h4 data-testid="end-chronicle-title" class="ew-serif text-xl font-semibold ew-fg mb-2.5 leading-tight">{{ chronicle.title }}</h4>
50 <p v-for="(para, i) in chronicle.paragraphs" :key="i" class="ew-serif ew-muted text-[15px] leading-relaxed mb-2.5 last:mb-0">{{ para }}</p>
51 </article>
52 </Transition>
53 </div>
54 
55 <!-- The words you sent — the keepsake: your actual messages into the past -->
56 <div v-if="dispatches.length" data-testid="dispatches" class="mt-7">
57 <span class="ew-label ew-label--rule">The words you sent</span>
58 <ul class="space-y-3.5">
59 <li v-for="(d, i) in dispatches" :key="i">
60 <p class="ew-label">to {{ d.figure }}<span v-if="d.era"> · {{ d.era }}</span></p>
61 <blockquote class="ew-serif ew-fg text-base leading-relaxed border-l-2 ew-line pl-3 mt-1">"{{ d.text }}"</blockquote>
62 </li>
63 </ul>
64 </div>
65 
66 <!-- Stats — derived from the real resolved turns -->
67 <div data-testid="game-summary" class="mt-7">
68 <span class="ew-label ew-label--rule">How it played out</span>
69 <div class="flex flex-wrap gap-10">
70 <!-- "Peaked at X%" — shown only when the run slipped from its high point
71 (the peak sits above the floored final %). The case it exists for is a
72 staked last dispatch that crit-fails to 0% from a real peak, where the
73 final % alone reads "as if you did nothing" (#129). A clean win never
74 shows it: its peak IS the final 100%. -->
75 <div v-if="showPeak" data-testid="peak-progress">
76 <div class="ew-mono text-3xl font-extrabold ew-accent leading-none">{{ gameStore.peakProgress }}%</div>
77 <div class="ew-label mt-1">peaked at</div>
78 </div>
79 <div data-testid="message-efficiency">
80 <div class="ew-mono text-3xl font-extrabold ew-fg leading-none">{{ messagesUsed }}</div>
81 <div class="ew-label mt-1">{{ messagesUsed === 1 ? 'message' : 'messages' }} sent</div>
82 </div>
83 <div v-if="gameSummary.bestRoll" data-testid="best-roll">
84 <div class="ew-mono text-3xl font-extrabold ew-ok leading-none">{{ gameSummary.bestRoll.diceRoll }}</div>
85 <div class="ew-label mt-1">{{ messagesUsed >= 2 ? 'best roll' : 'the roll that did it' }} · {{ gameSummary.bestRoll.diceOutcome }}</div>
86 </div>
87 <div v-if="gameSummary.worstRoll && messagesUsed >= 2" data-testid="worst-roll">
88 <div class="ew-mono text-3xl font-extrabold ew-bad leading-none">{{ gameSummary.worstRoll.diceRoll }}</div>
89 <div class="ew-label mt-1">worst roll · {{ gameSummary.worstRoll.diceOutcome }}</div>
90 </div>
91 <!-- The run's luck read (#129's sibling, issue #165): how the DICE themselves
92 ran — the natural d20s, before any craft tilt — against their 10.5 mean.
93 It names a variance-driven loss as cold dice, not bad skill. Shown once
94 there are ≥2 rolls to read "overall" (a lone roll is already "the roll
95 that did it" above). The number borrows the same red/green/ink the
96 best/worst stats use; the tooltip carries the below-median detail. -->
97 <div v-if="gameSummary.luck" data-testid="luck-read" :title="luckTitle">
98 <div class="ew-mono text-3xl font-extrabold leading-none" :class="luckClass">{{ gameSummary.luck.averageRoll.toFixed(1) }}</div>
99 <div class="ew-label mt-1">avg vs {{ gameSummary.luck.expectedRoll }} · {{ luckPhrase }}</div>
100 </div>
⋯ 112 lines hidden (lines 101–212)
101 </div>
102 </div>
103 
104 <!-- The history you wrote — the Spine itself, exactly as it filled in during
105 the run. Read-only here: no live "▷ now" present marker, no "the timeline
106 trembles…" empty state. Same component the player scrolled and tapped on
107 the board, so the verdict's history reads as richly as it did in play —
108 valence dots, ⚡/⚑ badges, the roll, and the Δ equation all carry over. -->
109 <div v-if="timeline.length > 0" class="mt-6">
110 <TimelineLedger readonly />
111 </div>
112 
113 <!-- Share this run (issue #88) — once the snapshot is durably saved. Proud of the
114 rewrite? Post it: the public page, the unfurl, the share card. -->
115 <div v-if="canShare" class="mt-8">
116 <ShareControls :run-id="shareRunId" :share-text="shareText" />
117 </div>
118 
119 <div class="mt-9 border-t ew-line pt-6 flex items-center gap-3 flex-wrap">
120 <button ref="playAgainRef" type="button" data-testid="play-again-button" class="ew-btn ew-btn--primary" @click="playAgain">
121 Try a new timeline <span aria-hidden="true"></span>
122 </button>
123 <!-- The finished run just joined your saved-runs history, so the end screen
124 offers an obvious way to reach it: a secondary button beside the primary
125 "Try a new timeline" (the button-styled /runs link mirrors the
126 retrospective's "Back to your runs", pages/runs/[id].vue), carrying the ◷
127 glyph and "Your runs" wording from the account popover (RunsBalance.vue). -->
128 <NuxtLink to="/runs" data-testid="end-your-runs-link" class="ew-btn inline-flex items-center gap-1.5">
129 <span aria-hidden="true"></span> Your runs
130 </NuxtLink>
131 <span class="ew-faint text-xs inline-flex items-center gap-1.5">
132 <span class="ew-dot" :class="isVictory ? 'ew-dot--ok' : 'ew-dot--bad'" aria-hidden="true" /> this timeline is written.
133 </span>
134 </div>
135 </div>
136 </div>
137</template>
138 
139<script setup lang="ts">
140/**
141 * EndGameScreen — the mission debrief. A full-bleed takeover (not a modal card):
142 * verdict line, the Chronicle epilogue + big-number stat trio, and the history you
143 * wrote. All stats derive from the real resolved turns.
144 */
145import { ref, computed, watch, nextTick } from 'vue'
146import { useGameStore } from '~/stores/game'
147import { usePrefersReducedMotion } from '~/composables/usePrefersReducedMotion'
148import { buildShareText, type RemixCredit } from '~/utils/run-snapshot'
149 
150const gameStore = useGameStore()
151 
152// When this run is a replay (issue #89), the "remixed from" credit, from the live seed —
153// shown on the end screen the moment the replay resolves. The title is omitted (the
154// objective is already in the masthead above), so it reads "Remixed from <source>".
155const remixCredit = computed<RemixCredit | null>(() =>
156 gameStore.replayState
157 ? { attribution: gameStore.replayState.sourceAttribution, objectiveTitle: '', sourceToken: gameStore.replayState.sourceToken }
158 : null
160const reduced = usePrefersReducedMotion()
161 
162const headingId = `end-game-heading-${Math.random().toString(36).slice(2, 11)}`
163 
164// Modal focus management: it's a true takeover (aria-modal), so when it opens we
165// move focus to the VERDICT heading (tabindex=-1: announced, focusable, outside
166// the Tab cycle), trap Tab inside, and restore focus when the run ends. Focusing
167// the heading — not the bottom Play Again button — also means the screen opens at
168// the top: the verdict and the Chronicle epilogue are the first things seen, not
169// scrolled past below the fold.
170const dialogRef = ref<HTMLElement | null>(null)
171const headingRef = ref<HTMLElement | null>(null)
172const playAgainRef = ref<HTMLElement | null>(null)
173let prevFocus: HTMLElement | null = null
174 
175watch(() => gameStore.gameStatus, async (s) => {
176 if (typeof document === 'undefined') return
177 if (s === 'victory' || s === 'defeat') {
178 prevFocus = document.activeElement as HTMLElement | null
179 await nextTick()
180 if (dialogRef.value) dialogRef.value.scrollTop = 0
181 headingRef.value?.focus()
182 } else if (prevFocus) {
183 prevFocus.focus?.()
184 prevFocus = null
185 }
186})
187 
188function onKeydown(e: KeyboardEvent) {
189 if (e.key !== 'Tab' || !dialogRef.value) return
190 const focusables = dialogRef.value.querySelectorAll<HTMLElement>(
191 'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
192 )
193 if (!focusables.length) return
194 const first = focusables[0]
195 const last = focusables[focusables.length - 1]
196 const active = document.activeElement
197 if (e.shiftKey && active === first) { e.preventDefault(); last.focus() }
198 else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus() }
200 
201const isVictory = computed(() => gameStore.gameStatus === 'victory')
202const victoryEfficiency = computed(() => gameStore.getVictoryEfficiency())
203 
204// The verdict never deflates: spending every message is reframed as "Hard-won," not
205// the limp "Standard." Only the laudatory tiers keep their rating as an adjective.
206const victoryPhrase = computed(() => {
207 const rating = victoryEfficiency.value?.efficiencyRating
208 if (!rating) return 'Victory'
209 return rating === 'Standard' ? 'Hard-won victory' : `${rating} victory`
210})
211const messagesUsed = computed(() => 5 - gameStore.remainingMessages)
212const gameSummary = computed(() => gameStore.gameSummary)
213 
214// The luck read's styling + phrasing (#165). Cold dice borrow the loss red, hot dice
215// the win green, an even run the neutral ink — the same at-a-glance colour language
216// as the best/worst-roll stats beside it. The phrasing stays honest: it names "the
217// dice," never the player's craft. The tooltip adds the below-median detail the
218// compact label leaves out.
219const luckClass = computed(() => {
220 const v = gameSummary.value.luck?.verdict
221 return v === 'cold' ? 'ew-bad' : v === 'hot' ? 'ew-ok' : 'ew-fg'
222})
223const luckPhrase = computed(() => {
224 const v = gameSummary.value.luck?.verdict
225 return v === 'cold' ? 'the dice ran cold' : v === 'hot' ? 'the dice ran hot' : 'the dice ran even'
226})
227const luckTitle = computed(() => {
228 const l = gameSummary.value.luck
229 if (!l) return undefined
230 return `The dice ran ${l.verdict}: your rolls averaged ${l.averageRoll.toFixed(1)} vs ${l.expectedRoll} expected, ${l.belowMedianCount} of ${l.rollCount} below the median.`
231})
232 
⋯ 106 lines hidden (lines 233–338)
233// "Peaked at X%" surfaces only when the run lost ground from its high-water mark —
234// the peak above the final %. The case it answers is a staked last dispatch that
235// crit-fails to 0% from a real peak, where the floored final % alone would read "as
236// if the player did nothing" (#129). A clean win never shows it (peak === final 100%).
237const showPeak = computed(() => gameStore.peakProgress > gameStore.objectiveProgress)
238 
239// Share affordance (issue #88): only for a run that's been durably saved (so there's a
240// server-side record to make public). `canShare` true ⟹ runId is a real saved uuid.
241const canShare = computed(() => gameStore.runSnapshotSaved && !!gameStore.runId)
242const shareRunId = computed(() => gameStore.runId ?? '')
243const shareText = computed(() =>
244 buildShareText(gameStore.currentObjective?.title ?? '', isVictory.value ? 'victory' : 'defeat', gameStore.objectiveProgress)
246const timeline = computed(() => gameStore.timelineEvents)
247const chronicle = computed(() => gameStore.chronicle)
248// The epilogue (terminal telling) is still being written — show the wait, never the
249// prior turn's stale telling. The cross-fade mirrors Chronicle.vue and is dropped
250// under prefers-reduced-motion (the empty name disables the Transition's classes).
251const epiloguePending = computed(() => gameStore.epiloguePending)
252const epilogueTransition = computed(() => (reduced.value ? '' : 'epilogue'))
253 
254// The keepsake: the player's verbatim messages, paired with whom/when they reached.
255const dispatches = computed(() => {
256 const users = gameStore.messageHistory.filter(m => m.sender === 'user')
257 const events = gameStore.timelineEvents
258 return users.map((m, i) => ({
259 text: m.text,
260 figure: m.figureName || events[i]?.figureName || 'the past',
261 era: events[i]?.era || ''
262 }))
263})
264 
265// The page owns the FULL reset (store + figure input + composer), so Play Again
266// emits instead of resetting the store directly — the two reset paths used to
267// diverge here, leaving the previous run's figure stranded in the input.
268const emit = defineEmits<{ 'play-again': [] }>()
269const playAgain = () => {
270 emit('play-again')
272</script>
273 
274<style scoped>
275/* The epilogue reveal — the terminal telling cross-fades in over the "final account…"
276 wait, the same gentle opacity fade Chronicle.vue uses for a rewrite. Gated by the
277 transition NAME (empty under prefers-reduced-motion), so reduced-motion gets an
278 instant swap with no classes attached. */
279.epilogue-enter-active,
280.epilogue-leave-active {
281 transition: opacity var(--ew-dur-3) var(--ew-ease);
283.epilogue-enter-from,
284.epilogue-leave-to {
285 opacity: 0;
287 
288/* Win and loss share the page frame but not its mood. The win is illuminated — a warm
289 wash blooms from the top; the loss is a colder, dimmer ground that has settled back. */
290.end-screen { background: var(--ew-bg); animation: end-in var(--ew-dur-3) var(--ew-ease) both; }
291@keyframes end-in { from { opacity: 0; } to { opacity: 1; } }
292@media (prefers-reduced-motion: reduce) { .end-screen { animation: none; } }
293.end--win {
294 background:
295 radial-gradient(135% 70% at 50% -12%, color-mix(in srgb, var(--ew-accent) 13%, transparent), transparent 68%),
296 var(--ew-bg);
298.end--loss {
299 background:
300 radial-gradient(135% 70% at 50% -12%, color-mix(in srgb, var(--ew-bad) 6%, transparent), transparent 60%),
301 var(--ew-tint);
303 
304/* The struck seal — the verdict's emblem. The win seal glows like fresh sealing wax;
305 the loss seal is a cold, flat stamp. */
306.verdict-seal {
307 display: inline-grid;
308 place-items: center;
309 width: 60px;
310 height: 60px;
311 border-radius: 50%;
312 border: 2px solid;
313 font-size: 1.6rem;
314 line-height: 1;
316.verdict-seal.is-win {
317 color: var(--ew-accent);
318 border-color: var(--ew-accent);
319 box-shadow:
320 0 0 0 5px color-mix(in srgb, var(--ew-accent) 12%, transparent),
321 0 0 30px color-mix(in srgb, var(--ew-accent) 32%, transparent);
322 animation: seal-strike 0.6s var(--ew-ease-spring) both;
324.verdict-seal.is-loss {
325 color: var(--ew-faint);
326 border-color: var(--ew-line);
327 box-shadow: inset 0 0 0 4px color-mix(in srgb, var(--ew-faint) 8%, transparent);
329 
330@keyframes seal-strike {
331 0% { opacity: 0; transform: scale(.5) rotate(-12deg); }
332 60% { transform: scale(1.12) rotate(3deg); }
333 100% { opacity: 1; transform: scale(1) rotate(0); }
335@media (prefers-reduced-motion: reduce) {
336 .verdict-seal.is-win { animation: none; }
338</style>

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
tests/unit/utils/game-summary.spec.ts273 lines · TypeScript
⋯ 204 lines hidden (lines 1–204)
1import { describe, it, expect, beforeEach } from 'vitest'
2import { setActivePinia, createPinia } from 'pinia'
3import { useGameStore } from '../../../stores/game'
4import { generateGameSummary } from '../../../utils/game-summary'
5import type { Message } from '../../../stores/game'
6import { DiceOutcome } from '../../../utils/dice'
7 
8// A turn in the real game is a user prompt followed by the resolved AI/figure
9// message, which is where the dice roll, outcome, and progress are recorded.
10function userTurn(text: string, figureName = 'Cleopatra'): Message {
11 return { text, sender: 'user', timestamp: new Date(), figureName }
13function figureTurn(opts: {
14 text?: string
15 figureName?: string
16 diceRoll: number
17 diceOutcome: DiceOutcome
18 naturalRoll?: number
19 progressChange?: number
20 timelineImpact?: string
21 timestamp?: Date
22}): Message {
23 return {
24 text: opts.text ?? 'A reply',
25 sender: 'ai',
26 figureName: opts.figureName ?? 'Cleopatra',
27 timestamp: opts.timestamp ?? new Date(),
28 diceRoll: opts.diceRoll,
29 diceOutcome: opts.diceOutcome,
30 naturalRoll: opts.naturalRoll,
31 progressChange: opts.progressChange,
32 timelineImpact: opts.timelineImpact
33 }
35 
36describe('Game Summary Generation', () => {
37 let gameStore: ReturnType<typeof useGameStore>
38 
39 beforeEach(() => {
40 setActivePinia(createPinia())
41 gameStore = useGameStore()
42 })
43 
44 describe('reads dice data from resolved (AI) turns — never from user prompts', () => {
45 it('never fabricates stats when only user prompts exist (regression)', () => {
46 // The old summary read user messages and, finding no rolls, invented
47 // them. Dice data now lives on AI turns, so a prompt-only history
48 // yields empty stats rather than fake "Best Roll: 19 / Worst: 2".
49 gameStore.messageHistory = [userTurn('do this'), userTurn('and that')]
50 
51 const summary = generateGameSummary(gameStore)
52 
53 expect(summary.bestRoll).toBeNull()
54 expect(summary.worstRoll).toBeNull()
55 expect(summary.criticalTurningPoints).toEqual([])
56 expect(summary.statistics.averageDiceRoll).toBe(0)
57 })
58 
59 it('derives best and worst rolls from the AI turns', () => {
60 gameStore.messageHistory = [
61 userTurn('m1'), figureTurn({ text: 'worst', diceRoll: 2, diceOutcome: DiceOutcome.CRITICAL_FAILURE, progressChange: -10 }),
62 userTurn('m2'), figureTurn({ text: 'mid', diceRoll: 15, diceOutcome: DiceOutcome.SUCCESS, progressChange: 15 }),
63 userTurn('m3'), figureTurn({ text: 'best', diceRoll: 19, diceOutcome: DiceOutcome.CRITICAL_SUCCESS, progressChange: 40 })
64 ]
65 
66 const summary = generateGameSummary(gameStore)
67 
68 expect(summary.bestRoll?.diceRoll).toBe(19)
69 expect(summary.bestRoll?.text).toBe('best')
70 expect(summary.worstRoll?.diceRoll).toBe(2)
71 expect(summary.worstRoll?.text).toBe('worst')
72 })
73 })
74 
75 describe('Critical Turning Points', () => {
76 beforeEach(() => {
77 gameStore.messageHistory = [
78 figureTurn({ diceRoll: 12, diceOutcome: DiceOutcome.NEUTRAL, progressChange: 0, timestamp: new Date('2024-01-01T10:00:00') }),
79 figureTurn({ diceRoll: 20, diceOutcome: DiceOutcome.CRITICAL_SUCCESS, progressChange: 50, timestamp: new Date('2024-01-01T10:05:00') }),
80 figureTurn({ diceRoll: 1, diceOutcome: DiceOutcome.CRITICAL_FAILURE, progressChange: -25, timestamp: new Date('2024-01-01T10:10:00') }),
81 figureTurn({ diceRoll: 16, diceOutcome: DiceOutcome.SUCCESS, progressChange: 20, timestamp: new Date('2024-01-01T10:15:00') })
82 ]
83 })
84 
85 it('lists the critical rolls as turning points', () => {
86 const summary = generateGameSummary(gameStore)
87 const critical = summary.criticalTurningPoints.filter(p => p.diceRoll === 20 || p.diceRoll === 1)
88 expect(critical.length).toBe(2)
89 })
90 
91 it('prioritizes the critical success first', () => {
92 const summary = generateGameSummary(gameStore)
93 expect(summary.criticalTurningPoints[0].diceRoll).toBe(20)
94 expect(summary.criticalTurningPoints[0].diceOutcome).toBe('Critical Success')
95 })
96 
97 it('carries the progress impact through', () => {
98 const summary = generateGameSummary(gameStore)
99 expect(summary.criticalTurningPoints.find(p => p.diceRoll === 20)?.progressChange).toBe(50)
100 expect(summary.criticalTurningPoints.find(p => p.diceRoll === 1)?.progressChange).toBe(-25)
101 })
102 })
103 
104 describe('Message Efficiency', () => {
105 it('reports efficiency for a victory with messages to spare', () => {
106 gameStore.gameStatus = 'victory'
107 gameStore.remainingMessages = 2
108 gameStore.objectiveProgress = 100
109 
110 const summary = generateGameSummary(gameStore)
111 
112 expect(summary.messageEfficiency.messagesUsed).toBe(3)
113 expect(summary.messageEfficiency.messagesSaved).toBe(2)
114 expect(summary.messageEfficiency.totalMessages).toBe(5)
115 expect(summary.messageEfficiency.efficiencyPercentage).toBe(40)
116 })
117 
118 it('rates a near-instant win as Legendary', () => {
119 gameStore.gameStatus = 'victory'
120 gameStore.remainingMessages = 4
121 
122 expect(generateGameSummary(gameStore).messageEfficiency.efficiencyRating).toBe('Legendary')
123 })
124 })
125 
126 describe('Overall Statistics', () => {
127 beforeEach(() => {
128 gameStore.gameStatus = 'victory'
129 gameStore.objectiveProgress = 100
130 gameStore.remainingMessages = 1
131 gameStore.messageHistory = [
132 figureTurn({ diceRoll: 8, diceOutcome: DiceOutcome.NEUTRAL, progressChange: 10, timestamp: new Date('2024-01-01T10:00:00') }),
133 figureTurn({ diceRoll: 18, diceOutcome: DiceOutcome.SUCCESS, progressChange: 30, timestamp: new Date('2024-01-01T10:05:00') }),
134 figureTurn({ diceRoll: 20, diceOutcome: DiceOutcome.CRITICAL_SUCCESS, progressChange: 60, timestamp: new Date('2024-01-01T10:10:00') })
135 ]
136 })
137 
138 it('computes the average dice roll', () => {
139 expect(generateGameSummary(gameStore).statistics.averageDiceRoll).toBe(15.33) // (8+18+20)/3
140 })
141 
142 it('sums the positive progress gained', () => {
143 expect(generateGameSummary(gameStore).statistics.totalProgressGained).toBe(100) // 10+30+60
144 })
145 
146 it('reports outcome and final progress', () => {
147 const stats = generateGameSummary(gameStore).statistics
148 expect(stats.gameOutcome).toBe('victory')
149 expect(stats.finalProgress).toBe(100)
150 })
151 
152 it('computes duration across the resolved turns', () => {
153 expect(generateGameSummary(gameStore).statistics.gameDurationMinutes).toBe(10)
154 })
155 })
156 
157 // The run-level luck read (#165): how the DICE themselves ran — the natural d20s,
158 // before any craft tilt — against their 10.5 mean. It names a variance-driven loss
159 // as cold dice, not bad skill. The verdict bands off the live dice table.
160 describe('Luck read (#165) — how the dice themselves ran', () => {
161 it('is null below two rolls (a lone roll is no "overall" read)', () => {
162 gameStore.messageHistory = [
163 userTurn('m1'), figureTurn({ naturalRoll: 4, diceRoll: 4, diceOutcome: DiceOutcome.FAILURE })
164 ]
165 expect(generateGameSummary(gameStore).luck).toBeNull()
166 })
167 
168 it('reads cold when the natural rolls averaged below the Failure/Neutral edge', () => {
169 gameStore.messageHistory = [
170 userTurn('m1'), figureTurn({ naturalRoll: 4, diceRoll: 4, diceOutcome: DiceOutcome.FAILURE }),
171 userTurn('m2'), figureTurn({ naturalRoll: 7, diceRoll: 7, diceOutcome: DiceOutcome.FAILURE })
172 ]
173 const luck = generateGameSummary(gameStore).luck
174 expect(luck?.verdict).toBe('cold')
175 expect(luck?.averageRoll).toBe(5.5)
176 expect(luck?.expectedRoll).toBe(10.5)
177 expect(luck?.belowMedianCount).toBe(2)
178 expect(luck?.rollCount).toBe(2)
179 })
180 
181 it('reads hot when the natural rolls averaged above the Neutral/Success edge', () => {
182 gameStore.messageHistory = [
183 userTurn('m1'), figureTurn({ naturalRoll: 15, diceRoll: 15, diceOutcome: DiceOutcome.SUCCESS }),
184 userTurn('m2'), figureTurn({ naturalRoll: 18, diceRoll: 18, diceOutcome: DiceOutcome.SUCCESS })
185 ]
186 const luck = generateGameSummary(gameStore).luck
187 expect(luck?.verdict).toBe('hot')
188 expect(luck?.averageRoll).toBe(16.5)
189 })
190 
191 it('reads even when the average sits inside the Neutral band, dead on the mean', () => {
192 gameStore.messageHistory = [
193 userTurn('m1'), figureTurn({ naturalRoll: 9, diceRoll: 9, diceOutcome: DiceOutcome.NEUTRAL }),
194 userTurn('m2'), figureTurn({ naturalRoll: 12, diceRoll: 12, diceOutcome: DiceOutcome.NEUTRAL })
195 ]
196 const luck = generateGameSummary(gameStore).luck
197 expect(luck?.verdict).toBe('even')
198 expect(luck?.averageRoll).toBe(10.5)
199 expect(luck?.belowMedianCount).toBe(1) // 9 is below the 10.5 median; 12 is not
200 })
201 
202 // The band cliffs sit at exactly 8.5 (Failure/Neutral edge) and 12.5
203 // (Neutral/Success edge); the verdict uses strict </>, so both edges read
204 // 'even'. These pin the boundary — a regression to <=/>= would flip them.
205 it('treats the low edge (avg 8.5) as even, not cold', () => {
206 gameStore.messageHistory = [
207 userTurn('m1'), figureTurn({ naturalRoll: 8, diceRoll: 8, diceOutcome: DiceOutcome.FAILURE }),
208 userTurn('m2'), figureTurn({ naturalRoll: 9, diceRoll: 9, diceOutcome: DiceOutcome.NEUTRAL })
209 ]
210 expect(generateGameSummary(gameStore).luck?.verdict).toBe('even') // 8.5 is not < 8.5
211 })
212 
213 it('treats the high edge (avg 12.5) as even, not hot', () => {
214 gameStore.messageHistory = [
215 userTurn('m1'), figureTurn({ naturalRoll: 12, diceRoll: 12, diceOutcome: DiceOutcome.NEUTRAL }),
216 userTurn('m2'), figureTurn({ naturalRoll: 13, diceRoll: 13, diceOutcome: DiceOutcome.SUCCESS })
217 ]
218 expect(generateGameSummary(gameStore).luck?.verdict).toBe('even') // 12.5 is not > 12.5
219 })
220 
221 it('reads the natural die, never the craft-tilted effective roll (skill is not luck)', () => {
222 // Masterful play (+2) lifted the EFFECTIVE rolls, but the dice themselves
223 // came up cold — the read must name the dice, not the craft.
224 gameStore.messageHistory = [
225 userTurn('m1'), figureTurn({ naturalRoll: 5, diceRoll: 7, diceOutcome: DiceOutcome.FAILURE }),
226 userTurn('m2'), figureTurn({ naturalRoll: 6, diceRoll: 8, diceOutcome: DiceOutcome.FAILURE })
227 ]
228 const luck = generateGameSummary(gameStore).luck
229 expect(luck?.averageRoll).toBe(5.5) // (5 + 6) / 2, not (7 + 8) / 2
230 expect(luck?.verdict).toBe('cold')
231 })
232 
233 it('falls back to the effective roll for older turns with no recorded natural roll', () => {
⋯ 40 lines hidden (lines 234–273)
234 gameStore.messageHistory = [
235 userTurn('m1'), figureTurn({ diceRoll: 4, diceOutcome: DiceOutcome.FAILURE }),
236 userTurn('m2'), figureTurn({ diceRoll: 6, diceOutcome: DiceOutcome.FAILURE })
237 ]
238 const luck = generateGameSummary(gameStore).luck
239 expect(luck?.averageRoll).toBe(5) // reads diceRoll when naturalRoll is absent
240 expect(luck?.verdict).toBe('cold')
241 })
242 
243 it('rounds the average to one decimal and counts the below-median rolls', () => {
244 gameStore.messageHistory = [
245 userTurn('m1'), figureTurn({ naturalRoll: 8, diceRoll: 8, diceOutcome: DiceOutcome.FAILURE }),
246 userTurn('m2'), figureTurn({ naturalRoll: 9, diceRoll: 9, diceOutcome: DiceOutcome.NEUTRAL }),
247 userTurn('m3'), figureTurn({ naturalRoll: 11, diceRoll: 11, diceOutcome: DiceOutcome.NEUTRAL })
248 ]
249 const luck = generateGameSummary(gameStore).luck
250 expect(luck?.averageRoll).toBe(9.3) // (8 + 9 + 11) / 3 = 9.333… → 9.3
251 expect(luck?.verdict).toBe('even')
252 expect(luck?.belowMedianCount).toBe(2) // 8 and 9 are below 10.5; 11 is not
253 expect(luck?.rollCount).toBe(3)
254 })
255 })
256 
257 describe('Edge Cases', () => {
258 it('handles an empty history', () => {
259 gameStore.messageHistory = []
260 const summary = generateGameSummary(gameStore)
261 expect(summary.bestRoll).toBeNull()
262 expect(summary.worstRoll).toBeNull()
263 expect(summary.criticalTurningPoints).toEqual([])
264 expect(summary.statistics.averageDiceRoll).toBe(0)
265 })
266 
267 it('does not throw when a turn lacks a progress change', () => {
268 gameStore.messageHistory = [figureTurn({ diceRoll: 15, diceOutcome: DiceOutcome.SUCCESS })]
269 expect(() => generateGameSummary(gameStore)).not.toThrow()
270 expect(generateGameSummary(gameStore).statistics.totalProgressGained).toBe(0)
271 })
272 })
273})

Natural 4/6 (avg 5.0, cold) behind effective 9/11 (avg 10, even).

tests/unit/components/EndGameScreen.spec.ts · 344 lines
tests/unit/components/EndGameScreen.spec.ts344 lines · TypeScript
⋯ 190 lines hidden (lines 1–190)
1import { mount } from '@vue/test-utils'
2import { describe, it, expect, beforeEach } from 'vitest'
3import { setActivePinia, createPinia } from 'pinia'
4import EndGameScreen from '../../../components/EndGameScreen.vue'
5import { useGameStore } from '../../../stores/game'
6import { DiceOutcome } from '../../../utils/dice'
7import type { GameObjective } from '../../../server/utils/objectives'
8import type { Message } from '../../../stores/game'
9 
10const OBJ: GameObjective = {
11 title: 'Reach the Moon by 1900',
12 description: 'Leave Earth half a century early.',
13 era: '19th century',
14 icon: '🚀'
16 
17function resolvedTurn(diceRoll: number, diceOutcome: DiceOutcome, progressChange: number, naturalRoll?: number): Message {
18 return {
19 text: 'reply', sender: 'ai', figureName: 'Tesla', timestamp: new Date(),
20 diceRoll, diceOutcome, progressChange, naturalRoll, timelineImpact: 'history shifts'
21 }
23function prompt(): Message {
24 return { text: 'prompt', sender: 'user', figureName: 'Tesla', timestamp: new Date() }
26 
27// NuxtLink can't resolve RouterLink under a bare @vue/test-utils mount, so stub it
28// per-mount to a plain anchor (href ← `to`): the footer "Your runs" link is the only
29// NuxtLink here, so this keeps every mount free of the resolve warning and lets the
30// link's text/href be asserted directly. (The real render is covered by the e2e.)
31const mountEnd = () =>
32 mount(EndGameScreen, { global: { stubs: { NuxtLink: { props: ['to'], template: '<a :href="to"><slot /></a>' } } } })
33 
34describe('EndGameScreen', () => {
35 let gameStore: ReturnType<typeof useGameStore>
36 
37 beforeEach(() => {
38 setActivePinia(createPinia())
39 gameStore = useGameStore()
40 })
41 
42 describe('Victory', () => {
43 beforeEach(() => {
44 gameStore.gameStatus = 'victory'
45 gameStore.objectiveProgress = 100
46 gameStore.currentObjective = OBJ
47 gameStore.remainingMessages = 2
48 })
49 
50 it('shows a victory headline', () => {
51 const v = mountEnd().find('[data-testid="victory-message"]')
52 expect(v.exists()).toBe(true)
53 expect(v.text()).toContain('History Rewritten')
54 })
55 
56 it('shows the objective and final progress', () => {
57 const w = mountEnd()
58 expect(w.find('[data-testid="objective-title"]').text()).toContain('Reach the Moon by 1900')
59 expect(w.find('[data-testid="final-progress"]').text()).toContain('100%')
60 })
61 
62 it('shows efficiency with messages saved', () => {
63 const e = mountEnd().find('[data-testid="efficiency-display"]')
64 expect(e.exists()).toBe(true)
65 expect(e.text()).toContain('saved')
66 })
67 })
68 
69 describe('Defeat', () => {
70 beforeEach(() => {
71 gameStore.gameStatus = 'defeat'
72 gameStore.objectiveProgress = 45
73 gameStore.currentObjective = OBJ
74 gameStore.remainingMessages = 0
75 })
76 
77 it('shows a defeat headline', () => {
78 const d = mountEnd().find('[data-testid="defeat-message"]')
79 expect(d.exists()).toBe(true)
80 expect(d.text()).toContain('Timeline Held')
81 })
82 
83 it('shows final progress below 100%', () => {
84 expect(mountEnd().find('[data-testid="final-progress"]').text()).toContain('45%')
85 })
86 
87 it('explains the loss', () => {
88 expect(mountEnd().find('[data-testid="defeat-explanation"]').exists()).toBe(true)
89 })
90 })
91 
92 // The epilogue is the run's payoff — the terminal telling that carries the verdict.
93 // While it's still being written we must never present the prior turn's stale,
94 // pre-ending telling as the epilogue (issue #107).
95 describe('The Chronicle epilogue — pending, reveal, and fallback', () => {
96 const stale = { title: 'Mid-run Telling', paragraphs: ['the world as it stood a turn ago'] }
97 const epilogue = { title: 'The World Remade', paragraphs: ['and so the timeline was rewritten'] }
98 
99 beforeEach(() => {
100 gameStore.gameStatus = 'victory'
101 gameStore.currentObjective = OBJ
102 })
103 
104 it('shows the "final account" wait while the epilogue is pending — not the stale prior telling', () => {
105 gameStore.chronicle = stale // a stale, pre-ending telling is still held
106 gameStore.epiloguePending = true // the terminal refresh is in flight
107 const w = mount(EndGameScreen)
108 expect(w.find('[data-testid="end-chronicle-loading"]').exists()).toBe(true)
109 // The prior-turn telling must NOT be presented as the epilogue.
110 expect(w.find('[data-testid="end-chronicle-body"]').exists()).toBe(false)
111 expect(w.text()).not.toContain('Mid-run Telling')
112 })
113 
114 it('reveals the epilogue once the terminal telling lands', () => {
115 gameStore.chronicle = epilogue
116 gameStore.epiloguePending = false
117 const w = mount(EndGameScreen)
118 expect(w.find('[data-testid="end-chronicle-loading"]').exists()).toBe(false)
119 expect(w.find('[data-testid="end-chronicle-body"]').exists()).toBe(true)
120 expect(w.find('[data-testid="end-chronicle-title"]').text()).toContain('The World Remade')
121 })
122 
123 it('falls back to the prior telling if the final refresh failed — never stuck on loading', () => {
124 // refreshChronicle keeps the prior telling on failure and clears pending.
125 gameStore.chronicle = stale
126 gameStore.epiloguePending = false
127 const w = mount(EndGameScreen)
128 expect(w.find('[data-testid="end-chronicle-loading"]').exists()).toBe(false)
129 expect(w.find('[data-testid="end-chronicle-body"]').exists()).toBe(true)
130 expect(w.find('[data-testid="end-chronicle-title"]').text()).toContain('Mid-run Telling')
131 })
132 })
133 
134 describe('Summary stats — derived from resolved AI turns', () => {
135 beforeEach(() => {
136 gameStore.gameStatus = 'victory'
137 gameStore.remainingMessages = 3 // 2 used
138 gameStore.messageHistory = [
139 prompt(), resolvedTurn(18, DiceOutcome.SUCCESS, 25),
140 prompt(), resolvedTurn(20, DiceOutcome.CRITICAL_SUCCESS, 75)
141 ]
142 })
143 
144 it('renders the summary block', () => {
145 expect(mountEnd().find('[data-testid="game-summary"]').exists()).toBe(true)
146 })
147 
148 it('highlights the best roll from the AI turns', () => {
149 const best = mountEnd().find('[data-testid="best-roll"]')
150 expect(best.exists()).toBe(true)
151 expect(best.text()).toContain('20')
152 })
153 
154 it('highlights the worst roll from the AI turns', () => {
155 gameStore.messageHistory[1] = resolvedTurn(2, DiceOutcome.CRITICAL_FAILURE, -10)
156 const worst = mountEnd().find('[data-testid="worst-roll"]')
157 expect(worst.exists()).toBe(true)
158 expect(worst.text()).toContain('2')
159 })
160 
161 it('shows the number of messages sent', () => {
162 const eff = mountEnd().find('[data-testid="message-efficiency"]')
163 expect(eff.exists()).toBe(true)
164 expect(eff.text()).toContain('2')
165 })
166 })
167 
168 // The run-level luck read (#165): names a variance-driven loss as cold dice, not
169 // bad skill. It reads the natural d20s before the craft tilt; with no naturalRoll on
170 // these fixtures it falls back to the effective roll (mirrors the real reveal).
171 describe('Luck read (#165) — the dice, not the craft', () => {
172 beforeEach(() => {
173 gameStore.gameStatus = 'defeat'
174 gameStore.objectiveProgress = 40
175 gameStore.remainingMessages = 3 // 2 used
176 })
177 
178 it('reads cold dice when the run rolled below the mean, in the loss red', () => {
179 gameStore.messageHistory = [
180 prompt(), resolvedTurn(4, DiceOutcome.FAILURE, -5),
181 prompt(), resolvedTurn(6, DiceOutcome.FAILURE, -3)
182 ]
183 const tile = mountEnd().find('[data-testid="luck-read"]')
184 expect(tile.exists()).toBe(true)
185 expect(tile.find('.ew-mono').text()).toBe('5.0') // (4 + 6) / 2, shown to one decimal
186 expect(tile.text()).toContain('avg vs 10.5')
187 expect(tile.text()).toContain('the dice ran cold')
188 expect(tile.find('.ew-mono').classes()).toContain('ew-bad')
189 })
190 
191 it('reads the natural die at the render boundary, not the craft-tilted effective roll', () => {
192 // Effective rolls (9, 11) would average 10 → even; the natural dice were cold.
193 gameStore.messageHistory = [
194 prompt(), resolvedTurn(9, DiceOutcome.NEUTRAL, 8, 4),
195 prompt(), resolvedTurn(11, DiceOutcome.NEUTRAL, 8, 6)
196 ]
197 const tile = mountEnd().find('[data-testid="luck-read"]')
198 expect(tile.find('.ew-mono').text()).toBe('5.0') // (4 + 6) / 2, not (9 + 11) / 2 = 10
199 expect(tile.text()).toContain('the dice ran cold')
200 })
201 
⋯ 143 lines hidden (lines 202–344)
202 it('reads hot dice when the run rolled above the mean, in the win green', () => {
203 gameStore.messageHistory = [
204 prompt(), resolvedTurn(15, DiceOutcome.SUCCESS, 25),
205 prompt(), resolvedTurn(18, DiceOutcome.SUCCESS, 30)
206 ]
207 const tile = mountEnd().find('[data-testid="luck-read"]')
208 expect(tile.text()).toContain('16.5')
209 expect(tile.text()).toContain('the dice ran hot')
210 expect(tile.find('.ew-mono').classes()).toContain('ew-ok')
211 })
212 
213 it('stays hidden on a single roll — no "overall" read to give', () => {
214 gameStore.messageHistory = [prompt(), resolvedTurn(15, DiceOutcome.SUCCESS, 20)]
215 expect(mountEnd().find('[data-testid="luck-read"]').exists()).toBe(false)
216 })
217 })
218 
219 // A run that built progress then lost ground — classically a staked final dispatch
220 // that crit-failed to 0% — must not read "as if the player did nothing." The summary
221 // surfaces "peaked at X%" whenever the peak sits above the floored final % (#129).
222 describe('Peaked at X% — when a run loses ground', () => {
223 it('shows the peak in the summary when the run slipped from its high point', () => {
224 gameStore.gameStatus = 'defeat'
225 gameStore.objectiveProgress = 0 // a staked last dispatch crit-failed to nothing
226 gameStore.peakProgress = 25 // but the run had really reached 25%
227 const peak = mountEnd().find('[data-testid="peak-progress"]')
228 expect(peak.exists()).toBe(true)
229 expect(peak.text()).toContain('25%')
230 })
231 
232 it('stays hidden on a clean win, where the peak is the final 100%', () => {
233 gameStore.gameStatus = 'victory'
234 gameStore.objectiveProgress = 100
235 gameStore.peakProgress = 100
236 expect(mountEnd().find('[data-testid="peak-progress"]').exists()).toBe(false)
237 })
238 })
239 
240 // The "history you wrote" renders through the shared Spine (TimelineLedger),
241 // not a bespoke list — so the verdict's history reads exactly like the in-game
242 // timeline (valence dots, badges, the Δ equation), per issue #84.
243 describe('The history you wrote — the shared Spine', () => {
244 beforeEach(() => {
245 gameStore.gameStatus = 'victory'
246 gameStore.addTimelineEvent({
247 figureName: 'Tesla', era: '1890s',
248 headline: 'The grid hums to life', detail: 'Alternating current spreads.',
249 diceRoll: 18, diceOutcome: DiceOutcome.SUCCESS, progressChange: 25
250 })
251 })
252 
253 it('renders via the shared TimelineLedger, not the old hand-rolled list', () => {
254 const w = mountEnd()
255 expect(w.find('[data-testid="timeline-ledger"]').exists()).toBe(true)
256 expect(w.find('[data-testid="timeline-event"]').exists()).toBe(true)
257 // The bespoke block folded into the Spine's testid.
258 expect(w.find('[data-testid="final-timeline"]').exists()).toBe(false)
259 })
260 
261 it('renders the Spine read-only: no live "▷ now · your move" present marker', () => {
262 expect(mountEnd().find('.spine-now').exists()).toBe(false)
263 })
264 })
265 
266 // The Chronicle epilogue fills the full debrief column like its siblings (the
267 // dispatches / stats / timeline). It used to carry an inner max-w-[62ch] measure
268 // that read as ~2/3 width next to those full-width sections (#109).
269 describe('Chronicle epilogue', () => {
270 beforeEach(() => {
271 gameStore.gameStatus = 'victory'
272 gameStore.chronicle = { title: 'The Library Stands', paragraphs: ['It did not burn.'] }
273 })
274 
275 it('renders the epilogue body', () => {
276 const body = mount(EndGameScreen).find('[data-testid="end-chronicle-body"]')
277 expect(body.exists()).toBe(true)
278 expect(body.text()).toContain('It did not burn.')
279 })
280 
281 it('fills the column — no max-w-[62ch] cap', () => {
282 const body = mount(EndGameScreen).find('[data-testid="end-chronicle-body"]')
283 expect(body.classes()).not.toContain('max-w-[62ch]')
284 })
285 })
286 
287 describe('Play again', () => {
288 beforeEach(() => { gameStore.gameStatus = 'victory' })
289 
290 it('offers a restart', () => {
291 const btn = mountEnd().find('[data-testid="play-again-button"]')
292 expect(btn.exists()).toBe(true)
293 expect(btn.text()).toContain('new timeline')
294 })
295 
296 it('asks the page for the unified reset when clicked (it never resets the store itself)', async () => {
297 const wrapper = mountEnd()
298 await wrapper.find('[data-testid="play-again-button"]').trigger('click')
299 
300 // The page owns the FULL reset (store + figure input + composer); the
301 // old direct store reset left the previous run's figure in the input.
302 expect(wrapper.emitted('play-again')).toHaveLength(1)
303 expect(gameStore.gameStatus).toBe('victory') // untouched by the component
304 })
305 })
306 
307 // The finished run becomes part of the player's history, so the end screen
308 // surfaces the same "Your runs" affordance the retrospective side uses.
309 describe('Your runs link', () => {
310 beforeEach(() => { gameStore.gameStatus = 'victory' })
311 
312 it('offers an obvious /runs link beside Play Again', () => {
313 const link = mountEnd().find('[data-testid="end-your-runs-link"]')
314 expect(link.exists()).toBe(true)
315 expect(link.text()).toContain('Your runs')
316 expect(link.attributes('href')).toContain('/runs')
317 })
318 
319 it('shows it on a loss too', () => {
320 gameStore.gameStatus = 'defeat'
321 expect(mountEnd().find('[data-testid="end-your-runs-link"]').exists()).toBe(true)
322 })
323 })
324 
325 describe('Edge cases', () => {
326 it('handles a missing objective', () => {
327 gameStore.gameStatus = 'victory'
328 gameStore.currentObjective = null
329 expect(() => mountEnd()).not.toThrow()
330 })
331 
332 it('does not render while still playing', () => {
333 gameStore.gameStatus = 'playing'
334 expect(mountEnd().find('[data-testid="end-game-screen"]').exists()).toBe(false)
335 })
336 
337 it('exposes dialog semantics', () => {
338 gameStore.gameStatus = 'victory'
339 const screen = mountEnd().find('[data-testid="end-game-screen"]')
340 expect(screen.attributes('role')).toBe('dialog')
341 expect(screen.attributes('aria-labelledby')).toBeTruthy()
342 })
343 })
344})