What changed, and why

Every anachronism-wager string told the player the timeline swings "harder, both ways" — which reads as symmetric. It isn't. Reaching beyond a figure's own era amplifies losses faster than gains, so a bold reach is a bet against the house, not a free power-up. Playtesting flagged this as the single biggest legibility gap (~71 mentions): players couldn't tell the wager was asymmetric.

This PR rewords the four player-facing anachronism strings to name the asymmetry. No mechanics change — the factor tables, the amplifier, the dice are all untouched. The only edits are to copy. And one important non-edit: the stake mechanic also says "both ways," but staking genuinely doubles symmetrically (×2 up and down), so every stake string is left exactly as it was.

StringBeforeAfter
play.vue wager-line"the harder the timeline swings — both ways""the wider the timeline swings — gains gently, losses hard"
play.vue wager chip"swings harder, both ways""a wider swing, losses harder than gains"
anachronism.ts ahead hint"widens the swing both ways, losses a touch harder""widens the swing, losses a touch harder than gains"
coaching.ts wager step"swings harder, for you and against you""swings wider, but losses harder than gains: a bold reach is a bet, not a free boost"

Why the old copy was wrong: the factor tables

The asymmetry is real and deterministic. Two tables in anachronism.ts set how much each anachronism tier multiplies the swing — and at every bold tier the loss factor exceeds the gain factor. At the extreme, an impossible reach grows a gain ×1.6 but roughly doubles a loss (×2.0). That gap is the whole strategic point of the wager, and the "both ways" copy hid it.

Gains amplify gently; losses harder — by design, so a bold reach is a real wager.

server/utils/anachronism.ts · 91 lines
server/utils/anachronism.ts91 lines · TypeScript
⋯ 21 lines hidden (lines 1–21)
1/**
2 * Anachronism risk-coupling (prototype).
3 *
4 * The wager at the heart of wielding future knowledge: the further beyond a figure's
5 * own era your message reaches, the WIDER the swing — bigger triumphs and bigger
6 * backfires. The Timeline Engine rates how anachronistic the player's nudge is for
7 * this figure + year; this pure factor then amplifies the magnitude of the resulting
8 * progress swing (in both directions). So research stops being a cheat code and
9 * becomes a calibration skill: how much truth can the past actually absorb?
10 *
11 * Deliberately a deterministic floor on top of the model's own judgment, in keeping
12 * with the project's "model proposes, a pure function tempers" pattern. The widening
13 * lives HERE only: the Timeline prompt rates the level but is told not to inflate its
14 * own swing for it, so the amplification is applied exactly once.
15 */
16import { MAX_PROGRESS_SWING } from '~/utils/game-config'
17 
18export type Anachronism = 'in-period' | 'ahead' | 'far-ahead' | 'impossible'
19 
20export const ANACHRONISM_LEVELS: Anachronism[] = ['in-period', 'ahead', 'far-ahead', 'impossible']
21 
22/**
23 * How much each level multiplies the swing — asymmetric by design. Gains amplify
24 * more gently than losses, so reaching further into the future is a real wager
25 * (priced in dread) rather than a strictly-dominant free lever: with a positive
26 * expected swing, a symmetric multiplier only ever helped. Research (the Archive)
27 * is how a player earns the right to take the bold tiers anyway.
28 */
29const GAIN_FACTOR: Record<Anachronism, number> = {
30 'in-period': 1,
31 'ahead': 1.15,
32 'far-ahead': 1.35,
33 'impossible': 1.6
35const LOSS_FACTOR: Record<Anachronism, number> = {
36 'in-period': 1,
37 'ahead': 1.25,
38 'far-ahead': 1.6,
39 'impossible': 2
⋯ 51 lines hidden (lines 41–91)
41 
42/**
43 * Soft knee: beyond ±KNEE the excess is halved before the hard clamp, so big
44 * amplified turns land spread through the 40s with their differences intact
45 * instead of all pinning at exactly ±MAX_PROGRESS_SWING (which read as fake).
46 */
47const KNEE = 40
48 
49/** Short player-facing label per level (for the ledger badge / hints). */
50export const ANACHRONISM_LABEL: Record<Anachronism, string> = {
51 'in-period': 'In period',
52 'ahead': 'Ahead of its time',
53 'far-ahead': 'Far ahead of its time',
54 'impossible': 'Impossibly advanced'
56 
57/** One-line hover hint per level — what the wager is and which way it bends the
58 * swing. Qualitative on purpose (the exact factors live above): losses always
59 * cut deeper than gains lift, and the gap widens the further you reach. */
60export const ANACHRONISM_HINT: Record<Anachronism, string> = {
61 'in-period': 'native to the era — no added swing',
62 'ahead': 'ahead of the era but reachable — widens the swing, losses a touch harder than gains',
63 'far-ahead': 'generations early — widens the swing hard, losses harder than gains',
64 'impossible': 'barely receivable — the widest swing of all, losses doubled'
66 
67/** Coerce an untrusted value to a known level; defaults to the safe 'in-period'. */
68export function toAnachronism(value: unknown): Anachronism {
69 return ANACHRONISM_LEVELS.includes(value as Anachronism) ? (value as Anachronism) : 'in-period'
71 
72/**
73 * Amplifies a base progress swing by the anachronism factor — gains gently,
74 * losses hard — soft-kneed above ±40 and re-clamped to ±MAX_PROGRESS_SWING.
75 * `in-period` is a no-op; bolder asks still widen the swing both ways, they
76 * just cut deeper than they lift.
77 */
78export function amplifyForAnachronism(progressChange: number, anachronism: Anachronism): number {
79 const plain = Math.max(-MAX_PROGRESS_SWING, Math.min(MAX_PROGRESS_SWING, Math.round(progressChange)))
80 // A true no-op: the knee shapes only genuinely AMPLIFIED swings.
81 if (anachronism === 'in-period' || !(anachronism in GAIN_FACTOR)) return plain
82 const table = progressChange >= 0 ? GAIN_FACTOR : LOSS_FACTOR
83 const amplified = progressChange * table[anachronism]
84 const magnitude = Math.abs(amplified)
85 const kneed = magnitude > KNEE ? KNEE + (magnitude - KNEE) / 2 : magnitude
86 const signed = amplified < 0 ? -kneed : kneed
87 const result = Math.max(-MAX_PROGRESS_SWING, Math.min(MAX_PROGRESS_SWING, Math.round(signed)))
88 // Monotonicity floor: amplification never pays less than no amplification
89 // (near the cap the knee could otherwise dip a bold gain under its plain value).
90 return progressChange >= 0 ? Math.max(result, plain) : Math.min(result, plain)

The per-tier hover hints live in the same file. Three of the four already named the asymmetry ("losses harder than gains", "losses doubled"); the ahead tier was the straggler still saying "both ways." It now matches its siblings.

The 'ahead' hint reworded to match the gradient the other tiers already used.

server/utils/anachronism.ts · 91 lines
server/utils/anachronism.ts91 lines · TypeScript
⋯ 59 lines hidden (lines 1–59)
1/**
2 * Anachronism risk-coupling (prototype).
3 *
4 * The wager at the heart of wielding future knowledge: the further beyond a figure's
5 * own era your message reaches, the WIDER the swing — bigger triumphs and bigger
6 * backfires. The Timeline Engine rates how anachronistic the player's nudge is for
7 * this figure + year; this pure factor then amplifies the magnitude of the resulting
8 * progress swing (in both directions). So research stops being a cheat code and
9 * becomes a calibration skill: how much truth can the past actually absorb?
10 *
11 * Deliberately a deterministic floor on top of the model's own judgment, in keeping
12 * with the project's "model proposes, a pure function tempers" pattern. The widening
13 * lives HERE only: the Timeline prompt rates the level but is told not to inflate its
14 * own swing for it, so the amplification is applied exactly once.
15 */
16import { MAX_PROGRESS_SWING } from '~/utils/game-config'
17 
18export type Anachronism = 'in-period' | 'ahead' | 'far-ahead' | 'impossible'
19 
20export const ANACHRONISM_LEVELS: Anachronism[] = ['in-period', 'ahead', 'far-ahead', 'impossible']
21 
22/**
23 * How much each level multiplies the swing — asymmetric by design. Gains amplify
24 * more gently than losses, so reaching further into the future is a real wager
25 * (priced in dread) rather than a strictly-dominant free lever: with a positive
26 * expected swing, a symmetric multiplier only ever helped. Research (the Archive)
27 * is how a player earns the right to take the bold tiers anyway.
28 */
29const GAIN_FACTOR: Record<Anachronism, number> = {
30 'in-period': 1,
31 'ahead': 1.15,
32 'far-ahead': 1.35,
33 'impossible': 1.6
35const LOSS_FACTOR: Record<Anachronism, number> = {
36 'in-period': 1,
37 'ahead': 1.25,
38 'far-ahead': 1.6,
39 'impossible': 2
41 
42/**
43 * Soft knee: beyond ±KNEE the excess is halved before the hard clamp, so big
44 * amplified turns land spread through the 40s with their differences intact
45 * instead of all pinning at exactly ±MAX_PROGRESS_SWING (which read as fake).
46 */
47const KNEE = 40
48 
49/** Short player-facing label per level (for the ledger badge / hints). */
50export const ANACHRONISM_LABEL: Record<Anachronism, string> = {
51 'in-period': 'In period',
52 'ahead': 'Ahead of its time',
53 'far-ahead': 'Far ahead of its time',
54 'impossible': 'Impossibly advanced'
56 
57/** One-line hover hint per level — what the wager is and which way it bends the
58 * swing. Qualitative on purpose (the exact factors live above): losses always
59 * cut deeper than gains lift, and the gap widens the further you reach. */
60export const ANACHRONISM_HINT: Record<Anachronism, string> = {
61 'in-period': 'native to the era — no added swing',
62 'ahead': 'ahead of the era but reachable — widens the swing, losses a touch harder than gains',
63 'far-ahead': 'generations early — widens the swing hard, losses harder than gains',
64 'impossible': 'barely receivable — the widest swing of all, losses doubled'
⋯ 26 lines hidden (lines 66–91)
66 
67/** Coerce an untrusted value to a known level; defaults to the safe 'in-period'. */
68export function toAnachronism(value: unknown): Anachronism {
69 return ANACHRONISM_LEVELS.includes(value as Anachronism) ? (value as Anachronism) : 'in-period'
71 
72/**
73 * Amplifies a base progress swing by the anachronism factor — gains gently,
74 * losses hard — soft-kneed above ±40 and re-clamped to ±MAX_PROGRESS_SWING.
75 * `in-period` is a no-op; bolder asks still widen the swing both ways, they
76 * just cut deeper than they lift.
77 */
78export function amplifyForAnachronism(progressChange: number, anachronism: Anachronism): number {
79 const plain = Math.max(-MAX_PROGRESS_SWING, Math.min(MAX_PROGRESS_SWING, Math.round(progressChange)))
80 // A true no-op: the knee shapes only genuinely AMPLIFIED swings.
81 if (anachronism === 'in-period' || !(anachronism in GAIN_FACTOR)) return plain
82 const table = progressChange >= 0 ? GAIN_FACTOR : LOSS_FACTOR
83 const amplified = progressChange * table[anachronism]
84 const magnitude = Math.abs(amplified)
85 const kneed = magnitude > KNEE ? KNEE + (magnitude - KNEE) / 2 : magnitude
86 const signed = amplified < 0 ? -kneed : kneed
87 const result = Math.max(-MAX_PROGRESS_SWING, Math.min(MAX_PROGRESS_SWING, Math.round(signed)))
88 // Monotonicity floor: amplification never pays less than no amplification
89 // (near the cap the knee could otherwise dip a bold gain under its plain value).
90 return progressChange >= 0 ? Math.max(result, plain) : Math.min(result, plain)

The compose-dock strings

The wager surfaces twice in the compose dock. When the Archive has stamped a known-since and a contact year is chosen, a chip translates the reach into the ⚡ tiers; otherwise a static wager-line states the general principle (and teaches it on the first turn). Both are reworded here.

Line 169: the static wager-line, shown when there's no chip to render yet.

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

Line 336: the chip text, built per tier from ANACHRONISM_LABEL.

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

The tutorial step — and what was left alone

The coaching tour's wager step described the same wager with the same symmetric tell, "for you and against you." It now names the asymmetry and the bet framing the playtesters were missing.

The wager step (id 'wager'): names the asymmetry, calls the reach a bet.

stores/coaching.ts · 242 lines
stores/coaching.ts242 lines · TypeScript
⋯ 56 lines hidden (lines 1–56)
1import { defineStore } from 'pinia'
2 
3/**
4 * Coaching store — the guided first-run tour: its state machine, the step data,
5 * and the one persisted flag that remembers it's done. A teaching layer for a
6 * brand-new player's first real game — it coaches each lever in context as it
7 * becomes relevant, then gets out of the way.
8 *
9 * Deliberately game-AGNOSTIC: it never imports the game store. pages/play.vue is
10 * the single orchestration site — it reads the game and drives this store one
11 * way — so coaching survives resetGame() untouched and the two never tangle.
12 */
13 
14export type CoachStatus = 'idle' | 'running' | 'completed' | 'dismissed'
15 
16export type CoachStepId = 'who-when' | 'craft' | 'wager' | 'send' | 'roll-swing' | 'fuse-stake'
17 
18export interface CoachStep {
19 id: CoachStepId
20 /** Primary anchor: a CSS selector for the live control this beat teaches. */
21 anchor: string
22 /** Used only when the primary isn't on screen (a conditionally-rendered chip). */
23 fallbackAnchor?: string
24 /** The phone tab that hosts this step's control, nudged into view on entry
25 * (md+ shows every zone at once, so the nudge is inert there). */
26 hostTab: 'compose' | 'board' | 'story' | null
27 /** An 11px uppercase kicker (rv-label). */
28 kicker: string
29 /** The lesson: one or two plain sentences that teach the effect, not the wiring. */
30 body: string
31 /** Gate-only steps wait for a real game signal (no Next button) — the player
32 * advances by doing the real thing. Every other step shows a Next/Done so the
33 * tour is never stuck. Gated steps may ALSO auto-advance via a play.vue watcher. */
34 gateOnly?: boolean
35 /** Preferred placement of the card relative to its anchor (auto-flips to fit). */
36 placement: 'top' | 'bottom' | 'left' | 'right'
38 
39/** The first run, beat by beat. The array order is the tour. */
40export const COACH_STEPS: CoachStep[] = [
41 {
42 id: 'who-when',
43 anchor: '[data-testid="figure-picker"]',
44 hostTab: 'compose',
45 kicker: 'Who & when',
46 body: "You're choosing a person and the moment in their life you reach. Slide the year to set when your message lands.",
47 placement: 'left'
48 },
49 {
50 id: 'craft',
51 anchor: '[data-testid="message-input"]',
52 hostTab: 'compose',
53 kicker: 'The dispatch',
54 body: 'One hundred sixty characters, sent into the past. Sharp, period-fit words land better than vague ones — your craft tilts the roll in your favor.',
55 placement: 'left'
56 },
57 {
58 id: 'wager',
59 anchor: '[data-testid="wager-chip"]',
60 fallbackAnchor: '[data-testid="wager-line"]',
61 hostTab: 'compose',
62 kicker: 'Read the wager',
63 body: '⚡ marks words reaching past their era — the timeline swings wider, but losses harder than gains: a bold reach is a bet, not a free boost. ⏳ marks a lone leap into the deep past, diffuse on its own but sharper once you build a chain forward. Read both before you send.',
64 placement: 'bottom'
65 },
⋯ 177 lines hidden (lines 66–242)
66 {
67 id: 'send',
68 anchor: 'button[aria-label="Send message"]',
69 hostTab: 'compose',
70 kicker: 'Send it',
71 body: "Send when you're ready. A twenty-sided die and the era's own logic decide how far history bends. Nothing here is undone — the past keeps what you wrote.",
72 gateOnly: true,
73 placement: 'left'
74 },
75 {
76 id: 'roll-swing',
77 anchor: '[data-testid="dice-roller"]',
78 hostTab: 'board',
79 kicker: 'The roll & the swing',
80 body: 'There is your roll, and the swing it bought. The die set the band; your craft nudged it a point or two. Lucky or not, the world has already moved.',
81 placement: 'right'
82 },
83 {
84 id: 'fuse-stake',
85 anchor: '[data-testid="messages-counter"]',
86 hostTab: null,
87 kicker: 'Five messages, one meter',
88 body: 'Five dispatches to move the meter to your objective. On your last one you can ⚑ stake the timeline — double the swing, both ways. That is the whole game. Go rewrite it.',
89 placement: 'bottom'
90 }
92 
93const STORAGE_KEY = 'revisionist:coaching:v1'
94 
95/** The terminal verdicts that suppress auto-start forever (kept distinct only so
96 * tests/analytics can tell a finished tour from a bailed one). */
97type PersistedStatus = 'completed' | 'dismissed'
98 
99// localStorage only exists in the browser — its absence is the SSR guard, so this
100// is inert on the server and never trips hydration. (hydrate() is called from a
101// client-only onMounted regardless; this is the belt to that suspenders.)
102function readPersisted(): PersistedStatus | null {
103 if (typeof localStorage === 'undefined') return null
104 try {
105 const raw = localStorage.getItem(STORAGE_KEY)
106 if (!raw) return null
107 const status = (JSON.parse(raw) as { status?: string }).status
108 if (status === 'completed' || status === 'dismissed') return status
109 } catch {
110 /* malformed, or storage blocked — treat as unseen */
111 }
112 return null
114 
115function writePersisted(status: PersistedStatus): void {
116 if (typeof localStorage === 'undefined') return
117 try {
118 localStorage.setItem(STORAGE_KEY, JSON.stringify({ status }))
119 } catch {
120 /* storage blocked (private mode / quota) — coaching just won't persist */
121 }
123 
124/** The slice of game truth play.vue hands in so the store stays game-agnostic. */
125export interface AutoStartCtx {
126 hasObjective: boolean
127 isPlaying: boolean
128 isFirstTurn: boolean
130 
131export const useCoachingStore = defineStore('coaching', {
132 state: () => ({
133 status: 'idle' as CoachStatus,
134 stepIndex: 0,
135 // false on the server and until the first client read, so SSR and the first
136 // client paint agree (no mark, no flash, no hydration mismatch).
137 hydrated: false,
138 // The verdict already on disk (set at hydrate, or by the first finish). Once
139 // set it is never overwritten — so a replay's later skip/complete can't clobber
140 // the original record, and a finished player can never auto-start again.
141 persistedVerdict: null as PersistedStatus | null
142 }),
143 
144 getters: {
145 steps: (): CoachStep[] => COACH_STEPS,
146 isRunning: (s): boolean => s.status === 'running',
147 activeStep: (s): CoachStep | null =>
148 s.status === 'running' ? (COACH_STEPS[s.stepIndex] ?? null) : null,
149 isLastStep: (s): boolean => s.stepIndex >= COACH_STEPS.length - 1
150 },
151 
152 actions: {
153 /** Read the persisted verdict once, client-side. Idempotent. */
154 hydrate() {
155 if (this.hydrated) return
156 const persisted = readPersisted()
157 if (persisted) {
158 this.status = persisted
159 this.persistedVerdict = persisted
160 }
161 this.hydrated = true
162 },
163 
164 /** Start the tour iff nothing's been seen and a genuinely fresh first turn
165 * is live — never mid-game, never for a returning player (the verdict guard
166 * holds even if a replay later left status at 'idle'). */
167 maybeAutoStart(ctx: AutoStartCtx) {
168 if (!this.hydrated || this.status !== 'idle' || this.persistedVerdict) return
169 if (ctx.hasObjective && ctx.isPlaying && ctx.isFirstTurn) this.start()
170 },
171 
172 start() {
173 this.status = 'running'
174 this.stepIndex = 0
175 },
176 
177 next() {
178 if (this.status !== 'running') return
179 if (this.stepIndex >= COACH_STEPS.length - 1) {
180 this.complete()
181 return
182 }
183 this.stepIndex++
184 },
185 
186 /** Jump to a later step; never an earlier one — a flapping signal can't
187 * bounce the tour backward. */
188 goTo(index: number) {
189 if (this.status !== 'running') return
190 if (index <= this.stepIndex || index >= COACH_STEPS.length) return
191 this.stepIndex = index
192 },
193 
194 /** Gate primitive: advance one beat only if the named step is the one
195 * showing, so a later game action can't re-fire a finished beat. */
196 advanceFrom(stepId: CoachStepId) {
197 if (this.status !== 'running') return
198 if (this.activeStep?.id === stepId) this.next()
199 },
200 
201 /** Monotonic fast-forward to a step by id — carries a player who blew past
202 * earlier beats straight to the relevant one (e.g. sent before reading). */
203 advanceTo(stepId: CoachStepId) {
204 const index = COACH_STEPS.findIndex((s) => s.id === stepId)
205 if (index >= 0) this.goTo(index)
206 },
207 
208 skip() {
209 this.status = 'dismissed'
210 this.recordVerdict('dismissed')
211 },
212 
213 complete() {
214 this.status = 'completed'
215 this.recordVerdict('completed')
216 },
217 
218 /** Persist a terminal verdict only the FIRST time one is reached — a replay's
219 * skip/complete updates the visible status but leaves the original on disk. */
220 recordVerdict(verdict: PersistedStatus) {
221 if (this.persistedVerdict) return
222 this.persistedVerdict = verdict
223 writePersisted(verdict)
224 },
225 
226 /** End a running tour WITHOUT a verdict — for when its board is torn down
227 * mid-tour (New timeline). Returns to the resting state: the on-disk verdict
228 * if one exists (so a replay that's reset can't auto-start again), else idle
229 * (so a brand-new player who reset before finishing is re-coached next run). */
230 stop() {
231 if (this.status === 'running') this.status = this.persistedVerdict ?? 'idle'
232 },
233 
234 /** Re-run on demand (the account popover). In memory only — the persisted
235 * verdict is left intact, so a mid-replay reload won't auto-start and
236 * finishing the replay returns to "never auto-start". */
237 replay() {
238 this.status = 'running'
239 this.stepIndex = 0
240 }
241 }
242})

The separate fuse-stake step: 'double the swing, both ways' — STAKE copy, correctly symmetric, untouched.

stores/coaching.ts · 242 lines
stores/coaching.ts242 lines · TypeScript
⋯ 82 lines hidden (lines 1–82)
1import { defineStore } from 'pinia'
2 
3/**
4 * Coaching store — the guided first-run tour: its state machine, the step data,
5 * and the one persisted flag that remembers it's done. A teaching layer for a
6 * brand-new player's first real game — it coaches each lever in context as it
7 * becomes relevant, then gets out of the way.
8 *
9 * Deliberately game-AGNOSTIC: it never imports the game store. pages/play.vue is
10 * the single orchestration site — it reads the game and drives this store one
11 * way — so coaching survives resetGame() untouched and the two never tangle.
12 */
13 
14export type CoachStatus = 'idle' | 'running' | 'completed' | 'dismissed'
15 
16export type CoachStepId = 'who-when' | 'craft' | 'wager' | 'send' | 'roll-swing' | 'fuse-stake'
17 
18export interface CoachStep {
19 id: CoachStepId
20 /** Primary anchor: a CSS selector for the live control this beat teaches. */
21 anchor: string
22 /** Used only when the primary isn't on screen (a conditionally-rendered chip). */
23 fallbackAnchor?: string
24 /** The phone tab that hosts this step's control, nudged into view on entry
25 * (md+ shows every zone at once, so the nudge is inert there). */
26 hostTab: 'compose' | 'board' | 'story' | null
27 /** An 11px uppercase kicker (rv-label). */
28 kicker: string
29 /** The lesson: one or two plain sentences that teach the effect, not the wiring. */
30 body: string
31 /** Gate-only steps wait for a real game signal (no Next button) — the player
32 * advances by doing the real thing. Every other step shows a Next/Done so the
33 * tour is never stuck. Gated steps may ALSO auto-advance via a play.vue watcher. */
34 gateOnly?: boolean
35 /** Preferred placement of the card relative to its anchor (auto-flips to fit). */
36 placement: 'top' | 'bottom' | 'left' | 'right'
38 
39/** The first run, beat by beat. The array order is the tour. */
40export const COACH_STEPS: CoachStep[] = [
41 {
42 id: 'who-when',
43 anchor: '[data-testid="figure-picker"]',
44 hostTab: 'compose',
45 kicker: 'Who & when',
46 body: "You're choosing a person and the moment in their life you reach. Slide the year to set when your message lands.",
47 placement: 'left'
48 },
49 {
50 id: 'craft',
51 anchor: '[data-testid="message-input"]',
52 hostTab: 'compose',
53 kicker: 'The dispatch',
54 body: 'One hundred sixty characters, sent into the past. Sharp, period-fit words land better than vague ones — your craft tilts the roll in your favor.',
55 placement: 'left'
56 },
57 {
58 id: 'wager',
59 anchor: '[data-testid="wager-chip"]',
60 fallbackAnchor: '[data-testid="wager-line"]',
61 hostTab: 'compose',
62 kicker: 'Read the wager',
63 body: '⚡ marks words reaching past their era — the timeline swings wider, but losses harder than gains: a bold reach is a bet, not a free boost. ⏳ marks a lone leap into the deep past, diffuse on its own but sharper once you build a chain forward. Read both before you send.',
64 placement: 'bottom'
65 },
66 {
67 id: 'send',
68 anchor: 'button[aria-label="Send message"]',
69 hostTab: 'compose',
70 kicker: 'Send it',
71 body: "Send when you're ready. A twenty-sided die and the era's own logic decide how far history bends. Nothing here is undone — the past keeps what you wrote.",
72 gateOnly: true,
73 placement: 'left'
74 },
75 {
76 id: 'roll-swing',
77 anchor: '[data-testid="dice-roller"]',
78 hostTab: 'board',
79 kicker: 'The roll & the swing',
80 body: 'There is your roll, and the swing it bought. The die set the band; your craft nudged it a point or two. Lucky or not, the world has already moved.',
81 placement: 'right'
82 },
83 {
84 id: 'fuse-stake',
85 anchor: '[data-testid="messages-counter"]',
86 hostTab: null,
87 kicker: 'Five messages, one meter',
88 body: 'Five dispatches to move the meter to your objective. On your last one you can ⚑ stake the timeline — double the swing, both ways. That is the whole game. Go rewrite it.',
89 placement: 'bottom'
90 }
⋯ 152 lines hidden (lines 91–242)
92 
93const STORAGE_KEY = 'revisionist:coaching:v1'
94 
95/** The terminal verdicts that suppress auto-start forever (kept distinct only so
96 * tests/analytics can tell a finished tour from a bailed one). */
97type PersistedStatus = 'completed' | 'dismissed'
98 
99// localStorage only exists in the browser — its absence is the SSR guard, so this
100// is inert on the server and never trips hydration. (hydrate() is called from a
101// client-only onMounted regardless; this is the belt to that suspenders.)
102function readPersisted(): PersistedStatus | null {
103 if (typeof localStorage === 'undefined') return null
104 try {
105 const raw = localStorage.getItem(STORAGE_KEY)
106 if (!raw) return null
107 const status = (JSON.parse(raw) as { status?: string }).status
108 if (status === 'completed' || status === 'dismissed') return status
109 } catch {
110 /* malformed, or storage blocked — treat as unseen */
111 }
112 return null
114 
115function writePersisted(status: PersistedStatus): void {
116 if (typeof localStorage === 'undefined') return
117 try {
118 localStorage.setItem(STORAGE_KEY, JSON.stringify({ status }))
119 } catch {
120 /* storage blocked (private mode / quota) — coaching just won't persist */
121 }
123 
124/** The slice of game truth play.vue hands in so the store stays game-agnostic. */
125export interface AutoStartCtx {
126 hasObjective: boolean
127 isPlaying: boolean
128 isFirstTurn: boolean
130 
131export const useCoachingStore = defineStore('coaching', {
132 state: () => ({
133 status: 'idle' as CoachStatus,
134 stepIndex: 0,
135 // false on the server and until the first client read, so SSR and the first
136 // client paint agree (no mark, no flash, no hydration mismatch).
137 hydrated: false,
138 // The verdict already on disk (set at hydrate, or by the first finish). Once
139 // set it is never overwritten — so a replay's later skip/complete can't clobber
140 // the original record, and a finished player can never auto-start again.
141 persistedVerdict: null as PersistedStatus | null
142 }),
143 
144 getters: {
145 steps: (): CoachStep[] => COACH_STEPS,
146 isRunning: (s): boolean => s.status === 'running',
147 activeStep: (s): CoachStep | null =>
148 s.status === 'running' ? (COACH_STEPS[s.stepIndex] ?? null) : null,
149 isLastStep: (s): boolean => s.stepIndex >= COACH_STEPS.length - 1
150 },
151 
152 actions: {
153 /** Read the persisted verdict once, client-side. Idempotent. */
154 hydrate() {
155 if (this.hydrated) return
156 const persisted = readPersisted()
157 if (persisted) {
158 this.status = persisted
159 this.persistedVerdict = persisted
160 }
161 this.hydrated = true
162 },
163 
164 /** Start the tour iff nothing's been seen and a genuinely fresh first turn
165 * is live — never mid-game, never for a returning player (the verdict guard
166 * holds even if a replay later left status at 'idle'). */
167 maybeAutoStart(ctx: AutoStartCtx) {
168 if (!this.hydrated || this.status !== 'idle' || this.persistedVerdict) return
169 if (ctx.hasObjective && ctx.isPlaying && ctx.isFirstTurn) this.start()
170 },
171 
172 start() {
173 this.status = 'running'
174 this.stepIndex = 0
175 },
176 
177 next() {
178 if (this.status !== 'running') return
179 if (this.stepIndex >= COACH_STEPS.length - 1) {
180 this.complete()
181 return
182 }
183 this.stepIndex++
184 },
185 
186 /** Jump to a later step; never an earlier one — a flapping signal can't
187 * bounce the tour backward. */
188 goTo(index: number) {
189 if (this.status !== 'running') return
190 if (index <= this.stepIndex || index >= COACH_STEPS.length) return
191 this.stepIndex = index
192 },
193 
194 /** Gate primitive: advance one beat only if the named step is the one
195 * showing, so a later game action can't re-fire a finished beat. */
196 advanceFrom(stepId: CoachStepId) {
197 if (this.status !== 'running') return
198 if (this.activeStep?.id === stepId) this.next()
199 },
200 
201 /** Monotonic fast-forward to a step by id — carries a player who blew past
202 * earlier beats straight to the relevant one (e.g. sent before reading). */
203 advanceTo(stepId: CoachStepId) {
204 const index = COACH_STEPS.findIndex((s) => s.id === stepId)
205 if (index >= 0) this.goTo(index)
206 },
207 
208 skip() {
209 this.status = 'dismissed'
210 this.recordVerdict('dismissed')
211 },
212 
213 complete() {
214 this.status = 'completed'
215 this.recordVerdict('completed')
216 },
217 
218 /** Persist a terminal verdict only the FIRST time one is reached — a replay's
219 * skip/complete updates the visible status but leaves the original on disk. */
220 recordVerdict(verdict: PersistedStatus) {
221 if (this.persistedVerdict) return
222 this.persistedVerdict = verdict
223 writePersisted(verdict)
224 },
225 
226 /** End a running tour WITHOUT a verdict — for when its board is torn down
227 * mid-tour (New timeline). Returns to the resting state: the on-disk verdict
228 * if one exists (so a replay that's reset can't auto-start again), else idle
229 * (so a brand-new player who reset before finishing is re-coached next run). */
230 stop() {
231 if (this.status === 'running') this.status = this.persistedVerdict ?? 'idle'
232 },
233 
234 /** Re-run on demand (the account popover). In memory only — the persisted
235 * verdict is left intact, so a mid-replay reload won't auto-start and
236 * finishing the replay returns to "never auto-start". */
237 replay() {
238 this.status = 'running'
239 this.stepIndex = 0
240 }
241 }
242})

Verification

- npx vitest run — 920/920 pass. - npx nuxt typecheck — clean (confirms the play.vue template literal stayed valid). - No unit, e2e, or eval test asserts on the old strings; the one ANACHRONISM_HINT test checks truthiness only, so the reworded hint still passes. - An independent adversarial review across three lenses — completeness (no symmetric anachronism copy left anywhere), no-collateral-damage (every stake string intact), and accuracy (the new claims are true at every tier) — returned zero actionable findings.