What changed, and why

The compose dock carries a pre-send "⏳" chip that forecasts the causal chain: how far, in years, your chosen moment lands from your nearest foothold (the objective's era, or a change you've already made). Land close and the message hits at full force; land in the deep past with nothing bridging it and the swing decays.

The chip only ever surfaced a caution. Its best tier — at-hinge, a foothold landing at full force — returned null, so the chip vanished. Doing the right thing showed nothing, and null carried two meanings at once: "you nailed the chain" and "the chain doesn't apply here" (an anchorless objective, or no footholds landed yet). A player on a perfect chain and a player off the grid saw the same blank. In playtesting the system read as "invisible".

This change surfaces at-hinge as a positive chip and lets null mean one thing. The blast radius is small: the chip's data already existed in the store; the fix is two lines of view logic plus tuned copy, pinned by a new end-to-end spec. The four files below.

The chip: surfaced for every tier, styled by stake

The view did the hiding, in two spots in pages/play.vue. The template now colors the chip by tier — at-hinge rv-ok (a win), diffuse rv-warn (a caution), the two middle tiers neutral — and the chainRead computed drops the tier === 'at-hinge' arm of its early-return, so every engaged tier reaches the chip. The only state that still returns null is the genuinely-absent one.

The chip's class is keyed by tier; chainRead returns null only when there is no status.

pages/play.vue · 620 lines
pages/play.vue620 lines · Vue
⋯ 169 lines hidden (lines 1–169)
1<template>
2 <div class="min-h-screen lg:h-screen rv-bg flex flex-col lg:overflow-hidden">
3 <!-- HUD: wordmark · objective mission-strip · gauge · status · dark · new-timeline -->
4 <header class="border-b rv-line">
5 <!-- On phones the bar wraps: controls stay on the top line, and the objective
6 (with its foldable brief) drops to its own full-width line so the brief
7 reads at full width instead of a thin column. Single row from sm up. -->
8 <div class="px-3 sm:px-5 py-2.5 flex flex-wrap items-center gap-x-2 gap-y-1.5 sm:flex-nowrap sm:gap-4">
9 <GameTitle />
10 
11 <div v-if="gameStore.currentObjective" class="order-last basis-full min-w-0 sm:order-none sm:basis-auto sm:flex-1">
12 <ObjectiveDisplay data-testid="objective-display" />
13 </div>
14 <div v-else class="flex-1" />
15 
16 <div v-if="gameStore.currentObjective" class="flex items-center gap-1.5 sm:gap-2 shrink-0 ml-auto sm:ml-0">
17 <MessagesCounter data-testid="messages-counter" />
18 <!-- One mis-tap must not erase five AI-resolved turns: with a run in
19 progress the reset arms a tiny inline confirm (auto-reverts); an
20 untouched run still resets in one tap. -->
21 <span v-if="resetArmed" class="flex items-center gap-1.5">
22 <span class="text-[11px] rv-warn">abandon this history?</span>
23 <button type="button" data-testid="reset-confirm" class="rv-tap rv-hover-fg text-sm leading-none rv-warn"
24 aria-label="Yes — abandon this history" @click="performReset"></button>
25 <button type="button" data-testid="reset-cancel" class="rv-tap rv-hover-fg text-sm leading-none"
26 aria-label="Keep playing" @click="disarmReset"></button>
27 </span>
28 <button v-else type="button" data-testid="reset-button" class="rv-tap rv-hover-fg text-base leading-none"
29 title="New timeline" aria-label="New timeline" @click="handleResetGame"></button>
30 </div>
31 
32 <!-- Runs gauge — always visible (briefing + board), and the entry to the
33 account popover / the run-packs modal. -->
34 <RunsBalance class="shrink-0" :class="gameStore.currentObjective ? '' : 'ml-auto'" />
35 
36 <!-- Sound on/off — beside the dark toggle, persisted the same way. The svg is
37 decorative (aria-hidden); the button carries the state-reflecting name. -->
38 <button type="button" data-testid="mute-toggle" class="rv-tap rv-hover-fg leading-none shrink-0"
39 :aria-label="audio.muted ? 'Unmute sound effects' : 'Mute sound effects'"
40 :title="audio.muted ? 'Sound off' : 'Sound on'" :aria-pressed="!audio.muted" @click="toggleMute">
41 <svg width="17" height="17" viewBox="0 0 24 24" fill="none" aria-hidden="true">
42 <path d="M4 9h3l5-4v14l-5-4H4z" fill="currentColor" stroke="currentColor"
43 stroke-width="1.6" stroke-linejoin="round" />
44 <template v-if="!audio.muted">
45 <path d="M16.5 8.5a5 5 0 0 1 0 7" stroke="currentColor" stroke-width="1.8"
46 stroke-linecap="round" />
47 <path d="M19 6a8.5 8.5 0 0 1 0 12" stroke="currentColor" stroke-width="1.8"
48 stroke-linecap="round" />
49 </template>
50 <template v-else>
51 <path d="M16.5 9.5l5 5m0-5l-5 5" stroke="currentColor" stroke-width="1.8"
52 stroke-linecap="round" />
53 </template>
54 </svg>
55 </button>
56 
57 <button type="button" class="rv-tap rv-hover-fg text-base leading-none shrink-0"
58 :aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'" @click="toggleDark"></button>
59 </div>
60 </header>
61 
62 <!-- Post-checkout banner: the missing feedback after returning from Stripe — a
63 credited confirmation, or a gentle "no charge" on cancel. Self-dismisses. -->
64 <div v-if="gameStore.purchaseNotice" data-testid="purchase-notice" role="status" aria-live="polite"
65 class="border-b rv-line px-5 py-2.5 text-sm flex items-center gap-2"
66 :class="gameStore.purchaseNotice === 'success' ? 'rv-tint' : ''">
67 <span aria-hidden="true">{{ gameStore.purchaseNotice === 'success' ? '✓' : '○' }}</span>
68 <span class="rv-fg">
69 <template v-if="gameStore.purchaseNotice === 'success'">
70 Payment received — your runs have been added to your balance.
71 </template>
72 <template v-else>Checkout canceled — you weren't charged.</template>
73 </span>
74 <button type="button" data-testid="purchase-notice-close" class="rv-tap rv-hover-fg ml-auto shrink-0"
75 aria-label="Dismiss" @click="gameStore.clearPurchaseNotice()"></button>
76 </div>
77 
78 <!-- Waiting mode: a thin indeterminate sweep while a turn resolves — the whole
79 board reads as "history is being rewritten," in concert with the shaking die. -->
80 <div v-if="gameStore.isLoading" class="rv-loading-bar" role="status">
81 <span class="sr-only">Resolving your move — history is being rewritten…</span>
82 </div>
83 
84 <!-- At lg the board owns the viewport and scrolls inside its own panes, so main is
85 clipped (lg:overflow-hidden). The mission briefing has no such inner scroll, so
86 on a short viewport that clipping hid the Begin button under the footer — give
87 the briefing a scrollable main instead. -->
88 <main class="px-5 py-6 pb-24 md:pb-6 flex-1"
89 :class="gameStore.currentObjective ? 'lg:min-h-0 lg:py-0 lg:overflow-hidden' : 'lg:overflow-y-auto'">
90 <!-- Briefing: choose (or compose) an objective before the run begins -->
91 <MissionSelect v-if="!gameStore.currentObjective" />
92 
93 <template v-else>
94 <!-- The board, only while the run is live — once it ends, the end-screen
95 takeover fully replaces it (no board bleeding under the verdict).
96 On phones the three zones become one-at-a-time panels driven by the
97 bottom tab bar; from md up every zone is shown together as before. -->
98 <template v-if="gameStore.gameStatus === 'playing'">
99 <!-- THE WORKSPACE — at lg the board splits into two panes that fit the viewport:
100 a scrolling read-model (roll · shift · Spine · Chronicle · Thread) on the
101 left, and the act-model (the compose dock) pinned on the right, so the move
102 is never buried below the read. Below lg this wrapper is inert and the zones
103 fall back to the stacked page (md) / phone tab panels (sm). -->
104 <div class="rv-fade-in lg:h-full lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(360px,440px)] lg:grid-rows-1">
105 <!-- LEFT pane — the read-model. At lg it's a flex column the height of the
106 pane: the board/Spine on top at its natural height, then the story zone
107 flexes to fill the rest, so the Chronicle & Thread are full-height panels
108 (each scrolls inside itself) rather than a single scrolling stack. -->
109 <div class="lg:min-h-0 lg:min-w-0 lg:flex lg:flex-col lg:overflow-hidden lg:pr-6 lg:py-6">
110 <!-- ZONE 1 — the board state: the roll + the shift, then the Spine -->
111 <section class="rv-panel space-y-4 lg:space-y-6 lg:flex-none" :class="[{ 'rv-dim': isFirstTurn }, mobileTab === 'board' ? '' : 'hidden', 'md:block']">
112 <div class="grid sm:grid-cols-2 gap-4">
113 <div class="sm:border-r rv-line sm:pr-6 py-1">
114 <span class="rv-label mb-2 block">Dice of fate</span>
115 <DiceRoller />
116 <!-- The bands, disclosed: the player can always see how fate maps to
117 swing — the same table the Timeline Engine is instructed with. -->
118 <details data-testid="roll-legend" class="mt-2 text-[11px]">
119 <summary class="rv-faint cursor-pointer select-none hover:underline">how fate is weighed</summary>
120 <ul class="mt-1.5 space-y-0.5 rv-mono rv-muted">
121 <li v-for="row in rollLegend" :key="row.label" class="flex justify-between gap-3">
122 <span>{{ row.range }} · {{ row.label }}</span><span :class="row.cls">{{ row.swing }}</span>
123 </li>
124 </ul>
125 <p class="rv-faint mt-1.5 leading-snug">✒ craft tilts the roll (±2) · ⚡ anachronism widens the result — gains gently, losses hard · ⚑ a staked final dispatch doubles everything</p>
126 </details>
127 </div>
128 <div class="sm:pl-2 flex flex-col justify-center gap-3">
129 <ProgressTracker />
130 <MomentumMeter />
131 </div>
132 </div>
133 <TimelineLedger />
134 </section>
135 
136 <!-- ZONE 2 — the story: the living chronicle · the conversation thread. At lg
137 this flexes to fill the rest of the pane and lays the two out side by side
138 as equal full-height columns (md keeps the two-up grid in the page flow). -->
139 <section class="rv-panel mt-9 lg:mt-10 gap-x-8 gap-y-2" :class="[{ 'rv-dim': isFirstTurn }, mobileTab === 'story' ? 'grid' : 'hidden', 'md:grid md:grid-cols-2', 'lg:grid-rows-1 lg:flex-1 lg:min-h-0']">
140 <Chronicle :force-open="isMd" />
141 <!-- Thread: a <div> from md up (see Chronicle for why a <details> can't fill the
142 column height), a collapsible <details> on phones. -->
143 <component :is="isMd ? 'div' : 'details'" class="rv-rail lg:h-full lg:min-h-0 lg:flex lg:flex-col" :open="isMd ? null : threadOpen" @toggle="onThreadToggle">
144 <summary class="rv-summary" :class="{ 'rv-summary--static': isMd }">
145 <span class="rv-label">Thread<span class="normal-case tracking-normal rv-faint font-normal"> · your messages<span v-if="gameStore.activeFigureName"> · {{ gameStore.activeFigureName }}</span></span></span>
146 </summary>
147 <div class="pt-2 lg:flex-1 lg:min-h-0 lg:overflow-y-auto">
148 <MessageHistory data-testid="message-history" />
149 </div>
150 </component>
151 </section>
152 </div><!-- /LEFT pane -->
153 
154 <!-- RIGHT pane — the act-model, pinned beside the read column at lg -->
155 <div class="lg:min-h-0 lg:overflow-y-auto lg:border-l rv-line lg:pl-6 lg:py-6">
156 <!-- ZONE 3 — your turn: the compose dock (the page-stack divider is dropped at
157 lg, where it's a column of its own, not a section below the read). -->
158 <section class="rv-panel mt-9 border-t rv-line pt-5 lg:mt-0 lg:border-t-0 lg:pt-0" :class="[mobileTab === 'compose' ? '' : 'hidden', 'md:block']">
159 <div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
160 <span class="rv-label">Send a message into the past</span>
161 <!-- The wager, read BEFORE the roll: when the Archive has stamped a
162 known-since and a contact year is chosen, the chip translates the
163 reach into the same ⚡ tiers the spine uses. Otherwise the general
164 principle is stated — on the first turn too, where it teaches most. -->
165 <span v-if="wagerRead" data-testid="wager-chip" class="text-[11px]"
166 :class="wagerRead.tier === 'in-period' ? 'rv-muted' : 'rv-warn'">
167 <span aria-hidden="true">{{ wagerRead.pips }}</span> {{ wagerRead.text }}
168 </span>
169 <span v-else data-testid="wager-line" class="text-[11px] rv-warn"><span aria-hidden="true"></span> the further your words reach beyond their era, the harder the timeline swings — both ways</span>
170 <!-- The chain, read BEFORE the roll: how far this moment lands from your
171 nearest foothold (the objective's era or a change you've made). A
172 lone leap into the deep past is diffuse (warned); landing on a
173 foothold is at-hinge, full force (a positive read) — so good
174 chaining is confirmed, not silent. -->
175 <span v-if="chainRead" data-testid="chain-chip" class="text-[11px]"
176 :class="chainRead.tier === 'at-hinge' ? 'rv-ok' : chainRead.tier === 'diffuse' ? 'rv-warn' : 'rv-muted'">
177 <span aria-hidden="true"></span> {{ chainRead.text }}
⋯ 163 lines hidden (lines 178–340)
178 </span>
179 </div>
180 
181 <p v-if="isFirstTurn" class="rv-accent text-sm mb-4">
182 Reach someone in history, write up to 160 characters, and send — one message can bend the whole timeline.
183 </p>
184 
185 <div class="grid md:grid-cols-2 lg:grid-cols-1 gap-x-8 gap-y-6">
186 <div>
187 <span class="rv-label block mb-2"><span class="rv-accent">1</span> · choose who &amp; when</span>
188 <FigurePicker v-model="figure" :disabled="!gameStore.canSendMessage" />
189 </div>
190 
191 <div class="space-y-3 rv-line lg:border-t lg:pt-6">
192 <span class="rv-label block mb-2"><span class="rv-accent">2</span> · write &amp; send</span>
193 <MessageInput ref="messageInputRef" />
194 <!-- The last stand: offered only when the final dispatch can no
195 longer win inside the normal per-turn cap — a true last resort,
196 never a free doubling from a winnable position. -->
197 <div v-if="gameStore.canStake" data-testid="stake-control"
198 class="border rounded-sm px-2.5 py-2 flex items-start gap-2"
199 :class="stakeArmed ? 'stake-armed' : 'rv-line'">
200 <input id="stake-toggle" v-model="stakeArmed" type="checkbox" class="mt-0.5"
201 :style="{ accentColor: 'var(--rv-accent)' }" />
202 <label for="stake-toggle" class="text-[11px] leading-snug cursor-pointer">
203 <span class="rv-warn font-semibold uppercase tracking-wide"><span aria-hidden="true"></span> Stake the timeline</span>
204 <span class="rv-muted block">Your final dispatch cuts twice as deep — both ways — and can move the whole meter. The last stand.</span>
205 </label>
206 </div>
207 <div class="flex items-center gap-3 flex-wrap">
208 <SendButton data-testid="send-button" :input-valid="canSend" @click="handleSendMessage" />
209 <div v-if="gameStore.error" data-testid="error-alert" role="status" aria-live="polite"
210 class="flex items-center gap-2 text-xs flex-1 min-w-0 border-l-2 rv-line pl-2 py-0.5">
211 <span class="rv-label shrink-0">notice</span>
212 <span class="rv-muted truncate">{{ gameStore.error }}</span>
213 <button type="button" data-testid="error-close" class="rv-tap rv-hover-fg shrink-0 ml-auto" aria-label="Dismiss notice" @click="gameStore.setError(null)"></button>
214 </div>
215 <div v-if="gameStore.moderationNotice" data-testid="moderation-alert" role="alert" aria-live="assertive"
216 class="flex items-start gap-2 text-xs flex-1 min-w-0 border-l-2 border-red-500/70 bg-red-500/5 pl-2 py-1 text-red-300">
217 <span class="shrink-0 font-semibold uppercase tracking-wide">⚠ blocked</span>
218 <span class="min-w-0">{{ gameStore.moderationNotice }}</span>
219 <button type="button" data-testid="moderation-close" class="rv-tap shrink-0 ml-auto hover:text-red-100" aria-label="Dismiss" @click="gameStore.clearModerationNotice()"></button>
220 </div>
221 </div>
222 <ArchiveLookup />
223 </div>
224 </div>
225 </section>
226 </div><!-- /RIGHT pane -->
227 </div><!-- /workspace -->
228 </template>
229 </template>
230 </main>
231 
232 <!-- Phone-only tab bar: the three zones as fixed panels you switch between, so the
233 board never becomes one infinite scroll and your next move is always one tap
234 away. Hidden from md up (where every zone is shown at once). -->
235 <!-- A labelled nav of view-switch buttons (NOT an ARIA tablist: there's no roving
236 tabindex / arrow-key tabset here, and the zones are simple show/hide regions).
237 The active view is conveyed with aria-current, the honest, complete pattern. -->
238 <nav v-if="gameStore.currentObjective && gameStore.gameStatus === 'playing'"
239 class="md:hidden fixed bottom-0 inset-x-0 z-30 border-t rv-line rv-bg grid grid-cols-3"
240 aria-label="Switch board view">
241 <button v-for="t in mobileTabs" :key="t.id" type="button" :aria-current="mobileTab === t.id ? 'true' : undefined"
242 class="mobile-tab rv-press" :class="{ 'is-active': mobileTab === t.id }" @click="mobileTab = t.id">
243 <span class="text-lg leading-none" aria-hidden="true">{{ t.glyph }}</span>
244 <span class="rv-label flex items-center gap-1">
245 {{ t.label }}
246 <span v-if="t.id === 'compose' && yourMove" class="rv-dot rv-dot--accent" aria-hidden="true" />
247 </span>
248 </button>
249 </nav>
250 
251 <!-- A ledger running-foot: header + footer frame the page as a document.
252 (Hidden on phones, where the tab bar is the foot.) -->
253 <footer class="border-t rv-line px-5 py-3 hidden md:block text-[11px] rv-faint">
254 <div class="flex items-center gap-2">
255 <span class="rv-mono uppercase tracking-wide">Everwhen</span>
256 <span class="rv-hair-c" aria-hidden="true">·</span>
257 <span class="rv-mono">a history you are writing</span>
258 <span class="ml-auto rv-serif italic">the past is not fixed</span>
259 </div>
260 <!-- Persistent compliance line (AI disclosure + fiction + adults-only). -->
261 <p data-testid="footer-disclosure" class="mt-1.5">
262 Figures are AI, not real people · replies are AI-generated fiction, not historical fact · for adults (18+)
263 </p>
264 </footer>
265 
266 <EndGameScreen @play-again="performReset" />
267 
268 <!-- The run-packs store: opened from the header/account or automatically when a
269 commit is refused for being out of runs. Mounted once, here. -->
270 <RunPacksModal />
271 
272 <!-- The guided first run (issue #60): a skippable, non-blocking coach-mark that
273 teaches a brand-new player the core loop in context, then gets out of the
274 way. Client-only — it reads localStorage and positions against live rects. -->
275 <ClientOnly><CoachMark /></ClientOnly>
276 
277 <!-- Developer mode (issue #105): a floating panel to traverse stages and force
278 outcomes through the real mechanics. Self-gates on import.meta.dev /
279 public.devMode — tree-shaken out of a normal production build. -->
280 <ClientOnly><DevPanel /></ClientOnly>
281 </div>
282</template>
283 
284<script setup lang="ts">
285/**
286 * Main game page — Everwhen, "The Spine Console" layout.
287 *
288 * A full-bleed board grouped into three super-zones — board-state (roll + shift +
289 * Spine), story (chronicle · thread), and your-turn (the compose dock) — separated
290 * by space, not chrome. State lives in the store; this only arranges it.
291 */
292import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
293import { useGameStore, formatContactYear } from '~/stores/game'
294import { useCoachingStore } from '~/stores/coaching'
295import { useAudioStore } from '~/stores/audio'
296import { useGameSoundscape } from '~/composables/useGameSoundscape'
297import { SWING_BANDS } from '~/utils/swing-bands'
298import { DiceOutcome, CRIT_FAIL_MAX, FAILURE_MAX, NEUTRAL_MAX, CRIT_SUCCESS_MIN } from '~/utils/dice'
299import { parseKnownSinceYear, wagerTier } from '~/utils/known-since'
300import { CHAIN_HINT } from '~/utils/causal-chain'
301import { ANACHRONISM_LABEL } from '~/server/utils/anachronism'
302import { readAuthReturnParams, hasAuthReturn, finalizeAuthReturn } from '~/utils/auth-return'
303 
304const gameStore = useGameStore()
305 
306// The soundscape (issue #92): this one call wires every game transition to its cue
307// (decoupled — the store stays sound-agnostic) and returns the engine handle for the
308// few UI-driven cues. The audio store holds the persisted mute/volume the header
309// control toggles. Both survive resetGame() untouched.
310const audio = useAudioStore()
311const soundscape = useGameSoundscape()
312 
313// The disclosed band table — rendered from the same constants the Timeline
314// Engine is instructed with, so the legend can't lie.
315const fmtSwing = (b: { min: number; max: number }) =>
316 `${b.min >= 0 ? '+' : ''}${b.min}${b.max >= 0 ? '+' : ''}${b.max}%`
317const rollLegend = [
318 { range: `${CRIT_SUCCESS_MIN}–20`, label: 'Critical Success', swing: fmtSwing(SWING_BANDS[DiceOutcome.CRITICAL_SUCCESS]), cls: 'rv-ok' },
319 { range: `${NEUTRAL_MAX + 1}${CRIT_SUCCESS_MIN - 1}`, label: 'Success', swing: fmtSwing(SWING_BANDS[DiceOutcome.SUCCESS]), cls: 'rv-ok' },
320 { range: `${FAILURE_MAX + 1}${NEUTRAL_MAX}`, label: 'Neutral', swing: fmtSwing(SWING_BANDS[DiceOutcome.NEUTRAL]), cls: 'rv-muted' },
321 { range: `${CRIT_FAIL_MAX + 1}${FAILURE_MAX}`, label: 'Failure', swing: fmtSwing(SWING_BANDS[DiceOutcome.FAILURE]), cls: 'rv-warn' },
322 { range: `1–${CRIT_FAIL_MAX}`, label: 'Critical Failure', swing: fmtSwing(SWING_BANDS[DiceOutcome.CRITICAL_FAILURE]), cls: 'rv-bad' }
324 
325// The pre-send wager chip: Archive known-since × chosen contact year → ⚡ tier.
326const wagerRead = computed(() => {
327 const lookup = gameStore.archiveResult
328 const when = gameStore.contactWhen
329 if (!lookup?.knownSince || when == null) return null
330 const knownYear = parseKnownSinceYear(lookup.knownSince)
331 if (knownYear == null) return null
332 const tier = wagerTier(knownYear, when)
333 const pips = tier === 'impossible' ? '⚡⚡⚡' : tier === 'far-ahead' ? '⚡⚡' : tier === 'ahead' ? '⚡' : '✓'
334 // Hedged copy: this is a year-gap compass, not the engine's verdict.
335 const text = tier === 'in-period'
336 ? `${lookup.topic}: already known by ${formatContactYear(when)} — steady ground`
337 : `${lookup.topic}: reads ≈ ${ANACHRONISM_LABEL[tier].toLowerCase()} for ${formatContactYear(when)} — swings harder, both ways`
338 return { tier, pips, text }
339})
340 
341// The pre-send chain chip: how far the chosen moment lands from the nearest
342// foothold (the objective's era or a prior change). Surfaced for every tier —
343// at-hinge reads as a win (full force), diffuse as a caution — so good chaining
344// gets the same confirmation a caution does, not silence. A null status now
345// means only "no chain to read" (anchorless objective / no footholds yet).
346const chainRead = computed(() => {
347 const s = gameStore.causalChainRead
348 if (!s) return null
349 return { tier: s.tier, text: CHAIN_HINT[s.tier] }
350})
⋯ 270 lines hidden (lines 351–620)
351 
352// The last stand, armed by the player on their final dispatch. Arming it is the
353// wager committed — a tension swell marks the doubling.
354const stakeArmed = ref(false)
355watch(stakeArmed, (armed) => { if (armed) soundscape.play('stake') })
356 
357const figure = ref('')
358watch(figure, (name) => gameStore.setActiveFigure((name || '').trim()))
359 
360const messageInputRef = ref()
361const messageInputValid = computed(() => messageInputRef.value?.isValid ?? false)
362const canSend = computed(() => messageInputValid.value && figure.value.trim().length > 0 && gameStore.canContact)
363 
364// The very first turn: the read-model is empty, so quiet it and lead the eye to the dock.
365const isFirstTurn = computed(() => gameStore.timelineEvents.length === 0)
366 
367// Phone tab bar: the three zones as switchable panels (md+ shows them all at once, so
368// this state is inert there). The run opens on the move; the instant a turn is rolling
369// we flip to the Board so the die + shift + spine land as the payoff beat, then the
370// player taps back to Compose. A nudge dot marks Compose when it's their move.
371type MobileTab = 'board' | 'story' | 'compose'
372const mobileTabs = [
373 { id: 'board', glyph: '🎲', label: 'Board' },
374 { id: 'story', glyph: '📖', label: 'Story' },
375 { id: 'compose', glyph: '✎', label: 'Compose' }
376] as const
377const mobileTab = ref<MobileTab>('compose')
378const yourMove = computed(() => gameStore.canSendMessage && !gameStore.isLoading && mobileTab.value !== 'compose')
379watch(() => gameStore.isLoading, (loading) => { if (loading) mobileTab.value = 'board' })
380watch(() => gameStore.currentObjective, (obj) => { if (obj) mobileTab.value = 'compose' })
381 
382// Thread rail (below md): open by default (the figure's replies are the game's most
383// charming content — collapsed-by-default made them too easy to miss). A fold is the
384// player's explicit choice now, so nothing force-reopens it. From md up it's forced
385// open as a full panel beside the Chronicle (isMd), so its toggle is inert there —
386// guard the handler so the native toggle event can't flip our own state.
387const threadOpen = ref(true)
388function onThreadToggle(e: Event) { if (!isMd.value) threadOpen.value = (e.target as HTMLDetailsElement).open }
389 
390// When a run begins, drop the cursor straight into the "who" field.
391function focusFigure() {
392 nextTick(() => (document.querySelector('[data-testid="figure-input"]') as HTMLElement | null)?.focus())
394watch(() => gameStore.currentObjective, (obj) => { if (obj) focusFigure() })
395 
396// Lock body scroll while the end-screen takeover is up (so the board can't peek).
397watch(() => gameStore.gameStatus, (s) => {
398 if (typeof document === 'undefined') return
399 document.body.style.overflow = (s === 'victory' || s === 'defeat') ? 'hidden' : ''
400})
401 
402// md+ breakpoint — from md up every zone is shown together, so the Chronicle is
403// forced open as the prose payoff rather than a tap-to-open rail (below md it stays
404// a collapsible rail in the Story tab); tracked reactively.
405const isMd = ref(false)
406let mdMql: MediaQueryList | null = null
407function syncMd() { isMd.value = mdMql?.matches ?? false }
408onMounted(() => {
409 mdMql = window.matchMedia('(min-width: 768px)')
410 syncMd()
411 mdMql.addEventListener('change', syncMd)
412})
413onUnmounted(() => { mdMql?.removeEventListener('change', syncMd) })
414 
415// Load the device's run balance for the header gauge, and handle the return from
416// Stripe Checkout: ?purchase=success|cancel drives the banner + a balance refresh,
417// then the query is stripped so a later refresh can't re-fire it.
418const route = useRoute()
419const router = useRouter()
420const supabase = useSupabaseClient()
421onMounted(async () => {
422 // A magic-link / email-change sign-in lands back here carrying an artifact to
423 // consume (?code= the Supabase client auto-exchanges; ?token_hash= we verify;
424 // ?error= means the link failed). On success the anonymous account upgrades in
425 // place — same id, runs carried over — so re-read the balance (the account view
426 // is server-derived) and strip the query so a reload can't re-fire a spent link.
427 const authReturn = readAuthReturnParams(route.query)
428 if (hasAuthReturn(authReturn)) {
429 const result = await finalizeAuthReturn(authReturn, {
430 settle: async () => {
431 const { data } = await supabase.auth.getSession()
432 return Boolean(data.session && !data.session.user.is_anonymous)
433 },
434 verifyOtp: (params) => supabase.auth.verifyOtp(params)
435 })
436 if (result.status === 'error') {
437 console.warn('[auth] sign-in link could not be finalized:', authReturn.error, authReturn.errorDescription)
438 }
439 await gameStore.loadBalance()
440 router.replace({ query: {} })
441 return
442 }
443 
444 const purchase = route.query.purchase
445 if (purchase === 'success' || purchase === 'cancel') {
446 await gameStore.notePurchaseReturn(purchase)
447 router.replace({ query: {} })
448 // The webhook credits asynchronously and can lag Stripe's redirect, so the
449 // first read may predate the credit. Poll briefly so the header gauge
450 // converges to the credited total without a manual reload — stopping as soon
451 // as it changes, or after a few tries (the credit is guaranteed by the
452 // webhook + its retries regardless). The banner asserts no specific count.
453 if (purchase === 'success') {
454 const before = gameStore.runsRemaining
455 for (let i = 0; i < 4; i++) {
456 await new Promise((r) => setTimeout(r, 1500))
457 await gameStore.loadBalance()
458 if (gameStore.runsRemaining !== before) break
459 }
460 }
461 } else {
462 await gameStore.loadBalance()
463 }
464})
465 
466// Dark-mode toggle via @nuxtjs/color-mode (bundled with @nuxt/ui): persists across
467// reloads, respects the OS preference, and ships a no-flash inline script — replacing
468// the old manual classList toggle that did none of those.
469const colorMode = useColorMode()
470const isDark = computed(() => colorMode.value === 'dark')
471function toggleDark() {
472 colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
474 
475// Sound on/off (issue #92), persisted like the dark toggle. The click is itself a
476// user gesture, so unmuting unlocks the audio context here and chirps a tick to
477// confirm sound is live; muting falls silent (the cue would be inaudible anyway).
478function toggleMute() {
479 audio.toggleMute()
480 if (!audio.muted) {
481 soundscape.unlock()
482 soundscape.play('uiTick')
483 }
485 
486const handleSendMessage = async () => {
487 if (!messageInputRef.value) return
488 const messageText = messageInputRef.value.message?.trim()
489 const target = figure.value.trim()
490 if (!messageText || !messageInputRef.value.isValid || !target) return
491 
492 // Clear the composer only when the turn actually resolved — a blocked or
493 // failed send keeps the player's crafted 160 characters for another try.
494 const resolved = await gameStore.sendMessage(messageText, target, { stake: stakeArmed.value })
495 if (resolved) {
496 stakeArmed.value = false
497 if (messageInputRef.value) messageInputRef.value.message = ''
498 }
500 
501// The header reset arms a confirm when a run is in progress (and auto-disarms);
502// performReset is the single unified reset path — the end screen's Play Again
503// lands here too, so every reset clears the figure input and composer alike.
504const resetArmed = ref(false)
505let resetArmTimer: ReturnType<typeof setTimeout> | null = null
506 
507const handleResetGame = () => {
508 // One tap only when there is truly nothing to lose: no resolved turns AND no
509 // words or contact in progress (a typed first dispatch is work too).
510 const composerDirty = !!messageInputRef.value?.message?.trim() || !!figure.value.trim()
511 if (gameStore.timelineEvents.length === 0 && !composerDirty) {
512 performReset()
513 return
514 }
515 resetArmed.value = true
516 if (resetArmTimer) clearTimeout(resetArmTimer)
517 resetArmTimer = setTimeout(() => { resetArmed.value = false }, 3500)
519 
520function disarmReset() {
521 resetArmed.value = false
522 if (resetArmTimer) clearTimeout(resetArmTimer)
524 
525function performReset() {
526 disarmReset()
527 stakeArmed.value = false
528 gameStore.resetGame()
529 figure.value = ''
530 if (messageInputRef.value) messageInputRef.value.message = ''
531 focusFigure()
533 
534onUnmounted(() => { if (resetArmTimer) clearTimeout(resetArmTimer) })
535 
536// ── Guided first run (issue #60) ──────────────────────────────────────────
537// A teaching layer for a brand-new player's first game. This page is the single
538// orchestration site: it reads the game and drives the coaching store one way,
539// so the store stays game-agnostic and survives resetGame() untouched. The board
540// never blocks — the coach-mark only points; the player plays through it.
541const coaching = useCoachingStore()
542const autoStartCtx = () => ({
543 hasObjective: !!gameStore.currentObjective,
544 isPlaying: gameStore.gameStatus === 'playing',
545 isFirstTurn: gameStore.timelineEvents.length === 0
546})
547onMounted(() => {
548 audio.hydrate()
549 coaching.hydrate()
550 coaching.maybeAutoStart(autoStartCtx())
551})
552// A run beginning cues a brand-new player's first beat; tearing the board down
553// (New timeline) ends a tour left running rather than stranding it over an empty board.
554watch(() => gameStore.currentObjective, (obj) => {
555 if (obj) coaching.maybeAutoStart(autoStartCtx())
556 else coaching.stop()
557})
558// Replays can begin mid-run, so the reveal + overstay beats key off turns resolved
559// SINCE the tour started, not absolute counts (a fresh first run baselines at 0).
560const tourBaseTurns = ref(0)
561watch(() => coaching.isRunning, (running) => {
562 if (running) tourBaseTurns.value = gameStore.timelineEvents.length
563})
564// Auto-advance on the real action. Picking who & when clears the first beat; the
565// reading beats (the dispatch, the wager) advance on the card's own Next; the
566// send beat waits for an actual resolved turn below.
567watch(
568 () => !!gameStore.figureGrounding?.resolved && gameStore.contactWhen != null,
569 (ready) => { if (ready) coaching.advanceFrom('who-when') }
571// The first turn resolved since the tour began is the payoff beat; advanceTo is
572// monotonic, so it also fast-forwards a player who sent before pressing Next on the
573// readers. One turn further and the loop has plainly landed, so a tour still running
574// has overstayed — it bows out on its own (the engaged reader pressed Done already).
575watch(() => gameStore.timelineEvents.length, (n) => {
576 if (!coaching.isRunning) return
577 if (n >= tourBaseTurns.value + 2) coaching.complete()
578 else if (n > tourBaseTurns.value) coaching.advanceTo('roll-swing')
579})
580// On each new beat, bring its host control into view on phones (md+ shows every
581// zone at once, so this is inert there). Only on a real step change — never yanks
582// a player who tapped elsewhere mid-beat back.
583watch(() => coaching.activeStep?.hostTab, (tab) => {
584 if (tab && !isMd.value) mobileTab.value = tab
585})
586 
587useHead({
588 title: 'Everwhen — Rewrite History',
589 meta: [
590 { name: 'description', content: 'Send 160-character messages to anyone in history. A D20 and an AI decide how the timeline bends.' }
591 ]
592})
593</script>
594 
595<style scoped>
596/* Phone tab bar — letterpress, not chrome: a hairline-topped strip, the active tab
597 marked by the one accent and a terracotta top rule. ≥44px tall for the thumb. */
598.mobile-tab {
599 display: flex;
600 flex-direction: column;
601 align-items: center;
602 justify-content: center;
603 gap: 2px;
604 padding: 8px 0 9px;
605 color: var(--rv-faint);
606 border-top: 2px solid transparent;
607 transition: color var(--rv-dur-1) var(--rv-ease), border-color var(--rv-dur-1) var(--rv-ease);
609.mobile-tab .rv-label { color: inherit; }
610.mobile-tab.is-active {
611 color: var(--rv-accent);
612 border-top-color: var(--rv-accent);
614 
615/* The armed last stand — a quiet accent wash, not a flashing alarm. */
616.stake-armed {
617 border-color: var(--rv-accent);
618 background: color-mix(in srgb, var(--rv-accent) 7%, transparent);
620</style>

That null now means exactly one thing, because it is a straight pass-through of the store getter. causalChainRead builds the footholds (the objective's anchor plus every dated change) and asks chainStatus, which returns null only for an ungrounded contact or an anchorless objective with nothing landed yet — never for at-hinge.

The chip's source. Null comes from chainStatus, not from a tier check.

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

Copy that reads as a win — and the label left alone

A surfaced at-hinge chip needs copy that lands as a win, in the same shape as its siblings. The hint is tuned from "right at the hinge of events — full force" to "anchored at the hinge — lands at full force": the same "[state] — [consequence]" form as bridged ("lands strong"), reaching ("landing diluted"), and diffuse ("build a chain toward it first").

CHAIN_HINT['at-hinge'] tuned to a win; CHAIN_LABEL left untouched.

utils/causal-chain.ts · 129 lines
utils/causal-chain.ts129 lines · TypeScript
⋯ 60 lines hidden (lines 1–60)
1/**
2 * The causal chain — a deterministic dial that makes history bend by STEPPING
3 * STONES, not single blasts. It is the opposite-pulling sibling of the
4 * anachronism amplifier (server/utils/anachronism.ts): anachronism WIDENS the
5 * swing for reaching beyond a figure's own era; this DECAYS the swing for a
6 * message that lands far from anything you've already changed.
7 *
8 * The philosophy (see docs/ROADMAP.md, CLAUDE.md):
9 * A message should lose its power the further it lands, in time, from your
10 * nearest FOOTHOLD — and your footholds are the objective itself plus every
11 * change you have already landed. So:
12 * - CHAINING is the intended, rewarded path. A far-back seed (tell Leonardo
13 * about gunpowder, aiming at the Moon by 1900) lands only modestly on its
14 * own, but it plants a foothold; the next move near it is now strong, and
15 * the chain marches forward to the event, compounding.
16 * - A LONE BLAST is diffuse by design. Message Caesar in 50 BC to change
17 * 1960 and the nudge fizzles across two millennia with nothing bridging
18 * it — even a critical success lands a sliver. You cannot one-shot a
19 * rewrite of deep history; you must build the chain forward to it.
20 *
21 * Rails, not walls: a far reach is never BLOCKED, only diffuse (the factor has
22 * a floor, never zero), so an expert can still open from antiquity and grind a
23 * chain up through the centuries. Deterministic + legible: the gap is computed
24 * from signed years we already hold (no model judgment, no calendar math), and
25 * a ⏳ chip shows the tier before you send, the way ⚡ shows the wager.
26 *
27 * Engages only for GROUNDED contacts (a known contact year) against a known
28 * objective anchor or a dated prior change. Free-form / ungrounded play, or an
29 * objective with no anchor year, simply no-ops (factor 1).
30 */
31 
32export type ChainTier = 'at-hinge' | 'bridged' | 'reaching' | 'diffuse'
33 
34export const CHAIN_TIERS: ChainTier[] = ['at-hinge', 'bridged', 'reaching', 'diffuse']
35 
36export interface ChainStatus {
37 /** Years to the nearest foothold (objective anchor or a landed change). */
38 gap: number
39 /** Decay multiplier applied to the swing magnitude, in [FLOOR, 1]. */
40 factor: number
41 tier: ChainTier
43 
44/**
45 * Tuning intent. Within BRIDGE_FREE years of a foothold the move is at full
46 * power — adjacent-era chaining (a step every couple of generations) is
47 * frictionless, which is what makes building a chain forward feel fluid. Beyond
48 * that the factor decays exponentially with SCALE and never falls below FLOOR
49 * (diffuse, never inert; the reach is weak, not forbidden).
50 *
51 * Calibrated against the two anchor cases:
52 * - a ~400yr opening reach (Leonardo 1500 → Moon-by-1900) ≈ 0.55: a real,
53 * worthwhile seed that needs the chain built behind it to pay off;
54 * - a ~2000yr lone reach (Caesar 50 BC → 1960) → FLOOR: a sliver even on a
55 * crit, so the one-shot rewrite is structurally near-impossible.
56 */
57export const BRIDGE_FREE = 75
58const SCALE = 550
59export const CHAIN_FLOOR = 0.12
60 
61export const CHAIN_LABEL: Record<ChainTier, string> = {
62 'at-hinge': 'At the hinge',
63 bridged: 'Bridged',
64 reaching: 'Reaching',
65 diffuse: 'Diffuse'
⋯ 1 line hidden (lines 67–67)
67 
68/** A one-line player hint per tier (for the ⏳ chip / ledger badge). */
69export const CHAIN_HINT: Record<ChainTier, string> = {
70 'at-hinge': 'anchored at the hinge — lands at full force',
71 bridged: 'bridged to your chain — lands strong',
72 reaching: 'reaching past your footholds — landing diluted',
73 diffuse: 'adrift in time — build a chain toward it first'
⋯ 55 lines hidden (lines 75–129)
75 
76/**
77 * Years from a contact year to the NEAREST foothold. Footholds are the
78 * objective's anchor year plus every dated change already landed. Returns null
79 * when there is nothing to measure against (no anchor, no dated changes) or no
80 * contact year — the dial no-ops.
81 */
82export function nearestFootholdGap(
83 figureYear: number | null | undefined,
84 footholds: ReadonlyArray<number | null | undefined>
85): number | null {
86 if (figureYear == null || !Number.isFinite(figureYear)) return null
87 const valid = footholds.filter((f): f is number => typeof f === 'number' && Number.isFinite(f))
88 if (!valid.length) return null
89 return Math.min(...valid.map(f => Math.abs(figureYear - f)))
91 
92/** Gap (years to nearest foothold) → decay factor in [CHAIN_FLOOR, 1]. */
93export function chainDecay(gap: number | null): number {
94 if (gap == null) return 1 // no foothold to measure against — no-op
95 if (gap <= BRIDGE_FREE) return 1
96 return Math.max(CHAIN_FLOOR, Math.exp(-(gap - BRIDGE_FREE) / SCALE))
98 
99/** Bucket a decay factor into a legible tier for the player-facing signal. */
100export function chainTier(factor: number): ChainTier {
101 if (factor >= 0.85) return 'at-hinge'
102 if (factor >= 0.55) return 'bridged'
103 if (factor >= 0.25) return 'reaching'
104 return 'diffuse'
106 
107/**
108 * The full chain status for a contact year against a set of footholds, or null
109 * when the dial doesn't engage (ungrounded / no anchor). Shared by the server
110 * (the real decay) and the client (the pre-send ⏳ chip).
111 */
112export function chainStatus(
113 figureYear: number | null | undefined,
114 footholds: ReadonlyArray<number | null | undefined>
115): ChainStatus | null {
116 const gap = nearestFootholdGap(figureYear, footholds)
117 if (gap == null) return null
118 const factor = chainDecay(gap)
119 return { gap, factor, tier: chainTier(factor) }
121 
122/**
123 * Decays a swing magnitude toward zero by the chain factor — both signs (a far,
124 * unbridged reach is a faint nudge whether it would have helped or hurt). A
125 * factor of 1 (engaged near a foothold, or a no-op) returns the swing unchanged.
126 */
127export function decayForChain(progressChange: number, factor: number): number {
128 return Math.round(progressChange * factor)

Pinning both outcomes

The fix lives in a render computed, so the discriminating test is an end-to-end one that drives the real compose dock with grounding mocked — making the chip's tier a pure function of the objective's anchor and the contact year. Archduke Franz Ferdinand (1863–1914) against the 1914-anchored "Prevent the Great War" sits inside the bridge-free radius, so the chain reads at-hinge; an anchorless objective has no foothold to measure against, so the chip is absent.

Two cases: at-hinge surfaces with rv-ok; an anchorless objective shows no chip.

tests/e2e/features/chain-chip.spec.ts · 61 lines
tests/e2e/features/chain-chip.spec.ts61 lines · TypeScript
⋯ 15 lines hidden (lines 1–15)
1import { test, expect } from '@nuxt/test-utils/playwright'
2import { mockFigure, mockFigureSearch, mockSuggestions, selectFigure } from '../support/game'
3 
4/**
5 * The pre-send causal-chain "⏳" chip (issue #136). It used to surface only a
6 * caution: the best tier (at-hinge) returned null, so perfect chaining showed
7 * nothing and null was overloaded ("you nailed it" vs "chain doesn't apply").
8 * Now at-hinge reads as a POSITIVE chip, and null means only "no chain to read".
9 *
10 * These drive the real compose dock with grounding mocked, so the chip's tier is
11 * a pure function of the chosen objective's anchor + the contact year.
12 */
13test.describe('Causal-chain chip (pre-send)', () => {
14 /** Heir to Austria-Hungary, 1863–1914: any contact year sits within the
15 * bridge-free radius of the 1914 anchor, so the chain reads at-hinge. */
16 const FRANZ_FERDINAND = {
17 resolved: true,
18 name: 'Archduke Franz Ferdinand',
19 description: 'Heir to the Austro-Hungarian throne',
20 born: { signed: 1863, display: '1863' },
21 died: { signed: 1914, display: '1914' },
22 living: false
23 }
24 
25 async function beginObjective(page: import('@playwright/test').Page, title: string) {
26 await mockFigureSearch(page)
27 await mockSuggestions(page)
28 await page.goto('/play')
29 await expect(page.locator('[data-testid="mission-select"]')).toBeVisible()
30 await page.locator('[data-testid="objective-option"]', { hasText: title }).first().click()
31 await page.locator('[data-testid="begin-button"]').click()
32 await expect(page.locator('[data-testid="message-input"]')).toBeVisible()
33 }
⋯ 1 line hidden (lines 34–34)
34 
35 test('landing on the anchor surfaces a positive at-hinge chip', async ({ page, goto }) => {
36 await goto('/play', { waitUntil: 'hydration' })
37 // A 1914-era figure against the 1914-anchored objective → at-hinge.
38 await mockFigure(page, FRANZ_FERDINAND)
39 await beginObjective(page, 'Prevent the Great War')
40 await selectFigure(page, 'Archduke Franz Ferdinand')
41 
42 const chip = page.locator('[data-testid="chain-chip"]')
43 await expect(chip).toBeVisible()
44 await expect(chip).toContainText('full force')
45 // Styled as a win, not the diffuse caution.
46 await expect(chip).toHaveClass(/rv-ok/)
47 await expect(chip).not.toHaveClass(/rv-warn/)
48 })
49 
50 test('an anchorless objective shows no chip (null means only "no chain")', async ({ page, goto }) => {
51 await goto('/play', { waitUntil: 'hydration' })
52 // Grounded contact, but the objective spans all history (no anchor) and no
53 // change has landed yet → there is nothing to measure against → no chip.
54 await mockFigure(page, FRANZ_FERDINAND)
55 await beginObjective(page, 'Establish Global Democracy')
56 await selectFigure(page, 'Archduke Franz Ferdinand')
57 
58 await expect(page.locator('[data-testid="figure-dossier"]')).toBeVisible()
59 await expect(page.locator('[data-testid="chain-chip"]')).toHaveCount(0)
60 })
⋯ 1 line hidden (lines 61–61)
61})

The spec needs to stand up its own board on a specific objective, so it reuses the suggestion mock that startCuratedRun already registers. That helper was file-private; the one production-side change to the support file is to export it.

mockSuggestions promoted from file-private to exported.

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