What changed, and why

A play-tester paid with a test card and got nothing — no runs, no feedback, no way forward. Two problems hid behind that one symptom, and three UX gaps made it worse. This change fixes the crediting bug and closes the gaps.

The bug: the Stripe flow worked end to end except the credit. The Supabase function that adds runs to a balance had an ambiguous SQL reference, so every paid webhook threw and no runs were ever added. The gaps: the balance was invisible (nothing showed how many runs you had), the packs were an easy-to-miss inline strip, and the return from Stripe gave no confirmation.

Blast radius is the monetization layer plus a route move: the game leaves / so a new landing page can live there. Gameplay logic is untouched. Read the credit fix first — it's the one that was losing money — then the four UX pieces.

The bug: a paid webhook that never credited

credit_runs adds a pack's runs to a device's balance, once per Stripe session. It returns a table whose columns include runs_remaining — and PL/pgSQL turns those OUT columns into in-scope variables. So the bare returning runs_remaining in the upsert was ambiguous: the table column, or the OUT variable? Postgres refused it with column reference "runs_remaining" is ambiguous, the function threw, and the webhook never credited.

The fix is one qualifier: balances.runs_remaining. In an INSERT … ON CONFLICT, the target table is referenceable by name in RETURNING, so this names the column unambiguously — the same shape the working spend_run uses.

The whole function; line 41 is the one-word fix.

supabase/migrations/0004_credits.sql · 44 lines
supabase/migrations/0004_credits.sql44 lines · Transact-SQL
⋯ 11 lines hidden (lines 1–11)
1-- credited_sessions: one row per Stripe checkout session whose runs have been
2-- credited — the idempotency ledger so a retried webhook never double-credits.
3-- RLS on with no policy: service-key-only, like the rest.
4create table if not exists public.credited_sessions (
5 session_id text primary key,
6 device_id text not null,
7 runs int not null,
8 created_at timestamptz not null default now()
9);
10 
11alter table public.credited_sessions enable row level security;
12 
13-- credit_runs: credit p_runs to the device, ONCE per Stripe session. Claims the
14-- session row first (insert; on conflict do nothing) — if the claim took, add the
15-- runs to the balance (creating it if absent); if it conflicted, the session was
16-- already credited, so report the current balance without adding. Atomic, so two
17-- concurrent deliveries of the same webhook credit exactly once.
18create or replace function public.credit_runs(p_device text, p_runs int, p_session text)
19returns table(credited boolean, runs_remaining int)
20language plpgsql as $$
21declare
22 v_remaining int;
23begin
24 insert into public.credited_sessions(session_id, device_id, runs)
25 values (p_session, p_device, p_runs)
26 on conflict (session_id) do nothing;
27 
28 if not found then
29 -- Already credited (the insert hit the conflict): no-op, report balance.
30 select b.runs_remaining into v_remaining from public.balances b where b.device_id = p_device;
31 return query select false, coalesce(v_remaining, 0);
32 return;
33 end if;
34 
35 insert into public.balances(device_id, runs_remaining)
36 values (p_device, p_runs)
37 on conflict (device_id)
38 do update set runs_remaining = public.balances.runs_remaining + p_runs, updated_at = now()
39 -- Qualify with the table name: unqualified runs_remaining is ambiguous with
40 -- the function's OUT column of the same name (mirrors spend_run's b.runs_remaining).
41 returning balances.runs_remaining into v_remaining;
42 return query select true, v_remaining;
43end;
44$$;

There's no Postgres in CI, so a unit test can't catch a live SQL ambiguity. The fix was applied to the live database and verified by crediting the tester's stuck session through this exact function (it returned credited: true).

Runs made visible: an endpoint, a gauge, an account

You couldn't see your balance anywhere. Now a tiny endpoint reads it (granting the free trial on a brand-new device, so a first-timer sees their free run, not zero), and a persistent header gauge shows it.

Device-keyed; ensureDevice grants the free run on first sight.

server/api/balance.get.ts · 16 lines
server/api/balance.get.ts16 lines · TypeScript
1/**
2 * GET /api/balance — the device's run balance, for the header gauge + account
3 * popover. Reads (and, on a brand-new device, grants) the free trial via
4 * ensureDevice, so a first-time visitor's gauge shows their free run rather than
5 * zero. Server-side only; keyed by the opaque device cookie.
6 */
7import { deviceId } from '~/server/utils/device'
8import { balanceStore, FREE_RUNS } from '~/server/utils/balance-store'
9 
10export default defineEventHandler(async (event) => {
11 const device = deviceId(event)
12 const runsRemaining = await balanceStore().ensureDevice(device)
13 // A short, non-secret handle for support ("which device am I?") — the cookie is
14 // httpOnly, so the client can't read it directly; surface a truncated form here.
15 return { runsRemaining, freeRuns: FREE_RUNS, deviceRef: device.slice(0, 8) }
16})

The ◈ N runs gauge, and the account popover it opens.

components/RunsBalance.vue · 90 lines
components/RunsBalance.vue90 lines · Vue
1<template>
2 <!-- The run balance, as a header gauge (◈ N runs), doubling as the entry to a
3 small account popover. Mirrors MessagesCounter's mono-gauge idiom. -->
4 <div ref="rootRef" data-testid="runs-balance" class="relative">
5 <button type="button" data-testid="runs-balance-toggle"
6 class="inline-flex items-baseline gap-1 rv-mono text-sm rv-tap rv-hover-fg"
7 :aria-expanded="open" aria-haspopup="dialog" :aria-label="aria" @click="toggle">
8 <span class="rv-faint" aria-hidden="true"></span>
9 <span class="rv-fg font-semibold">{{ display }}</span>
10 <span class="rv-faint text-xs hidden sm:inline">{{ runs === 1 ? 'run' : 'runs' }}</span>
11 </button>
12 
13 <Transition name="rv-pop">
14 <div v-if="open" data-testid="account-popover" role="dialog" aria-label="Your account"
15 class="absolute right-0 mt-2 w-60 rv-bg border rv-line rounded-md shadow-xl z-40 p-3.5 text-left">
16 <p class="rv-label">Your runs</p>
17 <div class="flex items-baseline gap-1.5 mt-0.5">
18 <span class="rv-mono text-2xl font-extrabold rv-fg leading-none">{{ runs }}</span>
19 <span class="rv-muted text-xs">{{ runs === 1 ? 'run left' : 'runs left' }}</span>
20 </div>
21 <p class="rv-faint text-[11px] leading-relaxed mt-2">
22 <template v-if="runs <= 0">Out of runs — grab a pack to keep playing.</template>
23 <template v-else>Each run is one full game. Packs are one-time and never expire.</template>
24 </p>
25 
26 <button type="button" data-testid="account-get-runs" class="rv-btn rv-btn--primary w-full mt-3 text-sm justify-center"
27 @click="getRuns">Get more runs</button>
28 
29 <p v-if="gameStore.deviceRef" class="rv-faint text-[10px] mt-3 pt-2.5 border-t rv-line">
30 Anonymous play · support id <span class="rv-mono">{{ gameStore.deviceRef }}</span>
31 </p>
32 </div>
33 </Transition>
⋯ 57 lines hidden (lines 34–90)
34 </div>
35</template>
36 
37<script setup lang="ts">
38/**
39 * RunsBalance — the persistent header gauge for runs remaining, and the entry to a
40 * lightweight account popover (runs left, a one-line explainer, "get more runs",
41 * and a short support id). Play is anonymous (device-keyed) until real accounts
42 * exist, so "account" is deliberately minimal. Closes on outside-click / Escape.
43 */
44import { ref, computed, onMounted, onUnmounted } from 'vue'
45import { useGameStore } from '~/stores/game'
46 
47const gameStore = useGameStore()
48const open = ref(false)
49const rootRef = ref<HTMLElement | null>(null)
50 
51const runs = computed(() => gameStore.runsRemaining ?? 0)
52// A dash until the balance has loaded, so the gauge never flashes a misleading 0.
53const display = computed(() => (gameStore.runsRemaining == null ? '–' : String(runs.value)))
54const aria = computed(() =>
55 gameStore.runsRemaining == null ? 'Loading your runs' : `${runs.value} ${runs.value === 1 ? 'run' : 'runs'} remaining; open account`
57 
58function toggle() { open.value = !open.value }
59function close() { open.value = false }
60 
61function getRuns() {
62 close()
63 void gameStore.openBuyModal()
65 
66function onDocClick(e: MouseEvent) {
67 if (open.value && rootRef.value && !rootRef.value.contains(e.target as Node)) close()
69function onKeydown(e: KeyboardEvent) { if (e.key === 'Escape') close() }
70 
71onMounted(() => {
72 document.addEventListener('click', onDocClick)
73 document.addEventListener('keydown', onKeydown)
74})
75onUnmounted(() => {
76 document.removeEventListener('click', onDocClick)
77 document.removeEventListener('keydown', onKeydown)
78})
79</script>
80 
81<style scoped>
82.rv-pop-enter-active,
83.rv-pop-leave-active { transition: opacity var(--rv-dur-1) var(--rv-ease); }
84.rv-pop-enter-from,
85.rv-pop-leave-to { opacity: 0; }
86@media (prefers-reduced-motion: reduce) {
87 .rv-pop-enter-active,
88 .rv-pop-leave-active { transition: none; }
90</style>

The gauge shows a dash until the balance loads, so it never flashes a misleading zero. The store reads the balance on mount and again whenever a run is committed, so the number stays live without a manual reload.

loadBalance + the modal/purchase actions; loadPacks caches only a non-empty catalog.

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

Selling, made prominent (but not annoying)

The packs were a thin inline strip under the mission list — easy to miss. They move into a focused, dismissible modal that opens two ways: on demand from the header/account, and automatically the moment a commit is refused for being out of runs. It marks the cheapest per-run tier "best value".

The packs as ledger rows + the best-value mark; the Tab focus trap and buy/redirect.

components/RunPacksModal.vue · 153 lines
components/RunPacksModal.vue153 lines · Vue
⋯ 27 lines hidden (lines 1–27)
1<template>
2 <!-- The run-packs store: a focused, dismissible dialog (not the easily-missed
3 inline strip). Opened on demand from the header, and automatically when a
4 commit is refused for being out of runs. Mirrors EndGameScreen's bespoke
5 takeover idiom rather than @nuxt/ui chrome, to stay in the ledger aesthetic. -->
6 <Transition name="rv-modal">
7 <div v-if="gameStore.buyModalOpen" ref="dialogRef" data-testid="run-packs-modal"
8 role="dialog" aria-modal="true" aria-labelledby="run-packs-heading"
9 class="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-5"
10 @keydown="onKeydown">
11 <!-- Backdrop -->
12 <div class="absolute inset-0 rv-modal-backdrop" @click="close" />
13 
14 <!-- Panel -->
15 <div class="relative rv-bg border rv-line w-full sm:max-w-lg rounded-t-lg sm:rounded-lg
16 shadow-xl max-h-[92vh] overflow-y-auto">
17 <div class="px-5 sm:px-6 py-5">
18 <div class="flex items-start justify-between gap-4">
19 <div>
20 <p class="rv-label">Keep rewriting history</p>
21 <h2 id="run-packs-heading" class="rv-serif rv-fg text-2xl font-semibold mt-1 leading-tight">
22 {{ gameStore.outOfRuns ? "You're out of runs" : 'Get more runs' }}
23 </h2>
24 <p class="rv-muted text-sm mt-1.5">
25 Each run is a full game — five messages to remake a world. One-time, never expires.
26 </p>
27 </div>
28 <button ref="closeRef" type="button" data-testid="close-packs"
29 class="rv-tap rv-hover-fg text-lg leading-none shrink-0 -mr-1" aria-label="Close" @click="close"></button>
30 </div>
31 
32 <!-- Packs, as ledger rows: label · runs · price, with the per-run cost and a
33 single "best value" mark on the cheapest-per-run tier. -->
34 <ul class="mt-5 border-t rv-line">
35 <li v-for="pack in gameStore.packs" :key="pack.id" class="border-b rv-line">
36 <button type="button" :data-testid="`buy-${pack.id}`"
37 class="w-full text-left flex items-center gap-3 py-3.5 pr-1 transition rv-hover disabled:opacity-50"
38 :disabled="buyingPack !== null" @click="buy(pack.id)">
39 <span class="rv-dot mt-0.5 shrink-0" :class="pack.id === bestValueId ? 'rv-dot--accent' : 'rv-dot--hollow'" aria-hidden="true" />
40 <span class="min-w-0 flex-1">
41 <span class="flex items-center gap-2 flex-wrap">
42 <span class="rv-fg font-semibold">{{ pack.label }}</span>
43 <span v-if="pack.id === bestValueId" class="rv-accent text-[10px] uppercase tracking-wider">★ best value</span>
44 </span>
45 <span class="block rv-muted text-xs mt-0.5">
46 {{ pack.runs }} runs · {{ perRun(pack) }} each
47 </span>
48 </span>
49 <span class="rv-mono rv-fg font-semibold shrink-0">${{ dollars(pack.priceCents) }}</span>
50 <span class="rv-faint shrink-0 text-sm" aria-hidden="true">{{ buyingPack === pack.id ? '…' : '→' }}</span>
51 </button>
52 </li>
53 </ul>
54 
55 <p v-if="buyError" data-testid="buy-error" class="text-xs rv-bad mt-3">{{ buyError }}</p>
56 
57 <p class="rv-faint text-[11px] leading-relaxed mt-4">
58 Secure checkout by Stripe · you'll return here with your runs added.
59 </p>
60 </div>
⋯ 39 lines hidden (lines 61–99)
61 </div>
62 </div>
63 </Transition>
64</template>
65 
66<script setup lang="ts">
67/**
68 * RunPacksModal — the sales surface. Reads the catalog + open state from the store
69 * and drives Stripe Checkout via gameStore.buyPack (a full-page redirect). Loads
70 * the catalog when it opens (store.openBuyModal does this), marks the cheapest
71 * per-run tier as "best value", and is dismissible (backdrop · ✕ · Escape).
72 */
73import { ref, computed, watch, nextTick } from 'vue'
74import { useGameStore } from '~/stores/game'
75import type { Pack } from '~/server/utils/packs'
76 
77const gameStore = useGameStore()
78const buyingPack = ref<string | null>(null)
79const buyError = ref<string | null>(null)
80const dialogRef = ref<HTMLElement | null>(null)
81const closeRef = ref<HTMLButtonElement | null>(null)
82 
83const dollars = (cents: number) => (cents / 100).toFixed(cents % 100 === 0 ? 0 : 2)
84const perRun = (pack: Pack) => `$${(pack.priceCents / 100 / pack.runs).toFixed(2)}`
85 
86// The cheapest run, by per-run price — the one tier worth nudging toward.
87const bestValueId = computed(() => {
88 let best: Pack | null = null
89 for (const p of gameStore.packs) {
90 if (!best || p.priceCents / p.runs < best.priceCents / best.runs) best = p
91 }
92 return best?.id ?? null
93})
94 
95function close() {
96 buyError.value = null
97 gameStore.closeBuyModal()
99 
100// Escape closes; Tab is trapped within the panel so focus can't wander to the
101// page behind the backdrop (which aria-modal="true" declares inert).
102function onKeydown(e: KeyboardEvent) {
103 if (e.key === 'Escape') { close(); return }
104 if (e.key !== 'Tab' || !dialogRef.value) return
105 const focusables = Array.from(
106 dialogRef.value.querySelectorAll<HTMLElement>('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')
107 ).filter((el) => !el.hasAttribute('disabled'))
108 if (!focusables.length) return
109 const first = focusables[0]
110 const last = focusables[focusables.length - 1]
111 const active = document.activeElement
112 if (e.shiftKey && active === first) {
113 e.preventDefault()
114 last.focus()
115 } else if (!e.shiftKey && active === last) {
116 e.preventDefault()
117 first.focus()
118 }
120 
121async function buy(packId: string) {
122 if (buyingPack.value) return
123 buyingPack.value = packId
124 buyError.value = null
125 const url = await gameStore.buyPack(packId)
126 if (url) {
127 window.location.href = url // off to Stripe Checkout
128 } else {
129 buyingPack.value = null
130 buyError.value = 'Could not start checkout — please try again.'
131 }
133 
134// Move focus into the dialog when it opens (the close button), for keyboard + SR users.
135watch(() => gameStore.buyModalOpen, (open) => {
136 if (open) nextTick(() => closeRef.value?.focus())
137})
138</script>
139 
140<style scoped>
141.rv-modal-backdrop {
142 background: color-mix(in srgb, var(--rv-bg) 55%, #000 45%);
⋯ 11 lines hidden (lines 143–153)
143 backdrop-filter: blur(2px);
145.rv-modal-enter-active,
146.rv-modal-leave-active { transition: opacity var(--rv-dur-1) var(--rv-ease); }
147.rv-modal-enter-from,
148.rv-modal-leave-to { opacity: 0; }
149@media (prefers-reduced-motion: reduce) {
150 .rv-modal-enter-active,
151 .rv-modal-leave-active { transition: none; }
153</style>

The mission screen's paywall is now a short notice plus a button that reopens the modal (it auto-opens once on the refused commit; this is the way back if you dismissed it).

The paywall opens the modal instead of listing packs inline.

components/MissionSelect.vue · 132 lines
components/MissionSelect.vue132 lines · Vue
⋯ 49 lines hidden (lines 1–49)
1<template>
2 <!-- Mission briefing: choose (or compose) the run's grand objective, as a ledger. -->
3 <div data-testid="mission-select" class="rv-fade-in max-w-4xl mx-auto">
4 <div class="mb-7">
5 <p class="rv-label">Choose your crusade</p>
6 <h2 class="rv-serif rv-fg text-2xl sm:text-3xl font-semibold mt-1.5 leading-tight">What will you set right?</h2>
7 <p class="rv-muted text-sm mt-2 max-w-xl">
8 Five messages. Anyone in history. One world to remake. Pick the cause that will define your run — or have a fresh one composed.
9 </p>
10 </div>
11 
12 <!-- Objectives as borderless hairline rows (a freshly composed one leads) -->
13 <div role="radiogroup" aria-label="Choose an objective" class="border-t rv-line">
14 <button v-for="obj in options" :key="(obj === fresh ? 'fresh:' : 'curated:') + obj.title" type="button"
15 data-testid="objective-option" role="radio" :aria-checked="isSelected(obj)"
16 class="w-full text-left flex items-start gap-3 py-3 pr-2 pl-3 border-b rv-line transition rv-hover"
17 :class="isSelected(obj) ? 'rv-tint' : ''"
18 :style="{ borderLeft: `2px solid ${isSelected(obj) ? 'var(--rv-accent)' : 'transparent'}` }" @click="select(obj)">
19 <span class="rv-dot mt-1.5" :class="isSelected(obj) ? 'rv-dot--accent' : 'rv-dot--hollow'" aria-hidden="true" />
20 <span class="w-6 shrink-0 text-center text-lg leading-none select-none" aria-hidden="true">{{ obj.icon }}</span>
21 <span class="min-w-0 flex-1">
22 <span class="flex items-center gap-2 flex-wrap">
23 <h3 class="rv-fg font-semibold">{{ obj.title }}</h3>
24 <span v-if="obj.era" class="rv-label">{{ obj.era }}</span>
25 <span v-if="obj === fresh" data-testid="fresh-badge" class="rv-accent text-[10px] uppercase tracking-wider">
26 ✨ freshly composed
27 </span>
28 </span>
29 <span class="block rv-muted text-sm mt-0.5 leading-relaxed">{{ obj.description }}</span>
30 </span>
31 </button>
32 </div>
33 
34 <!-- Actions: compose a fresh objective · begin the run -->
35 <div class="mt-5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
36 <div class="flex flex-col items-start gap-1">
37 <button type="button" data-testid="compose-objective" class="rv-btn text-sm" :disabled="composing" @click="rollFresh">
38 <span aria-hidden="true"></span> {{ composing ? 'Composing a fresh fate…' : 'Compose a fresh objective with AI' }}
39 </button>
40 <p v-if="composeError" data-testid="compose-error" class="text-xs rv-bad">{{ composeError }}</p>
41 </div>
42 
43 <button type="button" data-testid="begin-button" class="rv-btn rv-btn--primary" :disabled="!selected || beginning" @click="begin">
44 {{ beginning ? 'Charting the timeline…' : 'Begin rewriting history' }} <span aria-hidden="true"></span>
45 </button>
46 </div>
47 
48 <!-- Paywall: the free run is spent. The run-packs modal opens automatically on
49 the refused commit; this persistent notice lets the player reopen it if they
50 dismissed it. -->
51 <div v-if="gameStore.outOfRuns" data-testid="paywall" class="mt-4 pt-4 border-t rv-line">
52 <p class="text-sm rv-fg font-semibold">You're out of runs — your free run is spent.</p>
53 <p class="rv-muted text-xs mt-0.5">Grab a pack to keep rewriting history. One-time, never expires.</p>
54 <button type="button" data-testid="open-packs" class="rv-btn rv-btn--primary mt-3"
55 @click="gameStore.openBuyModal()">
56 See run packs <span aria-hidden="true"></span>
57 </button>
⋯ 75 lines hidden (lines 58–132)
58 </div>
59 
60 <!-- Capacity: the global spend cap paused new runs (in-progress runs continue). -->
61 <p v-else-if="gameStore.atCapacity" data-testid="at-capacity" class="text-sm rv-bad mt-3">
62 The timeline is at capacity right now — please try again shortly.
63 </p>
64 
65 <!-- Compliance disclosure, shown at the start of every run (Anthropic Usage
66 Policy: AI disclosure at session start; outputs may be inaccurate; an
67 adults-only posture). -->
68 <p data-testid="ai-disclosure" class="rv-faint text-xs leading-relaxed mt-8 pt-4 border-t rv-line max-w-2xl">
69 The historical figures here are played by AI, not real people, and every reply is
70 AI-generated <span class="rv-fg">alternate history — dramatized fiction, not historical fact</span>;
71 don't rely on it as accurate. Intended for adults (18+).
72 </p>
73 </div>
74</template>
75 
76<script setup lang="ts">
77/**
78 * MissionSelect — the opening briefing. Curated objectives + a live-composed one, as
79 * borderless hairline rows; selection uses identity, not titles. Nothing commits
80 * until Begin, so a freshly composed objective can be previewed and reconsidered.
81 */
82import { ref, shallowRef, computed } from 'vue'
83import { useGameStore } from '~/stores/game'
84import { listCuratedObjectives, type GameObjective } from '~/server/utils/objectives'
85 
86const gameStore = useGameStore()
87 
88const curated = listCuratedObjectives()
89// shallowRef, not ref: selection is by object identity (isSelected uses ===), and the
90// curated options are plain objects. A deep ref would return a *reactive proxy* on
91// `.value`, so `selected.value === obj` (raw) would be false — the row would highlight
92// on hover but its radio dot / aria-checked would never flip. shallowRef stores the
93// value as-is, keeping identity intact.
94const fresh = shallowRef<GameObjective | null>(null)
95const selected = shallowRef<GameObjective | null>(null)
96const composing = ref(false)
97const composeError = ref<string | null>(null)
98const beginning = ref(false)
99 
100const options = computed(() => (fresh.value ? [fresh.value, ...curated] : curated))
101 
102function isSelected(obj: GameObjective) {
103 return selected.value === obj
105 
106function select(obj: GameObjective) {
107 selected.value = obj
109 
110async function rollFresh() {
111 composing.value = true
112 composeError.value = null
113 const objective = await gameStore.fetchAIObjective()
114 composing.value = false
115 
116 if (objective) {
117 fresh.value = objective
118 selected.value = objective // auto-select the fresh one so Begin is one tap away
119 } else {
120 composeError.value = 'The archives went quiet — pick one above, or try composing again.'
121 }
123 
124async function begin() {
125 if (!selected.value || beginning.value) return
126 beginning.value = true
127 // Charges one run from the balance. If out of runs, chooseObjective raises the
128 // paywall (gameStore.outOfRuns) and opens the run-packs modal; no run starts.
129 await gameStore.chooseObjective(selected.value)
130 beginning.value = false
132</script>

chooseObjective: capture the post-charge balance; open the modal on a 402.

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

The return from Stripe

Checkout now sends the player back to the board with a query flag. The page reads it once, shows a confirmation banner, refreshes the balance, then strips the query so a later refresh can't re-fire the banner.

Redirects target /play (the game's new home).

server/api/checkout.post.ts · 56 lines
server/api/checkout.post.ts56 lines · TypeScript
⋯ 43 lines hidden (lines 1–43)
1/**
2 * POST /api/checkout — start a one-time purchase of a run pack. Builds a Stripe
3 * Checkout session from the SERVER-side packs catalog (never a client-supplied
4 * price or run count) and stamps the device id + run count into the session
5 * metadata, so the webhook credits exactly what was bought to the right device.
6 * Returns { url } for the client to redirect to. 503 when Stripe isn't configured.
7 */
8import { stripeClient } from '~/server/utils/stripe'
9import { deviceId } from '~/server/utils/device'
10import { packById } from '~/server/utils/packs'
11 
12export default defineEventHandler(async (event) => {
13 if (getMethod(event) !== 'POST') {
14 throw createError({ statusCode: 405, statusMessage: 'Method Not Allowed' })
15 }
16 const body = await readBody(event)
17 const pack = packById(typeof body?.packId === 'string' ? body.packId : '')
18 if (!pack) {
19 throw createError({ statusCode: 400, statusMessage: 'Unknown pack' })
20 }
21 const stripe = stripeClient()
22 if (!stripe) {
23 throw createError({ statusCode: 503, statusMessage: 'Payments unavailable' })
24 }
25 
26 const device = deviceId(event)
27 // Pin the post-payment redirect to the configured canonical origin, NOT the
28 // client-supplied Host header (getRequestURL derives origin from Host), so a
29 // spoofed Host can't route a victim's return to an attacker. Falls back to the
30 // request origin only when unset (local dev).
31 const origin = (useRuntimeConfig().siteUrl as string) || getRequestURL(event).origin
32 try {
33 const session = await stripe.checkout.sessions.create({
34 mode: 'payment',
35 line_items: [
36 {
37 price_data: {
38 currency: 'usd',
39 product_data: { name: `Revisionist — ${pack.label} (${pack.runs} runs)` },
40 unit_amount: pack.priceCents
41 },
42 quantity: 1
43 }
44 ],
45 metadata: { device_id: device, pack_id: pack.id, runs: String(pack.runs) },
46 // Return to the board (the game lives at /play), where the purchase=*
47 // query drives the confirmation + a live balance refresh.
48 success_url: `${origin}/play?purchase=success`,
49 cancel_url: `${origin}/play?purchase=cancel`
50 })
⋯ 6 lines hidden (lines 51–56)
51 return { url: session.url }
52 } catch (error) {
53 console.error('Checkout session creation failed:', error)
54 throw createError({ statusCode: 502, statusMessage: 'Could not start checkout' })
55 }
56})

Read purchase=success|cancel once, then strip the query.

pages/play.vue · 498 lines
pages/play.vue498 lines · Vue
⋯ 381 lines hidden (lines 1–381)
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 <span class="flex items-center gap-1.5 pl-1">
19 <span class="rv-dot" :class="statusDot" aria-hidden="true" />
20 <span class="rv-mono text-[11px] uppercase tracking-wide rv-muted sr-only sm:not-sr-only">{{ statusWord }}</span>
21 </span>
22 <!-- One mis-tap must not erase five AI-resolved turns: with a run in
23 progress the reset arms a tiny inline confirm (auto-reverts); an
24 untouched run still resets in one tap. -->
25 <span v-if="resetArmed" class="flex items-center gap-1.5">
26 <span class="text-[11px] rv-warn">abandon this history?</span>
27 <button type="button" data-testid="reset-confirm" class="rv-tap rv-hover-fg text-sm leading-none rv-warn"
28 aria-label="Yes — abandon this history" @click="performReset"></button>
29 <button type="button" data-testid="reset-cancel" class="rv-tap rv-hover-fg text-sm leading-none"
30 aria-label="Keep playing" @click="disarmReset"></button>
31 </span>
32 <button v-else type="button" data-testid="reset-button" class="rv-tap rv-hover-fg text-base leading-none"
33 title="New timeline" aria-label="New timeline" @click="handleResetGame"></button>
34 </div>
35 
36 <!-- Runs gauge — always visible (briefing + board), and the entry to the
37 account popover / the run-packs modal. -->
38 <RunsBalance class="shrink-0" :class="gameStore.currentObjective ? '' : 'ml-auto'" />
39 
40 <button type="button" class="rv-tap rv-hover-fg text-base leading-none shrink-0"
41 :aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'" @click="toggleDark"></button>
42 </div>
43 </header>
44 
45 <!-- Post-checkout banner: the missing feedback after returning from Stripe — a
46 credited confirmation, or a gentle "no charge" on cancel. Self-dismisses. -->
47 <div v-if="gameStore.purchaseNotice" data-testid="purchase-notice" role="status" aria-live="polite"
48 class="border-b rv-line px-5 py-2.5 text-sm flex items-center gap-2"
49 :class="gameStore.purchaseNotice === 'success' ? 'rv-tint' : ''">
50 <span aria-hidden="true">{{ gameStore.purchaseNotice === 'success' ? '✓' : '○' }}</span>
51 <span class="rv-fg">
52 <template v-if="gameStore.purchaseNotice === 'success'">
53 Payment received — your runs have been added to your balance.
54 </template>
55 <template v-else>Checkout canceled — you weren't charged.</template>
56 </span>
57 <button type="button" data-testid="purchase-notice-close" class="rv-tap rv-hover-fg ml-auto shrink-0"
58 aria-label="Dismiss" @click="gameStore.clearPurchaseNotice()"></button>
59 </div>
60 
61 <!-- Waiting mode: a thin indeterminate sweep while a turn resolves — the whole
62 board reads as "history is being rewritten," in concert with the shaking die. -->
63 <div v-if="gameStore.isLoading" class="rv-loading-bar" role="status">
64 <span class="sr-only">Resolving your move — history is being rewritten…</span>
65 </div>
66 
67 <!-- At lg the board owns the viewport and scrolls inside its own panes, so main is
68 clipped (lg:overflow-hidden). The mission briefing has no such inner scroll, so
69 on a short viewport that clipping hid the Begin button under the footer — give
70 the briefing a scrollable main instead. -->
71 <main class="px-5 py-6 pb-24 md:pb-6 flex-1"
72 :class="gameStore.currentObjective ? 'lg:min-h-0 lg:py-0 lg:overflow-hidden' : 'lg:overflow-y-auto'">
73 <!-- Briefing: choose (or compose) an objective before the run begins -->
74 <MissionSelect v-if="!gameStore.currentObjective" />
75 
76 <template v-else>
77 <!-- The board, only while the run is live — once it ends, the end-screen
78 takeover fully replaces it (no board bleeding under the verdict).
79 On phones the three zones become one-at-a-time panels driven by the
80 bottom tab bar; from md up every zone is shown together as before. -->
81 <template v-if="gameStore.gameStatus === 'playing'">
82 <!-- THE WORKSPACE — at lg the board splits into two panes that fit the viewport:
83 a scrolling read-model (roll · shift · Spine · Chronicle · Thread) on the
84 left, and the act-model (the compose dock) pinned on the right, so the move
85 is never buried below the read. Below lg this wrapper is inert and the zones
86 fall back to the stacked page (md) / phone tab panels (sm). -->
87 <div class="rv-fade-in lg:h-full lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(360px,440px)] lg:grid-rows-1">
88 <!-- LEFT pane — the read-model. At lg it's a flex column the height of the
89 pane: the board/Spine on top at its natural height, then the story zone
90 flexes to fill the rest, so the Chronicle & Thread are full-height panels
91 (each scrolls inside itself) rather than a single scrolling stack. -->
92 <div class="lg:min-h-0 lg:min-w-0 lg:flex lg:flex-col lg:overflow-hidden lg:pr-6 lg:py-6">
93 <!-- ZONE 1 — the board state: the roll + the shift, then the Spine -->
94 <section class="rv-panel space-y-4 lg:space-y-6 lg:flex-none" :class="[{ 'rv-dim': isFirstTurn }, mobileTab === 'board' ? '' : 'hidden', 'md:block']">
95 <div class="grid sm:grid-cols-2 gap-4">
96 <div class="sm:border-r rv-line sm:pr-6 py-1">
97 <span class="rv-label mb-2 block">Dice of fate</span>
98 <DiceRoller />
99 <!-- The bands, disclosed: the player can always see how fate maps to
100 swing — the same table the Timeline Engine is instructed with. -->
101 <details data-testid="roll-legend" class="mt-2 text-[11px]">
102 <summary class="rv-faint cursor-pointer select-none hover:underline">how fate is weighed</summary>
103 <ul class="mt-1.5 space-y-0.5 rv-mono rv-muted">
104 <li v-for="row in rollLegend" :key="row.label" class="flex justify-between gap-3">
105 <span>{{ row.range }} · {{ row.label }}</span><span :class="row.cls">{{ row.swing }}</span>
106 </li>
107 </ul>
108 <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>
109 </details>
110 </div>
111 <div class="sm:pl-2 flex flex-col justify-center">
112 <ProgressTracker />
113 </div>
114 </div>
115 <TimelineLedger />
116 </section>
117 
118 <!-- ZONE 2 — the story: the living chronicle · the conversation thread. At lg
119 this flexes to fill the rest of the pane and lays the two out side by side
120 as equal full-height columns (md keeps the two-up grid in the page flow). -->
121 <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']">
122 <Chronicle :force-open="isMd" />
123 <!-- Thread: a <div> from md up (see Chronicle for why a <details> can't fill the
124 column height), a collapsible <details> on phones. -->
125 <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">
126 <summary class="rv-summary" :class="{ 'rv-summary--static': isMd }">
127 <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>
128 </summary>
129 <div class="pt-2 lg:flex-1 lg:min-h-0 lg:overflow-y-auto">
130 <MessageHistory data-testid="message-history" />
131 </div>
132 </component>
133 </section>
134 </div><!-- /LEFT pane -->
135 
136 <!-- RIGHT pane — the act-model, pinned beside the read column at lg -->
137 <div class="lg:min-h-0 lg:overflow-y-auto lg:border-l rv-line lg:pl-6 lg:py-6">
138 <!-- ZONE 3 — your turn: the compose dock (the page-stack divider is dropped at
139 lg, where it's a column of its own, not a section below the read). -->
140 <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']">
141 <div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
142 <span class="rv-label">Send a message into the past</span>
143 <!-- The wager, read BEFORE the roll: when the Archive has stamped a
144 known-since and a contact year is chosen, the chip translates the
145 reach into the same ⚡ tiers the spine uses. Otherwise the general
146 principle is stated — on the first turn too, where it teaches most. -->
147 <span v-if="wagerRead" data-testid="wager-chip" class="text-[11px]"
148 :class="wagerRead.tier === 'in-period' ? 'rv-muted' : 'rv-warn'">
149 <span aria-hidden="true">{{ wagerRead.pips }}</span> {{ wagerRead.text }}
150 </span>
151 <span v-else 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>
152 <!-- The chain, read BEFORE the roll: how far this moment lands from your
153 nearest foothold (the objective's era or a change you've made). A
154 lone leap into the deep past is diffuse; building a chain forward
155 closes the gap. Hidden at the hinge, where there's nothing to warn. -->
156 <span v-if="chainRead" data-testid="chain-chip" class="text-[11px]"
157 :class="chainRead.tier === 'diffuse' ? 'rv-warn' : 'rv-muted'">
158 <span aria-hidden="true"></span> {{ chainRead.text }}
159 </span>
160 </div>
161 
162 <p v-if="isFirstTurn" class="rv-accent text-sm mb-4">
163 Reach someone in history, write up to 160 characters, and send — one message can bend the whole timeline.
164 </p>
165 
166 <div class="grid md:grid-cols-2 lg:grid-cols-1 gap-x-8 gap-y-6">
167 <div>
168 <span class="rv-label block mb-2"><span class="rv-accent">1</span> · choose who &amp; when</span>
169 <FigurePicker v-model="figure" :disabled="!gameStore.canSendMessage" />
170 </div>
171 
172 <div class="space-y-3 rv-line lg:border-t lg:pt-6">
173 <span class="rv-label block mb-2"><span class="rv-accent">2</span> · write &amp; send</span>
174 <MessageInput ref="messageInputRef" />
175 <!-- The last stand: offered only when the final dispatch can no
176 longer win inside the normal per-turn cap — a true last resort,
177 never a free doubling from a winnable position. -->
178 <div v-if="gameStore.canStake" data-testid="stake-control"
179 class="border rounded-sm px-2.5 py-2 flex items-start gap-2"
180 :class="stakeArmed ? 'stake-armed' : 'rv-line'">
181 <input id="stake-toggle" v-model="stakeArmed" type="checkbox" class="mt-0.5"
182 :style="{ accentColor: 'var(--rv-accent)' }" />
183 <label for="stake-toggle" class="text-[11px] leading-snug cursor-pointer">
184 <span class="rv-warn font-semibold uppercase tracking-wide"><span aria-hidden="true"></span> Stake the timeline</span>
185 <span class="rv-muted block">Your final dispatch cuts twice as deep — both ways — and can move the whole meter. The last stand.</span>
186 </label>
187 </div>
188 <div class="flex items-center gap-3 flex-wrap">
189 <SendButton data-testid="send-button" :input-valid="canSend" @click="handleSendMessage" />
190 <div v-if="gameStore.error" data-testid="error-alert" role="status" aria-live="polite"
191 class="flex items-center gap-2 text-xs flex-1 min-w-0 border-l-2 rv-line pl-2 py-0.5">
192 <span class="rv-label shrink-0">notice</span>
193 <span class="rv-muted truncate">{{ gameStore.error }}</span>
194 <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>
195 </div>
196 <div v-if="gameStore.moderationNotice" data-testid="moderation-alert" role="alert" aria-live="assertive"
197 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">
198 <span class="shrink-0 font-semibold uppercase tracking-wide">⚠ blocked</span>
199 <span class="min-w-0">{{ gameStore.moderationNotice }}</span>
200 <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>
201 </div>
202 </div>
203 <ArchiveLookup />
204 </div>
205 </div>
206 </section>
207 </div><!-- /RIGHT pane -->
208 </div><!-- /workspace -->
209 </template>
210 </template>
211 </main>
212 
213 <!-- Phone-only tab bar: the three zones as fixed panels you switch between, so the
214 board never becomes one infinite scroll and your next move is always one tap
215 away. Hidden from md up (where every zone is shown at once). -->
216 <!-- A labelled nav of view-switch buttons (NOT an ARIA tablist: there's no roving
217 tabindex / arrow-key tabset here, and the zones are simple show/hide regions).
218 The active view is conveyed with aria-current, the honest, complete pattern. -->
219 <nav v-if="gameStore.currentObjective && gameStore.gameStatus === 'playing'"
220 class="md:hidden fixed bottom-0 inset-x-0 z-30 border-t rv-line rv-bg grid grid-cols-3"
221 aria-label="Switch board view">
222 <button v-for="t in mobileTabs" :key="t.id" type="button" :aria-current="mobileTab === t.id ? 'true' : undefined"
223 class="mobile-tab rv-press" :class="{ 'is-active': mobileTab === t.id }" @click="mobileTab = t.id">
224 <span class="text-lg leading-none" aria-hidden="true">{{ t.glyph }}</span>
225 <span class="rv-label flex items-center gap-1">
226 {{ t.label }}
227 <span v-if="t.id === 'compose' && yourMove" class="rv-dot rv-dot--accent" aria-hidden="true" />
228 </span>
229 </button>
230 </nav>
231 
232 <!-- A ledger running-foot: header + footer frame the page as a document.
233 (Hidden on phones, where the tab bar is the foot.) -->
234 <footer class="border-t rv-line px-5 py-3 hidden md:block text-[11px] rv-faint">
235 <div class="flex items-center gap-2">
236 <span class="rv-mono uppercase tracking-wide">Revisionist</span>
237 <span class="rv-hair-c" aria-hidden="true">·</span>
238 <span class="rv-mono">a history you are writing</span>
239 <span class="ml-auto rv-serif italic">the past is not fixed</span>
240 </div>
241 <!-- Persistent compliance line (AI disclosure + fiction + adults-only). -->
242 <p data-testid="footer-disclosure" class="mt-1.5">
243 Figures are AI, not real people · replies are AI-generated fiction, not historical fact · for adults (18+)
244 </p>
245 </footer>
246 
247 <EndGameScreen @play-again="performReset" />
248 
249 <!-- The run-packs store: opened from the header/account or automatically when a
250 commit is refused for being out of runs. Mounted once, here. -->
251 <RunPacksModal />
252 </div>
253</template>
254 
255<script setup lang="ts">
256/**
257 * Main game page — Revisionist, "The Spine Console" layout.
258 *
259 * A full-bleed board grouped into three super-zones — board-state (roll + shift +
260 * Spine), story (chronicle · thread), and your-turn (the compose dock) — separated
261 * by space, not chrome. State lives in the store; this only arranges it.
262 */
263import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
264import { useGameStore, formatContactYear } from '~/stores/game'
265import { SWING_BANDS } from '~/utils/swing-bands'
266import { DiceOutcome, CRIT_FAIL_MAX, FAILURE_MAX, NEUTRAL_MAX, CRIT_SUCCESS_MIN } from '~/utils/dice'
267import { parseKnownSinceYear, wagerTier } from '~/utils/known-since'
268import { CHAIN_HINT } from '~/utils/causal-chain'
269import { ANACHRONISM_LABEL } from '~/server/utils/anachronism'
270 
271const gameStore = useGameStore()
272 
273// The disclosed band table — rendered from the same constants the Timeline
274// Engine is instructed with, so the legend can't lie.
275const fmtSwing = (b: { min: number; max: number }) =>
276 `${b.min >= 0 ? '+' : ''}${b.min}${b.max >= 0 ? '+' : ''}${b.max}%`
277const rollLegend = [
278 { range: `${CRIT_SUCCESS_MIN}–20`, label: 'Critical Success', swing: fmtSwing(SWING_BANDS[DiceOutcome.CRITICAL_SUCCESS]), cls: 'rv-ok' },
279 { range: `${NEUTRAL_MAX + 1}${CRIT_SUCCESS_MIN - 1}`, label: 'Success', swing: fmtSwing(SWING_BANDS[DiceOutcome.SUCCESS]), cls: 'rv-ok' },
280 { range: `${FAILURE_MAX + 1}${NEUTRAL_MAX}`, label: 'Neutral', swing: fmtSwing(SWING_BANDS[DiceOutcome.NEUTRAL]), cls: 'rv-muted' },
281 { range: `${CRIT_FAIL_MAX + 1}${FAILURE_MAX}`, label: 'Failure', swing: fmtSwing(SWING_BANDS[DiceOutcome.FAILURE]), cls: 'rv-warn' },
282 { range: `1–${CRIT_FAIL_MAX}`, label: 'Critical Failure', swing: fmtSwing(SWING_BANDS[DiceOutcome.CRITICAL_FAILURE]), cls: 'rv-bad' }
284 
285// The pre-send wager chip: Archive known-since × chosen contact year → ⚡ tier.
286const wagerRead = computed(() => {
287 const lookup = gameStore.archiveResult
288 const when = gameStore.contactWhen
289 if (!lookup?.knownSince || when == null) return null
290 const knownYear = parseKnownSinceYear(lookup.knownSince)
291 if (knownYear == null) return null
292 const tier = wagerTier(knownYear, when)
293 const pips = tier === 'impossible' ? '⚡⚡⚡' : tier === 'far-ahead' ? '⚡⚡' : tier === 'ahead' ? '⚡' : '✓'
294 // Hedged copy: this is a year-gap compass, not the engine's verdict.
295 const text = tier === 'in-period'
296 ? `${lookup.topic}: already known by ${formatContactYear(when)} — steady ground`
297 : `${lookup.topic}: reads ≈ ${ANACHRONISM_LABEL[tier].toLowerCase()} for ${formatContactYear(when)} — swings harder, both ways`
298 return { tier, pips, text }
299})
300 
301// The pre-send chain chip: how far the chosen moment lands from the nearest
302// foothold (the objective's era or a prior change). Hidden at the hinge (full
303// force, nothing to flag); otherwise it reads the dilution + how to close it.
304const chainRead = computed(() => {
305 const s = gameStore.causalChainRead
306 if (!s || s.tier === 'at-hinge') return null
307 return { tier: s.tier, text: CHAIN_HINT[s.tier] }
308})
309 
310// The last stand, armed by the player on their final dispatch.
311const stakeArmed = ref(false)
312 
313const figure = ref('')
314watch(figure, (name) => gameStore.setActiveFigure((name || '').trim()))
315 
316const messageInputRef = ref()
317const messageInputValid = computed(() => messageInputRef.value?.isValid ?? false)
318const canSend = computed(() => messageInputValid.value && figure.value.trim().length > 0 && gameStore.canContact)
319 
320// The very first turn: the read-model is empty, so quiet it and lead the eye to the dock.
321const isFirstTurn = computed(() => gameStore.timelineEvents.length === 0)
322 
323// Phone tab bar: the three zones as switchable panels (md+ shows them all at once, so
324// this state is inert there). The run opens on the move; the instant a turn is rolling
325// we flip to the Board so the die + shift + spine land as the payoff beat, then the
326// player taps back to Compose. A nudge dot marks Compose when it's their move.
327type MobileTab = 'board' | 'story' | 'compose'
328const mobileTabs = [
329 { id: 'board', glyph: '🎲', label: 'Board' },
330 { id: 'story', glyph: '📖', label: 'Story' },
331 { id: 'compose', glyph: '✎', label: 'Compose' }
332] as const
333const mobileTab = ref<MobileTab>('compose')
334const yourMove = computed(() => gameStore.canSendMessage && !gameStore.isLoading && mobileTab.value !== 'compose')
335watch(() => gameStore.isLoading, (loading) => { if (loading) mobileTab.value = 'board' })
336watch(() => gameStore.currentObjective, (obj) => { if (obj) mobileTab.value = 'compose' })
337 
338const statusDot = computed(() =>
339 gameStore.gameStatus === 'victory' ? 'rv-dot--ok' : gameStore.gameStatus === 'defeat' ? 'rv-dot--bad' : 'rv-dot--accent'
341const statusWord = computed(() =>
342 gameStore.gameStatus === 'victory' ? 'Victory' : gameStore.gameStatus === 'defeat' ? 'Defeat' : 'Active'
344 
345// Thread rail (below md): open by default (the figure's replies are the game's most
346// charming content — collapsed-by-default made them too easy to miss). A fold is the
347// player's explicit choice now, so nothing force-reopens it. From md up it's forced
348// open as a full panel beside the Chronicle (isMd), so its toggle is inert there —
349// guard the handler so the native toggle event can't flip our own state.
350const threadOpen = ref(true)
351function onThreadToggle(e: Event) { if (!isMd.value) threadOpen.value = (e.target as HTMLDetailsElement).open }
352 
353// When a run begins, drop the cursor straight into the "who" field.
354function focusFigure() {
355 nextTick(() => (document.querySelector('[data-testid="figure-input"]') as HTMLElement | null)?.focus())
357watch(() => gameStore.currentObjective, (obj) => { if (obj) focusFigure() })
358 
359// Lock body scroll while the end-screen takeover is up (so the board can't peek).
360watch(() => gameStore.gameStatus, (s) => {
361 if (typeof document === 'undefined') return
362 document.body.style.overflow = (s === 'victory' || s === 'defeat') ? 'hidden' : ''
363})
364 
365// md+ breakpoint — from md up every zone is shown together, so the Chronicle is
366// forced open as the prose payoff rather than a tap-to-open rail (below md it stays
367// a collapsible rail in the Story tab); tracked reactively.
368const isMd = ref(false)
369let mdMql: MediaQueryList | null = null
370function syncMd() { isMd.value = mdMql?.matches ?? false }
371onMounted(() => {
372 mdMql = window.matchMedia('(min-width: 768px)')
373 syncMd()
374 mdMql.addEventListener('change', syncMd)
375})
376onUnmounted(() => { mdMql?.removeEventListener('change', syncMd) })
377 
378// Load the device's run balance for the header gauge, and handle the return from
379// Stripe Checkout: ?purchase=success|cancel drives the banner + a balance refresh,
380// then the query is stripped so a later refresh can't re-fire it.
381const route = useRoute()
382const router = useRouter()
383onMounted(async () => {
384 const purchase = route.query.purchase
385 if (purchase === 'success' || purchase === 'cancel') {
386 await gameStore.notePurchaseReturn(purchase)
387 router.replace({ query: {} })
388 // The webhook credits asynchronously and can lag Stripe's redirect, so the
389 // first read may predate the credit. Poll briefly so the header gauge
390 // converges to the credited total without a manual reload — stopping as soon
391 // as it changes, or after a few tries (the credit is guaranteed by the
392 // webhook + its retries regardless). The banner asserts no specific count.
393 if (purchase === 'success') {
394 const before = gameStore.runsRemaining
395 for (let i = 0; i < 4; i++) {
396 await new Promise((r) => setTimeout(r, 1500))
397 await gameStore.loadBalance()
398 if (gameStore.runsRemaining !== before) break
399 }
400 }
401 } else {
402 await gameStore.loadBalance()
403 }
404})
⋯ 94 lines hidden (lines 405–498)
405 
406// Dark-mode toggle via @nuxtjs/color-mode (bundled with @nuxt/ui): persists across
407// reloads, respects the OS preference, and ships a no-flash inline script — replacing
408// the old manual classList toggle that did none of those.
409const colorMode = useColorMode()
410const isDark = computed(() => colorMode.value === 'dark')
411function toggleDark() {
412 colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
414 
415const handleSendMessage = async () => {
416 if (!messageInputRef.value) return
417 const messageText = messageInputRef.value.message?.trim()
418 const target = figure.value.trim()
419 if (!messageText || !messageInputRef.value.isValid || !target) return
420 
421 // Clear the composer only when the turn actually resolved — a blocked or
422 // failed send keeps the player's crafted 160 characters for another try.
423 const resolved = await gameStore.sendMessage(messageText, target, { stake: stakeArmed.value })
424 if (resolved) {
425 stakeArmed.value = false
426 if (messageInputRef.value) messageInputRef.value.message = ''
427 }
429 
430// The header reset arms a confirm when a run is in progress (and auto-disarms);
431// performReset is the single unified reset path — the end screen's Play Again
432// lands here too, so every reset clears the figure input and composer alike.
433const resetArmed = ref(false)
434let resetArmTimer: ReturnType<typeof setTimeout> | null = null
435 
436const handleResetGame = () => {
437 // One tap only when there is truly nothing to lose: no resolved turns AND no
438 // words or contact in progress (a typed first dispatch is work too).
439 const composerDirty = !!messageInputRef.value?.message?.trim() || !!figure.value.trim()
440 if (gameStore.timelineEvents.length === 0 && !composerDirty) {
441 performReset()
442 return
443 }
444 resetArmed.value = true
445 if (resetArmTimer) clearTimeout(resetArmTimer)
446 resetArmTimer = setTimeout(() => { resetArmed.value = false }, 3500)
448 
449function disarmReset() {
450 resetArmed.value = false
451 if (resetArmTimer) clearTimeout(resetArmTimer)
453 
454function performReset() {
455 disarmReset()
456 stakeArmed.value = false
457 gameStore.resetGame()
458 figure.value = ''
459 if (messageInputRef.value) messageInputRef.value.message = ''
460 focusFigure()
462 
463onUnmounted(() => { if (resetArmTimer) clearTimeout(resetArmTimer) })
464 
465useHead({
466 title: 'Revisionist — Rewrite History',
467 meta: [
468 { name: 'description', content: 'Send 160-character messages to anyone in history. A D20 and an AI decide how the timeline bends.' }
469 ]
470})
471</script>
472 
473<style scoped>
474/* Phone tab bar — letterpress, not chrome: a hairline-topped strip, the active tab
475 marked by the one accent and a terracotta top rule. ≥44px tall for the thumb. */
476.mobile-tab {
477 display: flex;
478 flex-direction: column;
479 align-items: center;
480 justify-content: center;
481 gap: 2px;
482 padding: 8px 0 9px;
483 color: var(--rv-faint);
484 border-top: 2px solid transparent;
485 transition: color var(--rv-dur-1) var(--rv-ease), border-color var(--rv-dur-1) var(--rv-ease);
487.mobile-tab .rv-label { color: inherit; }
488.mobile-tab.is-active {
489 color: var(--rv-accent);
490 border-top-color: var(--rv-accent);
492 
493/* The armed last stand — a quiet accent wash, not a flashing alarm. */
494.stake-armed {
495 border-color: var(--rv-accent);
496 background: color-mix(in srgb, var(--rv-accent) 7%, transparent);
498</style>

The post-checkout banner (success / cancel), self-dismissing.

pages/play.vue · 498 lines
pages/play.vue498 lines · Vue
⋯ 46 lines hidden (lines 1–46)
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 <span class="flex items-center gap-1.5 pl-1">
19 <span class="rv-dot" :class="statusDot" aria-hidden="true" />
20 <span class="rv-mono text-[11px] uppercase tracking-wide rv-muted sr-only sm:not-sr-only">{{ statusWord }}</span>
21 </span>
22 <!-- One mis-tap must not erase five AI-resolved turns: with a run in
23 progress the reset arms a tiny inline confirm (auto-reverts); an
24 untouched run still resets in one tap. -->
25 <span v-if="resetArmed" class="flex items-center gap-1.5">
26 <span class="text-[11px] rv-warn">abandon this history?</span>
27 <button type="button" data-testid="reset-confirm" class="rv-tap rv-hover-fg text-sm leading-none rv-warn"
28 aria-label="Yes — abandon this history" @click="performReset"></button>
29 <button type="button" data-testid="reset-cancel" class="rv-tap rv-hover-fg text-sm leading-none"
30 aria-label="Keep playing" @click="disarmReset"></button>
31 </span>
32 <button v-else type="button" data-testid="reset-button" class="rv-tap rv-hover-fg text-base leading-none"
33 title="New timeline" aria-label="New timeline" @click="handleResetGame"></button>
34 </div>
35 
36 <!-- Runs gauge — always visible (briefing + board), and the entry to the
37 account popover / the run-packs modal. -->
38 <RunsBalance class="shrink-0" :class="gameStore.currentObjective ? '' : 'ml-auto'" />
39 
40 <button type="button" class="rv-tap rv-hover-fg text-base leading-none shrink-0"
41 :aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'" @click="toggleDark"></button>
42 </div>
43 </header>
44 
45 <!-- Post-checkout banner: the missing feedback after returning from Stripe — a
46 credited confirmation, or a gentle "no charge" on cancel. Self-dismisses. -->
47 <div v-if="gameStore.purchaseNotice" data-testid="purchase-notice" role="status" aria-live="polite"
48 class="border-b rv-line px-5 py-2.5 text-sm flex items-center gap-2"
49 :class="gameStore.purchaseNotice === 'success' ? 'rv-tint' : ''">
50 <span aria-hidden="true">{{ gameStore.purchaseNotice === 'success' ? '✓' : '○' }}</span>
51 <span class="rv-fg">
52 <template v-if="gameStore.purchaseNotice === 'success'">
53 Payment received — your runs have been added to your balance.
54 </template>
55 <template v-else>Checkout canceled — you weren't charged.</template>
56 </span>
57 <button type="button" data-testid="purchase-notice-close" class="rv-tap rv-hover-fg ml-auto shrink-0"
58 aria-label="Dismiss" @click="gameStore.clearPurchaseNotice()"></button>
59 </div>
60 
61 <!-- Waiting mode: a thin indeterminate sweep while a turn resolves — the whole
62 board reads as "history is being rewritten," in concert with the shaking die. -->
⋯ 436 lines hidden (lines 63–498)
63 <div v-if="gameStore.isLoading" class="rv-loading-bar" role="status">
64 <span class="sr-only">Resolving your move — history is being rewritten…</span>
65 </div>
66 
67 <!-- At lg the board owns the viewport and scrolls inside its own panes, so main is
68 clipped (lg:overflow-hidden). The mission briefing has no such inner scroll, so
69 on a short viewport that clipping hid the Begin button under the footer — give
70 the briefing a scrollable main instead. -->
71 <main class="px-5 py-6 pb-24 md:pb-6 flex-1"
72 :class="gameStore.currentObjective ? 'lg:min-h-0 lg:py-0 lg:overflow-hidden' : 'lg:overflow-y-auto'">
73 <!-- Briefing: choose (or compose) an objective before the run begins -->
74 <MissionSelect v-if="!gameStore.currentObjective" />
75 
76 <template v-else>
77 <!-- The board, only while the run is live — once it ends, the end-screen
78 takeover fully replaces it (no board bleeding under the verdict).
79 On phones the three zones become one-at-a-time panels driven by the
80 bottom tab bar; from md up every zone is shown together as before. -->
81 <template v-if="gameStore.gameStatus === 'playing'">
82 <!-- THE WORKSPACE — at lg the board splits into two panes that fit the viewport:
83 a scrolling read-model (roll · shift · Spine · Chronicle · Thread) on the
84 left, and the act-model (the compose dock) pinned on the right, so the move
85 is never buried below the read. Below lg this wrapper is inert and the zones
86 fall back to the stacked page (md) / phone tab panels (sm). -->
87 <div class="rv-fade-in lg:h-full lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(360px,440px)] lg:grid-rows-1">
88 <!-- LEFT pane — the read-model. At lg it's a flex column the height of the
89 pane: the board/Spine on top at its natural height, then the story zone
90 flexes to fill the rest, so the Chronicle & Thread are full-height panels
91 (each scrolls inside itself) rather than a single scrolling stack. -->
92 <div class="lg:min-h-0 lg:min-w-0 lg:flex lg:flex-col lg:overflow-hidden lg:pr-6 lg:py-6">
93 <!-- ZONE 1 — the board state: the roll + the shift, then the Spine -->
94 <section class="rv-panel space-y-4 lg:space-y-6 lg:flex-none" :class="[{ 'rv-dim': isFirstTurn }, mobileTab === 'board' ? '' : 'hidden', 'md:block']">
95 <div class="grid sm:grid-cols-2 gap-4">
96 <div class="sm:border-r rv-line sm:pr-6 py-1">
97 <span class="rv-label mb-2 block">Dice of fate</span>
98 <DiceRoller />
99 <!-- The bands, disclosed: the player can always see how fate maps to
100 swing — the same table the Timeline Engine is instructed with. -->
101 <details data-testid="roll-legend" class="mt-2 text-[11px]">
102 <summary class="rv-faint cursor-pointer select-none hover:underline">how fate is weighed</summary>
103 <ul class="mt-1.5 space-y-0.5 rv-mono rv-muted">
104 <li v-for="row in rollLegend" :key="row.label" class="flex justify-between gap-3">
105 <span>{{ row.range }} · {{ row.label }}</span><span :class="row.cls">{{ row.swing }}</span>
106 </li>
107 </ul>
108 <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>
109 </details>
110 </div>
111 <div class="sm:pl-2 flex flex-col justify-center">
112 <ProgressTracker />
113 </div>
114 </div>
115 <TimelineLedger />
116 </section>
117 
118 <!-- ZONE 2 — the story: the living chronicle · the conversation thread. At lg
119 this flexes to fill the rest of the pane and lays the two out side by side
120 as equal full-height columns (md keeps the two-up grid in the page flow). -->
121 <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']">
122 <Chronicle :force-open="isMd" />
123 <!-- Thread: a <div> from md up (see Chronicle for why a <details> can't fill the
124 column height), a collapsible <details> on phones. -->
125 <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">
126 <summary class="rv-summary" :class="{ 'rv-summary--static': isMd }">
127 <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>
128 </summary>
129 <div class="pt-2 lg:flex-1 lg:min-h-0 lg:overflow-y-auto">
130 <MessageHistory data-testid="message-history" />
131 </div>
132 </component>
133 </section>
134 </div><!-- /LEFT pane -->
135 
136 <!-- RIGHT pane — the act-model, pinned beside the read column at lg -->
137 <div class="lg:min-h-0 lg:overflow-y-auto lg:border-l rv-line lg:pl-6 lg:py-6">
138 <!-- ZONE 3 — your turn: the compose dock (the page-stack divider is dropped at
139 lg, where it's a column of its own, not a section below the read). -->
140 <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']">
141 <div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
142 <span class="rv-label">Send a message into the past</span>
143 <!-- The wager, read BEFORE the roll: when the Archive has stamped a
144 known-since and a contact year is chosen, the chip translates the
145 reach into the same ⚡ tiers the spine uses. Otherwise the general
146 principle is stated — on the first turn too, where it teaches most. -->
147 <span v-if="wagerRead" data-testid="wager-chip" class="text-[11px]"
148 :class="wagerRead.tier === 'in-period' ? 'rv-muted' : 'rv-warn'">
149 <span aria-hidden="true">{{ wagerRead.pips }}</span> {{ wagerRead.text }}
150 </span>
151 <span v-else 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>
152 <!-- The chain, read BEFORE the roll: how far this moment lands from your
153 nearest foothold (the objective's era or a change you've made). A
154 lone leap into the deep past is diffuse; building a chain forward
155 closes the gap. Hidden at the hinge, where there's nothing to warn. -->
156 <span v-if="chainRead" data-testid="chain-chip" class="text-[11px]"
157 :class="chainRead.tier === 'diffuse' ? 'rv-warn' : 'rv-muted'">
158 <span aria-hidden="true"></span> {{ chainRead.text }}
159 </span>
160 </div>
161 
162 <p v-if="isFirstTurn" class="rv-accent text-sm mb-4">
163 Reach someone in history, write up to 160 characters, and send — one message can bend the whole timeline.
164 </p>
165 
166 <div class="grid md:grid-cols-2 lg:grid-cols-1 gap-x-8 gap-y-6">
167 <div>
168 <span class="rv-label block mb-2"><span class="rv-accent">1</span> · choose who &amp; when</span>
169 <FigurePicker v-model="figure" :disabled="!gameStore.canSendMessage" />
170 </div>
171 
172 <div class="space-y-3 rv-line lg:border-t lg:pt-6">
173 <span class="rv-label block mb-2"><span class="rv-accent">2</span> · write &amp; send</span>
174 <MessageInput ref="messageInputRef" />
175 <!-- The last stand: offered only when the final dispatch can no
176 longer win inside the normal per-turn cap — a true last resort,
177 never a free doubling from a winnable position. -->
178 <div v-if="gameStore.canStake" data-testid="stake-control"
179 class="border rounded-sm px-2.5 py-2 flex items-start gap-2"
180 :class="stakeArmed ? 'stake-armed' : 'rv-line'">
181 <input id="stake-toggle" v-model="stakeArmed" type="checkbox" class="mt-0.5"
182 :style="{ accentColor: 'var(--rv-accent)' }" />
183 <label for="stake-toggle" class="text-[11px] leading-snug cursor-pointer">
184 <span class="rv-warn font-semibold uppercase tracking-wide"><span aria-hidden="true"></span> Stake the timeline</span>
185 <span class="rv-muted block">Your final dispatch cuts twice as deep — both ways — and can move the whole meter. The last stand.</span>
186 </label>
187 </div>
188 <div class="flex items-center gap-3 flex-wrap">
189 <SendButton data-testid="send-button" :input-valid="canSend" @click="handleSendMessage" />
190 <div v-if="gameStore.error" data-testid="error-alert" role="status" aria-live="polite"
191 class="flex items-center gap-2 text-xs flex-1 min-w-0 border-l-2 rv-line pl-2 py-0.5">
192 <span class="rv-label shrink-0">notice</span>
193 <span class="rv-muted truncate">{{ gameStore.error }}</span>
194 <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>
195 </div>
196 <div v-if="gameStore.moderationNotice" data-testid="moderation-alert" role="alert" aria-live="assertive"
197 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">
198 <span class="shrink-0 font-semibold uppercase tracking-wide">⚠ blocked</span>
199 <span class="min-w-0">{{ gameStore.moderationNotice }}</span>
200 <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>
201 </div>
202 </div>
203 <ArchiveLookup />
204 </div>
205 </div>
206 </section>
207 </div><!-- /RIGHT pane -->
208 </div><!-- /workspace -->
209 </template>
210 </template>
211 </main>
212 
213 <!-- Phone-only tab bar: the three zones as fixed panels you switch between, so the
214 board never becomes one infinite scroll and your next move is always one tap
215 away. Hidden from md up (where every zone is shown at once). -->
216 <!-- A labelled nav of view-switch buttons (NOT an ARIA tablist: there's no roving
217 tabindex / arrow-key tabset here, and the zones are simple show/hide regions).
218 The active view is conveyed with aria-current, the honest, complete pattern. -->
219 <nav v-if="gameStore.currentObjective && gameStore.gameStatus === 'playing'"
220 class="md:hidden fixed bottom-0 inset-x-0 z-30 border-t rv-line rv-bg grid grid-cols-3"
221 aria-label="Switch board view">
222 <button v-for="t in mobileTabs" :key="t.id" type="button" :aria-current="mobileTab === t.id ? 'true' : undefined"
223 class="mobile-tab rv-press" :class="{ 'is-active': mobileTab === t.id }" @click="mobileTab = t.id">
224 <span class="text-lg leading-none" aria-hidden="true">{{ t.glyph }}</span>
225 <span class="rv-label flex items-center gap-1">
226 {{ t.label }}
227 <span v-if="t.id === 'compose' && yourMove" class="rv-dot rv-dot--accent" aria-hidden="true" />
228 </span>
229 </button>
230 </nav>
231 
232 <!-- A ledger running-foot: header + footer frame the page as a document.
233 (Hidden on phones, where the tab bar is the foot.) -->
234 <footer class="border-t rv-line px-5 py-3 hidden md:block text-[11px] rv-faint">
235 <div class="flex items-center gap-2">
236 <span class="rv-mono uppercase tracking-wide">Revisionist</span>
237 <span class="rv-hair-c" aria-hidden="true">·</span>
238 <span class="rv-mono">a history you are writing</span>
239 <span class="ml-auto rv-serif italic">the past is not fixed</span>
240 </div>
241 <!-- Persistent compliance line (AI disclosure + fiction + adults-only). -->
242 <p data-testid="footer-disclosure" class="mt-1.5">
243 Figures are AI, not real people · replies are AI-generated fiction, not historical fact · for adults (18+)
244 </p>
245 </footer>
246 
247 <EndGameScreen @play-again="performReset" />
248 
249 <!-- The run-packs store: opened from the header/account or automatically when a
250 commit is refused for being out of runs. Mounted once, here. -->
251 <RunPacksModal />
252 </div>
253</template>
254 
255<script setup lang="ts">
256/**
257 * Main game page — Revisionist, "The Spine Console" layout.
258 *
259 * A full-bleed board grouped into three super-zones — board-state (roll + shift +
260 * Spine), story (chronicle · thread), and your-turn (the compose dock) — separated
261 * by space, not chrome. State lives in the store; this only arranges it.
262 */
263import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
264import { useGameStore, formatContactYear } from '~/stores/game'
265import { SWING_BANDS } from '~/utils/swing-bands'
266import { DiceOutcome, CRIT_FAIL_MAX, FAILURE_MAX, NEUTRAL_MAX, CRIT_SUCCESS_MIN } from '~/utils/dice'
267import { parseKnownSinceYear, wagerTier } from '~/utils/known-since'
268import { CHAIN_HINT } from '~/utils/causal-chain'
269import { ANACHRONISM_LABEL } from '~/server/utils/anachronism'
270 
271const gameStore = useGameStore()
272 
273// The disclosed band table — rendered from the same constants the Timeline
274// Engine is instructed with, so the legend can't lie.
275const fmtSwing = (b: { min: number; max: number }) =>
276 `${b.min >= 0 ? '+' : ''}${b.min}${b.max >= 0 ? '+' : ''}${b.max}%`
277const rollLegend = [
278 { range: `${CRIT_SUCCESS_MIN}–20`, label: 'Critical Success', swing: fmtSwing(SWING_BANDS[DiceOutcome.CRITICAL_SUCCESS]), cls: 'rv-ok' },
279 { range: `${NEUTRAL_MAX + 1}${CRIT_SUCCESS_MIN - 1}`, label: 'Success', swing: fmtSwing(SWING_BANDS[DiceOutcome.SUCCESS]), cls: 'rv-ok' },
280 { range: `${FAILURE_MAX + 1}${NEUTRAL_MAX}`, label: 'Neutral', swing: fmtSwing(SWING_BANDS[DiceOutcome.NEUTRAL]), cls: 'rv-muted' },
281 { range: `${CRIT_FAIL_MAX + 1}${FAILURE_MAX}`, label: 'Failure', swing: fmtSwing(SWING_BANDS[DiceOutcome.FAILURE]), cls: 'rv-warn' },
282 { range: `1–${CRIT_FAIL_MAX}`, label: 'Critical Failure', swing: fmtSwing(SWING_BANDS[DiceOutcome.CRITICAL_FAILURE]), cls: 'rv-bad' }
284 
285// The pre-send wager chip: Archive known-since × chosen contact year → ⚡ tier.
286const wagerRead = computed(() => {
287 const lookup = gameStore.archiveResult
288 const when = gameStore.contactWhen
289 if (!lookup?.knownSince || when == null) return null
290 const knownYear = parseKnownSinceYear(lookup.knownSince)
291 if (knownYear == null) return null
292 const tier = wagerTier(knownYear, when)
293 const pips = tier === 'impossible' ? '⚡⚡⚡' : tier === 'far-ahead' ? '⚡⚡' : tier === 'ahead' ? '⚡' : '✓'
294 // Hedged copy: this is a year-gap compass, not the engine's verdict.
295 const text = tier === 'in-period'
296 ? `${lookup.topic}: already known by ${formatContactYear(when)} — steady ground`
297 : `${lookup.topic}: reads ≈ ${ANACHRONISM_LABEL[tier].toLowerCase()} for ${formatContactYear(when)} — swings harder, both ways`
298 return { tier, pips, text }
299})
300 
301// The pre-send chain chip: how far the chosen moment lands from the nearest
302// foothold (the objective's era or a prior change). Hidden at the hinge (full
303// force, nothing to flag); otherwise it reads the dilution + how to close it.
304const chainRead = computed(() => {
305 const s = gameStore.causalChainRead
306 if (!s || s.tier === 'at-hinge') return null
307 return { tier: s.tier, text: CHAIN_HINT[s.tier] }
308})
309 
310// The last stand, armed by the player on their final dispatch.
311const stakeArmed = ref(false)
312 
313const figure = ref('')
314watch(figure, (name) => gameStore.setActiveFigure((name || '').trim()))
315 
316const messageInputRef = ref()
317const messageInputValid = computed(() => messageInputRef.value?.isValid ?? false)
318const canSend = computed(() => messageInputValid.value && figure.value.trim().length > 0 && gameStore.canContact)
319 
320// The very first turn: the read-model is empty, so quiet it and lead the eye to the dock.
321const isFirstTurn = computed(() => gameStore.timelineEvents.length === 0)
322 
323// Phone tab bar: the three zones as switchable panels (md+ shows them all at once, so
324// this state is inert there). The run opens on the move; the instant a turn is rolling
325// we flip to the Board so the die + shift + spine land as the payoff beat, then the
326// player taps back to Compose. A nudge dot marks Compose when it's their move.
327type MobileTab = 'board' | 'story' | 'compose'
328const mobileTabs = [
329 { id: 'board', glyph: '🎲', label: 'Board' },
330 { id: 'story', glyph: '📖', label: 'Story' },
331 { id: 'compose', glyph: '✎', label: 'Compose' }
332] as const
333const mobileTab = ref<MobileTab>('compose')
334const yourMove = computed(() => gameStore.canSendMessage && !gameStore.isLoading && mobileTab.value !== 'compose')
335watch(() => gameStore.isLoading, (loading) => { if (loading) mobileTab.value = 'board' })
336watch(() => gameStore.currentObjective, (obj) => { if (obj) mobileTab.value = 'compose' })
337 
338const statusDot = computed(() =>
339 gameStore.gameStatus === 'victory' ? 'rv-dot--ok' : gameStore.gameStatus === 'defeat' ? 'rv-dot--bad' : 'rv-dot--accent'
341const statusWord = computed(() =>
342 gameStore.gameStatus === 'victory' ? 'Victory' : gameStore.gameStatus === 'defeat' ? 'Defeat' : 'Active'
344 
345// Thread rail (below md): open by default (the figure's replies are the game's most
346// charming content — collapsed-by-default made them too easy to miss). A fold is the
347// player's explicit choice now, so nothing force-reopens it. From md up it's forced
348// open as a full panel beside the Chronicle (isMd), so its toggle is inert there —
349// guard the handler so the native toggle event can't flip our own state.
350const threadOpen = ref(true)
351function onThreadToggle(e: Event) { if (!isMd.value) threadOpen.value = (e.target as HTMLDetailsElement).open }
352 
353// When a run begins, drop the cursor straight into the "who" field.
354function focusFigure() {
355 nextTick(() => (document.querySelector('[data-testid="figure-input"]') as HTMLElement | null)?.focus())
357watch(() => gameStore.currentObjective, (obj) => { if (obj) focusFigure() })
358 
359// Lock body scroll while the end-screen takeover is up (so the board can't peek).
360watch(() => gameStore.gameStatus, (s) => {
361 if (typeof document === 'undefined') return
362 document.body.style.overflow = (s === 'victory' || s === 'defeat') ? 'hidden' : ''
363})
364 
365// md+ breakpoint — from md up every zone is shown together, so the Chronicle is
366// forced open as the prose payoff rather than a tap-to-open rail (below md it stays
367// a collapsible rail in the Story tab); tracked reactively.
368const isMd = ref(false)
369let mdMql: MediaQueryList | null = null
370function syncMd() { isMd.value = mdMql?.matches ?? false }
371onMounted(() => {
372 mdMql = window.matchMedia('(min-width: 768px)')
373 syncMd()
374 mdMql.addEventListener('change', syncMd)
375})
376onUnmounted(() => { mdMql?.removeEventListener('change', syncMd) })
377 
378// Load the device's run balance for the header gauge, and handle the return from
379// Stripe Checkout: ?purchase=success|cancel drives the banner + a balance refresh,
380// then the query is stripped so a later refresh can't re-fire it.
381const route = useRoute()
382const router = useRouter()
383onMounted(async () => {
384 const purchase = route.query.purchase
385 if (purchase === 'success' || purchase === 'cancel') {
386 await gameStore.notePurchaseReturn(purchase)
387 router.replace({ query: {} })
388 // The webhook credits asynchronously and can lag Stripe's redirect, so the
389 // first read may predate the credit. Poll briefly so the header gauge
390 // converges to the credited total without a manual reload — stopping as soon
391 // as it changes, or after a few tries (the credit is guaranteed by the
392 // webhook + its retries regardless). The banner asserts no specific count.
393 if (purchase === 'success') {
394 const before = gameStore.runsRemaining
395 for (let i = 0; i < 4; i++) {
396 await new Promise((r) => setTimeout(r, 1500))
397 await gameStore.loadBalance()
398 if (gameStore.runsRemaining !== before) break
399 }
400 }
401 } else {
402 await gameStore.loadBalance()
403 }
404})
405 
406// Dark-mode toggle via @nuxtjs/color-mode (bundled with @nuxt/ui): persists across
407// reloads, respects the OS preference, and ships a no-flash inline script — replacing
408// the old manual classList toggle that did none of those.
409const colorMode = useColorMode()
410const isDark = computed(() => colorMode.value === 'dark')
411function toggleDark() {
412 colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
414 
415const handleSendMessage = async () => {
416 if (!messageInputRef.value) return
417 const messageText = messageInputRef.value.message?.trim()
418 const target = figure.value.trim()
419 if (!messageText || !messageInputRef.value.isValid || !target) return
420 
421 // Clear the composer only when the turn actually resolved — a blocked or
422 // failed send keeps the player's crafted 160 characters for another try.
423 const resolved = await gameStore.sendMessage(messageText, target, { stake: stakeArmed.value })
424 if (resolved) {
425 stakeArmed.value = false
426 if (messageInputRef.value) messageInputRef.value.message = ''
427 }
429 
430// The header reset arms a confirm when a run is in progress (and auto-disarms);
431// performReset is the single unified reset path — the end screen's Play Again
432// lands here too, so every reset clears the figure input and composer alike.
433const resetArmed = ref(false)
434let resetArmTimer: ReturnType<typeof setTimeout> | null = null
435 
436const handleResetGame = () => {
437 // One tap only when there is truly nothing to lose: no resolved turns AND no
438 // words or contact in progress (a typed first dispatch is work too).
439 const composerDirty = !!messageInputRef.value?.message?.trim() || !!figure.value.trim()
440 if (gameStore.timelineEvents.length === 0 && !composerDirty) {
441 performReset()
442 return
443 }
444 resetArmed.value = true
445 if (resetArmTimer) clearTimeout(resetArmTimer)
446 resetArmTimer = setTimeout(() => { resetArmed.value = false }, 3500)
448 
449function disarmReset() {
450 resetArmed.value = false
451 if (resetArmTimer) clearTimeout(resetArmTimer)
453 
454function performReset() {
455 disarmReset()
456 stakeArmed.value = false
457 gameStore.resetGame()
458 figure.value = ''
459 if (messageInputRef.value) messageInputRef.value.message = ''
460 focusFigure()
462 
463onUnmounted(() => { if (resetArmTimer) clearTimeout(resetArmTimer) })
464 
465useHead({
466 title: 'Revisionist — Rewrite History',
467 meta: [
468 { name: 'description', content: 'Send 160-character messages to anyone in history. A D20 and an AI decide how the timeline bends.' }
469 ]
470})
471</script>
472 
473<style scoped>
474/* Phone tab bar — letterpress, not chrome: a hairline-topped strip, the active tab
475 marked by the one accent and a terracotta top rule. ≥44px tall for the thumb. */
476.mobile-tab {
477 display: flex;
478 flex-direction: column;
479 align-items: center;
480 justify-content: center;
481 gap: 2px;
482 padding: 8px 0 9px;
483 color: var(--rv-faint);
484 border-top: 2px solid transparent;
485 transition: color var(--rv-dur-1) var(--rv-ease), border-color var(--rv-dur-1) var(--rv-ease);
487.mobile-tab .rv-label { color: inherit; }
488.mobile-tab.is-active {
489 color: var(--rv-accent);
490 border-top-color: var(--rv-accent);
492 
493/* The armed last stand — a quiet accent wash, not a flashing alarm. */
494.stake-armed {
495 border-color: var(--rv-accent);
496 background: color-mix(in srgb, var(--rv-accent) 7%, transparent);
498</style>

A landing page, and the route move

The game now lives at /play; / is a new static landing that orients a newcomer — the premise, how a turn works, and the hook that the first run is free — with a Begin CTA into the game. It's pure presentation: no store, no AI or cost calls.

The hero + Begin CTA, and the three how-it-works beats.

pages/index.vue · 104 lines
pages/index.vue104 lines · Vue
⋯ 11 lines hidden (lines 1–11)
1<template>
2 <!-- Landing: a short orientation before the board. What the game is, how a turn
3 works, and the one promise that gets you in — the first run is free. Kept to a
4 single scannable screen in the warm-ledger aesthetic; "Begin" is one tap in. -->
5 <div class="min-h-screen rv-bg flex flex-col">
6 <header class="border-b rv-line">
7 <div class="px-5 py-2.5 flex items-center gap-4 max-w-3xl mx-auto w-full">
8 <GameTitle />
9 <button type="button" class="rv-tap rv-hover-fg text-base leading-none shrink-0 ml-auto"
10 :aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'" @click="toggleDark"></button>
11 </div>
12 </header>
13 
14 <main class="flex-1 px-5 py-10 sm:py-14">
15 <div class="max-w-2xl mx-auto">
16 <!-- Hero -->
17 <p class="rv-label">A history you are writing</p>
18 <h2 class="rv-serif rv-fg text-4xl sm:text-5xl font-bold mt-2 leading-[1.05]">
19 Rewrite history,<br >one message at a time.
20 </h2>
21 <p class="rv-muted text-base sm:text-lg mt-4 leading-relaxed max-w-xl">
22 Send a 160-character message to anyone who ever lived. A roll of the dice and an
23 AI decide how the timeline bends — for better or worse.
24 </p>
25 
26 <div class="mt-7 flex flex-col sm:flex-row sm:items-center gap-3">
27 <NuxtLink to="/play" data-testid="begin-cta" class="rv-btn rv-btn--primary text-base">
28 Begin — your first run is free <span aria-hidden="true"></span>
29 </NuxtLink>
30 <span class="rv-faint text-xs">More runs are one-time packs · they never expire.</span>
31 </div>
32 
33 <!-- How it works — three ledger beats -->
⋯ 38 lines hidden (lines 34–71)
34 <div class="mt-12 border-t rv-line">
35 <div v-for="(step, i) in steps" :key="step.title" class="flex items-start gap-4 py-4 border-b rv-line">
36 <span class="rv-mono rv-accent text-sm font-semibold mt-0.5 w-5 shrink-0">{{ i + 1 }}</span>
37 <span class="text-2xl leading-none mt-0.5 w-7 shrink-0 text-center select-none" aria-hidden="true">{{ step.glyph }}</span>
38 <span class="min-w-0">
39 <h3 class="rv-fg font-semibold">{{ step.title }}</h3>
40 <p class="rv-muted text-sm mt-0.5 leading-relaxed">{{ step.body }}</p>
41 </span>
42 </div>
43 </div>
44 
45 <p class="rv-faint text-xs leading-relaxed mt-8 max-w-xl">
46 Figures are played by AI, not real people, and every reply is AI-generated
47 <span class="rv-fg">alternate history — dramatized fiction, not historical fact</span>.
48 Intended for adults (18+).
49 </p>
50 </div>
51 </main>
52 
53 <footer class="border-t rv-line px-5 py-3 text-[11px] rv-faint">
54 <div class="max-w-2xl mx-auto flex items-center gap-2">
55 <span class="rv-mono uppercase tracking-wide">Revisionist</span>
56 <span class="rv-hair-c" aria-hidden="true">·</span>
57 <span class="rv-serif italic">the past is not fixed</span>
58 </div>
59 </footer>
60 </div>
61</template>
62 
63<script setup lang="ts">
64/**
65 * Landing — the orientation screen at /. Explains the premise and a turn's shape,
66 * then sends the player into the game at /play (the first run is free). Deliberately
67 * static: no store, no AI calls; the dark toggle mirrors the game's so the choice
68 * carries across.
69 */
70import { computed } from 'vue'
71 
72const steps = [
73 {
74 glyph: '✶',
75 title: 'Pick a cause',
76 body: 'A wrong to right, an era to remake. Choose from curated objectives — or have a fresh one composed for you.'
77 },
78 {
79 glyph: '✎',
80 title: 'Write to history',
81 body: 'Choose a figure and a year, then send up to 160 characters. Five messages to a run — make them count.'
82 },
83 {
84 glyph: '🎲',
85 title: 'Watch it bend',
86 body: 'A D20 and the Timeline Engine resolve each move. The world rewrites itself, told back to you in a living chronicle.'
87 }
⋯ 16 lines hidden (lines 89–104)
89 
90// Dark-mode toggle, shared with the game via @nuxtjs/color-mode (persists + respects
91// the OS preference), so a choice made here carries into /play and back.
92const colorMode = useColorMode()
93const isDark = computed(() => colorMode.value === 'dark')
94function toggleDark() {
95 colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
97 
98useHead({
99 title: 'Revisionist — Rewrite History',
100 meta: [
101 { name: 'description', content: 'Send 160-character messages to anyone in history. A D20 and an AI decide how the timeline bends. Your first run is free.' }
102 ]
103})
104</script>

Tests and verification

New unit tests cover the balance endpoint's free-grant contract, the store's loadBalance / openBuyModal / notePurchaseReturn (including that an empty packs read isn't cached), and the two new components — the gauge/popover and the modal (buy flow, best-value mark, and the Tab focus trap). The MissionSelect paywall test now asserts the modal opens. Full suite green (534) and nuxt typecheck clean.

Beyond green tests: the dev server was booted and every surface eyeballed — landing, gauge, account popover, modal on desktop and mobile, and the success banner. An independent adversarial review of the diff returned no critical findings; its three flagged issues (the banner-vs-webhook race, the empty-packs cache, and the modal focus trap) are the fixes folded in above.