What changed, and why

A saved run at /runs/:id used to show its timeline as a plain text list: headline, "era · figure", a bare signed %. The live end screen, meanwhile, renders the real Spine — a horizontal era-axis with valence dots, ⚡/⚑ badges, the roll, and the Δ (base → final) equation. So a finished run read more flatly in the archive than it did the moment it ended.

That gap was left over from #84, which moved EndGameScreen onto the shared Spine but left RunView on the old hand-rolled <ol>. #84 even anticipated the fix: a reusable read-only Spine the saved-run view could share.

The change is small and has two halves. First, the Spine learns to render from a passed list of events, not just the live store, so RunView can feed it a saved snapshot. Second, the saved snapshot is widened to carry the two fields the Spine renders that it wasn't yet storing — baseProgressChange (the Δ equation) and staked (the ⚑ badge). Live play is untouched, and older saved runs still load.

The Spine learns a second source

TimelineLedger (the Spine) sourced its events straight from the Pinia store. This adds an optional events prop and falls back to the store only when it's absent. In live play nothing is passed, so the computed resolves to exactly what it did before; the read-only run view passes a snapshot's ledger instead.

The new prop, and the one-line change to where events comes from.

components/TimelineLedger.vue · 209 lines
components/TimelineLedger.vue209 lines · Vue
⋯ 105 lines hidden (lines 1–105)
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: base → amplified/staked final. -->
79 <span v-if="selected.baseProgressChange !== undefined && selected.baseProgressChange !== selected.progressChange"
80 data-testid="swing-equation" class="rv-faint">({{ signed(selected.baseProgressChange) }}{{ signed(selected.progressChange) }})</span>
81 <SymbolHint v-if="isAnachronistic(selected.anachronism)" :text="anachronismHint(selected.anachronism)">
82 <span data-testid="anachronism-badge" class="rv-warn font-semibold ml-1">{{ anachronismLabel(selected.anachronism) }}</span>
83 </SymbolHint>
84 <SymbolHint v-if="selected.staked" :text="STAKE_HINT">
85 <span data-testid="staked-badge" class="rv-warn font-semibold ml-1">⚑ staked</span>
86 </SymbolHint>
87 </div>
88 </div>
89 </div>
90</template>
91 
92<script setup lang="ts">
93/**
94 * TimelineLedger — the Spine. Each resolved turn is a node on a horizontal era-axis
95 * (valence dot + mono year/figure/roll/Δ + ⚡), newest on the right with a ▷now marker;
96 * clicking a node snaps its full payload into the node-detail strip, which defaults to
97 * the newest change so the latest turn is always visible. Pure glyphs on the axis; the
98 * only prose lives in the strip. (Re-skin only — the store data is unchanged.)
99 */
100import { computed, ref, watch } from 'vue'
101import { useGameStore } from '~/stores/game'
102import type { Valence } from '~/stores/game'
103import { ANACHRONISM_HINT, ANACHRONISM_LABEL, type Anachronism } from '~/server/utils/anachronism'
104import { CRAFT_HINT, CRAFT_LABEL } from '~/utils/craft'
105import { STAKE_HINT } from '~/utils/swing-bands'
106import type { RunLedgerEntry } from '~/utils/run-snapshot'
107 
108const gameStore = useGameStore()
109const props = defineProps<{
110 /** Verdict / past-run view (the end screen): drop the live "▷ now" present
111 * marker and the "the timeline trembles…" loading empty state, and frame the
112 * lead in past tense. In-game usage passes nothing, so its behavior is
113 * unchanged — the Spine the player scrolled and tapped renders the same. */
114 readonly?: boolean
115 /** Events to render. Omitted in live play, where the Spine reads the live store.
116 * The saved-run view (RunView) passes a snapshot's ledger so a past run renders
117 * the same Spine from the saved record, not the (empty/irrelevant) live store.
118 * `RunLedgerEntry` is the view-facing projection of the store's `TimelineEvent`,
119 * so the live events satisfy it too — the fallback stays byte-identical. */
120 events?: RunLedgerEntry[]
121}>()
122const events = computed<RunLedgerEntry[]>(() => props.events ?? gameStore.timelineEvents)
⋯ 87 lines hidden (lines 123–209)
123 
124const selectedId = ref<string | null>(null)
125// Default the detail strip to the newest change, and snap to it whenever one lands.
126watch(() => events.value.length, () => {
127 const last = events.value[events.value.length - 1]
128 if (last) selectedId.value = last.id
129}, { immediate: true })
130 
131const selected = computed(() =>
132 events.value.find(e => e.id === selectedId.value) ?? events.value[events.value.length - 1] ?? null
134const anachCount = computed(() => events.value.filter(e => isAnachronistic(e.anachronism)).length)
135 
136function select(id: string) { selectedId.value = id }
137function signed(n: number) { return `${n > 0 ? '+' : ''}${n}` }
138function isAnachronistic(a?: Anachronism) { return !!a && a !== 'in-period' }
139function anachronismLabel(a?: Anachronism) { return a ? ANACHRONISM_LABEL[a] : '' }
140function anachronismHint(a?: Anachronism) { return a ? ANACHRONISM_HINT[a] : '' }
141/** A quantified, glanceable anachronism tier on the spine node: 1–3 amber pips. */
142function anachPips(a?: Anachronism) {
143 return a === 'impossible' ? '⚡⚡⚡' : a === 'far-ahead' ? '⚡⚡' : a === 'ahead' ? '⚡' : ''
145 
146function dotClass(v: Valence) {
147 return v === 'positive' ? 'rv-dot--ok' : v === 'negative' ? 'rv-dot--bad' : 'rv-dot--mut'
149function deltaClass(change: number) {
150 if (change > 0) return 'rv-ok'
151 if (change < 0) return 'rv-bad'
152 return 'rv-muted'
154</script>
155 
156<style scoped>
157.spine-node,
158.spine-now {
159 flex: 0 0 auto;
160 min-width: 92px;
161 padding: 2px 4px;
162 cursor: pointer;
163 border: none;
164 background: transparent;
165 scroll-snap-align: center;
167.spine-scroll { scroll-snap-type: x proximity; -webkit-overflow-scrolling: touch; }
168/* Reserve a stable sign slot so a column of +/− deltas stacks rule-straight. */
169.delta-cell { display: inline-block; min-width: 1.9em; text-align: left; }
170.spine-now { cursor: default; }
171.spine-node:focus-visible { outline: 2px solid var(--rv-accent); outline-offset: 2px; border-radius: 3px; }
172 
173/* The axis line is drawn by a full-width connector behind each node's dot. */
174.dot-wrap {
175 position: relative;
176 display: flex;
177 justify-content: center;
178 align-items: center;
179 height: 16px;
180 margin: 3px 0;
182.connector {
183 position: absolute;
184 top: 50%;
185 left: 0;
186 right: 0;
187 height: 1px;
188 background: var(--rv-line);
189 transform: translateY(-50%);
191.connector--half { right: 50%; }
192.dot-wrap > .rv-dot { position: relative; z-index: 1; }
193.spine-node.is-selected .dot-wrap > .rv-dot {
194 width: 12px;
195 height: 12px;
196 box-shadow: 0 0 0 3px var(--rv-bg), 0 0 0 4px var(--rv-accent);
198.spine-node:hover .dot-wrap > .rv-dot { transform: translateY(-1px); }
199 
200/* Preserved entry/move animation (now driving the right-edge node arrival). */
201.timeline-enter-active { transition: opacity var(--rv-dur-3) var(--rv-ease), transform var(--rv-dur-3) var(--rv-ease); }
202.timeline-enter-from { opacity: 0; transform: translateY(-10px); }
203.timeline-move { transition: transform var(--rv-dur-3) var(--rv-ease); }
204 
205@media (prefers-reduced-motion: reduce) {
206 .timeline-enter-active, .timeline-move { transition: none; }
207 .spine-node:hover .dot-wrap > .rv-dot { transform: none; }
209</style>

RunView drops the flat list for the Spine

With the prop in place, the retrospective view swaps its <ol> for the shared component, read-only, fed the snapshot's ledger. It mirrors EndGameScreen exactly: no separate section label (the Spine's own header carries it), the same mt-6 wrapper, the same readonly. The only difference from the end screen is the source — a saved snapshot instead of the live store.

The whole timeline section is now one line of Spine.

components/RunView.vue · 102 lines
components/RunView.vue102 lines · Vue
⋯ 68 lines hidden (lines 1–68)
1<template>
2 <!-- A finished run, read-only — the same verdict / epilogue / ledger the end screen
3 shows, but rendered from a saved snapshot rather than the live store, so a past
4 run reads identically to the moment it ended. Verdict strings, glyphs, and the
5 rv-* tokens are deliberately the same as EndGameScreen's. -->
6 <div data-testid="run-view" class="run-view" :class="isVictory ? 'run--win' : 'run--loss'">
7 <!-- Verdict masthead -->
8 <div class="text-center border-b rv-line pb-6">
9 <div class="verdict-seal" :class="isVictory ? 'is-win' : 'is-loss'" aria-hidden="true">{{ isVictory ? '✦' : '⊘' }}</div>
10 <h2 v-if="isVictory" data-testid="run-verdict" class="rv-serif text-3xl sm:text-4xl font-bold rv-fg mt-3">History Rewritten</h2>
11 <h2 v-else data-testid="run-verdict" class="rv-serif text-3xl sm:text-4xl font-bold rv-muted mt-3">The Timeline Held</h2>
12 <p class="text-sm mt-2.5 flex items-center justify-center gap-2 flex-wrap">
13 <span class="rv-muted inline-flex items-center gap-1.5">
14 <span aria-hidden="true">{{ snapshot.objective.icon }}</span>
15 <strong data-testid="run-objective-title" class="font-semibold">{{ snapshot.objective.title || 'Historical Mission' }}</strong>
16 </span>
17 <span class="rv-hair-c" aria-hidden="true">·</span>
18 <span class="rv-mono font-bold" :class="isVictory ? 'rv-ok' : 'rv-bad'">
19 <span data-testid="run-progress">{{ snapshot.objectiveProgress }}%</span> rewritten
20 </span>
21 </p>
22 
23 <div v-if="snapshot.efficiency" data-testid="run-efficiency" class="mt-3 rv-mono text-sm">
24 <span class="rv-ok font-semibold">{{ victoryPhrase }}</span>
25 <span class="rv-muted"> · {{ snapshot.efficiency.messagesUsed }}/{{ snapshot.totalMessages }} used<span v-if="snapshot.efficiency.messagesSaved > 0"> · {{ snapshot.efficiency.messagesSaved }} saved</span></span>
26 <span v-if="snapshot.efficiency.isEarlyVictory" class="rv-ok"> · 🌟 to spare</span>
27 </div>
28 </div>
29 
30 <!-- The Chronicle's final telling — the epilogue -->
31 <div v-if="snapshot.chronicle" data-testid="run-chronicle" class="mt-6">
32 <span class="rv-label rv-label--rule"><span aria-hidden="true">📖</span> The Chronicle</span>
33 <article class="max-w-[62ch]">
34 <h4 data-testid="run-chronicle-title" class="rv-serif text-xl font-semibold rv-fg mb-2.5 leading-tight">{{ snapshot.chronicle.title }}</h4>
35 <p v-for="(para, i) in snapshot.chronicle.paragraphs" :key="i" class="rv-serif rv-muted text-[15px] leading-relaxed mb-2.5 last:mb-0">{{ para }}</p>
36 </article>
37 </div>
38 
39 <!-- The words you sent — the keepsake -->
40 <div v-if="snapshot.dispatches.length" data-testid="run-dispatches" class="mt-7">
41 <span class="rv-label rv-label--rule">The words you sent</span>
42 <ul class="space-y-3.5">
43 <li v-for="(d, i) in snapshot.dispatches" :key="i">
44 <p class="rv-label">to {{ d.figure }}<span v-if="d.era"> · {{ d.era }}</span></p>
45 <blockquote class="rv-serif rv-fg text-base leading-relaxed border-l-2 rv-line pl-3 mt-1">"{{ d.text }}"</blockquote>
46 </li>
47 </ul>
48 </div>
49 
50 <!-- Stats -->
51 <div data-testid="run-summary" class="mt-7">
52 <span class="rv-label rv-label--rule">How it played out</span>
53 <div class="flex flex-wrap gap-10">
54 <div data-testid="run-message-efficiency">
55 <div class="rv-mono text-3xl font-extrabold rv-fg leading-none">{{ snapshot.messagesUsed }}</div>
56 <div class="rv-label mt-1">{{ snapshot.messagesUsed === 1 ? 'message' : 'messages' }} sent</div>
57 </div>
58 <div v-if="snapshot.bestRoll" data-testid="run-best-roll">
59 <div class="rv-mono text-3xl font-extrabold rv-ok leading-none">{{ snapshot.bestRoll.diceRoll }}</div>
60 <div class="rv-label mt-1">{{ snapshot.messagesUsed >= 2 ? 'best roll' : 'the roll that did it' }} · {{ snapshot.bestRoll.diceOutcome }}</div>
61 </div>
62 <div v-if="snapshot.worstRoll && snapshot.messagesUsed >= 2" data-testid="run-worst-roll">
63 <div class="rv-mono text-3xl font-extrabold rv-bad leading-none">{{ snapshot.worstRoll.diceRoll }}</div>
64 <div class="rv-label mt-1">worst roll · {{ snapshot.worstRoll.diceOutcome }}</div>
65 </div>
66 </div>
67 </div>
68 
69 <!-- The history you wrote — the Spine itself, read-only, rendered from the saved
70 snapshot rather than the live store, so a past run reads exactly as it did
71 when it ended: valence dots, ⚡/⚑ badges, the roll, and the Δ equation all
72 carry over, identical to EndGameScreen (which renders the same component). -->
73 <div v-if="snapshot.timeline.length > 0" data-testid="run-timeline" class="mt-6">
74 <TimelineLedger readonly :events="snapshot.timeline" />
75 </div>
⋯ 27 lines hidden (lines 76–102)
76 </div>
77</template>
78 
79<script setup lang="ts">
80/**
81 * RunView — a read-only render of a saved run (a RunSnapshot). It mirrors
82 * EndGameScreen's verdict / epilogue / ledger, reading everything from the passed
83 * snapshot rather than the live store, so it can replay a past run (and later, a
84 * shared one). The timeline renders through the shared Spine (TimelineLedger), fed
85 * the snapshot's ledger via :events — the same component the end screen uses, so the
86 * two read identically. RunView itself stays store-free: no AI, no controls.
87 */
88import { computed } from 'vue'
89import type { RunSnapshot } from '~/utils/run-snapshot'
90 
91const props = defineProps<{ snapshot: RunSnapshot }>()
92 
93const isVictory = computed(() => props.snapshot.status === 'victory')
94 
95// The verdict never deflates: a full-message win reads "Hard-won," not "Standard"
96// (matches EndGameScreen's phrasing exactly).
97const victoryPhrase = computed(() => {
98 const rating = props.snapshot.efficiency?.efficiencyRating
99 if (!rating) return 'Victory'
100 return rating === 'Standard' ? 'Hard-won victory' : `${rating} victory`
101})
102</script>

The old block (a <v-for> over <li> rows, plus a local deltaClass helper) is gone, and so is the helper — the Spine owns all that rendering now.

The two fields the Spine renders, that the snapshot lacked

The Spine reads two fields off each event that the saved RunLedgerEntry didn't carry yet. The Δ equation only shows when the pre-amplifier swing differs from the final one, and the ⚑ badge only when the turn was the staked last stand:

baseProgressChange drives the swing equation; staked the ⚑ badge.

components/TimelineLedger.vue · 209 lines
components/TimelineLedger.vue209 lines · Vue
⋯ 77 lines hidden (lines 1–77)
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: base → amplified/staked final. -->
79 <span v-if="selected.baseProgressChange !== undefined && selected.baseProgressChange !== selected.progressChange"
80 data-testid="swing-equation" class="rv-faint">({{ signed(selected.baseProgressChange) }}{{ signed(selected.progressChange) }})</span>
81 <SymbolHint v-if="isAnachronistic(selected.anachronism)" :text="anachronismHint(selected.anachronism)">
82 <span data-testid="anachronism-badge" class="rv-warn font-semibold ml-1">{{ anachronismLabel(selected.anachronism) }}</span>
83 </SymbolHint>
84 <SymbolHint v-if="selected.staked" :text="STAKE_HINT">
85 <span data-testid="staked-badge" class="rv-warn font-semibold ml-1">⚑ staked</span>
86 </SymbolHint>
⋯ 123 lines hidden (lines 87–209)
87 </div>
88 </div>
89 </div>
90</template>
91 
92<script setup lang="ts">
93/**
94 * TimelineLedger — the Spine. Each resolved turn is a node on a horizontal era-axis
95 * (valence dot + mono year/figure/roll/Δ + ⚡), newest on the right with a ▷now marker;
96 * clicking a node snaps its full payload into the node-detail strip, which defaults to
97 * the newest change so the latest turn is always visible. Pure glyphs on the axis; the
98 * only prose lives in the strip. (Re-skin only — the store data is unchanged.)
99 */
100import { computed, ref, watch } from 'vue'
101import { useGameStore } from '~/stores/game'
102import type { Valence } from '~/stores/game'
103import { ANACHRONISM_HINT, ANACHRONISM_LABEL, type Anachronism } from '~/server/utils/anachronism'
104import { CRAFT_HINT, CRAFT_LABEL } from '~/utils/craft'
105import { STAKE_HINT } from '~/utils/swing-bands'
106import type { RunLedgerEntry } from '~/utils/run-snapshot'
107 
108const gameStore = useGameStore()
109const props = defineProps<{
110 /** Verdict / past-run view (the end screen): drop the live "▷ now" present
111 * marker and the "the timeline trembles…" loading empty state, and frame the
112 * lead in past tense. In-game usage passes nothing, so its behavior is
113 * unchanged — the Spine the player scrolled and tapped renders the same. */
114 readonly?: boolean
115 /** Events to render. Omitted in live play, where the Spine reads the live store.
116 * The saved-run view (RunView) passes a snapshot's ledger so a past run renders
117 * the same Spine from the saved record, not the (empty/irrelevant) live store.
118 * `RunLedgerEntry` is the view-facing projection of the store's `TimelineEvent`,
119 * so the live events satisfy it too — the fallback stays byte-identical. */
120 events?: RunLedgerEntry[]
121}>()
122const events = computed<RunLedgerEntry[]>(() => props.events ?? gameStore.timelineEvents)
123 
124const selectedId = ref<string | null>(null)
125// Default the detail strip to the newest change, and snap to it whenever one lands.
126watch(() => events.value.length, () => {
127 const last = events.value[events.value.length - 1]
128 if (last) selectedId.value = last.id
129}, { immediate: true })
130 
131const selected = computed(() =>
132 events.value.find(e => e.id === selectedId.value) ?? events.value[events.value.length - 1] ?? null
134const anachCount = computed(() => events.value.filter(e => isAnachronistic(e.anachronism)).length)
135 
136function select(id: string) { selectedId.value = id }
137function signed(n: number) { return `${n > 0 ? '+' : ''}${n}` }
138function isAnachronistic(a?: Anachronism) { return !!a && a !== 'in-period' }
139function anachronismLabel(a?: Anachronism) { return a ? ANACHRONISM_LABEL[a] : '' }
140function anachronismHint(a?: Anachronism) { return a ? ANACHRONISM_HINT[a] : '' }
141/** A quantified, glanceable anachronism tier on the spine node: 1–3 amber pips. */
142function anachPips(a?: Anachronism) {
143 return a === 'impossible' ? '⚡⚡⚡' : a === 'far-ahead' ? '⚡⚡' : a === 'ahead' ? '⚡' : ''
145 
146function dotClass(v: Valence) {
147 return v === 'positive' ? 'rv-dot--ok' : v === 'negative' ? 'rv-dot--bad' : 'rv-dot--mut'
149function deltaClass(change: number) {
150 if (change > 0) return 'rv-ok'
151 if (change < 0) return 'rv-bad'
152 return 'rv-muted'
154</script>
155 
156<style scoped>
157.spine-node,
158.spine-now {
159 flex: 0 0 auto;
160 min-width: 92px;
161 padding: 2px 4px;
162 cursor: pointer;
163 border: none;
164 background: transparent;
165 scroll-snap-align: center;
167.spine-scroll { scroll-snap-type: x proximity; -webkit-overflow-scrolling: touch; }
168/* Reserve a stable sign slot so a column of +/− deltas stacks rule-straight. */
169.delta-cell { display: inline-block; min-width: 1.9em; text-align: left; }
170.spine-now { cursor: default; }
171.spine-node:focus-visible { outline: 2px solid var(--rv-accent); outline-offset: 2px; border-radius: 3px; }
172 
173/* The axis line is drawn by a full-width connector behind each node's dot. */
174.dot-wrap {
175 position: relative;
176 display: flex;
177 justify-content: center;
178 align-items: center;
179 height: 16px;
180 margin: 3px 0;
182.connector {
183 position: absolute;
184 top: 50%;
185 left: 0;
186 right: 0;
187 height: 1px;
188 background: var(--rv-line);
189 transform: translateY(-50%);
191.connector--half { right: 50%; }
192.dot-wrap > .rv-dot { position: relative; z-index: 1; }
193.spine-node.is-selected .dot-wrap > .rv-dot {
194 width: 12px;
195 height: 12px;
196 box-shadow: 0 0 0 3px var(--rv-bg), 0 0 0 4px var(--rv-accent);
198.spine-node:hover .dot-wrap > .rv-dot { transform: translateY(-1px); }
199 
200/* Preserved entry/move animation (now driving the right-edge node arrival). */
201.timeline-enter-active { transition: opacity var(--rv-dur-3) var(--rv-ease), transform var(--rv-dur-3) var(--rv-ease); }
202.timeline-enter-from { opacity: 0; transform: translateY(-10px); }
203.timeline-move { transition: transform var(--rv-dur-3) var(--rv-ease); }
204 
205@media (prefers-reduced-motion: reduce) {
206 .timeline-enter-active, .timeline-move { transition: none; }
207 .spine-node:hover .dot-wrap > .rv-dot { transform: none; }
209</style>

So the contract gains both, optional. Optional is the point: a turn that didn't amplify has no equation to show, and most turns aren't staked — and an older saved run that predates these fields simply renders without them.

The two additions to the saved-run ledger contract.

utils/run-snapshot.ts · 113 lines
utils/run-snapshot.ts113 lines · TypeScript
⋯ 66 lines hidden (lines 1–66)
1/**
2 * The saved-run contract — the immutable, versioned record of a finished run.
3 *
4 * A run is otherwise client-only: the objective, the dispatches, the timeline
5 * ledger, the Chronicle epilogue, and the verdict all live in the Pinia store and
6 * evaporate on reload. This is the persisted form of that end-state: the store builds
7 * it on game end, the server stores it keyed by the run id, and a read-only "your
8 * runs" view replays it. It is a SNAPSHOT, not a live record — in-progress
9 * save/resume is out of scope; a run is short (five messages) and persists at the end.
10 *
11 * Naming: "run" is overloaded. The header gauge's balance ("runs remaining") is a
12 * purchasable CREDIT (the `balances` table); this is a PLAYED run (the game record,
13 * the `run_snapshots` table). Same noun, two states — kept distinct in the schema.
14 *
15 * The shape is VERSIONED (`version`) so a later change can't silently break runs
16 * saved under an older shape: a reader can branch on `version` and migrate. It is a
17 * flat, fully JSON-native projection of the store state — the `Date` timestamps on
18 * the in-memory ledger/thread are dropped (a snapshot needs none of the duration
19 * math), so the stored blob round-trips through jsonb cleanly.
20 *
21 * This module is the SHARED contract (types + version), client-safe by design: it
22 * carries no runtime imports, only type-only ones, so the client store can import the
23 * version + types without pulling any server-only code. The server-side boundary
24 * validator lives separately in `server/utils/run-snapshot.ts`.
25 */
26import type { DiceOutcome } from '~/utils/dice'
27import type { Craft } from '~/utils/craft'
28import type { Anachronism } from '~/server/utils/anachronism'
29import type { GameObjective } from '~/server/utils/objectives'
30import type { ChronicleEntry } from '~/server/utils/openai'
31 
32/** The current saved-run shape. Bump when the shape changes incompatibly; a reader
33 * branches on it to migrate older snapshots rather than mis-reading them. */
34export const RUN_SNAPSHOT_VERSION = 1
35 
36/** Only a finished run is ever saved — the two terminal verdicts. */
37export type RunOutcome = 'victory' | 'defeat'
38 
39/** The valence of a ledger swing (mirrors the store's `Valence`). */
40export type RunValence = 'positive' | 'negative' | 'neutral'
41 
42/** The five efficiency tiers a victory can earn (mirrors the store's rating). */
43export type EfficiencyRating = 'Legendary' | 'Excellent' | 'Great' | 'Good' | 'Standard'
44 
45/** The bit of a roll the read-only view shows: the number and its named outcome. */
46export interface RollMark {
47 diceRoll: number
48 diceOutcome: DiceOutcome
50 
51/** A dispatch keepsake — the player's verbatim message and whom/when it reached. */
52export interface RunDispatch {
53 text: string
54 figure: string
55 era: string
57 
58/** The victory efficiency block (null on defeat) — the same data the end screen shows. */
59export interface RunEfficiency {
60 messagesSaved: number
61 messagesUsed: number
62 efficiencyPercentage: number
63 efficiencyRating: EfficiencyRating
64 isEarlyVictory: boolean
66 
67/** One ledger entry, trimmed to the fields a read-only run view renders (the live
68 * `TimelineEvent` minus its `Date` timestamp and the internals the view ignores). */
69export interface RunLedgerEntry {
70 id: string
71 figureName: string
72 era: string
73 headline: string
74 detail: string
75 diceRoll: number
76 diceOutcome: DiceOutcome
77 progressChange: number
78 /** The pre-amplifier swing — the Spine reads it to show the Δ equation
79 * (`base → final`). Absent on turns that didn't amplify (none to disclose). */
80 baseProgressChange?: number
81 valence: RunValence
82 craft?: Craft
83 anachronism?: Anachronism
84 /** True when this was the run's staked last stand — the Spine's ⚑ badge. */
85 staked?: boolean
⋯ 27 lines hidden (lines 87–113)
87 
88/** The full saved run — everything a read-only view needs to replay the end screen. */
89export interface RunSnapshot {
90 version: number
91 runId: string
92 status: RunOutcome
93 objective: GameObjective
94 objectiveProgress: number
95 messagesUsed: number
96 totalMessages: number
97 bestRoll: RollMark | null
98 worstRoll: RollMark | null
99 efficiency: RunEfficiency | null
100 dispatches: RunDispatch[]
101 timeline: RunLedgerEntry[]
102 chronicle: ChronicleEntry | null
104 
105/** The list projection — what the "your runs" list shows per run, without pulling
106 * the full snapshot blob for every row. */
107export interface RunSummary {
108 runId: string
109 status: RunOutcome
110 title: string
111 progress: number
112 createdAt: string

Plumbing the fields through the save and the boundary

Two consumers of the contract follow. The store's snapshot builder copies the fields off the live ledger when it persists a finished run:

baseProgressChange and staked join the persisted ledger entry.

stores/game.ts · 1382 lines
stores/game.ts1382 lines · TypeScript
⋯ 1306 lines hidden (lines 1–1306)
1import { defineStore } from 'pinia'
2import type { GameObjective, ObjectiveSteer } from '~/server/utils/objectives'
3import type { Pack } from '~/server/utils/packs'
4import type { GroundedFigure } from '~/server/utils/figure-grounding'
5import { formatContactMoment, type ContactMoment } from '~/utils/contact-moment'
6import type { FigureSuggestion } from '~/server/utils/figure-suggester'
7import type { ChronicleEntry } from '~/server/utils/openai'
8import type { FigureStudy, ArchiveLookup } from '~/server/utils/prompt-builder'
9import type { Anachronism } from '~/server/utils/anachronism'
10import { chainStatus, type ChainStatus } from '~/utils/causal-chain'
11import type { Craft } from '~/utils/craft'
12import type { Continuity } from '~/utils/continuity'
13import type { DiceOutcome } from '~/utils/dice'
14import { generateGameSummary, rateEfficiency, type GameSummary } from '~/utils/game-summary'
15import { TOTAL_MESSAGES, MAX_PROGRESS_SWING, MAX_PROGRESS } from '~/utils/game-config'
16import { RUN_SNAPSHOT_VERSION, type RunSnapshot } from '~/utils/run-snapshot'
17 
18export type Valence = 'positive' | 'negative' | 'neutral'
19 
20/**
21 * A historical figure the player has reached out to. Figures are freeform — the
22 * player can write to anyone, in any era. The AI infers each figure's era and a
23 * short descriptor on first contact, which we cache here for the UI.
24 */
25export interface HistoricalFigure {
26 name: string
27 era: string
28 descriptor: string
30 
31/**
32 * Timeline Ledger entry — a single recorded change to history.
33 *
34 * The ledger is the heart of the game: every resolved turn appends one entry
35 * describing how the world bent, so the player literally watches the timeline
36 * rewrite itself as they play.
37 */
38export interface TimelineEvent {
39 id: string
40 figureName: string
41 era: string
42 headline: string
43 detail: string
44 diceRoll: number
45 diceOutcome: DiceOutcome
46 progressChange: number
47 /** The engine's swing BEFORE the anachronism amplifier — shown so the player
48 * can see exactly what their wager did ("+8 ⚡ far-ahead → +11%"). */
49 baseProgressChange?: number
50 valence: Valence
51 /** How anachronistic the player's nudge was — it widened this swing. */
52 anachronism?: Anachronism
53 /** How far this landed from the nearest foothold — it decayed this swing. */
54 causalChain?: ChainStatus
55 /** The Judge's grade of the dispatch that caused this change. */
56 craft?: Craft
57 /** Signed year of the intervention, when the contact was grounded. */
58 whenSigned?: number
59 /** True when this was the run's staked last stand (doubled swing). */
60 staked?: boolean
61 timestamp: Date
63 
64/**
65 * Message Interface — one line in the conversation with a figure.
66 * Dice / outcome / action / impact / progress are recorded on the AI (figure)
67 * turn, since that is the resolved result of the roll.
68 */
69export interface Message {
70 text: string
71 sender: 'user' | 'ai' | 'system'
72 timestamp: Date
73 figureName?: string
74 /** The effective (craft-tilted) roll — what the bands judged. */
75 diceRoll?: number
76 diceOutcome?: DiceOutcome
77 /** The die as it actually landed, before the craft modifier. */
78 naturalRoll?: number
79 /** The Judge's tilt on the roll (±2..0), and the grade + reason behind it. */
80 rollModifier?: number
81 craft?: Craft
82 craftReason?: string
83 /** Whether the dispatch built on the run's thread (issue #62) — drives the
84 * momentum meter and the 'builds' badge in the reveal. */
85 continuity?: Continuity
86 characterAction?: string
87 timelineImpact?: string
88 progressChange?: number
89 /** The pre-amplifier swing + the wager level, for the reveal's equation. */
90 baseProgressChange?: number
91 /** The PRE-turn momentum that amplified this swing — for the reveal's equation. */
92 momentumAtSwing?: number
93 anachronism?: Anachronism
94 /** The causal-chain decay applied to this swing (for the reveal's equation). */
95 causalChain?: ChainStatus
96 /** True when this turn was the staked last stand. */
97 staked?: boolean
99 
100export type GameStatus = 'playing' | 'victory' | 'defeat'
101 
102/**
103 * Shape returned by `POST /api/send-message` (see `server/api/send-message.post.ts`).
104 * Discriminated by `success`: when `true`, all fields downstream of the dice roll are
105 * guaranteed; when `false`, only `userMessage` + `error` are guaranteed (and the
106 * Character-AI-failure path doesn't even surface a dice roll). The discriminant lets
107 * the consumer destructure without optional chaining or null gymnastics.
108 */
109type SendMessageApiResponse =
110 | {
111 success: true
112 message: string
113 data: {
114 userMessage: string
115 figure: { name: string; era: string; descriptor: string }
116 diceRoll: number
117 diceOutcome: DiceOutcome
118 // Judge-of-craft fields (optional so older mocks/fixtures stay valid;
119 // the live server always sends them).
120 naturalRoll?: number
121 rollModifier?: number
122 craft?: Craft
123 craftReason?: string
124 continuity?: Continuity
125 momentum?: number
126 staked?: boolean
127 characterResponse: { message: string; action: string }
128 timeline: {
129 headline: string
130 detail: string
131 era: string
132 progressChange: number
133 baseProgressChange?: number
134 momentumAtSwing?: number
135 valence: Valence
136 anachronism?: Anachronism
137 causalChain?: ChainStatus
138 }
139 error: null
140 }
141 }
142 | {
143 success: false
144 message: string
145 data: {
146 userMessage: string
147 diceRoll?: number
148 diceOutcome?: DiceOutcome
149 characterResponse?: { message: string; action: string }
150 error: string
151 /** A content-moderation block (not an infra failure): the dispatch was
152 * refused by the detector, the Sentinel, or the model itself. */
153 blocked?: boolean
154 moderationReason?: string
155 }
156 }
157 
158const RATE_LIMIT_MS = 1000 // message cooldown window, in ms
159 
160// Turn-reveal pacing. A resolved turn releases its beats one at a time — a beat of
161// suspense, then the die lands and the figure's reply writes itself in, then the
162// timeline shift, then the ledger node — so the resolution reads as one conducted
163// moment instead of everything firing in the same tick. Each component already
164// animates when its own slice of state changes; sequencing the WRITES sequences the
165// animations, with no component coordination. Tuned so the swing lands with the
166// reply's own "ripple" line (~0.55s into its staged reveal).
167export const REVEAL = { suspense: 450, swing: 550, node: 250 } as const
168const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
169/** Stagger the reveal only in a real browser with motion allowed. SSR and the test
170 * env (no `matchMedia`) collapse to an instant, atomic apply — so the store's
171 * contract (state present the moment sendMessage resolves) is unchanged for tests. */
172function canAnimateReveal(): boolean {
173 return typeof window !== 'undefined'
174 && typeof window.matchMedia === 'function'
175 && !window.matchMedia('(prefers-reduced-motion: reduce)').matches
177 
178function valenceOf(progressChange: number): Valence {
179 if (progressChange > 0) return 'positive'
180 if (progressChange < 0) return 'negative'
181 return 'neutral'
183 
184/** Formats a signed timeline year (AD positive, BCE negative) for display. */
185export function formatContactYear(signed: number): string {
186 return signed < 0 ? `${-signed} BC` : `${signed}`
188 
189/** Human lifespan line for a grounded figure, e.g. "69 BC – 30 BC" or "1942 – present". */
190function lifespanText(g: GroundedFigure): string | undefined {
191 if (!g.born) return undefined
192 if (g.died) return `${g.born.display}${g.died.display}`
193 return g.living ? `${g.born.display} – present` : g.born.display
195 
196/** True when a $fetch error is a 402 (out of runs) — the paywall signal. ofetch
197 * surfaces the status as statusCode and on the response, so check both. */
198function isPaymentRequired(error: unknown): boolean {
199 const e = error as { statusCode?: number; status?: number; response?: { status?: number } }
200 return e?.statusCode === 402 || e?.status === 402 || e?.response?.status === 402
202 
203/** True when a $fetch error is a 503 / at-capacity — the spend cap paused new
204 * runs (distinct from the per-device 402 paywall). */
205function isAtCapacity(error: unknown): boolean {
206 const e = error as { statusCode?: number; status?: number; response?: { status?: number }; data?: { atCapacity?: boolean } }
207 return e?.statusCode === 503 || e?.status === 503 || e?.response?.status === 503 || e?.data?.atCapacity === true
209 
210export type ContactLiveness = 'ok' | 'before-birth' | 'after-death' | 'living' | 'unresolved' | 'unknown'
211 
212/**
213 * Game Store — core state for a session of freeform timeline editing.
214 */
215export const useGameStore = defineStore('game', {
216 state: () => ({
217 remainingMessages: TOTAL_MESSAGES,
218 messageHistory: [] as Message[],
219 gameStatus: 'playing' as GameStatus,
220 isLoading: false,
221 error: null as string | null,
222 lastMessageTime: null as number | null,
223 isRateLimited: false,
224 // Staleness plumbing, not run state. runEpoch increments on every reset so an
225 // async result from a dead run can never write into a fresh one; the seq
226 // counters order overlapping requests of the same kind so only the latest
227 // lands (an earlier chronicle rewrite must not overwrite a later one).
228 // Deliberately NOT restored by resetGame — they must survive it to work.
229 runEpoch: 0,
230 chronicleSeq: 0,
231 groundingSeq: 0,
232 // The current run's id — lazily minted on the run's first server call and
233 // cleared by resetGame so each run gets a fresh one. Tags every AI call
234 // (via the x-run-id header) so the gateway's cost telemetry groups per
235 // run: the GTM cost instrument.
236 runId: null as string | null,
237 // The in-flight begin-run, so concurrent first-calls of a run share one
238 // mint (one POST /api/run, one runs row) instead of racing two. Cleared
239 // by resetGame. (Vue leaves a Promise unproxied, so awaiting it is safe.)
240 runIdInflight: null as Promise<string> | null,
241 // outOfRuns gates the mission screen's paywall (set when a commit is
242 // refused with 402). The run packs offered there are loaded into `packs`.
243 outOfRuns: false,
244 // atCapacity gates the "at capacity" notice (set when a commit is refused
245 // with 503 because the global spend cap paused new runs).
246 atCapacity: false,
247 packs: [] as Pack[],
248 // The device's run balance, for the header gauge + account popover. null
249 // until first loaded (GET /api/balance); the run-commit response also
250 // refreshes it so the gauge stays live without a second round-trip.
251 runsRemaining: null as number | null,
252 freeRuns: 1,
253 deviceRef: '' as string,
254 // Account state (from /api/balance): the signed-in email, and whether the
255 // current user is anonymous (→ must sign in before buying). Drives the
256 // account UI + the sign-in-to-buy gate. false/null in device-fallback mode.
257 accountEmail: null as string | null,
258 accountAnonymous: false,
259 // The run-packs sales modal — opened on demand (header) or automatically
260 // when a commit is refused for being out of runs.
261 buyModalOpen: false,
262 // A one-shot notice after returning from Stripe Checkout ('success' shows a
263 // credited confirmation; 'cancel' a gentle "no charge" note). Cleared by the UI.
264 purchaseNotice: null as 'success' | 'cancel' | null,
265 currentObjective: null as GameObjective | null,
266 objectiveProgress: 0,
267 // The run-level momentum meter (0..MOMENTUM_MAX): a coherent arc compounds its
268 // gains amplifier; a reset or catastrophe shatters it back to 0 (issue #62).
269 momentum: 0,
270 timelineEvents: [] as TimelineEvent[],
271 figures: [] as HistoricalFigure[],
272 activeFigureName: '' as string,
273 // Grounding for the active contact: real facts + the chosen year to reach them.
274 figureGrounding: null as GroundedFigure | null,
275 groundingLoading: false,
276 contactWhen: null as number | null,
277 /** Optional sub-year refinement of contactWhen — display/prompt flavor
278 * only; every mechanic keeps doing arithmetic on the YEAR (issue #32). */
279 contactMoment: null as ContactMoment | null,
280 // Era-relevant figure suggestions for the current objective (the on-ramp).
281 figureSuggestions: [] as FigureSuggestion[],
282 suggestionsLoading: false,
283 suggestionsFor: '' as string,
284 // The living Chronicle (Layer 3): a prose telling of the altered timeline,
285 // rewritten each turn. Non-blocking — see refreshChronicle.
286 chronicle: null as ChronicleEntry | null,
287 chronicleLoading: false,
288 // The Archive (prototype): an objective-blind brief on the active figure, so
289 // the player can research who they're reaching without leaving the game. The
290 // brief is moment-specific, so it's cached against the figure AND the year.
291 figureStudy: null as FigureStudy | null,
292 studyLoading: false,
293 studyFor: '' as string,
294 studyWhen: null as number | null,
295 // Archive topic lookup (the "yellow" layer): concrete domain facts on demand.
296 archiveResult: null as ArchiveLookup | null,
297 lookupLoading: false,
298 // A content-moderation block — distinct from `error` (an infra hiccup) so
299 // the UI shows an honest, visibly different banner. One per surface that
300 // takes untrusted input into a model: the dispatch/turn, the Archive lookup,
301 // the Archivist study, and the figure-suggestions step.
302 moderationNotice: null as string | null,
303 archiveNotice: null as string | null,
304 studyNotice: null as string | null,
305 suggestionsNotice: null as string | null
306 }),
307 
308 getters: {
309 /**
310 * Can the player send right now? (messages left, still playing, not
311 * mid-request, not rate-limited)
312 */
313 canSendMessage(): boolean {
314 return this.remainingMessages > 0 &&
315 this.gameStatus === 'playing' &&
316 !this.isLoading &&
317 !this.isRateLimited
318 },
319 
320 /**
321 * The conversation thread with a single figure (their turns + the
322 * player's turns addressed to them). Each figure keeps a coherent,
323 * independent thread.
324 */
325 conversationWith(): (name: string) => Message[] {
326 return (name: string) => this.messageHistory.filter(
327 m => m.figureName === name && m.sender !== 'system'
328 )
329 },
330 
331 /** Total progress, net of setbacks, the player has clawed back. */
332 gameSummary(): GameSummary {
333 return generateGameSummary(this)
334 },
335 
336 /**
337 * Whether the active figure can be reached at the chosen `when`.
338 *
339 * Require grounding (#73): an UNRESOLVED name can't be reached at all —
340 * 'unresolved' (free-form contact is removed; the picker guides you to a real
341 * match). Deceased-only floor (#72): a RESOLVED figure with no confirmed
342 * death is living/undatable and never contactable — 'living', fail closed. A
343 * resolved, deceased figure is gated to their lifetime (before-birth /
344 * after-death). 'unknown' remains only for a resolved, deceased figure we
345 * can't fully place (no birth year, or no contact year chosen yet), which
346 * stays permissible.
347 */
348 contactLiveness(): ContactLiveness {
349 const g = this.figureGrounding
350 if (!g || !g.resolved) return 'unresolved'
351 if (!g.died) return 'living'
352 if (!g.born || this.contactWhen == null) return 'unknown'
353 if (this.contactWhen < g.born.signed) return 'before-birth'
354 if (this.contactWhen > g.died.signed) return 'after-death'
355 return 'ok'
356 },
357 
358 /** Can the chosen contact + when actually be reached? */
359 canContact(): boolean {
360 return this.contactLiveness === 'ok' || this.contactLiveness === 'unknown'
361 },
362 
363 /**
364 * The last stand is offered ONLY when the final dispatch can no longer win
365 * inside the normal per-turn fuse — from any nearer position an unstaked
366 * throw can still land it, and offering the (strictly win-probability-
367 * increasing) doubling there would be a dominance trap, not a decision.
368 */
369 canStake(): boolean {
370 return this.remainingMessages === 1 &&
371 this.gameStatus === 'playing' &&
372 (MAX_PROGRESS - this.objectiveProgress) > MAX_PROGRESS_SWING
373 },
374 
375 /**
376 * The active figure's age at the chosen `when` — so the player never has to
377 * do lifetime math in their head. Null when we lack a birth year or a chosen
378 * year (a resolved figure we can't date, or before a year is chosen). Corrects for the missing year zero
379 * when a life spans the BC→AD boundary (1 BC → AD 1 is one year, not two).
380 */
381 contactAge(): number | null {
382 const g = this.figureGrounding
383 if (!g?.resolved || !g.born || this.contactWhen == null) return null
384 const bornSigned = g.born.signed
385 const whenSigned = this.contactWhen
386 if (whenSigned < bornSigned) return null // before birth — no meaningful age
387 const crossedZero = bornSigned < 0 && whenSigned > 0 ? 1 : 0
388 return whenSigned - bornSigned - crossedZero
389 },
390 
391 /**
392 * The causal-chain read for the CURRENT contact — the pre-send ⏳ chip's
393 * source, mirroring what the Timeline Engine will compute. Footholds are the
394 * objective's anchor year plus every dated change already on the ledger; the
395 * gap to the nearest one decays the swing. Null (no chip) for ungrounded
396 * contacts or an anchorless objective with no dated changes yet.
397 */
398 causalChainRead(): ChainStatus | null {
399 const footholds = [
400 this.currentObjective?.anchorYear,
401 ...this.timelineEvents.map(e => e.whenSigned)
402 ]
403 return chainStatus(this.contactWhen, footholds)
404 }
405 },
406 
407 actions: {
408 // ---------- message helpers ----------
409 addUserMessage(text: string, figureName?: string) {
410 if (this.gameStatus !== 'playing') return
411 this.messageHistory.push({
412 text,
413 sender: 'user',
414 timestamp: new Date(),
415 figureName
416 })
417 },
418 
419 addAIMessage(text: string, figureName?: string) {
420 this.messageHistory.push({
421 text,
422 sender: 'ai',
423 timestamp: new Date(),
424 figureName
425 })
426 },
427 
428 addAIMessageWithData(messageData: Partial<Message> & { text: string, sender: 'ai' }) {
429 this.messageHistory.push({
430 text: messageData.text,
431 sender: messageData.sender,
432 timestamp: messageData.timestamp || new Date(),
433 figureName: messageData.figureName,
434 diceRoll: messageData.diceRoll,
435 diceOutcome: messageData.diceOutcome,
436 naturalRoll: messageData.naturalRoll,
437 rollModifier: messageData.rollModifier,
438 craft: messageData.craft,
439 craftReason: messageData.craftReason,
440 continuity: messageData.continuity,
441 characterAction: messageData.characterAction,
442 timelineImpact: messageData.timelineImpact,
443 progressChange: messageData.progressChange,
444 baseProgressChange: messageData.baseProgressChange,
445 momentumAtSwing: messageData.momentumAtSwing,
446 anachronism: messageData.anachronism,
447 causalChain: messageData.causalChain,
448 staked: messageData.staked
449 })
450 },
451 
452 // ---------- figures ----------
453 registerFigure(figure: HistoricalFigure) {
454 const existing = this.figures.find(f => f.name === figure.name)
455 if (existing) {
456 if (figure.era) existing.era = figure.era
457 if (figure.descriptor) existing.descriptor = figure.descriptor
458 } else {
459 this.figures.push({ ...figure })
460 }
461 },
462 
463 setActiveFigure(name: string) {
464 this.activeFigureName = name
465 },
466 
467 /**
468 * Synchronously clears the dossier the moment the contact NAME changes, so
469 * a send racing the grounding debounce/fetch is gated as ungrounded (#73)
470 * instead of wearing the PREVIOUS figure's facts, year, and liveness gate —
471 * and so the wrong year can never be written into the ledger's whenSigned.
472 */
473 clearGrounding() {
474 this.groundingSeq++ // invalidate any lookup still in flight
475 this.figureGrounding = null
476 this.contactWhen = null
477 this.contactMoment = null
478 this.groundingLoading = false
479 this.figureStudy = null
480 this.studyFor = ''
481 this.studyWhen = null
482 this.studyNotice = null
483 },
484 
485 /**
486 * Resolves the active figure against the grounding service and defaults the
487 * "when" to a point inside any known lifetime. Never throws: an unresolved
488 * figure simply clears grounding; an ungrounded contact is then blocked (#73).
489 */
490 async groundActiveFigure(name: string): Promise<void> {
491 const target = (name || '').trim()
492 // A new contact makes any prior Archive study stale.
493 this.figureStudy = null
494 this.studyFor = ''
495 this.studyWhen = null
496 this.studyNotice = null
497 if (!target) {
498 this.groundingSeq++ // invalidate any lookup still in flight
499 this.figureGrounding = null
500 this.contactWhen = null
501 this.contactMoment = null
502 this.groundingLoading = false
503 return
504 }
505 // Out-of-order guard: only the LATEST lookup may land. Without it, a slow
506 // response for the previous name attaches the wrong dossier (and a wrong
507 // figureContext on a fast send) to whatever the player typed next.
508 const epoch = this.runEpoch
509 const seq = ++this.groundingSeq
510 this.groundingLoading = true
511 try {
512 const grounded = await $fetch('/api/figure', { params: { name: target }, headers: await this.aiHeaders() }) as GroundedFigure
513 if (epoch !== this.runEpoch || seq !== this.groundingSeq) return
514 this.figureGrounding = grounded
515 this.contactMoment = null
516 if (grounded?.resolved && grounded.born) {
517 const latest = grounded.died ? grounded.died.signed : new Date().getFullYear()
518 this.contactWhen = Math.round((grounded.born.signed + latest) / 2)
519 } else {
520 this.contactWhen = null
521 }
522 } catch (error) {
523 if (epoch !== this.runEpoch || seq !== this.groundingSeq) return
524 console.error('Figure grounding failed:', error)
525 this.figureGrounding = null
526 this.contactWhen = null
527 this.contactMoment = null
528 } finally {
529 if (epoch === this.runEpoch && seq === this.groundingSeq) this.groundingLoading = false
530 }
531 },
532 
533 /** The current run's id, minted server-side via POST /api/run on first
534 * need. The run is a server fact (a persisted, charged row); the id then
535 * tags every AI call so cost telemetry groups per run, and the gameplay
536 * gate verifies it. Fails CLOSED: if begin-run can't return an id this
537 * REJECTS (no local fallback) — a run we can't back server-side must not
538 * start, or the paywall gate would reject it mid-run anyway. */
539 async ensureRunId(): Promise<string> {
540 if (this.runId) return this.runId
541 // Collapse concurrent first-calls onto ONE begin-run (two overlapping
542 // calls would otherwise mint two rows). The epoch guards a resetGame
543 // mid-flight — a dead run's id must not become the fresh run's.
544 if (!this.runIdInflight) {
545 const epoch = this.runEpoch
546 this.runIdInflight = (async () => {
547 const res = await $fetch('/api/run', { method: 'POST' }) as { runId?: string }
548 if (!res?.runId) throw new Error('begin-run returned no run id')
549 if (epoch === this.runEpoch) {
550 this.runId = res.runId
551 this.runIdInflight = null
552 }
553 return res.runId
554 })().catch((err) => {
555 if (epoch === this.runEpoch) this.runIdInflight = null
556 throw err
557 })
558 }
559 return this.runIdInflight
560 },
561 
562 /** Headers that tag a server call with the current run (the cost
563 * instrument), minting the run id first if needed. */
564 async aiHeaders(base: Record<string, string> = {}): Promise<Record<string, string>> {
565 return { ...base, 'x-run-id': await this.ensureRunId() }
566 },
567 
568 setContactWhen(year: number | null) {
569 // Scrubbing the year keeps any pinned month/day — the pin refines
570 // whichever year is chosen, it doesn't belong to one.
571 this.contactWhen = year
572 },
573 
574 setContactMoment(moment: ContactMoment | null) {
575 this.contactMoment = moment
576 },
577 
578 /**
579 * Studies the active (grounded) figure via the Archivist — an objective-blind
580 * brief on who they are AT THE CHOSEN MOMENT, so the player can research in the
581 * game instead of a browser tab. The brief is moment-specific, so it's cached
582 * against the figure AND the year: move the contact slider and the player can
583 * re-study them at the new moment. Graceful on failure (leaves the prior brief).
584 * Only resolved figures can be studied — an unresolved name has no record.
585 */
586 async studyActiveFigure(): Promise<void> {
587 const g = this.figureGrounding
588 if (!g?.resolved) {
589 this.figureStudy = null
590 return
591 }
592 // Cache hit only when both the figure AND the studied year still match.
593 if (this.studyFor === g.name && this.studyWhen === this.contactWhen && this.figureStudy) return
594 
595 // Capture the moment being studied NOW: the brief describes this figure at
596 // this year. If the slider moves mid-flight, recording the live value would
597 // mislabel the brief and silently suppress the Re-study button.
598 const epoch = this.runEpoch
599 const studiedName = g.name
600 const studiedWhen = this.contactWhen
601 this.studyLoading = true
602 this.studyNotice = null
603 try {
604 const res = await $fetch('/api/research', {
605 method: 'POST',
606 headers: await this.aiHeaders({ 'Content-Type': 'application/json' }),
607 body: {
608 figureName: studiedName,
609 when: studiedWhen != null ? formatContactYear(studiedWhen) : undefined,
610 description: g.description,
611 extract: g.extract
612 }
613 }) as { success?: boolean; study?: FigureStudy; blocked?: boolean; error?: string }
614 if (epoch !== this.runEpoch) return
615 // If the contact changed mid-flight, the stale-study clear in
616 // groundActiveFigure already ran — don't resurrect the old brief.
617 if (res?.blocked && this.figureGrounding?.name === studiedName) {
618 this.studyNotice = res.error || 'That study was blocked by content moderation.'
619 } else if (res?.success && res.study && this.figureGrounding?.name === studiedName) {
620 this.figureStudy = res.study
621 this.studyFor = studiedName
622 this.studyWhen = studiedWhen
623 }
624 } catch (error) {
625 if (epoch !== this.runEpoch) return
626 console.error('Failed to study the figure:', error)
627 } finally {
628 if (epoch === this.runEpoch) this.studyLoading = false
629 }
630 },
631 
632 /**
633 * Asks the Archive about a freeform topic (the "yellow" layer) — concrete
634 * domain facts: what it is, what it takes, and when it first became known
635 * (the player's anachronism read). Graceful on failure. Topic-scoped, so it
636 * persists across figure changes within a run.
637 */
638 async askArchive(query: string): Promise<void> {
639 const q = (query || '').trim()
640 if (!q) return
641 const epoch = this.runEpoch
642 this.lookupLoading = true
643 this.archiveNotice = null
644 try {
645 const res = await $fetch('/api/lookup', {
646 method: 'POST',
647 headers: await this.aiHeaders({ 'Content-Type': 'application/json' }),
648 body: { query: q }
649 }) as { success?: boolean; lookup?: ArchiveLookup; blocked?: boolean; error?: string }
650 if (epoch !== this.runEpoch) return
651 if (res?.blocked) {
652 // The Archive is the sharpest elicitation vector — a block here is
653 // honest and visible, not a silent empty result.
654 this.archiveNotice = res.error || 'That lookup was blocked by content moderation.'
655 } else if (res?.success && res.lookup) {
656 this.archiveResult = res.lookup
657 }
658 } catch (error) {
659 if (epoch !== this.runEpoch) return
660 console.error('Archive lookup failed:', error)
661 } finally {
662 if (epoch === this.runEpoch) this.lookupLoading = false
663 }
664 },
665 
666 /**
667 * Loads era-relevant figure suggestions for the current objective (the
668 * educational on-ramp). Cached per objective; on any failure it leaves the
669 * list empty so the UI falls back to its generic starters.
670 */
671 async loadSuggestions(): Promise<void> {
672 const objective = this.currentObjective
673 if (!objective) {
674 this.figureSuggestions = []
675 this.suggestionsFor = ''
676 return
677 }
678 if (this.suggestionsFor === objective.title && this.figureSuggestions.length) return
679 
680 const epoch = this.runEpoch
681 this.suggestionsLoading = true
682 this.suggestionsNotice = null
683 try {
684 const res = await $fetch('/api/suggestions', {
685 method: 'POST',
686 headers: await this.aiHeaders({ 'Content-Type': 'application/json' }),
687 body: {
688 objective: {
689 title: objective.title,
690 description: objective.description,
691 era: objective.era
692 }
693 }
694 }) as { success?: boolean; suggestions?: FigureSuggestion[]; blocked?: boolean; reason?: string }
695 if (epoch !== this.runEpoch) return
696 // A blocked objective must not masquerade as an empty result — surface it.
697 if (res?.blocked) this.suggestionsNotice = res.reason || 'That objective was blocked by content moderation.'
698 this.figureSuggestions = res?.suggestions ?? []
699 this.suggestionsFor = objective.title
700 } catch (error) {
701 if (epoch !== this.runEpoch) return
702 console.error('Failed to load figure suggestions:', error)
703 this.figureSuggestions = []
704 // Record that this objective WAS asked, even though it failed — the
705 // picker uses suggestionsFor to tell "not yet asked" (skeletons)
706 // from "asked and came up empty" (the honest famous-names fallback).
707 this.suggestionsFor = objective.title
708 } finally {
709 if (epoch === this.runEpoch) this.suggestionsLoading = false
710 }
711 },
712 
713 // ---------- timeline ledger ----------
714 addTimelineEvent(
715 evt: Omit<TimelineEvent, 'id' | 'timestamp' | 'valence'> & { valence?: Valence; timestamp?: Date }
716 ) {
717 this.timelineEvents.push({
718 id: `evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
719 figureName: evt.figureName,
720 era: evt.era,
721 headline: evt.headline,
722 detail: evt.detail,
723 diceRoll: evt.diceRoll,
724 diceOutcome: evt.diceOutcome,
725 progressChange: evt.progressChange,
726 baseProgressChange: evt.baseProgressChange,
727 valence: evt.valence ?? valenceOf(evt.progressChange),
728 anachronism: evt.anachronism,
729 causalChain: evt.causalChain,
730 craft: evt.craft,
731 whenSigned: evt.whenSigned,
732 staked: evt.staked,
733 timestamp: evt.timestamp ?? new Date()
734 })
735 },
736 
737 // ---------- the living chronicle (Layer 3) ----------
738 /**
739 * Re-narrates the Chronicle from the freshly-bent timeline. Fired
740 * non-blocking after a resolved turn (and at game end): the dice/progress
741 * reveal never waits on prose. True to the game's name, the WHOLE account is
742 * rewritten each turn — a later change can re-frame how earlier events read.
743 * Graceful: a failed refresh keeps the prior telling, so the panel never
744 * blanks; an empty timeline clears it (nothing to chronicle yet).
745 */
746 async refreshChronicle(): Promise<void> {
747 if (!this.timelineEvents.length) {
748 this.chronicle = null
749 return
750 }
751 // Sequencing: rewrites overlap (each turn fires one, never awaited), so
752 // only the LATEST issued refresh may land — an earlier telling arriving
753 // late must not regress the account (or worse, the epilogue). The epoch
754 // guard keeps a dead run's telling out of a fresh run entirely.
755 const epoch = this.runEpoch
756 const seq = ++this.chronicleSeq
757 this.chronicleLoading = true
758 try {
759 const res = await $fetch('/api/chronicle', {
760 method: 'POST',
761 headers: await this.aiHeaders({ 'Content-Type': 'application/json' }),
762 body: {
763 objective: this.currentObjective,
764 status: this.gameStatus,
765 progress: this.objectiveProgress,
766 timeline: this.timelineEvents.map(e => ({
767 era: e.era,
768 figureName: e.figureName,
769 headline: e.headline,
770 detail: e.detail,
771 progressChange: e.progressChange
772 }))
773 }
774 }) as { success?: boolean; chronicle?: ChronicleEntry }
775 if (epoch !== this.runEpoch || seq !== this.chronicleSeq) return
776 if (res?.success && res.chronicle) this.chronicle = res.chronicle
777 } catch (error) {
778 if (epoch !== this.runEpoch || seq !== this.chronicleSeq) return
779 console.error('Failed to refresh the chronicle:', error)
780 // Keep the prior chronicle — a failed refresh never blanks the panel.
781 } finally {
782 if (epoch === this.runEpoch && seq === this.chronicleSeq) this.chronicleLoading = false
783 }
784 },
785 
786 // ---------- simple setters ----------
787 setLoading(loading: boolean) { this.isLoading = loading },
788 setError(error: string | null) { this.error = error },
789 clearModerationNotice() { this.moderationNotice = null },
790 clearArchiveNotice() { this.archiveNotice = null },
791 
792 // ---------- rate limiting ----------
793 checkRateLimit(): boolean {
794 const now = Date.now()
795 if (this.lastMessageTime && (now - this.lastMessageTime) < RATE_LIMIT_MS) return false
796 return true
797 },
798 
799 setRateLimit() {
800 this.isRateLimited = true
801 const remainingTime = this.lastMessageTime
802 ? Math.max(0, RATE_LIMIT_MS - (Date.now() - this.lastMessageTime))
803 : 0
804 setTimeout(() => { this.isRateLimited = false }, remainingTime || RATE_LIMIT_MS)
805 },
806 
807 // ---------- counter & status ----------
808 decrementMessages() {
809 if (this.remainingMessages > 0) this.remainingMessages--
810 },
811 
812 /**
813 * Rolls back an unresolved turn: refunds the spent message and drops the
814 * dangling user entry, so the counter and history can't drift out of sync.
815 * Used for BOTH failure shapes — a thrown request and a graceful HTTP-200
816 * `success:false` — an infra hiccup must never burn one of the five
817 * dispatches (or, on the last one, convert into an instant unearned defeat).
818 */
819 refundUnresolvedTurn() {
820 if (this.remainingMessages < TOTAL_MESSAGES) this.remainingMessages++
821 for (let i = this.messageHistory.length - 1; i >= 0; i--) {
822 if (this.messageHistory[i].sender === 'user') {
823 this.messageHistory.splice(i, 1)
824 break
825 }
826 }
827 },
828 
829 applyProgress(progressChange: number) {
830 this.objectiveProgress = Math.max(0, Math.min(MAX_PROGRESS, this.objectiveProgress + progressChange))
831 },
832 
833 /**
834 * Resolves win/lose AFTER a turn's progress has been applied.
835 *
836 * Victory the instant the objective hits 100%; defeat only once the
837 * final message is spent without reaching it. This fixes the old
838 * premature-defeat bug, where status flipped on the last decrement
839 * BEFORE that turn's progress had a chance to land.
840 */
841 resolveGameStatus() {
842 if (this.objectiveProgress >= MAX_PROGRESS) {
843 this.gameStatus = 'victory'
844 } else if (this.remainingMessages <= 0) {
845 this.gameStatus = 'defeat'
846 }
847 },
848 
849 // ---------- the turn ----------
850 /**
851 * Sends a 160-char message to a chosen figure and folds the result back
852 * into the world: the figure replies + acts, the dice decide the swing,
853 * and the Timeline Engine records how history bent.
854 *
855 * Returns `true` only when the turn actually RESOLVED (a ledger entry
856 * landed). A blocked or failed send returns `false` so the composer can
857 * keep the player's crafted words instead of wiping them.
858 *
859 * `opts.stake` arms the last stand — honored only when `canStake` holds
860 * (final message, win out of normal reach; the server doubles the resolved
861 * swing, both ways, past the usual cap).
862 */
863 async sendMessage(text: string, figureName?: string, opts?: { stake?: boolean }): Promise<boolean> {
864 const target = (figureName ?? this.activeFigureName ?? '').trim()
865 const stake = opts?.stake === true && this.canStake
866 
867 if (!this.canSendMessage) return false
868 if (!target) {
869 this.setError('Choose who in history to send your message to first.')
870 return false
871 }
872 if (!this.checkRateLimit()) {
873 this.setRateLimit()
874 this.setError('The timeline needs a moment — wait before sending again.')
875 return false
876 }
877 if (!this.canContact) {
878 const who = this.figureGrounding?.name || target
879 this.setError(
880 this.contactLiveness === 'unresolved'
881 ? (this.figureGrounding?.transient
882 ? `Couldn't reach the record to verify "${target}" — try again in a moment.`
883 : `No historical record found for "${target}" — reach for a real figure (pick a match as you type).`)
884 : this.contactLiveness === 'before-birth'
885 ? `${who} isn't born yet in that year — choose a later moment to reach them.`
886 : this.contactLiveness === 'living'
887 ? (this.figureGrounding?.born
888 ? `${who} is still living — Revisionist only reaches figures from history.`
889 : `${who} couldn't be dated — Revisionist can only reach figures it can place in history.`)
890 : `${who} has died by that year — choose an earlier moment to reach them.`
891 )
892 return false
893 }
894 
895 this.setError(null)
896 this.moderationNotice = null
897 this.setActiveFigure(target)
898 this.addUserMessage(text, target)
899 this.decrementMessages()
900 this.setLoading(true)
901 this.lastMessageTime = Date.now()
902 
903 // If the run is reset while this request is in flight, every write below
904 // would land in a world that no longer exists — the epoch guard drops the
905 // response (no fold-in, no refund: the new run's counter is not ours).
906 const epoch = this.runEpoch
907 const ledgerBefore = this.timelineEvents.length
908 // Capture the contact year NOW: the slider can move while the request is
909 // in flight, and the ledger must record the year this turn was SENT to.
910 const sentWhen = this.contactWhen
911 const sentMoment = this.contactMoment
912 let resolved = false
913 // Decide once whether to stagger the reveal (browser + motion allowed).
914 const animate = canAnimateReveal()
915 
916 try {
917 const response = await $fetch<SendMessageApiResponse>('/api/send-message', {
918 method: 'POST',
919 headers: await this.aiHeaders({ 'Content-Type': 'application/json' }),
920 body: {
921 message: text,
922 figureName: target,
923 when: sentWhen != null ? formatContactMoment(sentWhen, sentMoment) : undefined,
924 whenSigned: sentWhen ?? undefined,
925 // The pinned moment travels as validated integers; the server
926 // re-derives the display string rather than trusting ours.
927 whenMonth: sentWhen != null ? sentMoment?.month : undefined,
928 whenDay: sentWhen != null ? sentMoment?.day : undefined,
929 stake,
930 // The pre-turn momentum the server amplifies the swing by, and
931 // returns advanced (the client stays the system of record).
932 momentum: this.momentum,
933 figureContext: this.figureGrounding?.resolved
934 ? {
935 description: this.figureGrounding.description,
936 lifespan: lifespanText(this.figureGrounding)
937 }
938 : undefined,
939 objective: this.currentObjective,
940 timeline: this.timelineEvents.map(e => ({
941 era: e.era,
942 figureName: e.figureName,
943 headline: e.headline,
944 detail: e.detail,
945 progressChange: e.progressChange,
946 whenSigned: e.whenSigned
947 })),
948 conversationHistory: this.conversationWith(target)
949 }
950 })
951 
952 if (epoch !== this.runEpoch) return false
953 
954 if (response?.success && response.data?.characterResponse) {
955 const { figure, characterResponse, diceRoll, diceOutcome, timeline } = response.data
956 
957 if (figure?.name) {
958 this.registerFigure({
959 name: figure.name,
960 era: figure.era || '',
961 descriptor: figure.descriptor || ''
962 })
963 }
964 
965 // ---- conducted reveal ----
966 // A beat of suspense (the die keeps shaking) before the result
967 // lands, so the resolution has a moment of anticipation.
968 if (animate) {
969 await wait(REVEAL.suspense)
970 if (epoch !== this.runEpoch) return false
971 }
972 
973 // Beat 1 — the die lands and the figure's reply writes itself in
974 // (its parts stage over ~0.55s via CSS). Flipping loading here
975 // (mid-sequence) is what lands the die now; the finally's flip
976 // becomes a no-op.
977 this.addAIMessageWithData({
978 text: characterResponse.message,
979 sender: 'ai',
980 figureName: target,
981 timestamp: new Date(),
982 diceRoll,
983 diceOutcome,
984 naturalRoll: response.data.naturalRoll ?? diceRoll,
985 rollModifier: response.data.rollModifier ?? 0,
986 craft: response.data.craft,
987 craftReason: response.data.craftReason,
988 continuity: response.data.continuity,
989 characterAction: characterResponse.action,
990 timelineImpact: timeline?.detail,
991 progressChange: timeline?.progressChange,
992 baseProgressChange: timeline?.baseProgressChange,
993 momentumAtSwing: timeline?.momentumAtSwing,
994 anachronism: timeline?.anachronism,
995 causalChain: timeline?.causalChain,
996 staked: response.data.staked
997 })
998 if (animate) this.setLoading(false)
999 
1000 if (timeline) {
1001 // Beat 2 — the timeline shift (the % gauge flies its delta),
1002 // timed to land with the reply's own "ripple" line.
1003 if (animate) {
1004 await wait(REVEAL.swing)
1005 if (epoch !== this.runEpoch) return false
1007 // Use !== undefined so a genuine neutral (0%) turn still
1008 // registers instead of silently vanishing.
1009 if (timeline.progressChange !== undefined) {
1010 this.applyProgress(timeline.progressChange)
1012 // The arc meter is server-authoritative for the result; advance
1013 // it WITH the swing it amplified — and only past the epoch check
1014 // above, so a stale turn never moves the meter (issue #62).
1015 this.momentum = response.data.momentum ?? this.momentum
1017 // Beat 3 — the change drops onto the Spine ledger.
1018 if (animate) {
1019 await wait(REVEAL.node)
1020 if (epoch !== this.runEpoch) return false
1022 this.addTimelineEvent({
1023 figureName: target,
1024 era: timeline.era || figure?.era || (this.currentObjective?.era ?? ''),
1025 headline: timeline.headline,
1026 detail: timeline.detail,
1027 diceRoll,
1028 diceOutcome,
1029 progressChange: timeline.progressChange ?? 0,
1030 baseProgressChange: timeline.baseProgressChange,
1031 valence: timeline.valence,
1032 anachronism: timeline.anachronism,
1033 causalChain: timeline.causalChain,
1034 craft: response.data.craft,
1035 whenSigned: sentWhen ?? undefined,
1036 staked: response.data.staked
1037 })
1038 resolved = true
1040 } else {
1041 // A graceful failure (HTTP 200, success:false): an AI layer gave
1042 // out mid-turn. No ripple landed, so the dispatch is refunded —
1043 // exactly like the thrown path below.
1044 // Narrow to the failure variant (its data carries `blocked`).
1045 const failed = response && !response.success ? response.data : undefined
1046 if (failed?.blocked) {
1047 // A content-moderation block, not an infra hiccup — show an
1048 // honest, distinct banner (not "try again"). The composer
1049 // keeps the player's words (sendMessage returns false).
1050 this.moderationNotice = failed.moderationReason || 'That dispatch was blocked by content moderation and cannot be sent.'
1051 } else {
1052 this.setError(failed?.error || 'History did not answer. Try again.')
1054 this.refundUnresolvedTurn()
1056 } catch (error) {
1057 if (epoch !== this.runEpoch) return false
1058 console.error('Error sending message:', error)
1059 this.setError('The timeline resisted your message. Please try again.')
1060 this.refundUnresolvedTurn()
1061 } finally {
1062 if (epoch === this.runEpoch) {
1063 this.setLoading(false)
1064 this.resolveGameStatus()
1068 // The living chronicle re-narrates the world from the new timeline — but
1069 // only when this turn actually added a change, and never awaited: the turn
1070 // reveal must not wait on prose. By here the status is resolved, so the
1071 // final turn's chronicle carries the victory/defeat framing (the epilogue).
1072 let refresh: Promise<void> | null = null
1073 if (resolved && this.timelineEvents.length > ledgerBefore) {
1074 refresh = this.refreshChronicle()
1075 void refresh
1077 // The run just ended: persist it (best-effort) so it survives reload, then
1078 // re-save once the epilogue's telling lands — the immediate save guards
1079 // against that refresh failing, the second captures the final Chronicle.
1080 if (this.gameStatus === 'victory' || this.gameStatus === 'defeat') {
1081 void this.saveRunSnapshot()
1082 if (refresh) void refresh.finally(() => { void this.saveRunSnapshot() })
1084 return resolved
1085 },
1087 /**
1088 * Commits a chosen objective and starts the run from a clean slate of
1089 * progress. Used by the mission-select screen for both curated picks and
1090 * freshly composed ones.
1091 */
1092 async chooseObjective(objective: GameObjective): Promise<boolean> {
1093 // Commit = the run is charged here. Mint the run id server-side, then
1094 // spend one run from the device's balance. Fails CLOSED: out of runs →
1095 // raise the paywall; begin-run or commit failing → surface an error and
1096 // do NOT start. A run that isn't a charged, server-backed run would be
1097 // rejected by the gameplay gate anyway, so starting it only strands the
1098 // player mid-run. The spend cap (not free play) is the outage backstop.
1099 let runId: string
1100 try {
1101 runId = await this.ensureRunId()
1102 } catch (error) {
1103 console.error('Could not begin the run:', error)
1104 this.error = 'Could not start the run. Please try again.'
1105 return false
1107 try {
1108 const res = await $fetch('/api/run-commit', {
1109 method: 'POST',
1110 headers: { 'Content-Type': 'application/json' },
1111 body: { runId }
1112 }) as { runsRemaining?: number }
1113 this.outOfRuns = false
1114 // Keep the header gauge live: the commit returns the post-charge
1115 // balance (−1 marks a degraded, ungated run — leave the gauge as is).
1116 if (typeof res?.runsRemaining === 'number' && res.runsRemaining >= 0) {
1117 this.runsRemaining = res.runsRemaining
1119 } catch (error) {
1120 if (isPaymentRequired(error)) {
1121 this.outOfRuns = true
1122 this.openBuyModal()
1123 return false
1125 if (isAtCapacity(error)) {
1126 this.atCapacity = true
1127 return false
1129 console.error('Could not charge the run:', error)
1130 this.error = 'Could not start the run. Please try again.'
1131 return false
1133 this.currentObjective = objective
1134 this.objectiveProgress = 0
1135 this.momentum = 0
1136 this.error = null
1137 return true
1138 },
1140 /**
1141 * Asks the server to compose a fresh objective with the model, WITHOUT
1142 * committing it — the caller previews it, then commits via chooseObjective.
1143 * Generation lives server-side because it needs the OpenAI key (the client
1144 * has no access to it). An optional steer (era + theme, closed enums) biases
1145 * the composition; the server re-validates it against the enums, so a bad
1146 * value is harmless. `avoid` is the titles already composed this session, so
1147 * a reroll lands somewhere new (#95) — the stateless server only knows what
1148 * the client supplies. Returns null rather than throwing if composing fails,
1149 * so the UI can quietly fall back to the curated pool.
1150 */
1151 async fetchAIObjective(steer: ObjectiveSteer = {}, avoid: string[] = []): Promise<GameObjective | null> {
1152 try {
1153 const query: Record<string, string | string[]> = {}
1154 if (steer.era) query.era = steer.era
1155 if (steer.theme) query.theme = steer.theme
1156 if (avoid.length) query.avoid = avoid
1157 const res = await $fetch('/api/objective', { method: 'GET', query, headers: await this.aiHeaders() }) as {
1158 success?: boolean
1159 objective?: GameObjective
1161 return res?.success && res.objective ? res.objective : null
1162 } catch (error) {
1163 console.error('Failed to compose a fresh objective:', error)
1164 return null
1166 },
1168 /**
1169 * Loads the device's run balance for the header gauge + account popover.
1170 * Grants the free trial on a brand-new device (server-side). Graceful: on
1171 * failure it leaves the prior value (the gauge simply doesn't update).
1172 */
1173 async loadBalance(): Promise<void> {
1174 try {
1175 const res = await $fetch('/api/balance') as { runsRemaining?: number; freeRuns?: number; deviceRef?: string; email?: string | null; isAnonymous?: boolean }
1176 if (typeof res?.runsRemaining === 'number') this.runsRemaining = res.runsRemaining
1177 if (typeof res?.freeRuns === 'number') this.freeRuns = res.freeRuns
1178 if (typeof res?.deviceRef === 'string') this.deviceRef = res.deviceRef
1179 this.accountEmail = res?.email ?? null
1180 this.accountAnonymous = res?.isAnonymous === true
1181 } catch (error) {
1182 console.error('Failed to load balance:', error)
1184 },
1186 /** Opens the run-packs sales modal, loading the catalog first. */
1187 async openBuyModal(): Promise<void> {
1188 this.buyModalOpen = true
1189 await this.loadPacks()
1190 },
1192 /** Closes the sales modal. */
1193 closeBuyModal(): void {
1194 this.buyModalOpen = false
1195 },
1197 /** Records the Stripe-return outcome and refreshes the balance on success
1198 * (the webhook credits asynchronously; re-read so the gauge reflects it). */
1199 async notePurchaseReturn(outcome: 'success' | 'cancel'): Promise<void> {
1200 this.purchaseNotice = outcome
1201 this.buyModalOpen = false
1202 if (outcome === 'success') {
1203 this.outOfRuns = false
1204 await this.loadBalance()
1206 },
1208 /** Dismisses the post-purchase notice. */
1209 clearPurchaseNotice(): void {
1210 this.purchaseNotice = null
1211 },
1213 /** Loads the run-pack catalog for the paywall. Caches only a NON-empty
1214 * result: a transient empty/failed read must not poison the cache and
1215 * strand the modal with zero packs for the rest of the session. */
1216 async loadPacks(): Promise<void> {
1217 if (this.packs.length) return
1218 try {
1219 const res = await $fetch('/api/packs') as { packs?: Pack[] }
1220 const packs = res?.packs ?? []
1221 if (packs.length) this.packs = packs
1222 } catch (error) {
1223 console.error('Failed to load packs:', error)
1225 },
1227 /** Starts checkout for a pack; returns the Stripe Checkout URL to redirect
1228 * to (null on failure). The caller does the redirect. */
1229 async buyPack(packId: string): Promise<string | null> {
1230 try {
1231 const res = await $fetch('/api/checkout', {
1232 method: 'POST',
1233 headers: { 'Content-Type': 'application/json' },
1234 body: { packId }
1235 }) as { url?: string }
1236 return res?.url ?? null
1237 } catch (error) {
1238 console.error('Failed to start checkout:', error)
1239 return null
1241 },
1243 getVictoryEfficiency() {
1244 if (this.gameStatus === 'victory') {
1245 const messagesSaved = this.remainingMessages
1246 const messagesUsed = TOTAL_MESSAGES - this.remainingMessages
1247 const efficiencyPercentage = Math.round((messagesSaved / TOTAL_MESSAGES) * 100)
1249 const efficiencyRating = rateEfficiency(messagesSaved)
1251 return {
1252 messagesSaved,
1253 messagesUsed,
1254 efficiencyPercentage,
1255 efficiencyRating,
1256 isEarlyVictory: messagesSaved > 0
1259 return null
1260 },
1262 /**
1263 * Persist the finished run's snapshot to the player's account, best-effort.
1264 * Captures exactly what the end screen shows — objective, verdict, ledger,
1265 * dispatches, rolls, and the Chronicle epilogue — keyed by the run id, so the
1266 * run survives reload and feeds the read-only "your runs" view. Fires and
1267 * forgets like refreshChronicle: a save failure must never disturb the end
1268 * screen. Only a completed (victory/defeat), server-backed (uuid run id) run
1269 * is saved; a degraded local id is dropped server-side.
1270 */
1271 async saveRunSnapshot(): Promise<void> {
1272 const epoch = this.runEpoch
1273 const status = this.gameStatus
1274 if (status !== 'victory' && status !== 'defeat') return
1275 const objective = this.currentObjective
1276 const runId = this.runId
1277 if (!objective || !runId) return
1279 const summary = this.gameSummary
1280 const events = this.timelineEvents
1281 // The dispatch keepsake — the player's verbatim messages, paired with
1282 // whom/when they reached (the same zip the end screen renders).
1283 const dispatches = this.messageHistory
1284 .filter((m) => m.sender === 'user')
1285 .map((m, i) => ({
1286 text: m.text,
1287 figure: m.figureName || events[i]?.figureName || 'the past',
1288 era: events[i]?.era || ''
1289 }))
1290 const mark = (m: typeof summary.bestRoll) =>
1291 m && m.diceRoll != null && m.diceOutcome != null
1292 ? { diceRoll: m.diceRoll, diceOutcome: m.diceOutcome }
1293 : null
1295 const snapshot: RunSnapshot = {
1296 version: RUN_SNAPSHOT_VERSION,
1297 runId,
1298 status,
1299 objective,
1300 objectiveProgress: this.objectiveProgress,
1301 messagesUsed: TOTAL_MESSAGES - this.remainingMessages,
1302 totalMessages: TOTAL_MESSAGES,
1303 bestRoll: mark(summary.bestRoll),
1304 worstRoll: mark(summary.worstRoll),
1305 efficiency: this.getVictoryEfficiency(),
1306 dispatches,
1307 timeline: events.map((e) => ({
1308 id: e.id,
1309 figureName: e.figureName,
1310 era: e.era,
1311 headline: e.headline,
1312 detail: e.detail,
1313 diceRoll: e.diceRoll,
1314 diceOutcome: e.diceOutcome,
1315 progressChange: e.progressChange,
1316 baseProgressChange: e.baseProgressChange,
1317 valence: e.valence,
1318 craft: e.craft,
1319 anachronism: e.anachronism,
1320 staked: e.staked
1321 })),
⋯ 61 lines hidden (lines 1322–1382)
1322 chronicle: this.chronicle
1325 try {
1326 await $fetch('/api/run-save', {
1327 method: 'POST',
1328 headers: { 'Content-Type': 'application/json' },
1329 body: snapshot
1330 })
1331 } catch (error) {
1332 // Saving the run is a nicety; a failure must not break the end screen.
1333 if (epoch === this.runEpoch) console.error('Could not save the run:', error)
1335 },
1337 resetGame() {
1338 // Tear the epoch first: anything still in flight belongs to the old run
1339 // and must find no purchase here. (The seq counters deliberately survive.)
1340 this.runEpoch++
1341 this.remainingMessages = TOTAL_MESSAGES
1342 this.messageHistory = []
1343 this.gameStatus = 'playing'
1344 this.isLoading = false
1345 this.error = null
1346 this.lastMessageTime = null
1347 this.isRateLimited = false
1348 // A new run gets a fresh id on its next server call; abandon any
1349 // in-flight mint so a dead run's id can't land in the new run.
1350 this.runId = null
1351 this.runIdInflight = null
1352 // The paywall / capacity notices re-check on the next commit.
1353 this.outOfRuns = false
1354 this.atCapacity = false
1355 this.currentObjective = null
1356 this.objectiveProgress = 0
1357 this.momentum = 0
1358 this.timelineEvents = []
1359 this.figures = []
1360 this.activeFigureName = ''
1361 this.figureGrounding = null
1362 this.groundingLoading = false
1363 this.contactWhen = null
1364 this.contactMoment = null
1365 this.figureSuggestions = []
1366 this.suggestionsLoading = false
1367 this.suggestionsFor = ''
1368 this.chronicle = null
1369 this.chronicleLoading = false
1370 this.figureStudy = null
1371 this.studyLoading = false
1372 this.studyFor = ''
1373 this.studyWhen = null
1374 this.archiveResult = null
1375 this.lookupLoading = false
1376 this.moderationNotice = null
1377 this.archiveNotice = null
1378 this.studyNotice = null
1379 this.suggestionsNotice = null

And the server's boundary validator — a saved run can be POSTed directly, so every field is coerced — bounds the two new ones. The pre-amplifier swing is signed-clamped like its sibling progressChange; staked is a strict-boolean flag, so only a real true survives.

The two new coercion branches sit beside craft/anachronism.

server/utils/run-snapshot.ts · 171 lines
server/utils/run-snapshot.ts171 lines · TypeScript
⋯ 117 lines hidden (lines 1–117)
1/**
2 * Server-side boundary validation for a saved run snapshot.
3 *
4 * The shared CONTRACT (types + version) lives in `~/utils/run-snapshot` so both the
5 * client store and the server can import it without pulling server-only code into the
6 * client bundle. This module is the server-only half: it coerces an untrusted POST
7 * body into a valid RunSnapshot (a direct POST can send anything), bounding every
8 * field with the same cleanString/cleanArray/clampInt helpers the other routes use.
9 */
10import { cleanString, cleanArray, clampInt } from './validate'
11import {
12 MAX_OBJECTIVE_TITLE_CHARS,
13 MAX_OBJECTIVE_DESC_CHARS,
14 MAX_ERA_CHARS,
15 MAX_FIGURE_NAME_CHARS,
16 MAX_LEDGER_DETAIL_CHARS,
17 MAX_TIMELINE_ENTRIES,
18 MAX_HISTORY_ENTRIES,
19 MAX_LEDGER_SWING
20} from './validate'
21import { sanitizeRunId } from './run-context'
22import { TOTAL_MESSAGES } from '~/utils/game-config'
23import {
24 RUN_SNAPSHOT_VERSION,
25 type RunSnapshot,
26 type RunOutcome,
27 type RunValence,
28 type RunEfficiency,
29 type RunDispatch,
30 type RunLedgerEntry,
31 type RollMark,
32 type EfficiencyRating
33} from '~/utils/run-snapshot'
34import type { DiceOutcome } from '~/utils/dice'
35import type { Craft } from '~/utils/craft'
36import type { Anachronism } from '~/server/utils/anachronism'
37import type { GameObjective } from '~/server/utils/objectives'
38import type { ChronicleEntry } from '~/server/utils/openai'
39 
40// Caps specific to a snapshot's own fields (the shared input caps live in validate.ts).
41const MAX_DISPATCH_CHARS = 320
42const MAX_HEADLINE_CHARS = 200
43const MAX_ICON_CHARS = 16
44const MAX_OUTCOME_CHARS = 24
45const MAX_RATING_CHARS = 24
46const MAX_HINT_CHARS = 24
47const MAX_ID_CHARS = 64
48const MAX_TOTAL_MESSAGES = 20
49const MAX_DICE = 40
50 
51const VALENCES: RunValence[] = ['positive', 'negative', 'neutral']
52const coerceValence = (v: unknown): RunValence =>
53 VALENCES.includes(v as RunValence) ? (v as RunValence) : 'neutral'
54 
55const clampNonNeg = (n: number): number => Math.max(0, n)
56 
57const cleanRoll = (v: unknown): RollMark | null => {
58 if (!v || typeof v !== 'object') return null
59 const m = v as Record<string, unknown>
60 const diceOutcome = cleanString(m.diceOutcome, MAX_OUTCOME_CHARS)
61 if (!diceOutcome) return null
62 return { diceRoll: clampNonNeg(clampInt(m.diceRoll, MAX_DICE)), diceOutcome: diceOutcome as DiceOutcome }
64 
65/**
66 * Coerce an untrusted body into a valid RunSnapshot, or null when it isn't a
67 * completed run we can save. Bounds every field, and stamps the server's own
68 * `version` rather than trusting the client's. Returns null when the run isn't
69 * terminal or has no objective / run id — there is nothing meaningful to persist then.
70 */
71export function sanitizeSnapshot(raw: unknown): RunSnapshot | null {
72 if (!raw || typeof raw !== 'object') return null
73 const r = raw as Record<string, unknown>
74 
75 const status: RunOutcome | null =
76 r.status === 'victory' || r.status === 'defeat' ? r.status : null
77 if (!status) return null
78 
79 const runId = sanitizeRunId(r.runId)
80 if (!runId) return null
81 
82 if (!r.objective || typeof r.objective !== 'object') return null
83 const o = r.objective as Record<string, unknown>
84 const objective: GameObjective = {
85 title: cleanString(o.title, MAX_OBJECTIVE_TITLE_CHARS),
86 description: cleanString(o.description, MAX_OBJECTIVE_DESC_CHARS),
87 era: cleanString(o.era, MAX_ERA_CHARS),
88 icon: cleanString(o.icon, MAX_ICON_CHARS)
89 }
90 if (typeof o.anchorYear === 'number' && Number.isFinite(o.anchorYear)) {
91 objective.anchorYear = Math.round(o.anchorYear)
92 }
93 
94 const totalMessages = clampNonNeg(clampInt(r.totalMessages, MAX_TOTAL_MESSAGES)) || TOTAL_MESSAGES
95 const messagesUsed = Math.min(totalMessages, clampNonNeg(clampInt(r.messagesUsed, MAX_TOTAL_MESSAGES)))
96 
97 let efficiency: RunEfficiency | null = null
98 if (r.efficiency && typeof r.efficiency === 'object') {
99 const e = r.efficiency as Record<string, unknown>
100 efficiency = {
101 messagesSaved: clampNonNeg(clampInt(e.messagesSaved, MAX_TOTAL_MESSAGES)),
102 messagesUsed: clampNonNeg(clampInt(e.messagesUsed, MAX_TOTAL_MESSAGES)),
103 efficiencyPercentage: clampNonNeg(clampInt(e.efficiencyPercentage, 100)),
104 efficiencyRating: (cleanString(e.efficiencyRating, MAX_RATING_CHARS) || 'Standard') as EfficiencyRating,
105 isEarlyVictory: e.isEarlyVictory === true
106 }
107 }
108 
109 const dispatches: RunDispatch[] = cleanArray(r.dispatches, MAX_HISTORY_ENTRIES).map((raw) => {
110 const d = (raw ?? {}) as Record<string, unknown>
111 return {
112 text: cleanString(d.text, MAX_DISPATCH_CHARS),
113 figure: cleanString(d.figure, MAX_FIGURE_NAME_CHARS),
114 era: cleanString(d.era, MAX_ERA_CHARS)
115 }
116 })
117 
118 const timeline: RunLedgerEntry[] = cleanArray(r.timeline, MAX_TIMELINE_ENTRIES).map((raw) => {
119 const t = (raw ?? {}) as Record<string, unknown>
120 const entry: RunLedgerEntry = {
121 id: cleanString(t.id, MAX_ID_CHARS),
122 figureName: cleanString(t.figureName, MAX_FIGURE_NAME_CHARS),
123 era: cleanString(t.era, MAX_ERA_CHARS),
124 headline: cleanString(t.headline, MAX_HEADLINE_CHARS),
125 detail: cleanString(t.detail, MAX_LEDGER_DETAIL_CHARS),
126 diceRoll: clampNonNeg(clampInt(t.diceRoll, MAX_DICE)),
127 diceOutcome: cleanString(t.diceOutcome, MAX_OUTCOME_CHARS) as DiceOutcome,
128 progressChange: clampInt(t.progressChange, MAX_LEDGER_SWING),
129 valence: coerceValence(t.valence)
130 }
131 // The pre-amplifier swing is signed and only present when it amplified; keep
132 // "absent" distinct from a real 0 so the Spine shows the Δ equation faithfully.
133 if (typeof t.baseProgressChange === 'number') {
134 entry.baseProgressChange = clampInt(t.baseProgressChange, MAX_LEDGER_SWING)
135 }
136 const craft = cleanString(t.craft, MAX_HINT_CHARS)
137 if (craft) entry.craft = craft as Craft
138 const anachronism = cleanString(t.anachronism, MAX_HINT_CHARS)
139 if (anachronism) entry.anachronism = anachronism as Anachronism
140 if (t.staked === true) entry.staked = true
141 return entry
⋯ 30 lines hidden (lines 142–171)
142 })
143 
144 let chronicle: ChronicleEntry | null = null
145 if (r.chronicle && typeof r.chronicle === 'object') {
146 const c = r.chronicle as Record<string, unknown>
147 chronicle = {
148 title: cleanString(c.title, MAX_HEADLINE_CHARS),
149 paragraphs: cleanArray(c.paragraphs, MAX_HISTORY_ENTRIES)
150 .map((p) => cleanString(p, MAX_OBJECTIVE_DESC_CHARS))
151 .filter(Boolean)
152 }
153 }
154 
155 return {
156 // The server owns the version stamp — never trust the client's.
157 version: RUN_SNAPSHOT_VERSION,
158 runId,
159 status,
160 objective,
161 objectiveProgress: clampNonNeg(clampInt(r.objectiveProgress, 100)),
162 messagesUsed,
163 totalMessages,
164 bestRoll: cleanRoll(r.bestRoll),
165 worstRoll: cleanRoll(r.worstRoll),
166 efficiency,
167 dispatches,
168 timeline,
169 chronicle
170 }

Tests: both sources, and plumbing that can't silently rot

A new spec drives the Spine from both sources. The passed-events path proves a saved run renders the full graphic — nodes, the Δ equation, both badges — and that it ignores the live store entirely; the no-prop path proves live play is unchanged, down to the ▷ now present marker. (SymbolHint wraps each badge in a tooltip that needs a provider the bare mount lacks, so it's stubbed to a slot passthrough — the same idiom MessageHistory.spec uses.)

Two describes: from a passed prop, and the store fallback.

tests/unit/components/TimelineLedger.spec.ts · 81 lines
tests/unit/components/TimelineLedger.spec.ts81 lines · TypeScript
⋯ 20 lines hidden (lines 1–20)
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 })
⋯ 4 lines hidden (lines 31–34)
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)
⋯ 21 lines hidden (lines 47–67)
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('is read-only: no live "▷ now" present marker', () => {
54 expect(mountSaved().find('.spine-now').exists()).toBe(false)
55 })
56 
57 it('ignores the live store entirely when events are passed', () => {
58 const store = useGameStore()
59 store.timelineEvents = [
60 { 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() }
61 ] as TimelineEvent[]
62 const w = mountSaved()
63 expect(w.text()).not.toContain('A live ghost')
64 expect(w.findAll('[data-testid="timeline-event"]')).toHaveLength(2)
65 })
66 })
67 
68 describe('without an events prop (live play, unchanged)', () => {
69 it('falls back to the store and shows the live present marker', () => {
70 const store = useGameStore()
71 store.timelineEvents = [
72 { 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() }
73 ] as TimelineEvent[]
⋯ 8 lines hidden (lines 74–81)
74 const w = mount(TimelineLedger, withHint)
75 expect(w.findAll('[data-testid="timeline-event"]')).toHaveLength(1)
76 expect(w.find('[data-testid="node-detail"]').text()).toContain('A spark leaps')
77 // Live (not read-only): the "▷ now" present marker is shown.
78 expect(w.find('.spine-now').exists()).toBe(true)
79 })
80 })
81})

The render tests hand-build fixtures with both fields present, so on their own they wouldn't catch the plumbing breaking. Two more assertions close that: one proves the store builder actually copies the fields into the saved snapshot, the other that the boundary validator preserves and bounds them. Drop either plumbing line and one of these goes red.

The save path carries the two fields into the POST body.

tests/unit/stores/game-save-run.spec.ts · 109 lines
tests/unit/stores/game-save-run.spec.ts109 lines · TypeScript
⋯ 80 lines hidden (lines 1–80)
1import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2import { setActivePinia, createPinia } from 'pinia'
3import { useGameStore } from '../../../stores/game'
4import { DiceOutcome } from '../../../utils/dice'
5import { RUN_SNAPSHOT_VERSION } from '../../../utils/run-snapshot'
6 
7/**
8 * saveRunSnapshot persists the finished run's end-state to the player's account. It
9 * captures exactly what the end screen shows (objective, verdict, ledger, dispatches,
10 * rolls, epilogue), keyed by the run id, and only fires for a completed, server-backed
11 * run — best-effort, so a failure never disturbs the end screen.
12 */
13const RUN_ID = '11111111-1111-4111-8111-111111111111'
14 
15function seedFinishedVictory(store: ReturnType<typeof useGameStore>) {
16 store.runId = RUN_ID
17 store.gameStatus = 'victory'
18 store.currentObjective = { title: 'Save the Library', description: 'Keep the scrolls.', era: 'Antiquity', icon: '📜' }
19 store.objectiveProgress = 100
20 store.remainingMessages = 2 // messagesUsed = 3
21 store.messageHistory = [
22 { text: 'Hide the scrolls.', sender: 'user', timestamp: new Date(), figureName: 'Hypatia' },
23 { text: 'It is done.', sender: 'ai', timestamp: new Date(), diceRoll: 19, diceOutcome: DiceOutcome.CRITICAL_SUCCESS, progressChange: 60 }
24 ] as never
25 store.timelineEvents = [
26 { id: 'evt-1', figureName: 'Hypatia', era: 'Antiquity', headline: 'The scrolls are hidden', detail: 'A copy survives.', diceRoll: 19, diceOutcome: DiceOutcome.CRITICAL_SUCCESS, progressChange: 60, baseProgressChange: 40, valence: 'positive', staked: true, timestamp: new Date() }
27 ] as never
28 store.chronicle = { title: 'The Library Stands', paragraphs: ['It did not burn.'] }
30 
31describe('game store · saveRunSnapshot', () => {
32 let store: ReturnType<typeof useGameStore>
33 let fetchMock: ReturnType<typeof vi.fn>
34 
35 beforeEach(() => {
36 setActivePinia(createPinia())
37 store = useGameStore()
38 fetchMock = vi.fn().mockResolvedValue({ saved: true })
39 vi.stubGlobal('$fetch', fetchMock)
40 })
41 
42 afterEach(() => {
43 vi.unstubAllGlobals()
44 vi.restoreAllMocks()
45 })
46 
47 it('POSTs the finished run snapshot to /api/run-save', async () => {
48 seedFinishedVictory(store)
49 await store.saveRunSnapshot()
50 
51 expect(fetchMock).toHaveBeenCalledTimes(1)
52 const [url, opts] = fetchMock.mock.calls[0]
53 expect(url).toBe('/api/run-save')
54 expect(opts.method).toBe('POST')
55 
56 const body = opts.body
57 expect(body).toMatchObject({
58 version: RUN_SNAPSHOT_VERSION,
59 runId: RUN_ID,
60 status: 'victory',
61 objective: { title: 'Save the Library', era: 'Antiquity', icon: '📜' },
62 objectiveProgress: 100,
63 messagesUsed: 3,
64 totalMessages: 5,
65 bestRoll: { diceRoll: 19, diceOutcome: 'Critical Success' },
66 dispatches: [{ text: 'Hide the scrolls.', figure: 'Hypatia', era: 'Antiquity' }],
67 chronicle: { title: 'The Library Stands', paragraphs: ['It did not burn.'] }
68 })
69 // The victory efficiency block is derived and carried.
70 expect(body.efficiency).toMatchObject({ messagesUsed: 3, efficiencyRating: expect.any(String) })
71 })
72 
73 it('serializes the ledger without the in-memory Date timestamp', async () => {
74 seedFinishedVictory(store)
75 await store.saveRunSnapshot()
76 const entry = fetchMock.mock.calls[0][1].body.timeline[0]
77 expect(entry).not.toHaveProperty('timestamp')
78 expect(entry).toMatchObject({ id: 'evt-1', headline: 'The scrolls are hidden', progressChange: 60, valence: 'positive' })
79 })
80 
81 it('carries the Spine fields the retro run view renders — baseProgressChange (the Δ equation) and staked (the ⚑)', async () => {
82 // The builder must copy these from the ledger into the snapshot, or a saved run
83 // loses the Δ equation and the ⚑ badge on /runs/:id (the #110 regression).
84 seedFinishedVictory(store)
85 await store.saveRunSnapshot()
86 const entry = fetchMock.mock.calls[0][1].body.timeline[0]
87 expect(entry).toMatchObject({ baseProgressChange: 40, staked: true })
88 })
⋯ 21 lines hidden (lines 89–109)
89 
90 it('does nothing while the run is still playing', async () => {
91 seedFinishedVictory(store)
92 store.gameStatus = 'playing'
93 await store.saveRunSnapshot()
94 expect(fetchMock).not.toHaveBeenCalled()
95 })
96 
97 it('does nothing without a server-backed run id', async () => {
98 seedFinishedVictory(store)
99 store.runId = null
100 await store.saveRunSnapshot()
101 expect(fetchMock).not.toHaveBeenCalled()
102 })
103 
104 it('swallows a save failure (never throws into the end screen)', async () => {
105 seedFinishedVictory(store)
106 fetchMock.mockRejectedValueOnce(new Error('network'))
107 await expect(store.saveRunSnapshot()).resolves.toBeUndefined()
108 })
109})

Preserve, signed-clamp, and the strict-boolean guard at the boundary.

tests/unit/server/utils/run-snapshot.spec.ts · 100 lines
tests/unit/server/utils/run-snapshot.spec.ts100 lines · TypeScript
⋯ 42 lines hidden (lines 1–42)
1import { describe, it, expect } from 'vitest'
2import { sanitizeSnapshot } from '../../../../server/utils/run-snapshot'
3import { RUN_SNAPSHOT_VERSION } from '../../../../utils/run-snapshot'
4 
5/**
6 * sanitizeSnapshot is the API-boundary gate for a saved run: a direct POST can send
7 * anything, so it must coerce-and-bound every field, reject a non-completed run, and
8 * stamp the server's own version rather than trusting the client's.
9 */
10const RUN_ID = '11111111-1111-4111-8111-111111111111'
11 
12function validRaw(overrides: Record<string, unknown> = {}) {
13 return {
14 version: 99, // client-supplied — must be ignored
15 runId: RUN_ID,
16 status: 'victory',
17 objective: { title: 'Save the Library', description: 'Keep the scrolls.', era: 'Antiquity', icon: '📜', anchorYear: -300 },
18 objectiveProgress: 100,
19 messagesUsed: 3,
20 totalMessages: 5,
21 bestRoll: { diceRoll: 19, diceOutcome: 'Critical Success' },
22 worstRoll: { diceRoll: 4, diceOutcome: 'Failure' },
23 efficiency: { messagesSaved: 2, messagesUsed: 3, efficiencyPercentage: 40, efficiencyRating: 'Great', isEarlyVictory: true },
24 dispatches: [{ text: 'Hide the scrolls.', figure: 'Hypatia', era: 'Antiquity' }],
25 timeline: [{ id: 'evt-1', figureName: 'Hypatia', era: 'Antiquity', headline: 'Hidden', detail: 'A copy survives.', diceRoll: 19, diceOutcome: 'Critical Success', progressChange: 60, valence: 'positive', craft: 'sharp', anachronism: 'in-period' }],
26 chronicle: { title: 'The Library Stands', paragraphs: ['It did not burn.', 'The knowledge endured.'] },
27 ...overrides
28 }
30 
31describe('sanitizeSnapshot', () => {
32 it('accepts a valid completed run and stamps the server version', () => {
33 const snap = sanitizeSnapshot(validRaw())
34 expect(snap).not.toBeNull()
35 expect(snap!.version).toBe(RUN_SNAPSHOT_VERSION)
36 expect(snap!.runId).toBe(RUN_ID)
37 expect(snap!.status).toBe('victory')
38 expect(snap!.objective).toEqual({ title: 'Save the Library', description: 'Keep the scrolls.', era: 'Antiquity', icon: '📜', anchorYear: -300 })
39 expect(snap!.timeline[0]).toMatchObject({ headline: 'Hidden', craft: 'sharp', anachronism: 'in-period', valence: 'positive' })
40 expect(snap!.bestRoll).toEqual({ diceRoll: 19, diceOutcome: 'Critical Success' })
41 })
42 
43 it('preserves the Spine fields baseProgressChange + staked, clamping/guarding them at the boundary', () => {
44 const base = { id: 'evt-1', figureName: 'Hypatia', era: 'Antiquity', headline: 'Hidden', detail: 'A copy survives.', diceRoll: 19, diceOutcome: 'Critical Success', progressChange: 60, valence: 'positive' }
45 // Present + in range: carried through to the read-only run view's Spine.
46 expect(sanitizeSnapshot(validRaw({ timeline: [{ ...base, baseProgressChange: 40, staked: true }] }))!.timeline[0])
47 .toMatchObject({ baseProgressChange: 40, staked: true })
48 // The pre-amplifier swing is signed-clamped (MAX_LEDGER_SWING = ±100).
49 expect(sanitizeSnapshot(validRaw({ timeline: [{ ...base, baseProgressChange: 9999 }] }))!.timeline[0].baseProgressChange).toBe(100)
50 expect(sanitizeSnapshot(validRaw({ timeline: [{ ...base, baseProgressChange: -9999 }] }))!.timeline[0].baseProgressChange).toBe(-100)
51 // staked is a strict-boolean flag: a truthy non-boolean is dropped, not coerced.
52 expect(sanitizeSnapshot(validRaw({ timeline: [{ ...base, staked: 'yes' }] }))!.timeline[0].staked).toBeUndefined()
53 // Absent stays absent — an older snapshot (no Δ/⚑) loads cleanly.
54 const bare = sanitizeSnapshot(validRaw())!.timeline[0]
55 expect(bare.baseProgressChange).toBeUndefined()
56 expect(bare.staked).toBeUndefined()
57 })
⋯ 43 lines hidden (lines 58–100)
58 
59 it('rejects a non-terminal status', () => {
60 expect(sanitizeSnapshot(validRaw({ status: 'playing' }))).toBeNull()
61 expect(sanitizeSnapshot(validRaw({ status: undefined }))).toBeNull()
62 })
63 
64 it('rejects a missing run id or objective', () => {
65 expect(sanitizeSnapshot(validRaw({ runId: '' }))).toBeNull()
66 expect(sanitizeSnapshot(validRaw({ objective: null }))).toBeNull()
67 })
68 
69 it('rejects a non-object body', () => {
70 expect(sanitizeSnapshot(null)).toBeNull()
71 expect(sanitizeSnapshot('nope')).toBeNull()
72 })
73 
74 it('clamps progress into 0..100', () => {
75 expect(sanitizeSnapshot(validRaw({ objectiveProgress: 9999 }))!.objectiveProgress).toBe(100)
76 expect(sanitizeSnapshot(validRaw({ objectiveProgress: -50 }))!.objectiveProgress).toBe(0)
77 })
78 
79 it('truncates an over-long objective title', () => {
80 const long = 'x'.repeat(500)
81 const snap = sanitizeSnapshot(validRaw({ objective: { title: long, description: '', era: '', icon: '' } }))
82 expect(snap!.objective.title.length).toBe(120)
83 })
84 
85 it('caps the timeline length and coerces a bad valence to neutral', () => {
86 const many = Array.from({ length: 80 }, (_, i) => ({ id: `e${i}`, valence: 'sideways' }))
87 const snap = sanitizeSnapshot(validRaw({ timeline: many }))
88 expect(snap!.timeline.length).toBe(50)
89 expect(snap!.timeline[0].valence).toBe('neutral')
90 })
91 
92 it('drops a roll with no named outcome', () => {
93 expect(sanitizeSnapshot(validRaw({ bestRoll: { diceRoll: 12 } }))!.bestRoll).toBeNull()
94 })
95 
96 it('filters non-string chronicle paragraphs', () => {
97 const snap = sanitizeSnapshot(validRaw({ chronicle: { title: 'T', paragraphs: ['ok', 123, '', 'fine'] } }))
98 expect(snap!.chronicle).toEqual({ title: 'T', paragraphs: ['ok', 'fine'] })
99 })
100})