What changed, and why

Issue #135. The per-turn swing breakdown — the little equation (+24 ✒ sharp → +29%) that shows how a base swing became the final percent — only rendered when an amplifier had actually moved the base. So it appeared on stake or chain turns and vanished everywhere else: a plain in-period sound turn (sound is ×1.0, nothing to show) and, more importantly, most losses. Craft and momentum are gains-only, so on a miss the base usually equals the final and the equation disappeared with it.

That second case buried the game's clearest lesson: a sharp dispatch buys no protection on a miss. Players saw the math only on some turns and asked why. The fix is one removed condition on each of the two surfaces that render the equation, so it shows on every resolved turn. On an unamplified turn it collapses to a single clean number, (+22 → +22%). Nothing else moves.

The Thread: the gate, dropped

In the Thread (the conversation rail), the equation lives inside the ripple line. The v-if calls showEquation(message), and each amplifier between base and final is a hover-hinted glyph emitted by equationTokens.

The ripple line: the equation span and its per-token glyphs.

components/MessageHistory.vue · 214 lines
components/MessageHistory.vue214 lines · Vue
⋯ 59 lines hidden (lines 1–59)
1<template>
2 <!-- Conversation with the currently-selected figure (the THREAD rail). -->
3 <div data-testid="message-history"
4 class="message-history-container overflow-y-auto rv-bg max-h-[340px] md:max-h-none md:overflow-visible"
5 aria-label="Conversation history" aria-live="polite" role="log">
6 <!-- Empty state -->
7 <div v-if="thread.length === 0 && !gameStore.isLoading" data-testid="empty-state"
8 class="flex flex-col items-center justify-center p-8 text-center">
9 <div class="text-3xl mb-2 opacity-50" aria-hidden="true">💬</div>
10 <p class="rv-muted text-sm">
11 {{ activeFigure ? `No words exchanged with ${activeFigure} yet` : 'No one contacted yet' }}
12 </p>
13 <p class="rv-faint text-xs mt-0.5">
14 {{ activeFigure ? 'Send a message to begin.' : 'Choose a figure and send your first message.' }}
15 </p>
16 </div>
17 
18 <!-- Conversation -->
19 <ol v-else>
20 <li v-for="(message, index) in thread" :key="index" data-testid="message-item" :data-sender="message.sender"
21 class="message-item py-3 border-b rv-line last:border-b-0">
22 <!-- Player message -->
23 <div v-if="message.sender === 'user'" data-testid="user-message">
24 <p class="text-[11px] rv-faint uppercase tracking-wide mb-0.5">You → {{ message.figureName || activeFigure }}</p>
25 <p class="rv-fg text-sm">{{ message.text }}</p>
26 <p class="text-[11px] rv-faint mt-1 rv-mono" data-testid="message-timestamp">{{ formatTimestamp(message.timestamp) }}</p>
27 </div>
28 
29 <!-- Figure reply -->
30 <div v-else-if="message.sender === 'ai'" data-testid="ai-message">
31 <div class="flex items-center justify-between gap-2 mb-1">
32 <p class="text-sm font-semibold rv-fg flex items-center gap-1.5 min-w-0">
33 <span class="rv-dot" :class="outcomeDot(message.diceOutcome)" aria-hidden="true" />
34 <span class="truncate">{{ message.figureName || activeFigure || 'The past' }}</span>
35 </p>
36 <div v-if="message.diceRoll !== undefined" class="flex items-center gap-1.5 rv-mono text-xs shrink-0">
37 <span data-testid="dice-icon" aria-hidden="true">🎲</span>
38 <!-- Craft tilts fate visibly: natural roll ✒mod = effective. -->
39 <span v-if="message.rollModifier" data-testid="craft-roll" class="rv-faint">
40 {{ message.naturalRoll }} <span :class="message.rollModifier > 0 ? 'rv-ok' : 'rv-bad'">{{ message.rollModifier > 0 ? '+' : '' }}{{ message.rollModifier }}</span> =
41 </span>
42 <span data-testid="dice-result" class="font-bold rv-fg">{{ message.diceRoll }}</span>
43 <span data-testid="dice-outcome" class="font-semibold" :class="outcomeText(message.diceOutcome)">{{ message.diceOutcome }}</span>
44 </div>
45 </div>
46 
47 <!-- The Judge's verdict on the dispatch that caused all this: the craft
48 grade (with a hover hint), a 'builds' mark when it picked up the run's
49 thread (the momentum signal), and the terse reason. -->
50 <div v-if="message.craft && (message.craft !== 'sound' || message.craftReason || message.continuity === 'builds')" data-testid="craft-verdict" class="text-[11px] mb-1.5">
51 <span class="rv-faint">✒ your dispatch · </span><SymbolHint :text="CRAFT_HINT[message.craft]"><span class="font-semibold" :class="craftClass(message.craft)">{{ CRAFT_LABEL[message.craft] }}</span></SymbolHint><span v-if="message.continuity === 'builds'" data-testid="continuity-builds" class="rv-ok font-semibold"> · ✦ builds</span><span v-if="message.craftReason" class="rv-faint">{{ message.craftReason }}</span>
52 </div>
53 
54 <p data-testid="character-message" class="rv-fg text-sm mb-2">{{ message.text }}</p>
55 
56 <div v-if="message.characterAction" data-testid="character-action" class="text-xs rv-muted border-l rv-line pl-2 mb-2">
57 <span class="rv-faint">acts · </span><span class="italic">{{ message.characterAction }}</span>
58 </div>
59 
60 <div v-if="message.timelineImpact" data-testid="timeline-impact" class="text-xs rv-muted border-l rv-line pl-2 mb-1">
61 <span class="rv-faint">ripple · </span>{{ message.timelineImpact }}
62 <span v-if="message.progressChange !== undefined" data-testid="progress-change"
63 class="rv-mono font-bold ml-1" :class="deltaText(message.progressChange)">
64 {{ message.progressChange > 0 ? '+' : '' }}{{ message.progressChange }}%
65 </span>
66 <!-- The wager's work, shown as an equation: base ⚡tier (⚑ staked) → final.
67 Each amplifier token carries its own hover hint for what it did. -->
68 <span v-if="showEquation(message)" data-testid="swing-equation" class="rv-mono rv-faint ml-1 whitespace-nowrap">({{ signed(message.baseProgressChange!) }}<template v-for="tok in equationTokens(message)" :key="tok.kind">{{ ' ' }}<SymbolHint :text="tok.hint"><span :data-testid="`equation-${tok.kind}`">{{ tok.label }}</span></SymbolHint></template>{{ ' → ' + signed(message.progressChange!) + '%' }})</span>
69 </div>
⋯ 145 lines hidden (lines 70–214)
70 
71 <p class="text-[11px] rv-faint rv-mono" data-testid="message-timestamp">{{ formatTimestamp(message.timestamp) }}</p>
72 </div>
73 </li>
74 
75 <!-- Loading indicator -->
76 <li v-if="gameStore.isLoading" data-testid="loading-indicator" class="py-3 flex items-center gap-2 rv-muted text-sm">
77 <span class="rv-spinner" aria-hidden="true" />
78 {{ activeFigure || 'The past' }} is weighing your words…
79 </li>
80 </ol>
81 </div>
82</template>
83 
84<script setup lang="ts">
85/**
86 * MessageHistory — the conversation thread with the active figure, as hairline rows
87 * led by an outcome dot. Figure-aware labels; each figure keeps an independent thread.
88 */
89import { computed } from 'vue'
90import { useGameStore, type Message } from '~/stores/game'
91import { ANACHRONISM_HINT, ANACHRONISM_LABEL } from '~/server/utils/anachronism'
92import { CHAIN_HINT, CHAIN_LABEL } from '~/utils/causal-chain'
93import { CRAFT_HINT, CRAFT_LABEL, type Craft } from '~/utils/craft'
94import { STAKE_HINT } from '~/utils/swing-bands'
95 
96const gameStore = useGameStore()
97 
98const activeFigure = computed(() => gameStore.activeFigureName)
99const thread = computed(() => gameStore.conversationWith(gameStore.activeFigureName))
100 
101const formatTimestamp = (timestamp: Date): string => {
102 return new Intl.DateTimeFormat('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(timestamp)
104 
105function outcomeDot(outcome?: string): string {
106 switch (outcome) {
107 case 'Critical Success':
108 case 'Success': return 'rv-dot--ok'
109 case 'Failure':
110 case 'Critical Failure': return 'rv-dot--bad'
111 default: return 'rv-dot--mut'
112 }
114function outcomeText(outcome?: string): string {
115 switch (outcome) {
116 case 'Critical Success':
117 case 'Success': return 'rv-ok'
118 case 'Failure':
119 case 'Critical Failure': return 'rv-bad'
120 default: return 'rv-muted'
121 }
123function deltaText(change?: number): string {
124 if (change === undefined || change === 0) return 'rv-muted'
125 return change > 0 ? 'rv-ok' : 'rv-bad'
127 
128function craftClass(craft?: Craft): string {
129 switch (craft) {
130 case 'masterful':
131 case 'sharp': return 'rv-ok'
132 case 'vague':
133 case 'reckless': return 'rv-warn'
134 default: return 'rv-muted'
135 }
137 
138// The swing equation renders on every resolved turn (issue #135) — always
139// disclosing base → final. On a plain turn it collapses to a single clean number
140// like (+22 → +22%), which is fine; consistency beats hiding the math, and a loss
141// now shows its breakdown too (where gains-only craft gave no protection — the
142// lesson it used to bury). Needs only that both base and final are known.
143function showEquation(m: Message): boolean {
144 return m.baseProgressChange !== undefined
145 && m.progressChange !== undefined
147function signed(n: number): string {
148 return `${n > 0 ? '+' : ''}${n}`
150 
151// The amplifier tokens between the base and final swing — anachronism, causal
152// chain, the stake — each rendered as a labeled glyph carrying its own hover
153// hint (what the token is and how it bent the swing). Mirrors the order the
154// server applies them; absent tokens drop out, matching the old flat string.
155interface EquationToken {
156 kind: 'anachronism' | 'chain' | 'craft' | 'momentum' | 'staked'
157 label: string
158 hint: string
160function equationTokens(m: Message): EquationToken[] {
161 const tokens: EquationToken[] = []
162 if (m.anachronism && m.anachronism !== 'in-period') {
163 const pips = m.anachronism === 'impossible' ? '⚡⚡⚡' : m.anachronism === 'far-ahead' ? '⚡⚡' : '⚡'
164 tokens.push({ kind: 'anachronism', label: `${pips} ${ANACHRONISM_LABEL[m.anachronism].toLowerCase()}`, hint: ANACHRONISM_HINT[m.anachronism] })
165 }
166 if (m.causalChain && m.causalChain.tier !== 'at-hinge') {
167 tokens.push({ kind: 'chain', label: `${CHAIN_LABEL[m.causalChain.tier].toLowerCase()}`, hint: CHAIN_HINT[m.causalChain.tier] })
168 }
169 // Craft scales the UPSIDE only, and only when it isn't the neutral 'sound' — so a
170 // sharp gain shows its lift and a vague one its drag, right in the math. This is
171 // where the player SEES craft drive the magnitude, not just tilt the die (#62).
172 if (m.progressChange !== undefined && m.progressChange > 0 && m.craft && m.craft !== 'sound') {
173 tokens.push({ kind: 'craft', label: `${CRAFT_LABEL[m.craft].toLowerCase()}`, hint: `a ${CRAFT_LABEL[m.craft].toLowerCase()} dispatch scales the gain it earned — craft, gains-only` })
174 }
175 // Momentum is the OTHER gains-only amplifier (the arc meter): show it whenever it
176 // lifted this turn, so the printed equation reconciles to the result (issue #62).
177 if (m.progressChange !== undefined && m.progressChange > 0 && m.momentumAtSwing && m.momentumAtSwing >= 1) {
178 tokens.push({ kind: 'momentum', label: `✦ momentum ${m.momentumAtSwing}`, hint: `momentum ${m.momentumAtSwing} of 4 — a coherent arc amplifies every gain` })
179 }
180 if (m.staked) tokens.push({ kind: 'staked', label: '⚑ staked', hint: STAKE_HINT })
181 return tokens
183</script>
184 
185<style scoped>
186/* Phone keeps a fixed scroll box (the Story tab is one panel); the 340px cap and
187 md+ release live as utilities on the element (a scoped rule would out-specify the
188 Tailwind md: override). From md up the thread grows into the panel's own scroll. */
189.message-history-container {
190 min-height: 120px;
192 
193/* Staged reveal: a resolved turn writes itself in — the figure speaks, then acts,
194 then the ripple through history lands. Elements stay in the DOM (opacity only). */
195[data-testid="character-message"],
196[data-testid="character-action"],
197[data-testid="timeline-impact"] {
198 animation: reveal-up 0.45s var(--rv-ease) both;
200[data-testid="character-message"] { animation-delay: 0.08s; }
201[data-testid="character-action"] { animation-delay: 0.30s; }
202[data-testid="timeline-impact"] { animation-delay: 0.55s; }
203 
204@keyframes reveal-up {
205 from { opacity: 0; transform: translateY(8px); }
206 to { opacity: 1; transform: none; }
208 
209@media (prefers-reduced-motion: reduce) {
210 [data-testid="character-message"],
211 [data-testid="character-action"],
212 [data-testid="timeline-impact"] { animation: none; }
214</style>

showEquation was three conditions. The last one — baseProgressChange !== progressChange — was the gate. Dropping it leaves only the guard that both numbers exist, since you can't print base → final without a base. The comment is rewritten to match.

The gate is gone; the both-defined guard stays.

components/MessageHistory.vue · 214 lines
components/MessageHistory.vue214 lines · Vue
⋯ 137 lines hidden (lines 1–137)
1<template>
2 <!-- Conversation with the currently-selected figure (the THREAD rail). -->
3 <div data-testid="message-history"
4 class="message-history-container overflow-y-auto rv-bg max-h-[340px] md:max-h-none md:overflow-visible"
5 aria-label="Conversation history" aria-live="polite" role="log">
6 <!-- Empty state -->
7 <div v-if="thread.length === 0 && !gameStore.isLoading" data-testid="empty-state"
8 class="flex flex-col items-center justify-center p-8 text-center">
9 <div class="text-3xl mb-2 opacity-50" aria-hidden="true">💬</div>
10 <p class="rv-muted text-sm">
11 {{ activeFigure ? `No words exchanged with ${activeFigure} yet` : 'No one contacted yet' }}
12 </p>
13 <p class="rv-faint text-xs mt-0.5">
14 {{ activeFigure ? 'Send a message to begin.' : 'Choose a figure and send your first message.' }}
15 </p>
16 </div>
17 
18 <!-- Conversation -->
19 <ol v-else>
20 <li v-for="(message, index) in thread" :key="index" data-testid="message-item" :data-sender="message.sender"
21 class="message-item py-3 border-b rv-line last:border-b-0">
22 <!-- Player message -->
23 <div v-if="message.sender === 'user'" data-testid="user-message">
24 <p class="text-[11px] rv-faint uppercase tracking-wide mb-0.5">You → {{ message.figureName || activeFigure }}</p>
25 <p class="rv-fg text-sm">{{ message.text }}</p>
26 <p class="text-[11px] rv-faint mt-1 rv-mono" data-testid="message-timestamp">{{ formatTimestamp(message.timestamp) }}</p>
27 </div>
28 
29 <!-- Figure reply -->
30 <div v-else-if="message.sender === 'ai'" data-testid="ai-message">
31 <div class="flex items-center justify-between gap-2 mb-1">
32 <p class="text-sm font-semibold rv-fg flex items-center gap-1.5 min-w-0">
33 <span class="rv-dot" :class="outcomeDot(message.diceOutcome)" aria-hidden="true" />
34 <span class="truncate">{{ message.figureName || activeFigure || 'The past' }}</span>
35 </p>
36 <div v-if="message.diceRoll !== undefined" class="flex items-center gap-1.5 rv-mono text-xs shrink-0">
37 <span data-testid="dice-icon" aria-hidden="true">🎲</span>
38 <!-- Craft tilts fate visibly: natural roll ✒mod = effective. -->
39 <span v-if="message.rollModifier" data-testid="craft-roll" class="rv-faint">
40 {{ message.naturalRoll }} <span :class="message.rollModifier > 0 ? 'rv-ok' : 'rv-bad'">{{ message.rollModifier > 0 ? '+' : '' }}{{ message.rollModifier }}</span> =
41 </span>
42 <span data-testid="dice-result" class="font-bold rv-fg">{{ message.diceRoll }}</span>
43 <span data-testid="dice-outcome" class="font-semibold" :class="outcomeText(message.diceOutcome)">{{ message.diceOutcome }}</span>
44 </div>
45 </div>
46 
47 <!-- The Judge's verdict on the dispatch that caused all this: the craft
48 grade (with a hover hint), a 'builds' mark when it picked up the run's
49 thread (the momentum signal), and the terse reason. -->
50 <div v-if="message.craft && (message.craft !== 'sound' || message.craftReason || message.continuity === 'builds')" data-testid="craft-verdict" class="text-[11px] mb-1.5">
51 <span class="rv-faint">✒ your dispatch · </span><SymbolHint :text="CRAFT_HINT[message.craft]"><span class="font-semibold" :class="craftClass(message.craft)">{{ CRAFT_LABEL[message.craft] }}</span></SymbolHint><span v-if="message.continuity === 'builds'" data-testid="continuity-builds" class="rv-ok font-semibold"> · ✦ builds</span><span v-if="message.craftReason" class="rv-faint">{{ message.craftReason }}</span>
52 </div>
53 
54 <p data-testid="character-message" class="rv-fg text-sm mb-2">{{ message.text }}</p>
55 
56 <div v-if="message.characterAction" data-testid="character-action" class="text-xs rv-muted border-l rv-line pl-2 mb-2">
57 <span class="rv-faint">acts · </span><span class="italic">{{ message.characterAction }}</span>
58 </div>
59 
60 <div v-if="message.timelineImpact" data-testid="timeline-impact" class="text-xs rv-muted border-l rv-line pl-2 mb-1">
61 <span class="rv-faint">ripple · </span>{{ message.timelineImpact }}
62 <span v-if="message.progressChange !== undefined" data-testid="progress-change"
63 class="rv-mono font-bold ml-1" :class="deltaText(message.progressChange)">
64 {{ message.progressChange > 0 ? '+' : '' }}{{ message.progressChange }}%
65 </span>
66 <!-- The wager's work, shown as an equation: base ⚡tier (⚑ staked) → final.
67 Each amplifier token carries its own hover hint for what it did. -->
68 <span v-if="showEquation(message)" data-testid="swing-equation" class="rv-mono rv-faint ml-1 whitespace-nowrap">({{ signed(message.baseProgressChange!) }}<template v-for="tok in equationTokens(message)" :key="tok.kind">{{ ' ' }}<SymbolHint :text="tok.hint"><span :data-testid="`equation-${tok.kind}`">{{ tok.label }}</span></SymbolHint></template>{{ ' → ' + signed(message.progressChange!) + '%' }})</span>
69 </div>
70 
71 <p class="text-[11px] rv-faint rv-mono" data-testid="message-timestamp">{{ formatTimestamp(message.timestamp) }}</p>
72 </div>
73 </li>
74 
75 <!-- Loading indicator -->
76 <li v-if="gameStore.isLoading" data-testid="loading-indicator" class="py-3 flex items-center gap-2 rv-muted text-sm">
77 <span class="rv-spinner" aria-hidden="true" />
78 {{ activeFigure || 'The past' }} is weighing your words…
79 </li>
80 </ol>
81 </div>
82</template>
83 
84<script setup lang="ts">
85/**
86 * MessageHistory — the conversation thread with the active figure, as hairline rows
87 * led by an outcome dot. Figure-aware labels; each figure keeps an independent thread.
88 */
89import { computed } from 'vue'
90import { useGameStore, type Message } from '~/stores/game'
91import { ANACHRONISM_HINT, ANACHRONISM_LABEL } from '~/server/utils/anachronism'
92import { CHAIN_HINT, CHAIN_LABEL } from '~/utils/causal-chain'
93import { CRAFT_HINT, CRAFT_LABEL, type Craft } from '~/utils/craft'
94import { STAKE_HINT } from '~/utils/swing-bands'
95 
96const gameStore = useGameStore()
97 
98const activeFigure = computed(() => gameStore.activeFigureName)
99const thread = computed(() => gameStore.conversationWith(gameStore.activeFigureName))
100 
101const formatTimestamp = (timestamp: Date): string => {
102 return new Intl.DateTimeFormat('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(timestamp)
104 
105function outcomeDot(outcome?: string): string {
106 switch (outcome) {
107 case 'Critical Success':
108 case 'Success': return 'rv-dot--ok'
109 case 'Failure':
110 case 'Critical Failure': return 'rv-dot--bad'
111 default: return 'rv-dot--mut'
112 }
114function outcomeText(outcome?: string): string {
115 switch (outcome) {
116 case 'Critical Success':
117 case 'Success': return 'rv-ok'
118 case 'Failure':
119 case 'Critical Failure': return 'rv-bad'
120 default: return 'rv-muted'
121 }
123function deltaText(change?: number): string {
124 if (change === undefined || change === 0) return 'rv-muted'
125 return change > 0 ? 'rv-ok' : 'rv-bad'
127 
128function craftClass(craft?: Craft): string {
129 switch (craft) {
130 case 'masterful':
131 case 'sharp': return 'rv-ok'
132 case 'vague':
133 case 'reckless': return 'rv-warn'
134 default: return 'rv-muted'
135 }
137 
138// The swing equation renders on every resolved turn (issue #135) — always
139// disclosing base → final. On a plain turn it collapses to a single clean number
140// like (+22 → +22%), which is fine; consistency beats hiding the math, and a loss
141// now shows its breakdown too (where gains-only craft gave no protection — the
142// lesson it used to bury). Needs only that both base and final are known.
143function showEquation(m: Message): boolean {
144 return m.baseProgressChange !== undefined
145 && m.progressChange !== undefined
⋯ 68 lines hidden (lines 147–214)
147function signed(n: number): string {
148 return `${n > 0 ? '+' : ''}${n}`
150 
151// The amplifier tokens between the base and final swing — anachronism, causal
152// chain, the stake — each rendered as a labeled glyph carrying its own hover
153// hint (what the token is and how it bent the swing). Mirrors the order the
154// server applies them; absent tokens drop out, matching the old flat string.
155interface EquationToken {
156 kind: 'anachronism' | 'chain' | 'craft' | 'momentum' | 'staked'
157 label: string
158 hint: string
160function equationTokens(m: Message): EquationToken[] {
161 const tokens: EquationToken[] = []
162 if (m.anachronism && m.anachronism !== 'in-period') {
163 const pips = m.anachronism === 'impossible' ? '⚡⚡⚡' : m.anachronism === 'far-ahead' ? '⚡⚡' : '⚡'
164 tokens.push({ kind: 'anachronism', label: `${pips} ${ANACHRONISM_LABEL[m.anachronism].toLowerCase()}`, hint: ANACHRONISM_HINT[m.anachronism] })
165 }
166 if (m.causalChain && m.causalChain.tier !== 'at-hinge') {
167 tokens.push({ kind: 'chain', label: `${CHAIN_LABEL[m.causalChain.tier].toLowerCase()}`, hint: CHAIN_HINT[m.causalChain.tier] })
168 }
169 // Craft scales the UPSIDE only, and only when it isn't the neutral 'sound' — so a
170 // sharp gain shows its lift and a vague one its drag, right in the math. This is
171 // where the player SEES craft drive the magnitude, not just tilt the die (#62).
172 if (m.progressChange !== undefined && m.progressChange > 0 && m.craft && m.craft !== 'sound') {
173 tokens.push({ kind: 'craft', label: `${CRAFT_LABEL[m.craft].toLowerCase()}`, hint: `a ${CRAFT_LABEL[m.craft].toLowerCase()} dispatch scales the gain it earned — craft, gains-only` })
174 }
175 // Momentum is the OTHER gains-only amplifier (the arc meter): show it whenever it
176 // lifted this turn, so the printed equation reconciles to the result (issue #62).
177 if (m.progressChange !== undefined && m.progressChange > 0 && m.momentumAtSwing && m.momentumAtSwing >= 1) {
178 tokens.push({ kind: 'momentum', label: `✦ momentum ${m.momentumAtSwing}`, hint: `momentum ${m.momentumAtSwing} of 4 — a coherent arc amplifies every gain` })
179 }
180 if (m.staked) tokens.push({ kind: 'staked', label: '⚑ staked', hint: STAKE_HINT })
181 return tokens
183</script>
184 
185<style scoped>
186/* Phone keeps a fixed scroll box (the Story tab is one panel); the 340px cap and
187 md+ release live as utilities on the element (a scoped rule would out-specify the
188 Tailwind md: override). From md up the thread grows into the panel's own scroll. */
189.message-history-container {
190 min-height: 120px;
192 
193/* Staged reveal: a resolved turn writes itself in — the figure speaks, then acts,
194 then the ripple through history lands. Elements stay in the DOM (opacity only). */
195[data-testid="character-message"],
196[data-testid="character-action"],
197[data-testid="timeline-impact"] {
198 animation: reveal-up 0.45s var(--rv-ease) both;
200[data-testid="character-message"] { animation-delay: 0.08s; }
201[data-testid="character-action"] { animation-delay: 0.30s; }
202[data-testid="timeline-impact"] { animation-delay: 0.55s; }
203 
204@keyframes reveal-up {
205 from { opacity: 0; transform: translateY(8px); }
206 to { opacity: 1; transform: none; }
208 
209@media (prefers-reduced-motion: reduce) {
210 [data-testid="character-message"],
211 [data-testid="character-action"],
212 [data-testid="timeline-impact"] { animation: none; }
214</style>

The Spine: the same one-line gate

The Spine's node-detail strip carries its own copy of the equation, so the two surfaces move together. Same edit: drop the base !== final half of the v-if, keep the !== undefined guard. The Spine's equation has no inline tokens — the ⚡/⚑ amplifiers render as separate badges beside it — so it always read as a bare (base → final) anyway.

The Spine equation, now ungated.

components/TimelineLedger.vue · 210 lines
components/TimelineLedger.vue210 lines · Vue
⋯ 75 lines hidden (lines 1–75)
1<template>
2 <!-- THE SPINE: history as a horizontal era-axis you travel left→right. -->
3 <div data-testid="timeline-ledger">
4 <div class="border-b rv-line pb-1.5 mb-3">
5 <div class="flex items-center justify-between gap-3">
6 <span class="rv-label">The Spine</span>
7 <span class="rv-mono rv-faint text-[11px] normal-case tracking-normal">
8 {{ events.length }} {{ events.length === 1 ? 'change' : 'changes' }}<template v-if="anachCount"> · ⚡{{ anachCount }}</template><span v-if="events.length" class="ml-2">scroll →</span>
9 </span>
10 </div>
11 <p class="rv-faint text-[11px] mt-0.5">{{ props.readonly ? 'the history you wrote' : "the timeline you're rewriting" }} — every change, in the order you made it<template v-if="events.length"> · <span class="rv-accent">tap any node to inspect</span></template></p>
12 </div>
13 
14 <!-- Empty state — but the instant a first move is resolving, it leans in rather
15 than still reading "unwritten" (the board never contradicts the act). -->
16 <div v-if="events.length === 0" data-testid="timeline-empty" class="text-center py-12">
17 <template v-if="!props.readonly && gameStore.isLoading">
18 <div class="text-4xl mb-3 opacity-50 rv-accent" aria-hidden="true"></div>
19 <p class="rv-serif rv-fg text-lg">The timeline trembles…</p>
20 <p class="rv-faint text-xs mt-1">Your message ripples into the past.</p>
21 </template>
22 <template v-else>
23 <div class="text-4xl mb-3 opacity-40" aria-hidden="true">📜</div>
24 <p class="rv-serif rv-fg text-lg">History is still unwritten.</p>
25 <p class="rv-faint text-xs mt-1">Send your first message to bend the timeline.</p>
26 </template>
27 </div>
28 
29 <!-- The axis -->
30 <div v-else class="spine-scroll overflow-x-auto px-1 pb-2">
31 <TransitionGroup name="timeline" tag="ol" class="flex items-start min-w-full" aria-label="Timeline of changes">
32 <li v-for="event in events" :key="event.id" data-testid="timeline-event"
33 class="spine-node rv-press" :class="{ 'is-selected': event.id === selectedId }"
34 role="button" tabindex="0" @click="select(event.id)" @keydown.enter="select(event.id)" @keydown.space.prevent="select(event.id)">
35 <span class="sr-only">{{ event.headline }}{{ event.detail }} ({{ signed(event.progressChange) }})</span>
36 <span class="block text-center rv-mono text-[11px] rv-faint">{{ event.era || '—' }}</span>
37 <span class="dot-wrap" aria-hidden="true">
38 <span class="connector" />
39 <span class="rv-dot" :class="event.id === selectedId ? 'rv-dot--accent' : dotClass(event.valence)" />
40 </span>
41 <span class="block text-center rv-mono text-xs rv-fg truncate px-1">{{ event.figureName }}</span>
42 <span class="block text-center rv-mono text-[11px] mt-0.5 tabular-nums">
43 <span class="rv-faint">🎲</span>{{ event.diceRoll }}
44 <span class="delta-cell" :class="deltaClass(event.progressChange)">{{ signed(event.progressChange) }}</span><span
45 v-if="isAnachronistic(event.anachronism)" class="rv-warn ml-0.5" :aria-label="anachronismLabel(event.anachronism)" :title="anachronismHint(event.anachronism)">{{ anachPips(event.anachronism) }}</span><span
46 v-if="event.staked" class="rv-warn ml-0.5" aria-label="Staked — the last stand" :title="STAKE_HINT"></span>
47 </span>
48 </li>
49 
50 <!-- the live present — only while the run is live; the verdict has no next move -->
51 <li v-if="!props.readonly" key="now" class="spine-now" aria-hidden="true">
52 <span class="block text-center rv-mono text-[11px] rv-accent font-semibold">▷ now</span>
53 <span class="dot-wrap"><span class="connector connector--half" /></span>
54 <span class="block text-center rv-faint text-[11px]">your move</span>
55 </li>
56 </TransitionGroup>
57 </div>
58 
59 <!-- Node-detail strip: the selected change in full (defaults to the newest).
60 The consequence headline leads — the second of the board's two focal peaks. -->
61 <div v-if="selected" data-testid="node-detail" class="border-t rv-line pt-2.5 mt-2">
62 <p class="rv-serif rv-fg text-lg lg:text-xl font-semibold leading-snug">{{ selected.headline }}</p>
63 <p class="rv-muted text-xs lg:text-sm mt-1 leading-relaxed max-w-2xl">{{ selected.detail }}</p>
64 <div class="flex items-center gap-x-2 gap-y-1 flex-wrap text-[11px] rv-mono mt-1.5 tabular-nums">
65 <span class="inline-flex items-center gap-1.5">
66 <span class="rv-dot" :class="dotClass(selected.valence)" aria-hidden="true" />
67 <span class="rv-faint">{{ selected.era || '—' }} · {{ selected.figureName }}</span>
68 </span>
69 <span class="rv-hair-c">·</span>
70 <span class="rv-muted">🎲 {{ selected.diceRoll }} · {{ selected.diceOutcome }}</span>
71 <span v-if="selected.craft && selected.craft !== 'sound'" class="rv-hair-c">·</span>
72 <SymbolHint v-if="selected.craft && selected.craft !== 'sound'" :text="CRAFT_HINT[selected.craft]">
73 <span data-testid="craft-badge"
74 class="font-semibold" :class="selected.craft === 'masterful' || selected.craft === 'sharp' ? 'rv-ok' : 'rv-warn'">{{ CRAFT_LABEL[selected.craft] }}</span>
75 </SymbolHint>
76 <span class="rv-hair-c">·</span>
77 <span class="font-bold" :class="deltaClass(selected.progressChange)">{{ signed(selected.progressChange) }}%</span>
78 <!-- The wager's arithmetic, in the open on every turn (issue #135): base →
79 amplified/staked final, collapsing to (+22 → +22%) when nothing moved it. -->
80 <span v-if="selected.baseProgressChange !== undefined"
81 data-testid="swing-equation" class="rv-faint">({{ signed(selected.baseProgressChange) }}{{ signed(selected.progressChange) }})</span>
82 <SymbolHint v-if="isAnachronistic(selected.anachronism)" :text="anachronismHint(selected.anachronism)">
83 <span data-testid="anachronism-badge" class="rv-warn font-semibold ml-1">{{ anachronismLabel(selected.anachronism) }}</span>
84 </SymbolHint>
85 <SymbolHint v-if="selected.staked" :text="STAKE_HINT">
86 <span data-testid="staked-badge" class="rv-warn font-semibold ml-1">⚑ staked</span>
⋯ 124 lines hidden (lines 87–210)
87 </SymbolHint>
88 </div>
89 </div>
90 </div>
91</template>
92 
93<script setup lang="ts">
94/**
95 * TimelineLedger — the Spine. Each resolved turn is a node on a horizontal era-axis
96 * (valence dot + mono year/figure/roll/Δ + ⚡), newest on the right with a ▷now marker;
97 * clicking a node snaps its full payload into the node-detail strip, which defaults to
98 * the newest change so the latest turn is always visible. Pure glyphs on the axis; the
99 * only prose lives in the strip. (Re-skin only — the store data is unchanged.)
100 */
101import { computed, ref, watch } from 'vue'
102import { useGameStore } from '~/stores/game'
103import type { Valence } from '~/stores/game'
104import { ANACHRONISM_HINT, ANACHRONISM_LABEL, type Anachronism } from '~/server/utils/anachronism'
105import { CRAFT_HINT, CRAFT_LABEL } from '~/utils/craft'
106import { STAKE_HINT } from '~/utils/swing-bands'
107import type { RunLedgerEntry } from '~/utils/run-snapshot'
108 
109const gameStore = useGameStore()
110const props = defineProps<{
111 /** Verdict / past-run view (the end screen): drop the live "▷ now" present
112 * marker and the "the timeline trembles…" loading empty state, and frame the
113 * lead in past tense. In-game usage passes nothing, so its behavior is
114 * unchanged — the Spine the player scrolled and tapped renders the same. */
115 readonly?: boolean
116 /** Events to render. Omitted in live play, where the Spine reads the live store.
117 * The saved-run view (RunView) passes a snapshot's ledger so a past run renders
118 * the same Spine from the saved record, not the (empty/irrelevant) live store.
119 * `RunLedgerEntry` is the view-facing projection of the store's `TimelineEvent`,
120 * so the live events satisfy it too — the fallback stays byte-identical. */
121 events?: RunLedgerEntry[]
122}>()
123const events = computed<RunLedgerEntry[]>(() => props.events ?? gameStore.timelineEvents)
124 
125const selectedId = ref<string | null>(null)
126// Default the detail strip to the newest change, and snap to it whenever one lands.
127watch(() => events.value.length, () => {
128 const last = events.value[events.value.length - 1]
129 if (last) selectedId.value = last.id
130}, { immediate: true })
131 
132const selected = computed(() =>
133 events.value.find(e => e.id === selectedId.value) ?? events.value[events.value.length - 1] ?? null
135const anachCount = computed(() => events.value.filter(e => isAnachronistic(e.anachronism)).length)
136 
137function select(id: string) { selectedId.value = id }
138function signed(n: number) { return `${n > 0 ? '+' : ''}${n}` }
139function isAnachronistic(a?: Anachronism) { return !!a && a !== 'in-period' }
140function anachronismLabel(a?: Anachronism) { return a ? ANACHRONISM_LABEL[a] : '' }
141function anachronismHint(a?: Anachronism) { return a ? ANACHRONISM_HINT[a] : '' }
142/** A quantified, glanceable anachronism tier on the spine node: 1–3 amber pips. */
143function anachPips(a?: Anachronism) {
144 return a === 'impossible' ? '⚡⚡⚡' : a === 'far-ahead' ? '⚡⚡' : a === 'ahead' ? '⚡' : ''
146 
147function dotClass(v: Valence) {
148 return v === 'positive' ? 'rv-dot--ok' : v === 'negative' ? 'rv-dot--bad' : 'rv-dot--mut'
150function deltaClass(change: number) {
151 if (change > 0) return 'rv-ok'
152 if (change < 0) return 'rv-bad'
153 return 'rv-muted'
155</script>
156 
157<style scoped>
158.spine-node,
159.spine-now {
160 flex: 0 0 auto;
161 min-width: 92px;
162 padding: 2px 4px;
163 cursor: pointer;
164 border: none;
165 background: transparent;
166 scroll-snap-align: center;
168.spine-scroll { scroll-snap-type: x proximity; -webkit-overflow-scrolling: touch; }
169/* Reserve a stable sign slot so a column of +/− deltas stacks rule-straight. */
170.delta-cell { display: inline-block; min-width: 1.9em; text-align: left; }
171.spine-now { cursor: default; }
172.spine-node:focus-visible { outline: 2px solid var(--rv-accent); outline-offset: 2px; border-radius: 3px; }
173 
174/* The axis line is drawn by a full-width connector behind each node's dot. */
175.dot-wrap {
176 position: relative;
177 display: flex;
178 justify-content: center;
179 align-items: center;
180 height: 16px;
181 margin: 3px 0;
183.connector {
184 position: absolute;
185 top: 50%;
186 left: 0;
187 right: 0;
188 height: 1px;
189 background: var(--rv-line);
190 transform: translateY(-50%);
192.connector--half { right: 50%; }
193.dot-wrap > .rv-dot { position: relative; z-index: 1; }
194.spine-node.is-selected .dot-wrap > .rv-dot {
195 width: 12px;
196 height: 12px;
197 box-shadow: 0 0 0 3px var(--rv-bg), 0 0 0 4px var(--rv-accent);
199.spine-node:hover .dot-wrap > .rv-dot { transform: translateY(-1px); }
200 
201/* Preserved entry/move animation (now driving the right-edge node arrival). */
202.timeline-enter-active { transition: opacity var(--rv-dur-3) var(--rv-ease), transform var(--rv-dur-3) var(--rv-ease); }
203.timeline-enter-from { opacity: 0; transform: translateY(-10px); }
204.timeline-move { transition: transform var(--rv-dur-3) var(--rv-ease); }
205 
206@media (prefers-reduced-motion: reduce) {
207 .timeline-enter-active, .timeline-move { transition: none; }
208 .spine-node:hover .dot-wrap > .rv-dot { transform: none; }
210</style>

What stays intact: base → final still reconciles

The issue's one caution: always-showing must still reconcile base → final. That contract lives in equationTokens, and this PR does not touch it. Every amplifier the server applies has a matching token — anachronism, causal chain, craft, momentum, stake — and the gains-only ones (craft at line 172, momentum at line 177) are gated on progressChange > 0, so they stay absent on a loss. No amplifier flips the swing's sign, so a loss reads cleanly: (-10 → -10%) with nothing between.

Untouched: the tokens that explain the gap between base and final.

components/MessageHistory.vue · 214 lines
components/MessageHistory.vue214 lines · Vue
⋯ 159 lines hidden (lines 1–159)
1<template>
2 <!-- Conversation with the currently-selected figure (the THREAD rail). -->
3 <div data-testid="message-history"
4 class="message-history-container overflow-y-auto rv-bg max-h-[340px] md:max-h-none md:overflow-visible"
5 aria-label="Conversation history" aria-live="polite" role="log">
6 <!-- Empty state -->
7 <div v-if="thread.length === 0 && !gameStore.isLoading" data-testid="empty-state"
8 class="flex flex-col items-center justify-center p-8 text-center">
9 <div class="text-3xl mb-2 opacity-50" aria-hidden="true">💬</div>
10 <p class="rv-muted text-sm">
11 {{ activeFigure ? `No words exchanged with ${activeFigure} yet` : 'No one contacted yet' }}
12 </p>
13 <p class="rv-faint text-xs mt-0.5">
14 {{ activeFigure ? 'Send a message to begin.' : 'Choose a figure and send your first message.' }}
15 </p>
16 </div>
17 
18 <!-- Conversation -->
19 <ol v-else>
20 <li v-for="(message, index) in thread" :key="index" data-testid="message-item" :data-sender="message.sender"
21 class="message-item py-3 border-b rv-line last:border-b-0">
22 <!-- Player message -->
23 <div v-if="message.sender === 'user'" data-testid="user-message">
24 <p class="text-[11px] rv-faint uppercase tracking-wide mb-0.5">You → {{ message.figureName || activeFigure }}</p>
25 <p class="rv-fg text-sm">{{ message.text }}</p>
26 <p class="text-[11px] rv-faint mt-1 rv-mono" data-testid="message-timestamp">{{ formatTimestamp(message.timestamp) }}</p>
27 </div>
28 
29 <!-- Figure reply -->
30 <div v-else-if="message.sender === 'ai'" data-testid="ai-message">
31 <div class="flex items-center justify-between gap-2 mb-1">
32 <p class="text-sm font-semibold rv-fg flex items-center gap-1.5 min-w-0">
33 <span class="rv-dot" :class="outcomeDot(message.diceOutcome)" aria-hidden="true" />
34 <span class="truncate">{{ message.figureName || activeFigure || 'The past' }}</span>
35 </p>
36 <div v-if="message.diceRoll !== undefined" class="flex items-center gap-1.5 rv-mono text-xs shrink-0">
37 <span data-testid="dice-icon" aria-hidden="true">🎲</span>
38 <!-- Craft tilts fate visibly: natural roll ✒mod = effective. -->
39 <span v-if="message.rollModifier" data-testid="craft-roll" class="rv-faint">
40 {{ message.naturalRoll }} <span :class="message.rollModifier > 0 ? 'rv-ok' : 'rv-bad'">{{ message.rollModifier > 0 ? '+' : '' }}{{ message.rollModifier }}</span> =
41 </span>
42 <span data-testid="dice-result" class="font-bold rv-fg">{{ message.diceRoll }}</span>
43 <span data-testid="dice-outcome" class="font-semibold" :class="outcomeText(message.diceOutcome)">{{ message.diceOutcome }}</span>
44 </div>
45 </div>
46 
47 <!-- The Judge's verdict on the dispatch that caused all this: the craft
48 grade (with a hover hint), a 'builds' mark when it picked up the run's
49 thread (the momentum signal), and the terse reason. -->
50 <div v-if="message.craft && (message.craft !== 'sound' || message.craftReason || message.continuity === 'builds')" data-testid="craft-verdict" class="text-[11px] mb-1.5">
51 <span class="rv-faint">✒ your dispatch · </span><SymbolHint :text="CRAFT_HINT[message.craft]"><span class="font-semibold" :class="craftClass(message.craft)">{{ CRAFT_LABEL[message.craft] }}</span></SymbolHint><span v-if="message.continuity === 'builds'" data-testid="continuity-builds" class="rv-ok font-semibold"> · ✦ builds</span><span v-if="message.craftReason" class="rv-faint">{{ message.craftReason }}</span>
52 </div>
53 
54 <p data-testid="character-message" class="rv-fg text-sm mb-2">{{ message.text }}</p>
55 
56 <div v-if="message.characterAction" data-testid="character-action" class="text-xs rv-muted border-l rv-line pl-2 mb-2">
57 <span class="rv-faint">acts · </span><span class="italic">{{ message.characterAction }}</span>
58 </div>
59 
60 <div v-if="message.timelineImpact" data-testid="timeline-impact" class="text-xs rv-muted border-l rv-line pl-2 mb-1">
61 <span class="rv-faint">ripple · </span>{{ message.timelineImpact }}
62 <span v-if="message.progressChange !== undefined" data-testid="progress-change"
63 class="rv-mono font-bold ml-1" :class="deltaText(message.progressChange)">
64 {{ message.progressChange > 0 ? '+' : '' }}{{ message.progressChange }}%
65 </span>
66 <!-- The wager's work, shown as an equation: base ⚡tier (⚑ staked) → final.
67 Each amplifier token carries its own hover hint for what it did. -->
68 <span v-if="showEquation(message)" data-testid="swing-equation" class="rv-mono rv-faint ml-1 whitespace-nowrap">({{ signed(message.baseProgressChange!) }}<template v-for="tok in equationTokens(message)" :key="tok.kind">{{ ' ' }}<SymbolHint :text="tok.hint"><span :data-testid="`equation-${tok.kind}`">{{ tok.label }}</span></SymbolHint></template>{{ ' → ' + signed(message.progressChange!) + '%' }})</span>
69 </div>
70 
71 <p class="text-[11px] rv-faint rv-mono" data-testid="message-timestamp">{{ formatTimestamp(message.timestamp) }}</p>
72 </div>
73 </li>
74 
75 <!-- Loading indicator -->
76 <li v-if="gameStore.isLoading" data-testid="loading-indicator" class="py-3 flex items-center gap-2 rv-muted text-sm">
77 <span class="rv-spinner" aria-hidden="true" />
78 {{ activeFigure || 'The past' }} is weighing your words…
79 </li>
80 </ol>
81 </div>
82</template>
83 
84<script setup lang="ts">
85/**
86 * MessageHistory — the conversation thread with the active figure, as hairline rows
87 * led by an outcome dot. Figure-aware labels; each figure keeps an independent thread.
88 */
89import { computed } from 'vue'
90import { useGameStore, type Message } from '~/stores/game'
91import { ANACHRONISM_HINT, ANACHRONISM_LABEL } from '~/server/utils/anachronism'
92import { CHAIN_HINT, CHAIN_LABEL } from '~/utils/causal-chain'
93import { CRAFT_HINT, CRAFT_LABEL, type Craft } from '~/utils/craft'
94import { STAKE_HINT } from '~/utils/swing-bands'
95 
96const gameStore = useGameStore()
97 
98const activeFigure = computed(() => gameStore.activeFigureName)
99const thread = computed(() => gameStore.conversationWith(gameStore.activeFigureName))
100 
101const formatTimestamp = (timestamp: Date): string => {
102 return new Intl.DateTimeFormat('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(timestamp)
104 
105function outcomeDot(outcome?: string): string {
106 switch (outcome) {
107 case 'Critical Success':
108 case 'Success': return 'rv-dot--ok'
109 case 'Failure':
110 case 'Critical Failure': return 'rv-dot--bad'
111 default: return 'rv-dot--mut'
112 }
114function outcomeText(outcome?: string): string {
115 switch (outcome) {
116 case 'Critical Success':
117 case 'Success': return 'rv-ok'
118 case 'Failure':
119 case 'Critical Failure': return 'rv-bad'
120 default: return 'rv-muted'
121 }
123function deltaText(change?: number): string {
124 if (change === undefined || change === 0) return 'rv-muted'
125 return change > 0 ? 'rv-ok' : 'rv-bad'
127 
128function craftClass(craft?: Craft): string {
129 switch (craft) {
130 case 'masterful':
131 case 'sharp': return 'rv-ok'
132 case 'vague':
133 case 'reckless': return 'rv-warn'
134 default: return 'rv-muted'
135 }
137 
138// The swing equation renders on every resolved turn (issue #135) — always
139// disclosing base → final. On a plain turn it collapses to a single clean number
140// like (+22 → +22%), which is fine; consistency beats hiding the math, and a loss
141// now shows its breakdown too (where gains-only craft gave no protection — the
142// lesson it used to bury). Needs only that both base and final are known.
143function showEquation(m: Message): boolean {
144 return m.baseProgressChange !== undefined
145 && m.progressChange !== undefined
147function signed(n: number): string {
148 return `${n > 0 ? '+' : ''}${n}`
150 
151// The amplifier tokens between the base and final swing — anachronism, causal
152// chain, the stake — each rendered as a labeled glyph carrying its own hover
153// hint (what the token is and how it bent the swing). Mirrors the order the
154// server applies them; absent tokens drop out, matching the old flat string.
155interface EquationToken {
156 kind: 'anachronism' | 'chain' | 'craft' | 'momentum' | 'staked'
157 label: string
158 hint: string
160function equationTokens(m: Message): EquationToken[] {
161 const tokens: EquationToken[] = []
162 if (m.anachronism && m.anachronism !== 'in-period') {
163 const pips = m.anachronism === 'impossible' ? '⚡⚡⚡' : m.anachronism === 'far-ahead' ? '⚡⚡' : '⚡'
164 tokens.push({ kind: 'anachronism', label: `${pips} ${ANACHRONISM_LABEL[m.anachronism].toLowerCase()}`, hint: ANACHRONISM_HINT[m.anachronism] })
165 }
166 if (m.causalChain && m.causalChain.tier !== 'at-hinge') {
167 tokens.push({ kind: 'chain', label: `${CHAIN_LABEL[m.causalChain.tier].toLowerCase()}`, hint: CHAIN_HINT[m.causalChain.tier] })
168 }
169 // Craft scales the UPSIDE only, and only when it isn't the neutral 'sound' — so a
170 // sharp gain shows its lift and a vague one its drag, right in the math. This is
171 // where the player SEES craft drive the magnitude, not just tilt the die (#62).
172 if (m.progressChange !== undefined && m.progressChange > 0 && m.craft && m.craft !== 'sound') {
173 tokens.push({ kind: 'craft', label: `${CRAFT_LABEL[m.craft].toLowerCase()}`, hint: `a ${CRAFT_LABEL[m.craft].toLowerCase()} dispatch scales the gain it earned — craft, gains-only` })
174 }
175 // Momentum is the OTHER gains-only amplifier (the arc meter): show it whenever it
176 // lifted this turn, so the printed equation reconciles to the result (issue #62).
177 if (m.progressChange !== undefined && m.progressChange > 0 && m.momentumAtSwing && m.momentumAtSwing >= 1) {
178 tokens.push({ kind: 'momentum', label: `✦ momentum ${m.momentumAtSwing}`, hint: `momentum ${m.momentumAtSwing} of 4 — a coherent arc amplifies every gain` })
179 }
180 if (m.staked) tokens.push({ kind: 'staked', label: '⚑ staked', hint: STAKE_HINT })
181 return tokens
⋯ 32 lines hidden (lines 183–214)
183</script>
184 
185<style scoped>
186/* Phone keeps a fixed scroll box (the Story tab is one panel); the 340px cap and
187 md+ release live as utilities on the element (a scoped rule would out-specify the
188 Tailwind md: override). From md up the thread grows into the panel's own scroll. */
189.message-history-container {
190 min-height: 120px;
192 
193/* Staged reveal: a resolved turn writes itself in — the figure speaks, then acts,
194 then the ripple through history lands. Elements stay in the DOM (opacity only). */
195[data-testid="character-message"],
196[data-testid="character-action"],
197[data-testid="timeline-impact"] {
198 animation: reveal-up 0.45s var(--rv-ease) both;
200[data-testid="character-message"] { animation-delay: 0.08s; }
201[data-testid="character-action"] { animation-delay: 0.30s; }
202[data-testid="timeline-impact"] { animation-delay: 0.55s; }
203 
204@keyframes reveal-up {
205 from { opacity: 0; transform: translateY(8px); }
206 to { opacity: 1; transform: none; }
208 
209@media (prefers-reduced-motion: reduce) {
210 [data-testid="character-message"],
211 [data-testid="character-action"],
212 [data-testid="timeline-impact"] { animation: none; }
214</style>

The tests that pin it

Three tests fix the new behavior in place — each one fails on the old gated code, where the equation was hidden when base equals final. In the Thread, a plain in-period gain collapses to (+22 → +22%) with no amplifier glyphs, and a losing turn with a sharp dispatch shows the breakdown but no craft token (the lesson made literal).

The plain-gain and the sharp-but-lost cases.

tests/unit/components/MessageHistory.spec.ts · 215 lines
tests/unit/components/MessageHistory.spec.ts215 lines · TypeScript
⋯ 143 lines hidden (lines 1–143)
1import { mount } from '@vue/test-utils'
2import { describe, it, expect, beforeEach } from 'vitest'
3import { setActivePinia, createPinia } from 'pinia'
4import MessageHistory from '../../../components/MessageHistory.vue'
5import { useGameStore } from '../../../stores/game'
6import { DiceOutcome } from '../../../utils/dice'
7 
8const FIG = 'Napoleon Bonaparte'
9 
10// SymbolHint wraps a label in a Nuxt UI tooltip that can't render in a bare unit
11// mount; stub it to its slot so the labelled text is still present to assert on.
12const withHint = { global: { stubs: { SymbolHint: { template: '<span><slot/></span>' } } } }
13 
14describe('MessageHistory', () => {
15 let gameStore: ReturnType<typeof useGameStore>
16 
17 beforeEach(() => {
18 setActivePinia(createPinia())
19 gameStore = useGameStore()
20 })
21 
22 it('renders a scrollable, accessible container', () => {
23 const wrapper = mount(MessageHistory, withHint)
24 const container = wrapper.find('[data-testid="message-history"]')
25 expect(container.exists()).toBe(true)
26 expect(container.classes()).toContain('overflow-y-auto')
27 expect(container.attributes('role')).toBe('log')
28 expect(container.attributes('aria-label')).toBe('Conversation history')
29 })
30 
31 it('shows an empty state when no one is contacted', () => {
32 const wrapper = mount(MessageHistory, withHint)
33 const empty = wrapper.find('[data-testid="empty-state"]')
34 expect(empty.exists()).toBe(true)
35 expect(empty.text()).toContain('No one contacted yet')
36 expect(empty.classes()).toContain('flex')
37 })
38 
39 describe('the active figure thread', () => {
40 beforeEach(() => {
41 gameStore.setActiveFigure(FIG)
42 })
43 
44 it('shows only the active figure’s conversation', () => {
45 gameStore.addUserMessage('Sire, abandon the Russian campaign.', FIG)
46 gameStore.addUserMessage('Winter will destroy your army.', FIG)
47 gameStore.addUserMessage('Unrelated note', 'Cleopatra')
48 
49 const wrapper = mount(MessageHistory, withHint)
50 const messages = wrapper.findAll('[data-testid="message-item"]')
51 expect(messages).toHaveLength(2)
52 expect(messages[0].text()).toContain('abandon the Russian campaign')
53 expect(wrapper.text()).not.toContain('Unrelated note')
54 })
55 
56 it('labels the player turn with the target figure', () => {
57 gameStore.addUserMessage('A warning from the future.', FIG)
58 const user = mount(MessageHistory, withHint).find('[data-sender="user"]')
59 expect(user.exists()).toBe(true)
60 expect(user.text()).toContain('You →')
61 expect(user.text()).toContain(FIG)
62 })
63 
64 it('labels the figure reply with the figure name, not a hardcoded one', () => {
65 gameStore.addAIMessage('History shall remember me.', FIG)
66 const ai = mount(MessageHistory, withHint).find('[data-sender="ai"]')
67 expect(ai.exists()).toBe(true)
68 expect(ai.text()).toContain(FIG)
69 expect(ai.text()).not.toContain('Franz Ferdinand')
70 })
71 
72 it('renders dice, action, and ripple on a resolved figure turn', () => {
73 gameStore.addAIMessageWithData({
74 text: 'It shall be done.',
75 sender: 'ai',
76 figureName: FIG,
77 diceRoll: 18,
78 diceOutcome: DiceOutcome.SUCCESS,
79 characterAction: 'Cancels the invasion',
80 timelineImpact: 'Russia is spared the march',
81 progressChange: 20
82 })
83 const wrapper = mount(MessageHistory, withHint)
84 expect(wrapper.find('[data-testid="dice-result"]').text()).toContain('18')
85 expect(wrapper.find('[data-testid="dice-outcome"]').text()).toContain('Success')
86 expect(wrapper.find('[data-testid="character-action"]').text()).toContain('Cancels the invasion')
87 expect(wrapper.find('[data-testid="timeline-impact"]').text()).toContain('Russia is spared')
88 })
89 
90 it('shows craft in the swing equation when it amplified the gain (issue #62)', () => {
91 gameStore.addAIMessageWithData({
92 text: 'It shall be done.',
93 sender: 'ai',
94 figureName: FIG,
95 diceRoll: 18,
96 diceOutcome: DiceOutcome.SUCCESS,
97 timelineImpact: 'Russia is spared the march',
98 craft: 'masterful',
99 baseProgressChange: 20,
100 progressChange: 29
101 })
102 const eq = mount(MessageHistory, withHint).find('[data-testid="swing-equation"]')
103 expect(eq.exists()).toBe(true)
104 expect(eq.text()).toContain('masterful')
105 expect(eq.text()).toContain('+20')
106 expect(eq.text()).toContain('+29')
107 })
108 
109 it('shows craft drag when a vague dispatch dampened the gain', () => {
110 gameStore.addAIMessageWithData({
111 text: 'I am unmoved.',
112 sender: 'ai',
113 figureName: FIG,
114 diceRoll: 14,
115 diceOutcome: DiceOutcome.SUCCESS,
116 timelineImpact: 'A half-measure ripples out',
117 craft: 'vague',
118 baseProgressChange: 20,
119 progressChange: 8
120 })
121 expect(mount(MessageHistory, withHint).find('[data-testid="swing-equation"]').text()).toContain('vague')
122 })
123 
124 it('shows momentum in the swing equation so the math reconciles (issue #62)', () => {
125 gameStore.addAIMessageWithData({
126 text: 'The arc holds.',
127 sender: 'ai',
128 figureName: FIG,
129 diceRoll: 16,
130 diceOutcome: DiceOutcome.SUCCESS,
131 timelineImpact: 'The plan compounds',
132 craft: 'sound',
133 momentumAtSwing: 4,
134 baseProgressChange: 10,
135 progressChange: 14
136 })
137 const eq = mount(MessageHistory, withHint).find('[data-testid="swing-equation"]')
138 expect(eq.exists()).toBe(true)
139 expect(eq.text()).toContain('momentum')
140 expect(eq.text()).toContain('+10')
141 expect(eq.text()).toContain('+14')
142 })
143 
144 it('always shows the swing equation on a plain in-period turn, collapsed to one clean number (issue #135)', () => {
145 gameStore.addAIMessageWithData({
146 text: 'A measured word.',
147 sender: 'ai',
148 figureName: FIG,
149 diceRoll: 12,
150 diceOutcome: DiceOutcome.SUCCESS,
151 timelineImpact: 'A steady ripple',
152 craft: 'sound',
153 anachronism: 'in-period',
154 baseProgressChange: 22,
155 progressChange: 22
156 })
157 const w = mount(MessageHistory, withHint)
158 const eq = w.find('[data-testid="swing-equation"]')
159 expect(eq.exists()).toBe(true)
160 expect(eq.text()).toContain('+22')
161 expect(eq.text()).toContain('→')
162 // Nothing moved the base, so the token loop is empty — no amplifier glyphs.
163 expect(w.findAll('[data-testid^="equation-"]')).toHaveLength(0)
164 })
165 
166 it('shows the swing breakdown on a loss, where gains-only craft gave no protection (issue #135)', () => {
167 gameStore.addAIMessageWithData({
168 text: 'It changes nothing.',
169 sender: 'ai',
170 figureName: FIG,
171 diceRoll: 6,
172 diceOutcome: DiceOutcome.FAILURE,
173 timelineImpact: 'The moment slips away',
174 craft: 'sharp',
175 baseProgressChange: -10,
176 progressChange: -10
177 })
178 const w = mount(MessageHistory, withHint)
179 const eq = w.find('[data-testid="swing-equation"]')
180 expect(eq.exists()).toBe(true)
181 expect(eq.text()).toContain('-10')
182 // Craft scales the upside only; on a miss it doesn't fire, so no craft token.
183 expect(w.find('[data-testid="equation-craft"]').exists()).toBe(false)
184 // And nothing else amplified this loss, so the token loop is empty.
185 expect(w.findAll('[data-testid^="equation-"]')).toHaveLength(0)
186 })
⋯ 29 lines hidden (lines 187–215)
187 
188 it('marks a dispatch that builds on the run’s thread (issue #62)', () => {
189 gameStore.addAIMessageWithData({
190 text: 'As you asked.',
191 sender: 'ai',
192 figureName: FIG,
193 diceRoll: 16,
194 diceOutcome: DiceOutcome.SUCCESS,
195 craft: 'sound',
196 continuity: 'builds'
197 })
198 const badge = mount(MessageHistory, withHint).find('[data-testid="continuity-builds"]')
199 expect(badge.exists()).toBe(true)
200 expect(badge.text()).toContain('builds')
201 })
202 })
203 
204 describe('loading state', () => {
205 it('shows the loading indicator while awaiting a reply', () => {
206 gameStore.setActiveFigure(FIG)
207 gameStore.setLoading(true)
208 expect(mount(MessageHistory, withHint).find('[data-testid="loading-indicator"]').exists()).toBe(true)
209 })
210 
211 it('hides the loading indicator otherwise', () => {
212 expect(mount(MessageHistory, withHint).find('[data-testid="loading-indicator"]').exists()).toBe(false)
213 })
214 })
215})

On the Spine, a node whose base equals its final now renders the equation too.

tests/unit/components/TimelineLedger.spec.ts · 91 lines
tests/unit/components/TimelineLedger.spec.ts91 lines · TypeScript
⋯ 52 lines hidden (lines 1–52)
1import { mount } from '@vue/test-utils'
2import { describe, it, expect, beforeEach } from 'vitest'
3import { setActivePinia, createPinia } from 'pinia'
4import TimelineLedger from '../../../components/TimelineLedger.vue'
5import { useGameStore, type TimelineEvent } from '../../../stores/game'
6import { DiceOutcome } from '../../../utils/dice'
7import type { RunLedgerEntry } from '../../../utils/run-snapshot'
8 
9/**
10 * TimelineLedger — the Spine. In live play it reads the store; the saved-run view
11 * (RunView) feeds it a snapshot's ledger via the `events` prop. These cover both
12 * sources: the passed-prop path (a past run renders the full Spine from the saved
13 * record) and the store-fallback path (live play, unchanged).
14 */
15 
16// SymbolHint wraps each badge in a reka-ui tooltip that needs a TooltipProvider
17// ancestor the bare mount lacks; stub it to a slot passthrough (the badge + its
18// testid still render), matching MessageHistory.spec's idiom.
19const withHint = { global: { stubs: { SymbolHint: { template: '<span><slot/></span>' } } } }
20 
21function savedLedger(): RunLedgerEntry[] {
22 return [
23 { id: 'e1', figureName: 'Archimedes', era: 'Antiquity', headline: 'The lever turns', detail: 'A first nudge to the past.', diceRoll: 12, diceOutcome: DiceOutcome.SUCCESS, progressChange: 8, valence: 'positive' },
24 // The newest change — amplified and staked, so it exercises the Δ equation
25 // and both ⚡/⚑ badges the Spine renders (the parity #110 restores).
26 { id: 'e2', figureName: 'Hypatia', era: 'Antiquity', headline: 'The scrolls endure', detail: 'A copy survives the fire.', diceRoll: 19, diceOutcome: DiceOutcome.CRITICAL_SUCCESS, progressChange: 60, baseProgressChange: 40, valence: 'positive', anachronism: 'far-ahead', staked: true }
27 ]
29 
30const mountSaved = () => mount(TimelineLedger, { props: { readonly: true, events: savedLedger() }, ...withHint })
31 
32describe('TimelineLedger', () => {
33 beforeEach(() => setActivePinia(createPinia()))
34 
35 describe('from a passed events prop (the saved-run view)', () => {
36 it('renders the Spine from the prop — a node per event, newest leading the detail strip', () => {
37 const w = mountSaved()
38 expect(w.find('[data-testid="timeline-ledger"]').exists()).toBe(true)
39 expect(w.findAll('[data-testid="timeline-event"]')).toHaveLength(2)
40 expect(w.find('[data-testid="node-detail"]').text()).toContain('The scrolls endure')
41 })
42 
43 it('surfaces the snapshot\'s Spine fields: the Δ equation and the ⚡/⚑ badges', () => {
44 const w = mountSaved()
45 const eq = w.find('[data-testid="swing-equation"]')
46 expect(eq.exists()).toBe(true)
47 expect(eq.text()).toContain('+40')
48 expect(eq.text()).toContain('+60')
49 expect(w.find('[data-testid="anachronism-badge"]').exists()).toBe(true)
50 expect(w.find('[data-testid="staked-badge"]').exists()).toBe(true)
51 })
52 
53 it('shows the swing equation even when nothing amplified the base (issue #135)', () => {
54 const events: RunLedgerEntry[] = [
55 { id: 'e1', figureName: 'Archimedes', era: 'Antiquity', headline: 'A steady nudge', detail: 'The base swing stands unamplified.', diceRoll: 12, diceOutcome: DiceOutcome.SUCCESS, progressChange: 22, baseProgressChange: 22, valence: 'positive' }
56 ]
57 const w = mount(TimelineLedger, { props: { readonly: true, events }, ...withHint })
58 const eq = w.find('[data-testid="swing-equation"]')
59 expect(eq.exists()).toBe(true)
60 expect(eq.text()).toContain('+22')
61 })
⋯ 30 lines hidden (lines 62–91)
62 
63 it('is read-only: no live "▷ now" present marker', () => {
64 expect(mountSaved().find('.spine-now').exists()).toBe(false)
65 })
66 
67 it('ignores the live store entirely when events are passed', () => {
68 const store = useGameStore()
69 store.timelineEvents = [
70 { id: 'ghost', figureName: 'Nobody', era: 'Nowhen', headline: 'A live ghost', detail: 'Should never render.', diceRoll: 1, diceOutcome: DiceOutcome.CRITICAL_FAILURE, progressChange: -5, valence: 'negative', timestamp: new Date() }
71 ] as TimelineEvent[]
72 const w = mountSaved()
73 expect(w.text()).not.toContain('A live ghost')
74 expect(w.findAll('[data-testid="timeline-event"]')).toHaveLength(2)
75 })
76 })
77 
78 describe('without an events prop (live play, unchanged)', () => {
79 it('falls back to the store and shows the live present marker', () => {
80 const store = useGameStore()
81 store.timelineEvents = [
82 { id: 's1', figureName: 'Tesla', era: '19th c.', headline: 'A spark leaps', detail: 'Current flows early.', diceRoll: 15, diceOutcome: DiceOutcome.SUCCESS, progressChange: 10, valence: 'positive', timestamp: new Date() }
83 ] as TimelineEvent[]
84 const w = mount(TimelineLedger, withHint)
85 expect(w.findAll('[data-testid="timeline-event"]')).toHaveLength(1)
86 expect(w.find('[data-testid="node-detail"]').text()).toContain('A spark leaps')
87 // Live (not read-only): the "▷ now" present marker is shown.
88 expect(w.find('.spine-now').exists()).toBe(true)
89 })
90 })
91})

One stale comment in the e2e fixtures claimed the equation only appears when a fixture asks for it. The change made that false, so it now says the equation always renders and a fixture only changes its contents.

tests/e2e/support/game.ts · 273 lines
tests/e2e/support/game.ts273 lines · TypeScript
⋯ 49 lines hidden (lines 1–49)
1import type { Page } from '@playwright/test'
2import { expect } from '@nuxt/test-utils/playwright'
3import type { TurnFixture } from '../../../utils/fixtures/turn-fixture'
4 
5/**
6 * Shared e2e helpers. The game is AI-backed, so real runs would be slow, costly,
7 * and non-deterministic — every spec mocks the two API routes (`page.route`) and
8 * drives the real UI flow against fixed responses. No OpenAI key is needed.
9 */
10 
11/**
12 * A scripted turn. Aliased to the canonical `TurnFixture` (issue #105) so the e2e
13 * mocks and dev mode's fixture lane share ONE shape and can't drift — the import is
14 * type-only (erased at build), so it adds no runtime coupling to the Playwright run.
15 */
16export type RippleFixture = TurnFixture
17 
18/** Builds a /api/send-message payload in the exact shape the game store consumes. */
19function sendMessageResponse(f: RippleFixture = {}) {
20 const progressChange = f.progressChange ?? 20
21 const era = f.era ?? 'Alexandria, 48 BC'
22 const diceRoll = f.diceRoll ?? 14
23 return {
24 success: true,
25 message: 'Timeline updated',
26 data: {
27 userMessage: 'mocked',
28 figure: {
29 name: f.figureName ?? 'Cleopatra',
30 era,
31 descriptor: f.descriptor ?? 'Queen of Egypt'
32 },
33 diceRoll,
34 diceOutcome: f.diceOutcome ?? 'Success',
35 // Judge-of-craft fields: the default is an untilted die (sound, ±0),
36 // so specs that predate the judge keep their exact dice displays.
37 naturalRoll: f.naturalRoll ?? diceRoll,
38 rollModifier: f.rollModifier ?? 0,
39 craft: f.craft ?? 'sound',
40 craftReason: f.craftReason ?? '',
41 staked: f.staked ?? false,
42 characterResponse: {
43 message: f.message ?? 'A bold notion — I shall weigh it.',
44 action: f.action ?? 'Cleopatra dispatches envoys to Rome a decade early'
45 },
46 timeline: {
47 headline: f.headline ?? 'Envoys redraw the map of power',
48 detail: f.detail ?? 'Egypt courts Rome ahead of schedule and alliances shift.',
49 era,
50 progressChange,
51 // Defaults baseProgressChange to the final swing. Since #135 the
52 // reveal's equation always renders (collapsing to (+N → +N%) on an
53 // unamplified turn); a fixture only changes its CONTENTS, not whether
54 // it appears.
55 baseProgressChange: f.baseProgressChange ?? progressChange,
⋯ 218 lines hidden (lines 56–273)
56 valence: f.valence ?? (progressChange > 0 ? 'positive' : progressChange < 0 ? 'negative' : 'neutral'),
57 anachronism: f.anachronism ?? 'in-period'
58 },
59 error: null
60 }
61 }
63 
64/** Routes /api/send-message to a fixed ripple (or a per-call function for variation). */
65export async function mockSendMessage(page: Page, fixture: RippleFixture | (() => RippleFixture) = {}) {
66 await page.route('**/api/send-message', async (route) => {
67 const f = typeof fixture === 'function' ? fixture() : fixture
68 // Echo back the figure the player actually addressed (the store registers
69 // contacts from the response), unless the fixture pins one explicitly.
70 let requested: string | undefined
71 try {
72 requested = (route.request().postDataJSON() as { figureName?: string })?.figureName
73 } catch {
74 /* no/!json body */
75 }
76 await route.fulfill({
77 status: 200,
78 contentType: 'application/json',
79 body: JSON.stringify(sendMessageResponse({ ...f, figureName: f.figureName ?? requested }))
80 })
81 })
83 
84/** Routes /api/objective to a fixed, freshly "composed" objective. */
85export async function mockObjective(
86 page: Page,
87 objective: { title: string; description: string; era: string; icon: string }
88) {
89 await page.route('**/api/objective', async (route) => {
90 await route.fulfill({
91 status: 200,
92 contentType: 'application/json',
93 body: JSON.stringify({ success: true, objective })
94 })
95 })
97 
98/** A resolved, DECEASED figure — the default contact fixture. #73 requires a
99 * grounded, deceased figure to send, so a deceased default keeps the happy-path
100 * specs sending without each re-registering grounding. (Cleopatra, 69–30 BC.) */
101const RESOLVED_FIGURE = {
102 resolved: true,
103 name: 'Cleopatra',
104 description: 'Queen of Egypt',
105 born: { signed: -69, display: '69 BC' },
106 died: { signed: -30, display: '30 BC' },
107 living: false
109 
110/**
111 * Routes /api/figure (the grounding lookup FigurePicker fires on selection) to a
112 * fixed result. Defaults to a resolved, deceased figure (RESOLVED_FIGURE) so the
113 * contact is sendable under the #73 require-grounding gate without every spec
114 * re-registering. Pass `{ resolved: false }` to exercise the unresolved/blocked path.
115 *
116 * NOTE: the route pattern must NOT swallow /api/figure-search (the autocomplete),
117 * which has its own mock below — hence the exact-or-query matcher.
118 */
119export async function mockFigure(page: Page, figure: Record<string, unknown> = RESOLVED_FIGURE) {
120 await page.route(/\/api\/figure(\?|$)/, async (route) => {
121 await route.fulfill({
122 status: 200,
123 contentType: 'application/json',
124 body: JSON.stringify({ name: '', resolved: false, ...figure })
125 })
126 })
128 
129/**
130 * Routes /api/figure-search (the name-autocomplete combobox, fired while typing)
131 * to a fixed result set — default empty, so existing specs type without a
132 * dropdown appearing and never touch live Wikipedia.
133 */
134export async function mockFigureSearch(
135 page: Page,
136 results: Array<{ name: string; description?: string; thumbnail?: string }> = []
137) {
138 await page.route('**/api/figure-search*', async (route) => {
139 await route.fulfill({
140 status: 200,
141 contentType: 'application/json',
142 body: JSON.stringify({ results })
143 })
144 })
146 
147const DEFAULT_SUGGESTIONS = [
148 { name: 'Wilhelm II', reason: 'German Emperor who could restrain Vienna before the crisis spread.', lifespan: '1859 – 1941' },
149 { name: 'Edward Grey', reason: "Britain's foreign secretary — early mediation could cool the crisis.", lifespan: '1862 – 1933' },
150 { name: 'Nicholas II', reason: 'Russian Emperor; limiting mobilization could avert escalation.', lifespan: '1868 – 1918' }
152 
153/**
154 * Routes /api/suggestions (the tool-using agent FigurePicker loads on mount) to a
155 * fixed set, so specs never trigger the live agent. Registered inside startCuratedRun
156 * because the load fires as the board mounts.
157 */
158async function mockSuggestions(page: Page, suggestions = DEFAULT_SUGGESTIONS) {
159 await page.route('**/api/suggestions', async (route) => {
160 await route.fulfill({
161 status: 200,
162 contentType: 'application/json',
163 body: JSON.stringify({ success: true, suggestions, fallback: false })
164 })
165 })
167 
168/**
169 * Routes /api/chronicle (the non-blocking living-chronicle refresh fired after each
170 * resolved turn) to a fixed telling, so specs stay offline + deterministic. The call
171 * is fire-and-forget, so most specs never assert on it — but it must be mocked or it
172 * would hit the live network mid-run.
173 */
174export async function mockChronicle(
175 page: Page,
176 chronicle: { title: string; paragraphs: string[] } = {
177 title: 'The Account So Far',
178 paragraphs: ["History bends under a stranger's words.", 'The old certainties waver.']
179 }
180) {
181 await page.route('**/api/chronicle', async (route) => {
182 await route.fulfill({
183 status: 200,
184 contentType: 'application/json',
185 body: JSON.stringify({ success: true, chronicle })
186 })
187 })
189 
190/**
191 * Routes /api/research (the Archivist study, fired when the player clicks "Study
192 * them") to a fixed brief, so specs stay offline + deterministic.
193 */
194export async function mockResearch(
195 page: Page,
196 study = {
197 atThisMoment: 'At the height of their powers.',
198 grasp: ['the state of their art'],
199 reaching: ['the next problem before them'],
200 cannotYetKnow: 'what the centuries after them would discover'
201 }
202) {
203 await page.route('**/api/research', async (route) => {
204 await route.fulfill({
205 status: 200,
206 contentType: 'application/json',
207 body: JSON.stringify({ success: true, study })
208 })
209 })
211 
212/** Routes /api/lookup (the Archive's freeform topic lookup) to a fixed result. */
213export async function mockLookup(
214 page: Page,
215 lookup = {
216 topic: 'Gunpowder',
217 summary: 'An explosive mix of saltpeter, charcoal, and sulfur.',
218 requires: ['saltpeter', 'charcoal', 'sulfur'],
219 knownSince: 'China, ~9th century'
220 }
221) {
222 await page.route('**/api/lookup', async (route) => {
223 await route.fulfill({
224 status: 200,
225 contentType: 'application/json',
226 body: JSON.stringify({ success: true, lookup })
227 })
228 })
230 
231/** From the mission briefing, choose the first curated objective and begin the run. */
232export async function startCuratedRun(page: Page) {
233 // FigurePicker grounds whoever you select and loads suggestions on mount; default
234 // both to deterministic, offline fixtures. (A later mockFigure call wins, since
235 // route handlers run last-registered-first; suggestions load on mount, so they're
236 // mocked here, before the board appears.) The chronicle refresh fires after the
237 // first turn resolves, and the Archive study fires on demand — mock both here too.
238 await mockFigure(page)
239 await mockFigureSearch(page)
240 await mockSuggestions(page)
241 await mockChronicle(page)
242 await mockResearch(page)
243 await mockLookup(page)
244 await expect(page.locator('[data-testid="mission-select"]')).toBeVisible()
245 await page.locator('[data-testid="objective-option"]').first().click()
246 await page.locator('[data-testid="begin-button"]').click()
247 // The board is now live.
248 await expect(page.locator('[data-testid="message-input"]')).toBeVisible()
250 
251/** Picks a figure by typing into the contact input (decoupled from the now
252 * objective-relevant suggestion chips). */
253export async function selectFigure(page: Page, name: string) {
254 await page.locator('[data-testid="figure-picker"]').getByRole('combobox').fill(name)
256 
257/**
258 * The send button. Located by its ARIA name rather than data-testid: the testid is
259 * a fallthrough attr on the <SendButton> wrapper and doesn't reach the rendered
260 * <button>, but the static aria-label does (and survives the "Sending…"/"Please
261 * wait…" label changes, since aria-label fixes the accessible name).
262 */
263export function sendButton(page: Page) {
264 return page.getByRole('button', { name: 'Send message' })
266 
267/** Fills the composer and sends, asserting the button is ready first. */
268export async function fillAndSend(page: Page, text: string) {
269 await page.locator('[data-testid="message-input"] textarea').fill(text)
270 const send = sendButton(page)
271 await expect(send).toBeEnabled()
272 await send.click()