What changed, and why

When a run ends, it is saved to your "your runs" history. The retrospective side already makes that history easy to reach: the run-detail page has a ← Your runs link, and the account popover has a ◷ Your past runs link. The live win/lose screen had no equivalent. Its footer offered only "Try a new timeline" (Play Again), so the moment a run became part of your history, there was no obvious way from that screen to go see it.

This adds a "Your runs" link to /runs in the end-screen footer, beside Play Again. The whole change is one element in one component, plus the unit and e2e tests that pin it. No store, routing, or data changes — the destination page and the saved-runs history already exist.

Proving it: unit + e2e

The unit suite mounts EndGameScreen with bare @vue/test-utils, which has no router, so a real NuxtLink renders empty there. The fix is a per-mount stub: a tiny anchor that echoes to into href. A mountEnd() helper applies it on every mount in the file, so the new link can be asserted directly and the older tests stop emitting a "can't resolve RouterLink" warning. The stub is scoped to each mount, not a shared global, so nothing leaks into other component specs.

The stub helper, and the two assertions: the link exists on win and loss, says 'Your runs', points at /runs.

tests/unit/components/EndGameScreen.spec.ts · 209 lines
tests/unit/components/EndGameScreen.spec.ts209 lines · TypeScript
⋯ 25 lines hidden (lines 1–25)
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): Message {
18 return {
19 text: 'reply', sender: 'ai', figureName: 'Tesla', timestamp: new Date(),
20 diceRoll, diceOutcome, progressChange, 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>' } } } })
⋯ 139 lines hidden (lines 33–171)
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 describe('Summary stats — derived from resolved AI turns', () => {
93 beforeEach(() => {
94 gameStore.gameStatus = 'victory'
95 gameStore.remainingMessages = 3 // 2 used
96 gameStore.messageHistory = [
97 prompt(), resolvedTurn(18, DiceOutcome.SUCCESS, 25),
98 prompt(), resolvedTurn(20, DiceOutcome.CRITICAL_SUCCESS, 75)
99 ]
100 })
101 
102 it('renders the summary block', () => {
103 expect(mountEnd().find('[data-testid="game-summary"]').exists()).toBe(true)
104 })
105 
106 it('highlights the best roll from the AI turns', () => {
107 const best = mountEnd().find('[data-testid="best-roll"]')
108 expect(best.exists()).toBe(true)
109 expect(best.text()).toContain('20')
110 })
111 
112 it('highlights the worst roll from the AI turns', () => {
113 gameStore.messageHistory[1] = resolvedTurn(2, DiceOutcome.CRITICAL_FAILURE, -10)
114 const worst = mountEnd().find('[data-testid="worst-roll"]')
115 expect(worst.exists()).toBe(true)
116 expect(worst.text()).toContain('2')
117 })
118 
119 it('shows the number of messages sent', () => {
120 const eff = mountEnd().find('[data-testid="message-efficiency"]')
121 expect(eff.exists()).toBe(true)
122 expect(eff.text()).toContain('2')
123 })
124 })
125 
126 // The "history you wrote" renders through the shared Spine (TimelineLedger),
127 // not a bespoke list — so the verdict's history reads exactly like the in-game
128 // timeline (valence dots, badges, the Δ equation), per issue #84.
129 describe('The history you wrote — the shared Spine', () => {
130 beforeEach(() => {
131 gameStore.gameStatus = 'victory'
132 gameStore.addTimelineEvent({
133 figureName: 'Tesla', era: '1890s',
134 headline: 'The grid hums to life', detail: 'Alternating current spreads.',
135 diceRoll: 18, diceOutcome: DiceOutcome.SUCCESS, progressChange: 25
136 })
137 })
138 
139 it('renders via the shared TimelineLedger, not the old hand-rolled list', () => {
140 const w = mountEnd()
141 expect(w.find('[data-testid="timeline-ledger"]').exists()).toBe(true)
142 expect(w.find('[data-testid="timeline-event"]').exists()).toBe(true)
143 // The bespoke block folded into the Spine's testid.
144 expect(w.find('[data-testid="final-timeline"]').exists()).toBe(false)
145 })
146 
147 it('renders the Spine read-only: no live "▷ now · your move" present marker', () => {
148 expect(mountEnd().find('.spine-now').exists()).toBe(false)
149 })
150 })
151 
152 describe('Play again', () => {
153 beforeEach(() => { gameStore.gameStatus = 'victory' })
154 
155 it('offers a restart', () => {
156 const btn = mountEnd().find('[data-testid="play-again-button"]')
157 expect(btn.exists()).toBe(true)
158 expect(btn.text()).toContain('new timeline')
159 })
160 
161 it('asks the page for the unified reset when clicked (it never resets the store itself)', async () => {
162 const wrapper = mountEnd()
163 await wrapper.find('[data-testid="play-again-button"]').trigger('click')
164 
165 // The page owns the FULL reset (store + figure input + composer); the
166 // old direct store reset left the previous run's figure in the input.
167 expect(wrapper.emitted('play-again')).toHaveLength(1)
168 expect(gameStore.gameStatus).toBe('victory') // untouched by the component
169 })
170 })
171 
172 // The finished run becomes part of the player's history, so the end screen
173 // surfaces the same "Your runs" affordance the retrospective side uses.
174 describe('Your runs link', () => {
175 beforeEach(() => { gameStore.gameStatus = 'victory' })
176 
177 it('offers an obvious /runs link beside Play Again', () => {
178 const link = mountEnd().find('[data-testid="end-your-runs-link"]')
179 expect(link.exists()).toBe(true)
180 expect(link.text()).toContain('Your runs')
181 expect(link.attributes('href')).toContain('/runs')
182 })
183 
184 it('shows it on a loss too', () => {
185 gameStore.gameStatus = 'defeat'
186 expect(mountEnd().find('[data-testid="end-your-runs-link"]').exists()).toBe(true)
187 })
188 })
⋯ 21 lines hidden (lines 189–209)
189 
190 describe('Edge cases', () => {
191 it('handles a missing objective', () => {
192 gameStore.gameStatus = 'victory'
193 gameStore.currentObjective = null
194 expect(() => mountEnd()).not.toThrow()
195 })
196 
197 it('does not render while still playing', () => {
198 gameStore.gameStatus = 'playing'
199 expect(mountEnd().find('[data-testid="end-game-screen"]').exists()).toBe(false)
200 })
201 
202 it('exposes dialog semantics', () => {
203 gameStore.gameStatus = 'victory'
204 const screen = mountEnd().find('[data-testid="end-game-screen"]')
205 expect(screen.attributes('role')).toBe('dialog')
206 expect(screen.attributes('aria-labelledby')).toBeTruthy()
207 })
208 })
209})

The unit test asserts against the stub, so the e2e closes the loop on the real render. In the victory flow, against a real browser and the real NuxtLink, it checks the link is visible beside Play Again and that its href is exactly /runs.

Real-browser proof: visible, and href='/runs'.

tests/e2e/features/end-game.spec.ts · 71 lines
tests/e2e/features/end-game.spec.ts71 lines · TypeScript
⋯ 29 lines hidden (lines 1–29)
1import { test, expect } from '@nuxt/test-utils/playwright'
2import { mockSendMessage, mockChronicle, startCuratedRun, selectFigure, fillAndSend } from '../support/game'
3 
4/**
5 * Win and lose resolution. Victory the moment progress hits 100%; defeat once the
6 * fifth message is spent short of the objective.
7 */
8test.describe('End game', () => {
9 test('reaching 100% wins, and "new timeline" returns to the briefing', async ({ page, goto }) => {
10 await goto('/play', { waitUntil: 'hydration' })
11 await mockSendMessage(page, {
12 diceRoll: 20,
13 diceOutcome: 'Critical Success',
14 headline: 'A single word remakes the age',
15 progressChange: 100,
16 valence: 'positive'
17 })
18 await startCuratedRun(page)
19 // Override the default chronicle AFTER startCuratedRun so this telling wins
20 // (route handlers run last-registered-first), letting us assert the epilogue.
21 await mockChronicle(page, { title: 'The World Remade', paragraphs: ['One message changed everything.'] })
22 await selectFigure(page, 'Cleopatra')
23 await fillAndSend(page, 'Change everything, now.')
24 
25 const endScreen = page.locator('[data-testid="end-game-screen"]')
26 await expect(endScreen).toBeVisible()
27 await expect(page.locator('[data-testid="victory-message"]')).toContainText('History Rewritten')
28 await expect(page.locator('[data-testid="final-progress"]')).toContainText('100%')
29 
30 // The Chronicle's final telling appears as the epilogue.
31 await expect(page.locator('[data-testid="end-chronicle-title"]')).toContainText('The World Remade')
32 
33 // The finished run is now part of your history — the end screen offers an
34 // obvious way into "your runs", visible right beside Play Again.
35 await expect(page.locator('[data-testid="end-your-runs-link"]')).toBeVisible()
36 await expect(page.locator('[data-testid="end-your-runs-link"]')).toHaveAttribute('href', '/runs')
⋯ 35 lines hidden (lines 37–71)
37 
38 // The end loop returns to a fresh briefing.
39 await page.locator('[data-testid="play-again-button"]').click()
40 await expect(page.locator('[data-testid="mission-select"]')).toBeVisible()
41 await expect(page.locator('[data-testid="message-input"]')).toHaveCount(0)
42 })
43 
44 test('spending all five messages short of the goal loses', async ({ page, goto }) => {
45 await goto('/play', { waitUntil: 'hydration' })
46 // Steady, small progress — never enough to reach 100% in five turns.
47 await mockSendMessage(page, { progressChange: 10, diceOutcome: 'Neutral', diceRoll: 9 })
48 await startCuratedRun(page)
49 await selectFigure(page, 'Cleopatra')
50 
51 for (let i = 1; i <= 5; i++) {
52 // The last stand is offered on (and only on) the final dispatch.
53 if (i === 5) await expect(page.locator('[data-testid="stake-control"]')).toBeVisible()
54 else await expect(page.locator('[data-testid="stake-control"]')).toHaveCount(0)
55 await fillAndSend(page, `Attempt number ${i}.`)
56 // The first four turns land in the thread; the fifth resolves straight
57 // into the defeat takeover, which replaces the board by design — its
58 // assertions are the end-screen ones below.
59 if (i < 5) {
60 await expect(page.locator('[data-testid="ai-message"]')).toHaveCount(i)
61 await page.waitForTimeout(1100) // clear the 1s rate limit
62 }
63 }
64 
65 await expect(page.locator('[data-testid="messages-counter"]')).toContainText('0/5')
66 const endScreen = page.locator('[data-testid="end-game-screen"]')
67 await expect(endScreen).toBeVisible()
68 await expect(page.locator('[data-testid="defeat-message"]')).toContainText('Timeline Held')
69 await expect(page.locator('[data-testid="final-progress"]')).toContainText('50%')
70 })
71})