What changed, and why

At 0% progress, the Timeline Shift gauge told everyone the same thing: History stands unchanged…. For a fresh run, that is right. But a turn can backfire, and progress cannot fall below zero, so a −15% Critical Failure from 0% still lands right back at 0% with that same line showing. A loss looked like a no-op. The player who had just made things worse read "nothing happened".

The fix is small. When progress is 0, statusText now consults the run's ledger before it speaks. An untouched run still reads "stands unchanged". A run that already spent a turn says history pushed back instead. No new state. The transient per-turn delta chip is left alone, and only the lingering cumulative line changes.

The status line learns to read the ledger

statusText maps cumulative progress to a line of copy. The only edit sits in the p === 0 branch, which used to return a single fixed string and now chooses between two. The tell is timelineEvents, the run's record of every resolved turn. A non-empty ledger at 0% means you acted and ended up back where you started. That is a setback. It is not a blank slate.

The whole statusText map; the only behavioral change is the forked p === 0 branch.

components/ProgressTracker.vue · 204 lines
components/ProgressTracker.vue204 lines · Vue
⋯ 118 lines hidden (lines 1–118)
1<template>
2 <div data-testid="progress-tracker">
3 <div class="flex items-baseline justify-between mb-1.5">
4 <span class="rv-label">Timeline shift</span>
5 <span class="relative inline-flex items-baseline gap-1">
6 <span data-testid="progress-percentage" class="rv-mono text-3xl font-extrabold leading-none" :class="[textClass, { 'pct-pop': popping }]">
7 {{ gameStore.objectiveProgress }}%
8 </span>
9 <span class="rv-mono text-xs rv-faint">→ 100%</span>
10 <span v-if="delta" :key="delta.id" data-testid="progress-delta" class="delta-chip rv-mono"
11 :class="delta.value > 0 ? 'delta-pos' : 'delta-neg'">
12 {{ delta.value > 0 ? '+' : '' }}{{ delta.value }}%
13 </span>
14 </span>
15 </div>
16 
17 <div data-testid="progress-bar" class="rv-track" :class="trackClass">
18 <div class="rv-fill" :style="{ width: gameStore.objectiveProgress + '%', background: fillColor }" />
19 </div>
20 
21 <div class="flex items-baseline justify-between gap-2 mt-1.5 flex-wrap">
22 <p data-testid="progress-status" class="text-xs transition-colors duration-300" :class="statusClass">{{ statusText }}</p>
23 <p v-if="showLedger" data-testid="progress-pace" class="rv-mono text-[11px] rv-faint tabular-nums text-right ml-auto">
24 {{ need }}% to go · {{ gameStore.remainingMessages }} {{ gameStore.remainingMessages === 1 ? 'message' : 'messages' }} left<template v-if="paceNote"> · <span :class="paceClass">{{ paceNote }}</span></template>
25 </p>
26 </div>
27 
28 <!-- The gauge's most important per-turn change, spoken to assistive tech (the visible
29 %/delta/status are not otherwise announced). -->
30 <span class="sr-only" aria-live="polite">{{ liveMessage }}</span>
31 </div>
32</template>
33 
34<script setup lang="ts">
35/**
36 * ProgressTracker — the TIMELINE SHIFT gauge: a big mono %, a 6px hairline track,
37 * and a status line. The drama is the transient ±delta chip that flies off the % on
38 * every change (animation preserved verbatim).
39 */
40import { ref, computed, watch, nextTick, onUnmounted } from 'vue'
41import { useGameStore } from '~/stores/game'
42import { usePrefersReducedMotion } from '~/composables/usePrefersReducedMotion'
43import { MAX_PROGRESS_SWING } from '~/utils/game-config'
44import { SWING_BANDS } from '~/utils/swing-bands'
45import { DiceOutcome } from '~/utils/dice'
46 
47const gameStore = useGameStore()
48const reducedMotion = usePrefersReducedMotion()
49 
50const delta = ref<{ id: number; value: number } | null>(null)
51// The headline % gives a struck-token bump whenever it moves (CSS-only flourish; the
52// rendered number is always the live store value, so it never lags behind a change).
53const popping = ref(false)
54// A polite spoken summary of each shift for screen-reader users.
55const liveMessage = ref('')
56let clearTimer: ReturnType<typeof setTimeout> | null = null
57let popTimer: ReturnType<typeof setTimeout> | null = null
58 
59watch(() => gameStore.objectiveProgress, (now, was) => {
60 const change = now - was
61 if (!change) return
62 delta.value = { id: Date.now() + Math.random(), value: change }
63 liveMessage.value = `Timeline shift ${now} percent, ${change > 0 ? 'up' : 'down'} ${Math.abs(change)}. ${statusText.value}`
64 if (clearTimer) clearTimeout(clearTimer)
65 clearTimer = setTimeout(() => { delta.value = null }, reducedMotion.value ? 900 : 1300)
66 // Restart the bump cleanly even if it was mid-play (false → render → true).
67 popping.value = false
68 if (popTimer) clearTimeout(popTimer)
69 void nextTick(() => {
70 popping.value = true
71 popTimer = setTimeout(() => { popping.value = false }, 360)
72 })
73})
74 
75onUnmounted(() => { if (clearTimer) clearTimeout(clearTimer); if (popTimer) clearTimeout(popTimer) })
76 
77// The gauge crescendos with its own copy. Each stage matches a line of statusText, so
78// the meter HEATS as history bends — a faint stir, ochre, then terracotta — and then
79// snaps to a locked, glowing olive the instant the new timeline holds at 100%. Red
80// stays reserved for negative swings (the delta chip); this ramp is the rising heat.
81type Stage = 'none' | 'crack' | 'bend' | 'hold' | 'brink' | 'complete'
82const stage = computed<Stage>(() => {
83 const p = gameStore.objectiveProgress
84 if (p >= 100) return 'complete'
85 if (p >= 75) return 'brink'
86 if (p >= 50) return 'hold'
87 if (p >= 25) return 'bend'
88 if (p > 0) return 'crack'
89 return 'none'
90})
91 
92const fillColor = computed(() => ({
93 none: 'var(--rv-line)',
94 crack: 'var(--rv-faint)',
95 bend: 'var(--rv-warn)',
96 hold: 'var(--rv-accent)',
97 brink: 'var(--rv-accent)',
98 complete: 'var(--rv-ok)'
99}[stage.value]))
100 
101// The track itself glows as the gauge nears the goal (a pulsing halo on the brink,
102// a steady one once history holds) — the box-shadow escapes the track's clipped fill.
103const trackClass = computed(() => ({
104 'track-brink': stage.value === 'brink',
105 'track-complete': stage.value === 'complete'
106}))
107 
108// The big % and the status line escalate in lockstep with the fill.
109const textClass = computed(() =>
110 stage.value === 'complete' ? 'rv-ok' : stage.value === 'brink' ? 'rv-accent' : 'rv-fg'
112const statusClass = computed(() => {
113 if (gameStore.isLoading) return 'rv-accent font-medium'
114 return stage.value === 'complete' ? 'rv-ok font-semibold'
115 : stage.value === 'brink' ? 'rv-accent font-medium'
116 : 'rv-muted'
117})
118 
119const statusText = computed(() => {
120 // While a turn resolves, the gauge leans in instead of reporting the old, stale
121 // state — so the board never says "unchanged" at the very moment you've acted.
122 if (gameStore.isLoading) return 'The timeline trembles — weighing your move…'
123 const p = gameStore.objectiveProgress
124 if (p === 0) {
125 // A 0% floored AFTER a setback is not a fresh, untouched run — the ledger
126 // tells them apart. A turn that backfires (or nets to nothing) and snaps
127 // back to the start must never read as a no-op (#128).
128 return gameStore.timelineEvents.length > 0
129 ? 'History resists — the timeline holds against you'
130 : 'History stands unchanged…'
131 }
132 if (p < 25) return 'The first cracks appear'
133 if (p < 50) return 'History is bending'
134 if (p < 75) return 'The new timeline takes hold'
135 if (p < 100) return 'On the brink of a new history'
136 return 'History rewritten!'
137})
⋯ 67 lines hidden (lines 138–204)
138 
139// The win condition, made plain: how far to the goal and how many messages remain.
140const showLedger = computed(() => gameStore.timelineEvents.length > 0)
141const need = computed(() => Math.max(0, 100 - gameStore.objectiveProgress))
142 
143// The pace, told honestly. With one message left and the win past the per-turn
144// fuse, the note points at the one move that still reaches it (the staked last
145// stand, ±100 — no position is ever mathematically dead). With more messages
146// left a win is always within stacked-crit reach, so the honest pressure signal
147// is the required average: above the Success band's ceiling means critical luck.
148const paceNote = computed(() => {
149 const remaining = gameStore.remainingMessages
150 if (gameStore.gameStatus !== 'playing' || remaining <= 0 || need.value === 0) return ''
151 const perMessage = Math.ceil(need.value / remaining)
152 if (remaining === 1 && need.value > MAX_PROGRESS_SWING) return 'the last stand can reach it'
153 if (perMessage > SWING_BANDS[DiceOutcome.SUCCESS].max) return `need +${perMessage} apiece — critical territory`
154 return `need +${perMessage} apiece`
155})
156const paceClass = computed(() =>
157 paceNote.value.includes('critical') || paceNote.value.includes('last stand')
158 ? 'rv-warn font-semibold'
159 : 'rv-faint'
161</script>
162 
163<style scoped>
164/* Crescendo glow — a halo on the track element (its own box-shadow isn't clipped by
165 the fill's overflow). Pulses on the brink, locks steady once history holds. */
166.rv-track { transition: box-shadow var(--rv-dur-2) var(--rv-ease); }
167.track-brink {
168 box-shadow: 0 0 10px color-mix(in srgb, var(--rv-accent) 45%, transparent);
169 animation: track-pulse 1.7s ease-in-out infinite;
171.track-complete {
172 box-shadow: 0 0 14px color-mix(in srgb, var(--rv-ok) 60%, transparent);
174@keyframes track-pulse {
175 0%, 100% { box-shadow: 0 0 6px color-mix(in srgb, var(--rv-accent) 30%, transparent); }
176 50% { box-shadow: 0 0 13px color-mix(in srgb, var(--rv-accent) 68%, transparent); }
178@media (prefers-reduced-motion: reduce) {
179 .track-brink { animation: none; }
181 
182.delta-chip {
183 position: absolute;
184 right: 0;
185 top: -0.35rem;
186 font-size: 0.8rem;
187 font-weight: 800;
188 white-space: nowrap;
189 pointer-events: none;
190 animation: delta-float 1.25s ease-out forwards;
192.delta-pos { color: var(--rv-ok); }
193.delta-neg { color: var(--rv-bad); }
194 
195@keyframes delta-float {
196 0% { opacity: 0; transform: translateY(6px) scale(.9); }
197 20% { opacity: 1; transform: translateY(-2px) scale(1.06); }
198 100% { opacity: 0; transform: translateY(-24px) scale(1); }
200 
201@media (prefers-reduced-motion: reduce) {
202 .delta-chip { animation: none; opacity: 1; transform: translateY(-18px); }
204</style>

Why the ledger is a sound tell

Two facts in the store make the ledger reliable. First, progress is clamped at zero. That clamp is the reason a setback can pass for a fresh start. Second, every resolved turn is appended to the ledger before the gauge re-renders, and that includes the turns that net to zero or worse. So a 0% with a non-empty ledger always means a turn happened and moved you nowhere. The old copy got that case wrong.

applyProgress floors at 0 (the ambiguity); within a turn the clamped swing lands, then the turn is recorded, negative turns included.

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

The test

Two specs pin the behavior. The first guards the fresh case: 0% with an empty ledger still reads "stands unchanged". The second is the one only this change can pass. It records a Critical-Failure turn, leaves progress floored at 0, and checks that the line is no longer the fresh-start copy. The two states are now observably different.

Fresh vs. floored-after-setback, asserted to differ.

tests/unit/components/ProgressTracker.spec.ts · 93 lines
tests/unit/components/ProgressTracker.spec.ts93 lines · TypeScript
⋯ 73 lines hidden (lines 1–73)
1import { describe, it, expect, beforeEach } from 'vitest'
2import { mount } from '@vue/test-utils'
3import { setActivePinia, createPinia } from 'pinia'
4import ProgressTracker from '../../../components/ProgressTracker.vue'
5import { useGameStore } from '../../../stores/game'
6import { DiceOutcome } from '../../../utils/dice'
7 
8describe('ProgressTracker', () => {
9 let gameStore: ReturnType<typeof useGameStore>
10 
11 beforeEach(() => {
12 setActivePinia(createPinia())
13 gameStore = useGameStore()
14 })
15 
16 it('displays the current progress percentage', () => {
17 gameStore.objectiveProgress = 45
18 const wrapper = mount(ProgressTracker)
19 expect(wrapper.find('[data-testid="progress-percentage"]').text()).toContain('45%')
20 })
21 
22 it('renders a progress bar and a status line', () => {
23 gameStore.objectiveProgress = 60
24 const wrapper = mount(ProgressTracker)
25 expect(wrapper.find('[data-testid="progress-bar"]').exists()).toBe(true)
26 expect(wrapper.find('[data-testid="progress-status"]').exists()).toBe(true)
27 })
28 
29 it('updates when progress changes', async () => {
30 gameStore.objectiveProgress = 30
31 const wrapper = mount(ProgressTracker)
32 expect(wrapper.text()).toContain('30%')
33 
34 await gameStore.$patch({ objectiveProgress: 70 })
35 await wrapper.vm.$nextTick()
36 
37 expect(wrapper.text()).toContain('70%')
38 })
39 
40 it('renders fine with no objective set', () => {
41 gameStore.currentObjective = null
42 gameStore.objectiveProgress = 0
43 const wrapper = mount(ProgressTracker)
44 expect(wrapper.find('[data-testid="progress-tracker"]').exists()).toBe(true)
45 })
46 
47 it('flashes a +delta chip when progress jumps', async () => {
48 gameStore.objectiveProgress = 20
49 const wrapper = mount(ProgressTracker)
50 expect(wrapper.find('[data-testid="progress-delta"]').exists()).toBe(false)
51 
52 await gameStore.$patch({ objectiveProgress: 42 })
53 await wrapper.vm.$nextTick()
54 
55 const chip = wrapper.find('[data-testid="progress-delta"]')
56 expect(chip.exists()).toBe(true)
57 expect(chip.text()).toContain('+22%')
58 // The headline number stays in lockstep with the store.
59 expect(wrapper.find('[data-testid="progress-percentage"]').text()).toContain('42%')
60 })
61 
62 it('flashes a negative delta chip when progress drops', async () => {
63 gameStore.objectiveProgress = 50
64 const wrapper = mount(ProgressTracker)
65 
66 await gameStore.$patch({ objectiveProgress: 35 })
67 await wrapper.vm.$nextTick()
68 
69 const chip = wrapper.find('[data-testid="progress-delta"]')
70 expect(chip.exists()).toBe(true)
71 expect(chip.text()).toContain('-15%')
72 })
73 
74 it('reads a fresh 0% as untouched history', () => {
75 gameStore.objectiveProgress = 0
76 const wrapper = mount(ProgressTracker)
77 expect(wrapper.find('[data-testid="progress-status"]').text()).toContain('stands unchanged')
78 })
79 
80 it('reads a 0% floored after a setback as resistance, not a no-op (#128)', () => {
81 // A backfired turn snaps progress back to 0, but the ledger recorded it —
82 // the status line must not be byte-identical to a fresh, untouched run.
83 gameStore.objectiveProgress = 0
84 gameStore.addTimelineEvent({
85 figureName: 'X', era: 'e', headline: 'h', detail: 'd',
86 diceRoll: 1, diceOutcome: DiceOutcome.CRITICAL_FAILURE, progressChange: -15
87 })
88 const wrapper = mount(ProgressTracker)
89 const status = wrapper.find('[data-testid="progress-status"]').text()
90 expect(status).not.toContain('stands unchanged')
91 expect(status).toContain('History resists')
92 })
⋯ 1 line hidden (lines 93–93)
93})