What changed, and why

Sharing a run already mints a public page at /r/:token. This change turns that into a growth loop: when a new player who arrived via a shared run signs up with an email, the sharer earns one run — the same balance players buy in packs.

The qualifying event is deliberately narrow. Not a page view, not a play, not a purchase: a free, non-paid email signup (an anonymous→email account upgrade). The reward is single-sided — only the sharer is credited.

The whole path is server-authoritative and reuses the money-path machinery already in the repo. A referral is just another credit source on the existing balances ledger, and it borrows the exact idempotency shape that credited_sessions gives Stripe webhooks. The new surface area is small: a referrals ledger + one SQL function, a claim endpoint fired after sign-in, a cookie marker set by middleware, and a passive "+N from sharing" line in the account popover.

Read the ledger first (it sets the contract), then the claim flow that drives it.

ConcernHow it's handled
Attributionan httpOnly ew_ref cookie holding the share token; resolved to the owner at signup
Trustthe qualifying email is read from the real Supabase session — the client only triggers the check
Idempotencyone credit per referred subject, ever (a referrals ledger, like credited_sessions)
Sybilone credited referral per browser; a per-referrer cap; email raises the cost of each fake
Notificationin-app tally + a batched threshold-or-window digest, never one ping per referral
The five things a referral program has to get right, and where each lives.

The ledger, and one atomic credit

referrals is the idempotency gate. Its primary key is the referred subject, so a given new player can credit their referrer exactly once, ever — the same trick credited_sessions plays for Stripe. A second column, referred_device, is a parallel idempotency axis that the Sybil guard keys on.

credit_referral does the whole decision in one round-trip. It claims the referred-subject row first (insert … on conflict do nothing); if the claim loses, this identity was already processed and the function no-ops. If it wins, two soft guards run before any credit: the device-burn (a browser that already earned a credit can't earn another) and the per-referrer cap. Only then does it add the reward to the referrer's balance, with the same upsert credit_runs uses.

The ledger table, and the claim-then-guard-then-credit function.

supabase/migrations/0009_referrals.sql · 109 lines
supabase/migrations/0009_referrals.sql109 lines · Transact-SQL
⋯ 22 lines hidden (lines 1–22)
1-- referrals: the referral ledger (issue #96) — one row per REFERRED identity, the
2-- idempotency gate that mirrors credited_sessions for Stripe. A NEW player who arrives
3-- via a shared run (/r/:token) and SIGNS UP WITH AN EMAIL credits the sharer one run,
4-- exactly once. A referral credit is just another credit *source* on the same balance
5-- players buy run-packs into. RLS on with no policy: service-key-only, like the rest of
6-- the money path (a client can never see or self-grant a referral).
7--
8-- referred_subject — the new player's account subject (PK): one credit per referred
9-- identity, EVER. The idempotency key (the credited_sessions twin).
10-- referred_device — the new player's device cookie at conversion. A second
11-- idempotency axis: a CREDITED referral burns the device, so a
12-- single browser can't farm credits by signing up with many emails
13-- (the residual Sybil vector once a real email is required).
14-- referrer_subject — the sharer credited (the shared run's owner subject). Resolved
15-- server-side from the share token; never exposed to any client.
16-- run_token — the share token the referral arrived on (provenance).
17-- runs — runs credited to the referrer (0 when recorded-but-not-credited).
18-- status — 'credited' | 'capped'. 'capped' = recorded for idempotency but
19-- not credited (device already used, or the referrer hit their cap).
20-- Self-referrals are rejected upstream and never reach here.
21-- notified_at — batched-digest accumulator: null until a "you've earned credits"
22-- notice has covered this row. Drives the throttle (never per-event).
23create table if not exists public.referrals (
24 referred_subject text primary key,
25 referred_device text,
26 referrer_subject text not null,
27 run_token text not null,
28 runs int not null default 0,
29 status text not null,
30 notified_at timestamptz,
31 created_at timestamptz not null default now()
32);
33 
34alter table public.referrals enable row level security;
35 
36-- A referrer's accrued referrals back the cap check, the in-app tally, and the digest
37-- throttle — all filter by referrer_subject, so index that access path.
38create index if not exists referrals_referrer_idx
39 on public.referrals (referrer_subject);
40 
41-- credit_referral: credit p_runs to the REFERRER for a NEW referred identity, ONCE.
42-- Mirrors credit_runs: claim the referred_subject row first (insert; on conflict do
43-- nothing) — if the claim conflicts, this identity was already processed, so it's a
44-- no-op. If the claim took, enforce the two soft guards before crediting:
45-- * device idempotency — a prior CREDITED referral from the same device burns it, so
46-- the same browser can't sign up repeatedly to farm credits.
47-- * per-referrer cap — at/above p_cap credited referrals, record 'capped' and DON'T
48-- credit (a soft budget guard; p_cap <= 0 disables it).
49-- Otherwise credit the referrer's balance (creating the row if absent), exactly like
50-- credit_runs. Atomic in one round-trip, so concurrent deliveries credit at most once.
⋯ 1 line hidden (lines 51–51)
51-- (Self-referral — referrer ≈ referred — is rejected by the caller before this runs.)
52create or replace function public.credit_referral(
53 p_referred text, p_device text, p_referrer text, p_token text, p_runs int, p_cap int
55returns table(credited boolean, runs_remaining int, status text)
56language plpgsql as $$
57declare
58 v_remaining int;
59 v_count int;
60 v_dupe_dev boolean;
61begin
62 insert into public.referrals(referred_subject, referred_device, referrer_subject, run_token, runs, status)
63 values (p_referred, p_device, p_referrer, p_token, 0, 'pending')
64 on conflict (referred_subject) do nothing;
65 if not found then
66 -- Already processed this referred identity: no-op, report the referrer balance.
67 select b.runs_remaining into v_remaining from public.balances b where b.device_id = p_referrer;
68 return query select false, coalesce(v_remaining, 0), 'duplicate'::text;
69 return;
70 end if;
71 
72 -- Device already used a credited referral? Burn-once-per-browser Sybil guard.
73 if p_device is not null then
74 select exists(
75 select 1 from public.referrals r
76 where r.referred_device = p_device and r.status = 'credited'
77 ) into v_dupe_dev;
78 if v_dupe_dev then
79 update public.referrals set status = 'capped' where referred_subject = p_referred;
80 select b.runs_remaining into v_remaining from public.balances b where b.device_id = p_referrer;
81 return query select false, coalesce(v_remaining, 0), 'duplicate-device'::text;
82 return;
83 end if;
84 end if;
85 
86 -- Per-referrer cap (soft: a concurrent pair can overshoot by one — acceptable for a
87 -- budget guard). p_cap <= 0 disables the cap entirely.
88 if p_cap > 0 then
89 select count(*) into v_count from public.referrals r
90 where r.referrer_subject = p_referrer and r.status = 'credited';
91 if v_count >= p_cap then
92 update public.referrals set status = 'capped' where referred_subject = p_referred;
93 select b.runs_remaining into v_remaining from public.balances b where b.device_id = p_referrer;
94 return query select false, coalesce(v_remaining, 0), 'capped'::text;
95 return;
96 end if;
97 end if;
98 
99 -- Credit the referrer (mirrors credit_runs' balance upsert: create the row at p_runs,
100 -- else add p_runs to the existing balance).
101 update public.referrals set status = 'credited', runs = p_runs where referred_subject = p_referred;
102 insert into public.balances(device_id, runs_remaining)
103 values (p_referrer, p_runs)
104 on conflict (device_id)
105 do update set runs_remaining = public.balances.runs_remaining + p_runs, updated_at = now()
106 returning balances.runs_remaining into v_remaining;
107 return query select true, v_remaining, 'credited'::text;
108end;
⋯ 1 line hidden (lines 109–109)
109$$;

Crediting the balance — two adapters, one behavior

Like the rest of the store, creditReferral has two implementations behind one port: an in-memory adapter (the default — dev and tests need nothing) and a Supabase adapter that calls the SQL function. The in-memory version mirrors the SQL exactly, guard for guard, so a test driving it proves the same logic the database runs.

Note the credit line: it adds the reward onto whatever balance exists, creating the row at the reward if there's none — it never re-grants the free run. That matches credit_runs, not the free-trial ensureDevice.

In-memory mirror (the readable spec) above; the Supabase RPC mapping below.

server/utils/balance-store.ts · 328 lines
server/utils/balance-store.ts328 lines · TypeScript
⋯ 146 lines hidden (lines 1–146)
1/**
2 * The run balance — the ledger that makes a run a thing you spend. A brand-new
3 * device gets FREE_RUNS for free (the doc's "first run free"); chargeRun spends
4 * one run when the player commits to an objective, once per run. Out of runs →
5 * the commit is refused (the paywall). Packs (Stripe) credit this balance later.
6 *
7 * Same two-adapter shape as the run store: in-memory by default (dev + tests need
8 * nothing), Supabase when configured. Keyed by an opaque device id (a server-set
9 * cookie) until accounts arrive, when a user id will supersede it.
10 *
11 * The Supabase charge goes through one SQL function (spend_run) so the
12 * ensure-grant, the idempotency check, and the decrement are atomic in a single
13 * round-trip — no read-modify-write race. The in-memory adapter mirrors that
14 * logic exactly.
15 */
16import { createClient, type SupabaseClient } from '@supabase/supabase-js'
17import { supabaseConfig } from './supabase'
18import type { ReferralDigestState } from '~/utils/referral-digest'
19 
20/** Runs granted to a brand-new device — the free trial ("first run free").
21 * Mirrored by the spend_run SQL function; keep the two in sync. */
22export const FREE_RUNS = 1
23 
24export interface ChargeResult {
25 /** False only when the device is out of runs (the paywall). */
26 charged: boolean
27 runsRemaining: number
29 
30export interface CreditResult {
31 /** False when this session was already credited (an idempotent no-op). */
32 credited: boolean
33 runsRemaining: number
35 
36/** How a referral credit landed: an actual credit, or a recorded-but-not-credited
37 * no-op (already processed, the device was already used, or the referrer's cap). */
38export type ReferralStatus = 'credited' | 'duplicate' | 'duplicate-device' | 'capped'
39 
40export interface ReferralResult {
41 /** True only when a run was actually credited to the referrer. */
42 credited: boolean
43 /** Why it landed this way (the idempotency / cap outcome). */
44 status: ReferralStatus
45 /** The referrer's resulting balance (unchanged unless credited). */
46 runsRemaining: number
48 
49export interface ReferralStats {
50 /** Total runs the subject has earned from referrals (the in-app "+N from sharing"). */
51 creditsEarned: number
52 /** How many qualified referrals they've earned. */
53 count: number
55 
56export interface BalanceStore {
57 /** Ensure a balance row for the device, granting the free trial on first
58 * sight; returns the current runs remaining. */
59 ensureDevice(deviceId: string): Promise<number>
60 /** Spend one run for the device, once per runId (idempotent — re-committing
61 * the same run never double-charges). charged=false means out of runs. */
62 chargeRun(deviceId: string, runId: string): Promise<ChargeResult>
63 /** Runs remaining for the device (0 if unknown). */
64 remaining(deviceId: string): Promise<number>
65 /** Is this run charged AND owned by this device? The gameplay AI path checks
66 * this before spending tokens, so a forged or unpaid run id can't draw free
67 * generation. */
68 runActiveFor(deviceId: string, runId: string): Promise<boolean>
69 /** Credit runs from a paid pack, idempotently per Stripe session — a retried
70 * webhook never double-credits. */
71 credit(deviceId: string, runs: number, sessionId: string): Promise<CreditResult>
72 /** Has this subject ever purchased (≥1 credited pack)? Lets run-commit exempt
73 * paying customers from the free-tier spend-cap gate. */
74 hasPurchased(deviceId: string): Promise<boolean>
75 /** Credit one referral reward to the REFERRER for a new referred identity (issue
76 * #96), ONCE — idempotent per referred subject AND per referred device, within
77 * the per-referrer cap. A referral is just another credit source on this balance. */
78 creditReferral(referrer: string, referred: string, referredDevice: string | null, runToken: string, runs: number, cap: number): Promise<ReferralResult>
79 /** The referrer's accrued referral credits (sum + count), for the in-app tally. */
80 referralStats(subject: string): Promise<ReferralStats>
81 /** The referrer's un-notified credited referrals (count + oldest), for the
82 * batched-digest throttle. */
83 referralDigestState(subject: string): Promise<ReferralDigestState>
84 /** Mark the referrer's credited referrals as covered by a notice (a digest sent). */
85 markReferralsNotified(subject: string): Promise<void>
87 
88/** Dev default — process-local balances, durable enough for dev. Mirrors the
89 * spend_run SQL function: free grant on first sight, one charge per run. */
90export class InMemoryBalanceStore implements BalanceStore {
91 private readonly balances = new Map<string, number>()
92 // runId → the device that charged it. Idempotency is per (device, run), so a
93 // different device can't ride another's already-charged run for free.
94 private readonly chargedRuns = new Map<string, string>()
95 // Stripe session ids already credited, so a retried webhook is a no-op.
96 private readonly creditedSessions = new Set<string>()
97 // Subjects who have purchased at least once (exempt from the spend-cap gate).
98 private readonly purchasers = new Set<string>()
99 // Referral ledger (issue #96): one row per referred identity. Mirrors the
100 // credit_referral SQL — idempotent per referred subject AND device, per-referrer cap.
101 private readonly referrals: Array<{
102 referredSubject: string
103 referredDevice: string | null
104 referrerSubject: string
105 runToken: string
106 runs: number
107 status: 'credited' | 'capped'
108 notifiedAt: number | null
109 createdAt: number
110 }> = []
111 
112 async ensureDevice(deviceId: string): Promise<number> {
113 if (!this.balances.has(deviceId)) this.balances.set(deviceId, FREE_RUNS)
114 return this.balances.get(deviceId) ?? 0
115 }
116 
117 async chargeRun(deviceId: string, runId: string): Promise<ChargeResult> {
118 const remaining = await this.ensureDevice(deviceId)
119 if (this.chargedRuns.get(runId) === deviceId) return { charged: true, runsRemaining: remaining }
120 if (remaining <= 0) return { charged: false, runsRemaining: 0 }
121 this.balances.set(deviceId, remaining - 1)
122 this.chargedRuns.set(runId, deviceId)
123 return { charged: true, runsRemaining: remaining - 1 }
124 }
125 
126 async remaining(deviceId: string): Promise<number> {
127 return this.balances.get(deviceId) ?? 0
128 }
129 
130 async runActiveFor(deviceId: string, runId: string): Promise<boolean> {
131 return this.chargedRuns.get(runId) === deviceId
132 }
133 
134 async credit(deviceId: string, runs: number, sessionId: string): Promise<CreditResult> {
135 const current = await this.ensureDevice(deviceId)
136 if (this.creditedSessions.has(sessionId)) return { credited: false, runsRemaining: current }
137 this.creditedSessions.add(sessionId)
138 this.purchasers.add(deviceId)
139 this.balances.set(deviceId, current + runs)
140 return { credited: true, runsRemaining: current + runs }
141 }
142 
143 async hasPurchased(deviceId: string): Promise<boolean> {
144 return this.purchasers.has(deviceId)
145 }
146 
147 async creditReferral(referrer: string, referred: string, referredDevice: string | null, runToken: string, runs: number, cap: number): Promise<ReferralResult> {
148 const balance = () => this.balances.get(referrer) ?? 0
149 const record = (status: 'credited' | 'capped', credited: number) =>
150 this.referrals.push({ referredSubject: referred, referredDevice, referrerSubject: referrer, runToken, runs: credited, status, notifiedAt: null, createdAt: Date.now() })
151 
152 // Idempotent per referred identity, ever (the credited_sessions twin).
153 if (this.referrals.some((r) => r.referredSubject === referred)) {
154 return { credited: false, status: 'duplicate', runsRemaining: balance() }
155 }
156 // Device already used a credited referral — burn-once-per-browser Sybil guard.
157 if (referredDevice != null && this.referrals.some((r) => r.referredDevice === referredDevice && r.status === 'credited')) {
158 record('capped', 0)
159 return { credited: false, status: 'duplicate-device', runsRemaining: balance() }
160 }
161 // Per-referrer cap (cap <= 0 disables it).
162 if (cap > 0 && this.referrals.filter((r) => r.referrerSubject === referrer && r.status === 'credited').length >= cap) {
163 record('capped', 0)
164 return { credited: false, status: 'capped', runsRemaining: balance() }
165 }
166 // Credit (mirrors credit_runs: add to the existing balance, else create at runs —
167 // no free-grant on a credit).
168 record('credited', runs)
169 const next = balance() + runs
170 this.balances.set(referrer, next)
171 return { credited: true, status: 'credited', runsRemaining: next }
172 }
⋯ 88 lines hidden (lines 173–260)
173 
174 async referralStats(subject: string): Promise<ReferralStats> {
175 const credited = this.referrals.filter((r) => r.referrerSubject === subject && r.status === 'credited')
176 return { creditsEarned: credited.reduce((s, r) => s + r.runs, 0), count: credited.length }
177 }
178 
179 async referralDigestState(subject: string): Promise<ReferralDigestState> {
180 const unnotified = this.referrals.filter((r) => r.referrerSubject === subject && r.status === 'credited' && r.notifiedAt == null)
181 const oldest = unnotified.reduce<number | null>((min, r) => (min == null ? r.createdAt : Math.min(min, r.createdAt)), null)
182 return { unnotifiedCount: unnotified.length, oldestUnnotifiedAt: oldest }
183 }
184 
185 async markReferralsNotified(subject: string): Promise<void> {
186 const now = Date.now()
187 for (const r of this.referrals) {
188 if (r.referrerSubject === subject && r.status === 'credited' && r.notifiedAt == null) r.notifiedAt = now
189 }
190 }
192 
193/** Service-key (RLS-bypassing) balance access; the table has no public policy. */
194export class SupabaseBalanceStore implements BalanceStore {
195 constructor(private readonly client: SupabaseClient) {}
196 
197 async ensureDevice(deviceId: string): Promise<number> {
198 const { error } = await this.client
199 .from('balances')
200 .insert({ device_id: deviceId, runs_remaining: FREE_RUNS })
201 // 23505 = unique_violation: the row already exists, which is fine.
202 if (error && error.code !== '23505') throw new Error(`balance ensure failed: ${error.message}`)
203 return this.remaining(deviceId)
204 }
205 
206 async chargeRun(deviceId: string, runId: string): Promise<ChargeResult> {
207 const { data, error } = await this.client.rpc('spend_run', {
208 p_device: deviceId,
209 p_run: runId
210 })
211 if (error) throw new Error(`spend_run failed: ${error.message}`)
212 const row = (Array.isArray(data) ? data[0] : data) as { charged: boolean; runs_remaining: number }
213 return { charged: row.charged, runsRemaining: row.runs_remaining }
214 }
215 
216 async remaining(deviceId: string): Promise<number> {
217 const { data, error } = await this.client
218 .from('balances')
219 .select('runs_remaining')
220 .eq('device_id', deviceId)
221 .maybeSingle()
222 if (error) throw new Error(`balance read failed: ${error.message}`)
223 return (data?.runs_remaining as number | undefined) ?? 0
224 }
225 
226 async runActiveFor(deviceId: string, runId: string): Promise<boolean> {
227 const { data, error } = await this.client
228 .from('runs')
229 .select('id')
230 .eq('id', runId)
231 .eq('device_id', deviceId)
232 .not('charged_at', 'is', null)
233 .maybeSingle()
234 if (error) throw new Error(`run check failed: ${error.message}`)
235 return !!data
236 }
237 
238 async credit(deviceId: string, runs: number, sessionId: string): Promise<CreditResult> {
239 const { data, error } = await this.client.rpc('credit_runs', {
240 p_device: deviceId,
241 p_runs: runs,
242 p_session: sessionId
243 })
244 if (error) throw new Error(`credit_runs failed: ${error.message}`)
245 const row = (Array.isArray(data) ? data[0] : data) as { credited: boolean; runs_remaining: number }
246 return { credited: row.credited, runsRemaining: row.runs_remaining }
247 }
248 
249 async hasPurchased(deviceId: string): Promise<boolean> {
250 // Any credited session for this subject means they've paid at least once.
251 const { data, error } = await this.client
252 .from('credited_sessions')
253 .select('session_id')
254 .eq('device_id', deviceId)
255 .limit(1)
256 .maybeSingle()
257 if (error) throw new Error(`purchase check failed: ${error.message}`)
258 return !!data
259 }
260 
261 async creditReferral(referrer: string, referred: string, referredDevice: string | null, runToken: string, runs: number, cap: number): Promise<ReferralResult> {
262 const { data, error } = await this.client.rpc('credit_referral', {
263 p_referred: referred,
264 p_device: referredDevice,
265 p_referrer: referrer,
266 p_token: runToken,
267 p_runs: runs,
268 p_cap: cap
269 })
270 if (error) throw new Error(`credit_referral failed: ${error.message}`)
271 const row = (Array.isArray(data) ? data[0] : data) as { credited: boolean; runs_remaining: number; status: ReferralStatus }
272 return { credited: row.credited, status: row.status, runsRemaining: row.runs_remaining }
273 }
⋯ 55 lines hidden (lines 274–328)
274 
275 async referralStats(subject: string): Promise<ReferralStats> {
276 const { data, error } = await this.client
277 .from('referrals')
278 .select('runs')
279 .eq('referrer_subject', subject)
280 .eq('status', 'credited')
281 if (error) throw new Error(`referral stats failed: ${error.message}`)
282 const rows = (data ?? []) as Array<{ runs: number }>
283 return { creditsEarned: rows.reduce((s, r) => s + (r.runs ?? 0), 0), count: rows.length }
284 }
285 
286 async referralDigestState(subject: string): Promise<ReferralDigestState> {
287 const { data, error } = await this.client
288 .from('referrals')
289 .select('created_at')
290 .eq('referrer_subject', subject)
291 .eq('status', 'credited')
292 .is('notified_at', null)
293 .order('created_at', { ascending: true })
294 if (error) throw new Error(`referral digest state failed: ${error.message}`)
295 const rows = (data ?? []) as Array<{ created_at: string }>
296 return {
297 unnotifiedCount: rows.length,
298 oldestUnnotifiedAt: rows.length ? Date.parse(rows[0].created_at) : null
299 }
300 }
301 
302 async markReferralsNotified(subject: string): Promise<void> {
303 const { error } = await this.client
304 .from('referrals')
305 .update({ notified_at: new Date().toISOString() })
306 .eq('referrer_subject', subject)
307 .eq('status', 'credited')
308 .is('notified_at', null)
309 if (error) throw new Error(`referral notify mark failed: ${error.message}`)
310 }
312 
313let cached: BalanceStore | null = null
314 
315/** The configured balance store (memoized). */
316export function balanceStore(): BalanceStore {
317 if (cached) return cached
318 const cfg = supabaseConfig()
319 cached = cfg
320 ? new SupabaseBalanceStore(createClient(cfg.url, cfg.secretKey, { auth: { persistSession: false } }))
321 : new InMemoryBalanceStore()
322 return cached
324 
325/** Test seam: clear the memoized store so the next balanceStore() re-reads config. */
326export function resetBalanceStore(): void {
327 cached = null

Resolving a token to its owner — server-only

The public share endpoint deliberately strips the owner from everything a viewer sees. But the referral needs to know who to credit, so a new server-internal resolver reads the owner subject straight off the run row by share token. It is never wired to a client response — only the claim path calls it.

The in-memory and Supabase resolvers — owner subject by share token.

server/utils/run-snapshot-store.ts · 410 lines
server/utils/run-snapshot-store.ts410 lines · TypeScript
⋯ 204 lines hidden (lines 1–204)
1/**
2 * The run-snapshot store — where a finished run becomes a durable, owned record.
3 * POST /api/run-save writes the snapshot here keyed by the run id; the "your runs"
4 * routes (GET /api/runs, GET /api/runs/:id) read it back, scoped to the account
5 * subject so a player only ever sees their own runs.
6 *
7 * Two adapters behind one port, chosen by config — the repo's env-degradation idiom
8 * (cf. runStore, balanceStore):
9 * - in-memory (default): no external dependency, so `npm run dev` and the unit
10 * tests need nothing. History doesn't outlive the process, but the round-trip is
11 * real, which is what the save -> list -> load test exercises.
12 * - Supabase: used when SUPABASE_URL + SUPABASE_SECRET_KEY are configured. Writes
13 * the `run_snapshots` row (and stamps the owner onto the billing `runs` row) via
14 * the service (secret) key, which bypasses RLS — the table has no public policy,
15 * so it is server-only.
16 *
17 * Ownership is by SUBJECT (the Supabase user id, falling back to the device id) — the
18 * same string balances/runs key on. Reads filter by it, so a non-owned run id reads
19 * as absent (the route turns that into a 404, never leaking another subject's run).
20 */
21import { randomUUID } from 'node:crypto'
22import { createClient, type SupabaseClient } from '@supabase/supabase-js'
23import { supabaseConfig } from './supabase'
24import { migrateSnapshot } from '~/utils/run-snapshot'
25import type { RunSnapshot, RunSummary, PublicRun, ShareState, RemixCredit } from '~/utils/run-snapshot'
26 
27/** Save-time extras the snapshot blob itself doesn't carry. */
28export interface SaveRunOptions {
29 /** When this run is a replay (issue #89), the SOURCE run's share token — resolved
30 * server-side to the parent's stable run id and stamped as the lineage pointer. The
31 * client only ever holds the public token (the run id is never exposed to it). */
32 derivedFromToken?: string | null
34 
35export interface RunSnapshotStore {
36 /** Persist (or overwrite) the finished run's snapshot, owned by `subject`. */
37 saveRun(subject: string, snapshot: RunSnapshot, opts?: SaveRunOptions): Promise<void>
38 /** A subject's saved runs, newest first (the list projection). */
39 listRuns(subject: string): Promise<RunSummary[]>
40 /** One saved run by id, only if `subject` owns it; null otherwise. */
41 getRun(subject: string, runId: string): Promise<RunSnapshot | null>
42 /**
43 * Make an owned run public (issue #88): mint a share token if it has none (reuse it
44 * if already shared, so the URL is stable), set the opt-in display name, and return
45 * the live share state. Null when `subject` doesn't own the run (reads as absent).
46 */
47 shareRun(subject: string, runId: string, name: string | null): Promise<ShareState | null>
48 /** Revoke an owned run's share (clear token + name). Idempotent; a no-op otherwise. */
49 unshareRun(subject: string, runId: string): Promise<void>
50 /** The owner's view of a run's share status; null when `subject` doesn't own it. */
51 getShareState(subject: string, runId: string): Promise<ShareState | null>
52 /** Resolve a public share token to its sanitized projection; null when unknown. */
53 getPublicRun(token: string): Promise<PublicRun | null>
54 /** Resolve a share token to its run's OWNER subject — server-internal only (the
55 * referral attribution key, issue #96). Never crosses to a client; the public
56 * projection above is the only thing a viewer ever sees. Null when the token is
57 * unknown / revoked. */
58 ownerSubjectByToken(token: string): Promise<string | null>
60 
61/** Project a snapshot down to the list row. */
62function toSummary(snapshot: RunSnapshot, createdAt: string): RunSummary {
63 return {
64 runId: snapshot.runId,
65 status: snapshot.status,
66 title: snapshot.objective.title,
67 progress: snapshot.objectiveProgress,
68 createdAt
69 }
71 
72/**
73 * Project a stored snapshot down to its PUBLIC, shareable form. The snapshot blob is
74 * already identity-free (the owner lives only in the row's `user_id` column, never in
75 * the data), so the sanitization here is deliberate-and-explicit: blank the run id (the
76 * share token is the only handle a public viewer gets) and surface only the opt-in
77 * display name. This is the boundary the public endpoint serves — never the raw row.
78 */
79export function toPublicRun(snapshot: RunSnapshot, name: string | null, remixedFrom: RemixCredit | null = null): PublicRun {
80 const trimmed = name?.trim()
81 // Normalize the stored blob to the current shape (an older snapshot may predate a
82 // field, e.g. v2's peakProgress) before it crosses to the public viewer.
83 const run = migrateSnapshot(snapshot)
84 return {
85 run: { ...run, runId: '' },
86 attribution: trimmed ? trimmed : null,
87 remixedFrom
88 }
90 
91/**
92 * Build the one-hop "remixed from" credit (issue #89) from the PARENT run's live fields.
93 * Only public, opt-in data crosses: the parent's display name (anonymous if blank/unset),
94 * its objective title, and its current share token (for an optional backlink, null when
95 * the parent is no longer shared). Never the parent's run id, owner, or device.
96 */
97function toRemixCredit(
98 parent: { title: string; shareName: string | null; shareToken: string | null } | null
99): RemixCredit | null {
100 if (!parent) return null
101 const name = parent.shareName?.trim()
102 return {
103 attribution: name ? name : null,
104 objectiveTitle: parent.title,
105 sourceToken: parent.shareToken ?? null
106 }
108 
109/** Dev/test default — keeps the round-trip real (save -> list -> load) without any
110 * external dependency. Not durable across process restarts; the Supabase adapter is
111 * the one that actually preserves history. */
112export class InMemoryRunSnapshotStore implements RunSnapshotStore {
113 private readonly runs: Array<{
114 subject: string
115 snapshot: RunSnapshot
116 createdAt: string
117 shareToken: string | null
118 shareName: string | null
119 derivedFromRunId: string | null
120 }> = []
121 
122 /** Resolve a source share token to the parent's stable run id (issue #89), guarding
123 * self-derivation. Returns null for an unknown token or no token. */
124 private resolveDerivedRunId(token: string | null | undefined, selfRunId: string): string | null {
125 if (!token) return null
126 const parent = this.runs.find((r) => r.shareToken === token)
127 return parent && parent.snapshot.runId !== selfRunId ? parent.snapshot.runId : null
128 }
129 
130 async saveRun(subject: string, snapshot: RunSnapshot, opts?: SaveRunOptions): Promise<void> {
131 const createdAt = new Date().toISOString()
132 const existing = this.runs.find((r) => r.snapshot.runId === snapshot.runId)
133 if (existing) {
134 // Snapshots are immutable, but the save fires again once the epilogue
135 // lands — overwrite in place (keep the original position/createdAt, and any
136 // share already minted for this run).
137 existing.subject = subject
138 existing.snapshot = snapshot
139 // Stamp the pointer only on a SUCCESSFUL resolve, and never overwrite one
140 // already set with null: it's stable once stamped, so a later un-share (the
141 // token stops resolving) leaves the lineage intact — only the walked credit
142 // goes anonymous (see getPublicRun).
143 const resolved = this.resolveDerivedRunId(opts?.derivedFromToken, snapshot.runId)
144 if (resolved) existing.derivedFromRunId = resolved
145 return
146 }
147 this.runs.push({
148 subject,
149 snapshot,
150 createdAt,
151 shareToken: null,
152 shareName: null,
153 derivedFromRunId: this.resolveDerivedRunId(opts?.derivedFromToken, snapshot.runId)
154 })
155 }
156 
157 async listRuns(subject: string): Promise<RunSummary[]> {
158 return this.runs
159 .filter((r) => r.subject === subject)
160 .map((r) => toSummary(r.snapshot, r.createdAt))
161 .reverse()
162 }
163 
164 async getRun(subject: string, runId: string): Promise<RunSnapshot | null> {
165 const found = this.runs.find((r) => r.snapshot.runId === runId && r.subject === subject)
166 return found ? migrateSnapshot(found.snapshot) : null
167 }
168 
169 async shareRun(subject: string, runId: string, name: string | null): Promise<ShareState | null> {
170 const found = this.runs.find((r) => r.snapshot.runId === runId && r.subject === subject)
171 if (!found) return null
172 found.shareToken = found.shareToken ?? randomUUID()
173 found.shareName = name
174 return { token: found.shareToken, name: found.shareName }
175 }
176 
177 async unshareRun(subject: string, runId: string): Promise<void> {
178 const found = this.runs.find((r) => r.snapshot.runId === runId && r.subject === subject)
179 if (!found) return
180 found.shareToken = null
181 found.shareName = null
182 }
183 
184 async getShareState(subject: string, runId: string): Promise<ShareState | null> {
185 const found = this.runs.find((r) => r.snapshot.runId === runId && r.subject === subject)
186 return found ? { token: found.shareToken, name: found.shareName } : null
187 }
188 
189 async getPublicRun(token: string): Promise<PublicRun | null> {
190 const found = this.runs.find((r) => r.shareToken === token)
191 if (!found) return null
192 // Walk one hop to the parent's LIVE row for the "remixed from" credit (issue #89),
193 // so the source's current opt-in (name / still-shared) is what surfaces.
194 const parent = found.derivedFromRunId
195 ? this.runs.find((r) => r.snapshot.runId === found.derivedFromRunId) ?? null
196 : null
197 const remixedFrom = toRemixCredit(
198 parent
199 ? { title: parent.snapshot.objective.title, shareName: parent.shareName, shareToken: parent.shareToken }
200 : null
201 )
202 return toPublicRun(found.snapshot, found.shareName, remixedFrom)
203 }
204 
205 async ownerSubjectByToken(token: string): Promise<string | null> {
206 const found = this.runs.find((r) => r.shareToken === token)
207 return found ? found.subject : null
208 }
⋯ 173 lines hidden (lines 209–381)
210 
211/** Records each saved run as a `run_snapshots` row via the service (secret) key,
212 * which bypasses RLS — the table has no public policy, so it is server-only. */
213export class SupabaseRunSnapshotStore implements RunSnapshotStore {
214 constructor(private readonly client: SupabaseClient) {}
215 
216 async saveRun(subject: string, snapshot: RunSnapshot, opts?: SaveRunOptions): Promise<void> {
217 // Stamp the owner onto the billing run row too (it carries only device_id
218 // today) so a run's ownership is legible from either table.
219 const owned = await this.client.from('runs').update({ user_id: subject }).eq('id', snapshot.runId)
220 if (owned.error) throw new Error(`runs owner update failed: ${owned.error.message}`)
221 
222 const row: Record<string, unknown> = {
223 run_id: snapshot.runId,
224 user_id: subject,
225 version: snapshot.version,
226 status: snapshot.status,
227 title: snapshot.objective.title,
228 progress: snapshot.objectiveProgress,
229 data: snapshot
230 }
231 // Stamp the lineage pointer ONLY for a replay (issue #89): resolve the source's
232 // public share token to the parent's STABLE run id (a token can be revoked / re-
233 // minted; the id can't), guarding self-derivation. The column is set only on a
234 // SUCCESSFUL resolve and is NEVER written as null — so an ordinary run's upsert is
235 // byte-identical to before, and a replay whose source un-shares before the run ends
236 // keeps the pointer an earlier save stamped (the credit then walks to an anonymized
237 // parent). It's a stable pointer, never overwritten with null.
238 if (opts?.derivedFromToken) {
239 const parentId = await this.resolveDerivedRunId(opts.derivedFromToken)
240 if (parentId && parentId !== snapshot.runId) row.derived_from_run_id = parentId
241 }
242 
243 // Upsert on the run-id PK so the second (post-epilogue) save overwrites the
244 // first rather than erroring — the snapshot is immutable, the write is not.
245 const { error } = await this.client.from('run_snapshots').upsert(row)
246 if (error) throw new Error(`run_snapshots upsert failed: ${error.message}`)
247 }
248 
249 /** Resolve a source share token to the parent's stable run id (issue #89); null when
250 * the token is unknown / revoked. */
251 private async resolveDerivedRunId(token: string): Promise<string | null> {
252 const { data, error } = await this.client
253 .from('run_snapshots')
254 .select('run_id')
255 .eq('share_token', token)
256 .maybeSingle()
257 if (error) throw new Error(`run_snapshots lineage resolve failed: ${error.message}`)
258 return (data?.run_id as string | null) ?? null
259 }
260 
261 /** Walk one hop to the parent's LIVE row for the "remixed from" credit (issue #89) —
262 * its current title, opt-in name, and share token. Null when there's no parent (not
263 * a replay) or the parent has since been deleted. */
264 private async resolveRemixCredit(parentRunId: string | null): Promise<RemixCredit | null> {
265 if (!parentRunId) return null
266 const { data, error } = await this.client
267 .from('run_snapshots')
268 .select('title, share_name, share_token')
269 .eq('run_id', parentRunId)
270 .maybeSingle()
271 if (error) throw new Error(`run_snapshots remix credit failed: ${error.message}`)
272 if (!data) return null
273 return toRemixCredit({
274 title: data.title as string,
275 shareName: (data.share_name as string | null) ?? null,
276 shareToken: (data.share_token as string | null) ?? null
277 })
278 }
279 
280 async listRuns(subject: string): Promise<RunSummary[]> {
281 const { data, error } = await this.client
282 .from('run_snapshots')
283 .select('run_id, status, title, progress, created_at')
284 .eq('user_id', subject)
285 .order('created_at', { ascending: false })
286 if (error) throw new Error(`run_snapshots list failed: ${error.message}`)
287 return ((data ?? []) as Array<{
288 run_id: string
289 status: RunSnapshot['status']
290 title: string
291 progress: number
292 created_at: string
293 }>).map((row) => ({
294 runId: row.run_id,
295 status: row.status,
296 title: row.title,
297 progress: row.progress,
298 createdAt: row.created_at
299 }))
300 }
301 
302 async getRun(subject: string, runId: string): Promise<RunSnapshot | null> {
303 const { data, error } = await this.client
304 .from('run_snapshots')
305 .select('data')
306 .eq('run_id', runId)
307 .eq('user_id', subject)
308 .maybeSingle()
309 if (error) throw new Error(`run_snapshots get failed: ${error.message}`)
310 // Normalize an older stored blob (e.g. a v1 row predating v2's peakProgress) so a
311 // saved-run view always reads a current-shape snapshot.
312 return data?.data ? migrateSnapshot(data.data as RunSnapshot) : null
313 }
314 
315 async shareRun(subject: string, runId: string, name: string | null): Promise<ShareState | null> {
316 // Mint a token ONLY if the owned run has none yet, in one row-atomic UPDATE (the
317 // `share_token IS NULL` guard). Two concurrent shares can't both mint — the second
318 // sees the first's token and matches no row — so re-sharing keeps one stable URL
319 // and never trips the unique constraint. (A wasted uuid when already shared is
320 // harmless: the guard excludes the row, so it's never written.)
321 const minted = await this.client
322 .from('run_snapshots')
323 .update({ share_token: randomUUID() })
324 .eq('run_id', runId)
325 .eq('user_id', subject)
326 .is('share_token', null)
327 if (minted.error) throw new Error(`run_snapshots share mint failed: ${minted.error.message}`)
328 
329 // Set the name and read back the live token (the freshly-minted one, or a token
330 // from a prior share). Scoped to the owner, so a run the subject doesn't own
331 // matches no row and reads as absent (null) — never shared on their behalf.
332 const { data, error } = await this.client
333 .from('run_snapshots')
334 .update({ share_name: name })
335 .eq('run_id', runId)
336 .eq('user_id', subject)
337 .select('share_token, share_name')
338 .maybeSingle()
339 if (error) throw new Error(`run_snapshots share failed: ${error.message}`)
340 if (!data) return null
341 return { token: data.share_token as string, name: (data.share_name as string | null) ?? null }
342 }
343 
344 async unshareRun(subject: string, runId: string): Promise<void> {
345 const { error } = await this.client
346 .from('run_snapshots')
347 .update({ share_token: null, share_name: null })
348 .eq('run_id', runId)
349 .eq('user_id', subject)
350 if (error) throw new Error(`run_snapshots unshare failed: ${error.message}`)
351 }
352 
353 async getShareState(subject: string, runId: string): Promise<ShareState | null> {
354 const { data, error } = await this.client
355 .from('run_snapshots')
356 .select('share_token, share_name')
357 .eq('run_id', runId)
358 .eq('user_id', subject)
359 .maybeSingle()
360 if (error) throw new Error(`run_snapshots share state failed: ${error.message}`)
361 if (!data) return null
362 return {
363 token: (data.share_token as string | null) ?? null,
364 name: (data.share_name as string | null) ?? null
365 }
366 }
367 
368 async getPublicRun(token: string): Promise<PublicRun | null> {
369 // Looked up by the share token ALONE — no owner filter — because the public page
370 // has no subject. The token is the capability; an unknown/revoked token misses.
371 const { data, error } = await this.client
372 .from('run_snapshots')
373 .select('data, share_name, derived_from_run_id')
374 .eq('share_token', token)
375 .maybeSingle()
376 if (error) throw new Error(`run_snapshots public get failed: ${error.message}`)
377 if (!data) return null
378 const remixedFrom = await this.resolveRemixCredit((data.derived_from_run_id as string | null) ?? null)
379 return toPublicRun(data.data as RunSnapshot, (data.share_name as string | null) ?? null, remixedFrom)
380 }
381 
382 async ownerSubjectByToken(token: string): Promise<string | null> {
383 // The owner subject lives only in the row's user_id — read it by token alone
384 // (no owner filter; the public page has no subject), for referral attribution.
385 const { data, error } = await this.client
386 .from('run_snapshots')
387 .select('user_id')
388 .eq('share_token', token)
389 .maybeSingle()
390 if (error) throw new Error(`run_snapshots owner resolve failed: ${error.message}`)
391 return (data?.user_id as string | null) ?? null
392 }
⋯ 17 lines hidden (lines 394–410)
394 
395let cached: RunSnapshotStore | null = null
396 
397/** The configured run-snapshot store (memoized — reuses one Supabase client). */
398export function runSnapshotStore(): RunSnapshotStore {
399 if (cached) return cached
400 const cfg = supabaseConfig()
401 cached = cfg
402 ? new SupabaseRunSnapshotStore(createClient(cfg.url, cfg.secretKey, { auth: { persistSession: false } }))
403 : new InMemoryRunSnapshotStore()
404 return cached
406 
407/** Test seam: clear the memoized store so the next call re-reads config. */
408export function resetRunSnapshotStore(): void {
409 cached = null

Attribution: the marker middleware

When someone opens a shared run, server middleware stamps an httpOnly ew_ref cookie holding the token only — never the owner. It runs on the real /r/:token page request, which matters: a cookie set inside the page's own SSR useFetch to /api/share would not propagate to the browser, but a cookie set by middleware on the page request does.

The path is untrusted input, so the token decode is guarded — a malformed URL like /r/%FF sets no marker instead of throwing a 500. The marker itself is just the opaque token already sitting in the URL, so it leaks nothing.

Match /r/:token, decode safely, stamp the marker.

server/middleware/referral.ts · 30 lines
server/middleware/referral.ts30 lines · TypeScript
⋯ 17 lines hidden (lines 1–17)
1/**
2 * Stamp the referral marker (issue #96) when a visitor opens a shared run page
3 * (/r/:token). This runs on the REAL page request — not the internal useFetch to
4 * /api/share that the page makes — so the Set-Cookie actually reaches the browser (a
5 * cookie set inside that nested SSR fetch would not propagate to the outer response).
6 *
7 * It only records "arrived via this token". Resolving the token to a sharer and every
8 * anti-abuse guard live at claim time (server/utils/referral.ts). The token is
9 * uuid-gated; any other path or a malformed token sets nothing. Last-touch: a fresh
10 * landing overwrites an older marker — the share that led to this session's signup is
11 * the proximate referrer (a self-referral is caught at claim regardless).
12 */
13import { setReferralMarker } from '~/server/utils/referral'
14import { isUuid } from '~/server/utils/uuid'
15 
16const SHARE_PATH = /^\/r\/([^/?#]+)/
17 
18export default defineEventHandler((event) => {
19 const match = SHARE_PATH.exec(event.path || '')
20 if (!match) return
21 // decodeURIComponent throws on malformed percent-encoding (e.g. /r/%FF) — a request
22 // path is untrusted, so guard it: a bad token simply sets no marker, never a 500.
23 let token: string
24 try {
25 token = decodeURIComponent(match[1])
26 } catch {
27 return
28 }
29 if (isUuid(token)) setReferralMarker(event, token)
30})

The cookie: httpOnly, sameSite lax, 30 days — token only.

server/utils/referral.ts · 209 lines
server/utils/referral.ts209 lines · TypeScript
⋯ 85 lines hidden (lines 1–85)
1/**
2 * Referral credits (issue #96) — the growth loop that rewards a sharer when a NEW
3 * player they referred SIGNS UP WITH AN EMAIL.
4 *
5 * The capability is the share link: opening /r/:token stamps a marker cookie (the
6 * opaque token, never the owner — see server/middleware/referral.ts). When that
7 * visitor later signs up — attaches an email to their anonymous account — the client
8 * fires POST /api/referral/claim, which resolves the marker to the sharer and credits
9 * them one run, exactly once.
10 *
11 * Everything here is SERVER-AUTHORITATIVE: the qualifying email signup is read from the
12 * real Supabase session (currentUser), so a client can only TRIGGER the check, never
13 * self-grant. The anti-abuse is layered: email-gated · self-referral rejected (same
14 * account OR same browser) · idempotent per referred subject AND per device · a
15 * per-referrer cap · a global model-spend cap already gates the cost of free plays.
16 */
17import { getCookie, setCookie, deleteCookie } from 'h3'
18import type { H3Event } from 'h3'
19import { currentUser } from './auth'
20import { deviceId } from './device'
21import { balanceStore } from './balance-store'
22import { runSnapshotStore } from './run-snapshot-store'
23import { isUuid } from './uuid'
24import {
25 shouldSendReferralDigest,
26 REFERRAL_DIGEST_THRESHOLD,
27 REFERRAL_DIGEST_WINDOW_MS
28} from '~/utils/referral-digest'
29 
30/** The marker cookie: the share token a visitor arrived on. Holds only the token (it's
31 * already in the URL — reveals nothing), httpOnly so just the server reads it at claim
32 * time. 30 days — long enough that "saw a share, signed up later" still attributes. */
33export const REFERRAL_COOKIE = 'ew_ref'
34const COOKIE_MAX_AGE = 60 * 60 * 24 * 30
35 
36/** Knobs (env-overridable; sane defaults). One run per qualified signup, a soft
37 * per-sharer cap, and the batched-digest throttle. */
38export interface ReferralConfig {
39 reward: number
40 cap: number
41 digestEnabled: boolean
42 digestThreshold: number
43 digestWindowMs: number
45 
46const DEFAULTS = {
47 reward: 1,
48 cap: 25,
49 digestEnabled: true,
50 digestThreshold: REFERRAL_DIGEST_THRESHOLD,
51 digestWindowMs: REFERRAL_DIGEST_WINDOW_MS
53 
54/** Resolve config from runtimeConfig in a Nuxt context, raw env otherwise (mirrors
55 * supabaseConfig — keeps this unit-testable in plain node). */
56export function referralConfig(): ReferralConfig {
57 let rc: Record<string, unknown> = {}
58 try {
59 rc = useRuntimeConfig() as unknown as Record<string, unknown>
60 } catch {
61 // not in a Nuxt context (eval harness / unit tests) — fall back to env.
62 }
63 const pick = (rcVal: unknown, env: string): string | undefined => {
64 const v = rcVal != null && rcVal !== '' ? String(rcVal) : undefined
65 return v ?? process.env[env]
66 }
67 const int = (s: string | undefined, dflt: number): number => {
68 const n = s != null ? parseInt(s, 10) : NaN
69 return Number.isFinite(n) ? n : dflt
70 }
71 const bool = (s: string | undefined, dflt: boolean): boolean =>
72 s == null ? dflt : s !== 'false' && s !== '0'
73 
74 const digestWindowHours = int(pick(rc.referralDigestWindowHours, 'EVERWHEN_REFERRAL_DIGEST_WINDOW_HOURS'), DEFAULTS.digestWindowMs / 3_600_000)
75 return {
76 reward: Math.max(0, int(pick(rc.referralReward, 'EVERWHEN_REFERRAL_REWARD'), DEFAULTS.reward)),
77 cap: int(pick(rc.referralCap, 'EVERWHEN_REFERRAL_CAP'), DEFAULTS.cap),
78 digestEnabled: bool(pick(rc.referralDigestEnabled, 'EVERWHEN_REFERRAL_DIGEST'), DEFAULTS.digestEnabled),
79 digestThreshold: Math.max(1, int(pick(rc.referralDigestThreshold, 'EVERWHEN_REFERRAL_DIGEST_THRESHOLD'), DEFAULTS.digestThreshold)),
80 digestWindowMs: Math.max(0, digestWindowHours) * 3_600_000
81 }
83 
84/** Stamp the "arrived via this share" marker. Called from server middleware on the real
85 * /r/:token page request, so the Set-Cookie reaches the browser. */
86export function setReferralMarker(event: H3Event, token: string): void {
87 setCookie(event, REFERRAL_COOKIE, token, {
88 httpOnly: true,
89 // localhost is a secure context, so dev over http still works (mirrors device.ts).
90 secure: !import.meta.dev,
91 sameSite: 'lax',
92 path: '/',
93 maxAge: COOKIE_MAX_AGE
94 })
96 
⋯ 113 lines hidden (lines 97–209)
97/** Why a claim landed the way it did — drives nothing client-side beyond telemetry; the
98 * reward (if any) is what matters. */
99export type ClaimReason =
100 | 'credited'
101 | 'disabled'
102 | 'not-signed-up'
103 | 'no-referral'
104 | 'unknown-token'
105 | 'self'
106 | 'duplicate'
107 | 'duplicate-device'
108 | 'capped'
109 | 'error'
110 
111export interface ClaimResult {
112 /** True only when a run was actually credited to the sharer. */
113 credited: boolean
114 reason: ClaimReason
115 /** Runs credited to the sharer (present only on a real credit). */
116 reward?: number
118 
119/**
120 * Process a referral claim for the current request. Fired right after a magic-link
121 * sign-in finalizes. Returns a benign no-op for every non-qualifying case (never throws
122 * to the caller — a referral hiccup must not break the post-sign-in flow). The credited
123 * party is the SHARER, not this request's user.
124 */
125export async function claimReferral(event: H3Event): Promise<ClaimResult> {
126 // 0. Program off-switch: reward <= 0 disables referrals entirely. Short-circuit BEFORE
127 // recording anything, so the idempotency ledger isn't burned while off — re-enabling
128 // later still credits a player who hadn't yet been processed.
129 const cfg = referralConfig()
130 if (cfg.reward <= 0) return { credited: false, reason: 'disabled' }
131 
132 // 1. The qualifying event: a genuine EMAIL signup. currentUser throws on a real auth
133 // error — treat that as not-signed-up (benign), never surface it.
134 let user
135 try {
136 user = await currentUser(event)
137 } catch {
138 return { credited: false, reason: 'not-signed-up' }
139 }
140 if (!user || user.isAnonymous || !user.email) {
141 return { credited: false, reason: 'not-signed-up' }
142 }
143 
144 // 2. The marker. No marker (or a tampered one) → nothing to attribute.
145 const token = getCookie(event, REFERRAL_COOKIE)
146 if (!token || !isUuid(token)) return { credited: false, reason: 'no-referral' }
147 
148 // 3. Resolve the token to the sharer's subject — server-internal, never exposed.
149 const referrer = await runSnapshotStore().ownerSubjectByToken(token)
150 if (!referrer) {
151 clearReferralMarker(event) // definitive: a dead token never resolves
152 return { credited: false, reason: 'unknown-token' }
153 }
154 
155 // 4. Self-referral: a sharer can't credit themselves — same account OR same browser
156 // (an anonymous, device-keyed sharer opening their own link in this browser).
157 const device = deviceId(event)
158 if (referrer === user.id || referrer === device) {
159 clearReferralMarker(event)
160 return { credited: false, reason: 'self' }
161 }
162 
163 // 5. Credit — atomic + idempotent + capped in the store. A throw here (transient
164 // store error) leaves the marker so the next sign-in can retry; otherwise the
165 // marker is consumed.
166 const res = await balanceStore().creditReferral(referrer, user.id, device, token, cfg.reward, cfg.cap)
167 clearReferralMarker(event)
168 
169 // 6. Notify the sharer — BATCHED, never per-referral (only on a real credit).
170 if (res.credited && cfg.digestEnabled) await maybeSendReferralDigest(referrer, cfg)
171 
172 return {
173 credited: res.credited,
174 reason: res.status as ClaimReason,
175 reward: res.credited ? cfg.reward : undefined
176 }
178 
179function clearReferralMarker(event: H3Event): void {
180 deleteCookie(event, REFERRAL_COOKIE, { path: '/' })
182 
183/**
184 * The batched referral notice. The pure throttle decides whether a digest is due
185 * (threshold OR window); if so, mark the referrer's credits notified and emit it. Today
186 * the "channel" is a single telemetry line (the [ai]/[mod] idiom) — the seam a real
187 * email/web-push for opted-in accounts (#83) slots into, with the throttle guaranteeing
188 * it stays a digest whatever the channel.
189 */
190async function maybeSendReferralDigest(referrer: string, cfg: ReferralConfig): Promise<void> {
191 const state = await balanceStore().referralDigestState(referrer)
192 const due = shouldSendReferralDigest({
193 ...state,
194 now: Date.now(),
195 threshold: cfg.digestThreshold,
196 windowMs: cfg.digestWindowMs
197 })
198 if (!due) return
199 await balanceStore().markReferralsNotified(referrer)
200 emitReferralDigest(referrer, state.unnotifiedCount)
202 
203function emitReferralDigest(referrer: string, newCredits: number): void {
204 // Suppressed under vitest (like the [ai] gateway line) — the throttle itself is what
205 // the tests assert, not the emission. A real channel resolves subject → address and
206 // honors per-user opt-out here.
207 if (process.env.VITEST) return
208 console.info(`[referral-digest] referrer=${referrer.slice(0, 8)} new_credits=${newCredits}`)

The claim — server-authoritative, end to end

This is the heart. The client fires POST /api/referral/claim after a sign-in finalizes, but the endpoint decides everything from the server's view of the request. Read the numbered steps top to bottom:

- 0 — an off-switch: reward <= 0 short-circuits before recording anything, so disabling the program doesn't burn the idempotency ledger. - 1 — the qualifying gate: the user must be a non-anonymous account with an email, read from the real Supabase session. A client can't fake this. - 2–3 — read the marker, resolve it to the sharer's subject. - 4 — reject self-referral: the sharer can't be the new player's account or their browser. - 5–6 — credit (atomic, idempotent, capped in the store), then maybe send a batched notice.

claimReferral — the qualifying gate, the guards, the credit.

server/utils/referral.ts · 209 lines
server/utils/referral.ts209 lines · TypeScript
⋯ 124 lines hidden (lines 1–124)
1/**
2 * Referral credits (issue #96) — the growth loop that rewards a sharer when a NEW
3 * player they referred SIGNS UP WITH AN EMAIL.
4 *
5 * The capability is the share link: opening /r/:token stamps a marker cookie (the
6 * opaque token, never the owner — see server/middleware/referral.ts). When that
7 * visitor later signs up — attaches an email to their anonymous account — the client
8 * fires POST /api/referral/claim, which resolves the marker to the sharer and credits
9 * them one run, exactly once.
10 *
11 * Everything here is SERVER-AUTHORITATIVE: the qualifying email signup is read from the
12 * real Supabase session (currentUser), so a client can only TRIGGER the check, never
13 * self-grant. The anti-abuse is layered: email-gated · self-referral rejected (same
14 * account OR same browser) · idempotent per referred subject AND per device · a
15 * per-referrer cap · a global model-spend cap already gates the cost of free plays.
16 */
17import { getCookie, setCookie, deleteCookie } from 'h3'
18import type { H3Event } from 'h3'
19import { currentUser } from './auth'
20import { deviceId } from './device'
21import { balanceStore } from './balance-store'
22import { runSnapshotStore } from './run-snapshot-store'
23import { isUuid } from './uuid'
24import {
25 shouldSendReferralDigest,
26 REFERRAL_DIGEST_THRESHOLD,
27 REFERRAL_DIGEST_WINDOW_MS
28} from '~/utils/referral-digest'
29 
30/** The marker cookie: the share token a visitor arrived on. Holds only the token (it's
31 * already in the URL — reveals nothing), httpOnly so just the server reads it at claim
32 * time. 30 days — long enough that "saw a share, signed up later" still attributes. */
33export const REFERRAL_COOKIE = 'ew_ref'
34const COOKIE_MAX_AGE = 60 * 60 * 24 * 30
35 
36/** Knobs (env-overridable; sane defaults). One run per qualified signup, a soft
37 * per-sharer cap, and the batched-digest throttle. */
38export interface ReferralConfig {
39 reward: number
40 cap: number
41 digestEnabled: boolean
42 digestThreshold: number
43 digestWindowMs: number
45 
46const DEFAULTS = {
47 reward: 1,
48 cap: 25,
49 digestEnabled: true,
50 digestThreshold: REFERRAL_DIGEST_THRESHOLD,
51 digestWindowMs: REFERRAL_DIGEST_WINDOW_MS
53 
54/** Resolve config from runtimeConfig in a Nuxt context, raw env otherwise (mirrors
55 * supabaseConfig — keeps this unit-testable in plain node). */
56export function referralConfig(): ReferralConfig {
57 let rc: Record<string, unknown> = {}
58 try {
59 rc = useRuntimeConfig() as unknown as Record<string, unknown>
60 } catch {
61 // not in a Nuxt context (eval harness / unit tests) — fall back to env.
62 }
63 const pick = (rcVal: unknown, env: string): string | undefined => {
64 const v = rcVal != null && rcVal !== '' ? String(rcVal) : undefined
65 return v ?? process.env[env]
66 }
67 const int = (s: string | undefined, dflt: number): number => {
68 const n = s != null ? parseInt(s, 10) : NaN
69 return Number.isFinite(n) ? n : dflt
70 }
71 const bool = (s: string | undefined, dflt: boolean): boolean =>
72 s == null ? dflt : s !== 'false' && s !== '0'
73 
74 const digestWindowHours = int(pick(rc.referralDigestWindowHours, 'EVERWHEN_REFERRAL_DIGEST_WINDOW_HOURS'), DEFAULTS.digestWindowMs / 3_600_000)
75 return {
76 reward: Math.max(0, int(pick(rc.referralReward, 'EVERWHEN_REFERRAL_REWARD'), DEFAULTS.reward)),
77 cap: int(pick(rc.referralCap, 'EVERWHEN_REFERRAL_CAP'), DEFAULTS.cap),
78 digestEnabled: bool(pick(rc.referralDigestEnabled, 'EVERWHEN_REFERRAL_DIGEST'), DEFAULTS.digestEnabled),
79 digestThreshold: Math.max(1, int(pick(rc.referralDigestThreshold, 'EVERWHEN_REFERRAL_DIGEST_THRESHOLD'), DEFAULTS.digestThreshold)),
80 digestWindowMs: Math.max(0, digestWindowHours) * 3_600_000
81 }
83 
84/** Stamp the "arrived via this share" marker. Called from server middleware on the real
85 * /r/:token page request, so the Set-Cookie reaches the browser. */
86export function setReferralMarker(event: H3Event, token: string): void {
87 setCookie(event, REFERRAL_COOKIE, token, {
88 httpOnly: true,
89 // localhost is a secure context, so dev over http still works (mirrors device.ts).
90 secure: !import.meta.dev,
91 sameSite: 'lax',
92 path: '/',
93 maxAge: COOKIE_MAX_AGE
94 })
96 
97/** Why a claim landed the way it did — drives nothing client-side beyond telemetry; the
98 * reward (if any) is what matters. */
99export type ClaimReason =
100 | 'credited'
101 | 'disabled'
102 | 'not-signed-up'
103 | 'no-referral'
104 | 'unknown-token'
105 | 'self'
106 | 'duplicate'
107 | 'duplicate-device'
108 | 'capped'
109 | 'error'
110 
111export interface ClaimResult {
112 /** True only when a run was actually credited to the sharer. */
113 credited: boolean
114 reason: ClaimReason
115 /** Runs credited to the sharer (present only on a real credit). */
116 reward?: number
118 
119/**
120 * Process a referral claim for the current request. Fired right after a magic-link
121 * sign-in finalizes. Returns a benign no-op for every non-qualifying case (never throws
122 * to the caller — a referral hiccup must not break the post-sign-in flow). The credited
123 * party is the SHARER, not this request's user.
124 */
125export async function claimReferral(event: H3Event): Promise<ClaimResult> {
126 // 0. Program off-switch: reward <= 0 disables referrals entirely. Short-circuit BEFORE
127 // recording anything, so the idempotency ledger isn't burned while off — re-enabling
128 // later still credits a player who hadn't yet been processed.
129 const cfg = referralConfig()
130 if (cfg.reward <= 0) return { credited: false, reason: 'disabled' }
131 
132 // 1. The qualifying event: a genuine EMAIL signup. currentUser throws on a real auth
133 // error — treat that as not-signed-up (benign), never surface it.
134 let user
135 try {
136 user = await currentUser(event)
137 } catch {
138 return { credited: false, reason: 'not-signed-up' }
139 }
140 if (!user || user.isAnonymous || !user.email) {
141 return { credited: false, reason: 'not-signed-up' }
142 }
143 
144 // 2. The marker. No marker (or a tampered one) → nothing to attribute.
145 const token = getCookie(event, REFERRAL_COOKIE)
146 if (!token || !isUuid(token)) return { credited: false, reason: 'no-referral' }
147 
148 // 3. Resolve the token to the sharer's subject — server-internal, never exposed.
149 const referrer = await runSnapshotStore().ownerSubjectByToken(token)
150 if (!referrer) {
151 clearReferralMarker(event) // definitive: a dead token never resolves
152 return { credited: false, reason: 'unknown-token' }
153 }
154 
155 // 4. Self-referral: a sharer can't credit themselves — same account OR same browser
156 // (an anonymous, device-keyed sharer opening their own link in this browser).
157 const device = deviceId(event)
158 if (referrer === user.id || referrer === device) {
159 clearReferralMarker(event)
160 return { credited: false, reason: 'self' }
161 }
162 
163 // 5. Credit — atomic + idempotent + capped in the store. A throw here (transient
164 // store error) leaves the marker so the next sign-in can retry; otherwise the
165 // marker is consumed.
166 const res = await balanceStore().creditReferral(referrer, user.id, device, token, cfg.reward, cfg.cap)
167 clearReferralMarker(event)
168 
169 // 6. Notify the sharer — BATCHED, never per-referral (only on a real credit).
170 if (res.credited && cfg.digestEnabled) await maybeSendReferralDigest(referrer, cfg)
171 
172 return {
173 credited: res.credited,
174 reason: res.status as ClaimReason,
175 reward: res.credited ? cfg.reward : undefined
176 }
178 
179function clearReferralMarker(event: H3Event): void {
180 deleteCookie(event, REFERRAL_COOKIE, { path: '/' })
182 
183/**
184 * The batched referral notice. The pure throttle decides whether a digest is due
185 * (threshold OR window); if so, mark the referrer's credits notified and emit it. Today
186 * the "channel" is a single telemetry line (the [ai]/[mod] idiom) — the seam a real
187 * email/web-push for opted-in accounts (#83) slots into, with the throttle guaranteeing
188 * it stays a digest whatever the channel.
⋯ 21 lines hidden (lines 189–209)
189 */
190async function maybeSendReferralDigest(referrer: string, cfg: ReferralConfig): Promise<void> {
191 const state = await balanceStore().referralDigestState(referrer)
192 const due = shouldSendReferralDigest({
193 ...state,
194 now: Date.now(),
195 threshold: cfg.digestThreshold,
196 windowMs: cfg.digestWindowMs
197 })
198 if (!due) return
199 await balanceStore().markReferralsNotified(referrer)
200 emitReferralDigest(referrer, state.unnotifiedCount)
202 
203function emitReferralDigest(referrer: string, newCredits: number): void {
204 // Suppressed under vitest (like the [ai] gateway line) — the throttle itself is what
205 // the tests assert, not the emission. A real channel resolves subject → address and
206 // honors per-user opt-out here.
207 if (process.env.VITEST) return
208 console.info(`[referral-digest] referrer=${referrer.slice(0, 8)} new_credits=${newCredits}`)

The conversion hook

Where does the claim fire? The magic-link return already lands on /play and finalizes the sign-in there. On a successful finalize, the page calls a thin store action that POSTs the claim, then reloads the balance. It's fire-and-forget by design: the credit lands on the sharer, so there's nothing in this player's UI to wait on, and a referral hiccup must never disturb the sign-in itself.

After finalize → signed-in, claim the referral, then reload the gauge.

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

The store action: a best-effort POST that never throws into sign-in.

stores/game.ts · 1758 lines
stores/game.ts1758 lines · TypeScript
⋯ 1354 lines hidden (lines 1–1354)
1import { defineStore } from 'pinia'
2import type { GameObjective, ObjectiveSteer } from '~/server/utils/objectives'
3import type { Pack } from '~/server/utils/packs'
4import type { GroundedFigure } from '~/server/utils/figure-grounding'
5import { formatContactMoment, type ContactMoment } from '~/utils/contact-moment'
6import type { FigureSuggestion } from '~/server/utils/figure-suggester'
7import type { ChronicleEntry } from '~/server/utils/openai'
8import type { FigureStudy, ArchiveLookup } from '~/server/utils/prompt-builder'
9import type { Anachronism } from '~/server/utils/anachronism'
10import { chainStatus, type ChainStatus } from '~/utils/causal-chain'
11import type { Craft } from '~/utils/craft'
12import type { Continuity } from '~/utils/continuity'
13import type { DiceOutcome } from '~/utils/dice'
14import { generateGameSummary, rateEfficiency, type GameSummary } from '~/utils/game-summary'
15import { TOTAL_MESSAGES, MAX_PROGRESS_SWING, MAX_PROGRESS } from '~/utils/game-config'
16import { RUN_SNAPSHOT_VERSION, RUN_DRAFT_VERSION, type RunSnapshot, type RunDraft } from '~/utils/run-snapshot'
17 
18export type Valence = 'positive' | 'negative' | 'neutral'
19 
20/**
21 * A historical figure the player has reached out to. Figures are freeform — the
22 * player can write to anyone, in any era. The AI infers each figure's era and a
23 * short descriptor on first contact, which we cache here for the UI.
24 */
25export interface HistoricalFigure {
26 name: string
27 era: string
28 descriptor: string
30 
31/**
32 * Timeline Ledger entry — a single recorded change to history.
33 *
34 * The ledger is the heart of the game: every resolved turn appends one entry
35 * describing how the world bent, so the player literally watches the timeline
36 * rewrite itself as they play.
37 */
38export interface TimelineEvent {
39 id: string
40 figureName: string
41 era: string
42 headline: string
43 detail: string
44 diceRoll: number
45 diceOutcome: DiceOutcome
46 progressChange: number
47 /** The engine's swing BEFORE the anachronism amplifier — shown so the player
48 * can see exactly what their wager did ("+8 ⚡ far-ahead → +11%"). */
49 baseProgressChange?: number
50 valence: Valence
51 /** How anachronistic the player's nudge was — it widened this swing. */
52 anachronism?: Anachronism
53 /** How far this landed from the nearest foothold — it decayed this swing. */
54 causalChain?: ChainStatus
55 /** The Judge's grade of the dispatch that caused this change. */
56 craft?: Craft
57 /** Signed year of the intervention, when the contact was grounded. */
58 whenSigned?: number
59 /** True when this was the run's staked last stand (doubled swing). */
60 staked?: boolean
61 timestamp: Date
63 
64/**
65 * Message Interface — one line in the conversation with a figure.
66 * Dice / outcome / action / impact / progress are recorded on the AI (figure)
67 * turn, since that is the resolved result of the roll.
68 */
69export interface Message {
70 text: string
71 sender: 'user' | 'ai' | 'system'
72 timestamp: Date
73 figureName?: string
74 /** The effective (craft-tilted) roll — what the bands judged. */
75 diceRoll?: number
76 diceOutcome?: DiceOutcome
77 /** The die as it actually landed, before the craft modifier. */
78 naturalRoll?: number
79 /** The Judge's tilt on the roll (±2..0), and the grade + reason behind it. */
80 rollModifier?: number
81 craft?: Craft
82 craftReason?: string
83 /** Whether the dispatch built on the run's thread (issue #62) — drives the
84 * momentum meter and the 'builds' badge in the reveal. */
85 continuity?: Continuity
86 characterAction?: string
87 timelineImpact?: string
88 progressChange?: number
89 /** The pre-amplifier swing + the wager level, for the reveal's equation. */
90 baseProgressChange?: number
91 /** The PRE-turn momentum that amplified this swing — for the reveal's equation. */
92 momentumAtSwing?: number
93 anachronism?: Anachronism
94 /** The causal-chain decay applied to this swing (for the reveal's equation). */
95 causalChain?: ChainStatus
96 /** True when this turn was the staked last stand. */
97 staked?: boolean
99 
100export type GameStatus = 'playing' | 'victory' | 'defeat'
101 
102/**
103 * Shape returned by `POST /api/send-message` (see `server/api/send-message.post.ts`).
104 * Discriminated by `success`: when `true`, all fields downstream of the dice roll are
105 * guaranteed; when `false`, only `userMessage` + `error` are guaranteed (and the
106 * Character-AI-failure path doesn't even surface a dice roll). The discriminant lets
107 * the consumer destructure without optional chaining or null gymnastics.
108 */
109type SendMessageApiResponse =
110 | {
111 success: true
112 message: string
113 data: {
114 userMessage: string
115 figure: { name: string; era: string; descriptor: string }
116 diceRoll: number
117 diceOutcome: DiceOutcome
118 // Judge-of-craft fields (optional so older mocks/fixtures stay valid;
119 // the live server always sends them).
120 naturalRoll?: number
121 rollModifier?: number
122 craft?: Craft
123 craftReason?: string
124 continuity?: Continuity
125 momentum?: number
126 staked?: boolean
127 characterResponse: { message: string; action: string }
128 timeline: {
129 headline: string
130 detail: string
131 era: string
132 progressChange: number
133 baseProgressChange?: number
134 momentumAtSwing?: number
135 valence: Valence
136 anachronism?: Anachronism
137 causalChain?: ChainStatus
138 }
139 error: null
140 }
141 }
142 | {
143 success: false
144 message: string
145 data: {
146 userMessage: string
147 diceRoll?: number
148 diceOutcome?: DiceOutcome
149 characterResponse?: { message: string; action: string }
150 error: string
151 /** A content-moderation block (not an infra failure): the dispatch was
152 * refused by the detector, the Sentinel, or the model itself. */
153 blocked?: boolean
154 moderationReason?: string
155 }
156 }
157 
158const RATE_LIMIT_MS = 1000 // message cooldown window, in ms
159 
160// Turn-reveal pacing. A resolved turn releases its beats one at a time — a beat of
161// suspense, then the die lands and the figure's reply writes itself in, then the
162// timeline shift, then the ledger node — so the resolution reads as one conducted
163// moment instead of everything firing in the same tick. Each component already
164// animates when its own slice of state changes; sequencing the WRITES sequences the
165// animations, with no component coordination. Tuned so the swing lands with the
166// reply's own "ripple" line (~0.55s into its staged reveal).
167export const REVEAL = { suspense: 450, swing: 550, node: 250 } as const
168const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
169/** Stagger the reveal only in a real browser with motion allowed. SSR and the test
170 * env (no `matchMedia`) collapse to an instant, atomic apply — so the store's
171 * contract (state present the moment sendMessage resolves) is unchanged for tests. */
172function canAnimateReveal(): boolean {
173 return typeof window !== 'undefined'
174 && typeof window.matchMedia === 'function'
175 && !window.matchMedia('(prefers-reduced-motion: reduce)').matches
177 
178function valenceOf(progressChange: number): Valence {
179 if (progressChange > 0) return 'positive'
180 if (progressChange < 0) return 'negative'
181 return 'neutral'
183 
184/** Formats a signed timeline year (AD positive, BCE negative) for display. */
185export function formatContactYear(signed: number): string {
186 return signed < 0 ? `${-signed} BC` : `${signed}`
188 
189/** The landed ledger as {era, headline} for the suggester's altered-timeline brief
190 * (issue #131) — the server bounds + caps it. Drops changes without a headline. */
191function ledgerForSuggester(events: TimelineEvent[]): { era: string; headline: string }[] {
192 return events.filter(e => e.headline).map(e => ({ era: e.era, headline: e.headline }))
194 
195/** Human lifespan line for a grounded figure, e.g. "69 BC – 30 BC" or "1942 – present". */
196function lifespanText(g: GroundedFigure): string | undefined {
197 if (!g.born) return undefined
198 if (g.died) return `${g.born.display}${g.died.display}`
199 return g.living ? `${g.born.display} – present` : g.born.display
201 
202/** True when a $fetch error is a 402 (out of runs) — the paywall signal. ofetch
203 * surfaces the status as statusCode and on the response, so check both. */
204function isPaymentRequired(error: unknown): boolean {
205 const e = error as { statusCode?: number; status?: number; response?: { status?: number } }
206 return e?.statusCode === 402 || e?.status === 402 || e?.response?.status === 402
208 
209/** True when a $fetch error is a 503 / at-capacity — the spend cap paused new
210 * runs (distinct from the per-device 402 paywall). */
211function isAtCapacity(error: unknown): boolean {
212 const e = error as { statusCode?: number; status?: number; response?: { status?: number }; data?: { atCapacity?: boolean } }
213 return e?.statusCode === 503 || e?.status === 503 || e?.response?.status === 503 || e?.data?.atCapacity === true
215 
216export type ContactLiveness = 'ok' | 'before-birth' | 'after-death' | 'living' | 'unresolved' | 'unknown'
217 
218/**
219 * A replay seed (issue #89) — when a player starts a run from a shared run's captured
220 * objective. It carries the objective to seed (verbatim, no generation), the SOURCE run's
221 * public share token (rides along to /api/run-save to stamp the lineage pointer), and the
222 * source's display name at seed time (for the "remixed from" credit shown before/after the
223 * run; the public page re-derives the credit live, honoring the source's current opt-in).
224 */
225export interface ReplaySeed {
226 objective: GameObjective
227 sourceToken: string
228 sourceAttribution: string | null
230 
231/**
232 * Game Store — core state for a session of freeform timeline editing.
233 */
234export const useGameStore = defineStore('game', {
235 state: () => ({
236 remainingMessages: TOTAL_MESSAGES,
237 messageHistory: [] as Message[],
238 gameStatus: 'playing' as GameStatus,
239 isLoading: false,
240 error: null as string | null,
241 lastMessageTime: null as number | null,
242 isRateLimited: false,
243 // Staleness plumbing, not run state. runEpoch increments on every reset so an
244 // async result from a dead run can never write into a fresh one; the seq
245 // counters order overlapping requests of the same kind so only the latest
246 // lands (an earlier chronicle rewrite must not overwrite a later one).
247 // Deliberately NOT restored by resetGame — they must survive it to work.
248 runEpoch: 0,
249 chronicleSeq: 0,
250 suggestionsSeq: 0,
251 groundingSeq: 0,
252 // The current run's id — lazily minted on the run's first server call and
253 // cleared by resetGame so each run gets a fresh one. Tags every AI call
254 // (via the x-run-id header) so the gateway's cost telemetry groups per
255 // run: the GTM cost instrument.
256 runId: null as string | null,
257 // The in-flight begin-run, so concurrent first-calls of a run share one
258 // mint (one POST /api/run, one runs row) instead of racing two. Cleared
259 // by resetGame. (Vue leaves a Promise unproxied, so awaiting it is safe.)
260 runIdInflight: null as Promise<string> | null,
261 // True once this run's snapshot is durably saved (POST /api/run-save returned
262 // saved). Gates the end-screen Share affordance (issue #88) so it appears only for
263 // a run that actually exists server-side to share — never for a degraded local id.
264 // Reset per run.
265 runSnapshotSaved: false,
266 // outOfRuns gates the mission screen's paywall (set when a commit is
267 // refused with 402). The run packs offered there are loaded into `packs`.
268 outOfRuns: false,
269 // atCapacity gates the "at capacity" notice (set when a commit is refused
270 // with 503 because the global spend cap paused new runs).
271 atCapacity: false,
272 packs: [] as Pack[],
273 // The device's run balance, for the header gauge + account popover. null
274 // until first loaded (GET /api/balance); the run-commit response also
275 // refreshes it so the gauge stays live without a second round-trip.
276 runsRemaining: null as number | null,
277 freeRuns: 1,
278 deviceRef: '' as string,
279 // Account state (from /api/balance): the signed-in email, and whether the
280 // current user is anonymous (→ must sign in before buying). Drives the
281 // account UI + the sign-in-to-buy gate. false/null in device-fallback mode.
282 accountEmail: null as string | null,
283 accountAnonymous: false,
284 // Referral credits earned from sharing (issue #96), from /api/balance — the
285 // passive "+N from sharing" tally in the account popover. Pull-based, never a ping.
286 referralCredits: 0,
287 referralCount: 0,
288 // The run-packs sales modal — opened on demand (header) or automatically
289 // when a commit is refused for being out of runs.
290 buyModalOpen: false,
291 // A one-shot notice after returning from Stripe Checkout ('success' shows a
292 // credited confirmation; 'cancel' a gentle "no charge" note). Cleared by the UI.
293 purchaseNotice: null as 'success' | 'cancel' | null,
294 currentObjective: null as GameObjective | null,
295 // Set when this run is a REPLAY of a shared run's objective (issue #89): seeded
296 // verbatim from the public projection, no generation. Survives chooseObjective so
297 // it can stamp the lineage pointer at save time and credit the source on the end
298 // screen; cleared by resetGame (a fresh "new timeline" carries no lineage).
299 replayState: null as ReplaySeed | null,
300 objectiveProgress: 0,
301 // The run's high-water mark (0..MAX_PROGRESS): the highest objectiveProgress ever
302 // reached this run. Tracked so the end screen can show "peaked at X%" when a run
303 // loses ground from its peak — classically a staked final dispatch that crit-fails
304 // to 0% — instead of the floored final % erasing the player's real effort (#129).
305 peakProgress: 0,
306 // The run-level momentum meter (0..MOMENTUM_MAX): a coherent arc compounds its
307 // gains amplifier; a reset or catastrophe shatters it back to 0 (issue #62).
308 momentum: 0,
309 timelineEvents: [] as TimelineEvent[],
310 figures: [] as HistoricalFigure[],
311 activeFigureName: '' as string,
312 // Grounding for the active contact: real facts + the chosen year to reach them.
313 figureGrounding: null as GroundedFigure | null,
314 groundingLoading: false,
315 contactWhen: null as number | null,
316 /** Optional sub-year refinement of contactWhen — display/prompt flavor
317 * only; every mechanic keeps doing arithmetic on the YEAR (issue #32). */
318 contactMoment: null as ContactMoment | null,
319 // Era-relevant figure suggestions for the current objective (the on-ramp).
320 figureSuggestions: [] as FigureSuggestion[],
321 suggestionsLoading: false,
322 suggestionsFor: '' as string,
323 // The living Chronicle (Layer 3): a prose telling of the altered timeline,
324 // rewritten each turn. Non-blocking — see refreshChronicle.
325 chronicle: null as ChronicleEntry | null,
326 chronicleLoading: false,
327 // The run's epilogue — the TERMINAL telling carrying the verdict — is being
328 // written. Set when the final turn fires its refresh and cleared when that
329 // telling lands or the refresh fails. Distinct from chronicleLoading (any
330 // refresh): it lets the end screen show "the final account…" instead of
331 // presenting the prior turn's stale, pre-ending telling as the epilogue.
332 epiloguePending: false,
333 // The Archive (prototype): an objective-blind brief on the active figure, so
334 // the player can research who they're reaching without leaving the game. The
335 // brief is moment-specific, so it's cached against the figure AND the year.
336 figureStudy: null as FigureStudy | null,
337 studyLoading: false,
338 studyFor: '' as string,
339 studyWhen: null as number | null,
340 // Archive topic lookup (the "yellow" layer): concrete domain facts on demand.
341 archiveResult: null as ArchiveLookup | null,
342 lookupLoading: false,
343 // A content-moderation block — distinct from `error` (an infra hiccup) so
344 // the UI shows an honest, visibly different banner. One per surface that
345 // takes untrusted input into a model: the dispatch/turn, the Archive lookup,
346 // the Archivist study, and the figure-suggestions step.
347 moderationNotice: null as string | null,
348 archiveNotice: null as string | null,
349 studyNotice: null as string | null,
350 suggestionsNotice: null as string | null
351 }),
352 
353 getters: {
354 /**
355 * Can the player send right now? (messages left, still playing, not
356 * mid-request, not rate-limited)
357 */
358 canSendMessage(): boolean {
359 return this.remainingMessages > 0 &&
360 this.gameStatus === 'playing' &&
361 !this.isLoading &&
362 !this.isRateLimited
363 },
364 
365 /**
366 * The conversation thread with a single figure (their turns + the
367 * player's turns addressed to them). Each figure keeps a coherent,
368 * independent thread.
369 */
370 conversationWith(): (name: string) => Message[] {
371 return (name: string) => this.messageHistory.filter(
372 m => m.figureName === name && m.sender !== 'system'
373 )
374 },
375 
376 /** Total progress, net of setbacks, the player has clawed back. */
377 gameSummary(): GameSummary {
378 return generateGameSummary(this)
379 },
380 
381 /**
382 * Whether the active figure can be reached at the chosen `when`.
383 *
384 * Require grounding (#73): an UNRESOLVED name can't be reached at all —
385 * 'unresolved' (free-form contact is removed; the picker guides you to a real
386 * match). Deceased-only floor (#72): a RESOLVED figure with no confirmed
387 * death is living/undatable and never contactable — 'living', fail closed. A
388 * resolved, deceased figure is gated to their lifetime (before-birth /
389 * after-death). 'unknown' remains only for a resolved, deceased figure we
390 * can't fully place (no birth year, or no contact year chosen yet), which
391 * stays permissible.
392 */
393 contactLiveness(): ContactLiveness {
394 const g = this.figureGrounding
395 if (!g || !g.resolved) return 'unresolved'
396 if (!g.died) return 'living'
397 if (!g.born || this.contactWhen == null) return 'unknown'
398 if (this.contactWhen < g.born.signed) return 'before-birth'
399 if (this.contactWhen > g.died.signed) return 'after-death'
400 return 'ok'
401 },
402 
403 /** Can the chosen contact + when actually be reached? */
404 canContact(): boolean {
405 return this.contactLiveness === 'ok' || this.contactLiveness === 'unknown'
406 },
407 
408 /**
409 * The last stand is offered ONLY when the final dispatch can no longer win
410 * inside the normal per-turn fuse — from any nearer position an unstaked
411 * throw can still land it, and offering the (strictly win-probability-
412 * increasing) doubling there would be a dominance trap, not a decision.
413 */
414 canStake(): boolean {
415 return this.remainingMessages === 1 &&
416 this.gameStatus === 'playing' &&
417 (MAX_PROGRESS - this.objectiveProgress) > MAX_PROGRESS_SWING
418 },
419 
420 /**
421 * The active figure's age at the chosen `when` — so the player never has to
422 * do lifetime math in their head. Null when we lack a birth year or a chosen
423 * year (a resolved figure we can't date, or before a year is chosen). Corrects for the missing year zero
424 * when a life spans the BC→AD boundary (1 BC → AD 1 is one year, not two).
425 */
426 contactAge(): number | null {
427 const g = this.figureGrounding
428 if (!g?.resolved || !g.born || this.contactWhen == null) return null
429 const bornSigned = g.born.signed
430 const whenSigned = this.contactWhen
431 if (whenSigned < bornSigned) return null // before birth — no meaningful age
432 const crossedZero = bornSigned < 0 && whenSigned > 0 ? 1 : 0
433 return whenSigned - bornSigned - crossedZero
434 },
435 
436 /**
437 * The causal-chain read for the CURRENT contact — the pre-send ⏳ chip's
438 * source, mirroring what the Timeline Engine will compute. Footholds are the
439 * objective's anchor year plus every dated change already on the ledger; the
440 * gap to the nearest one decays the swing. Null (no chip) for ungrounded
441 * contacts or an anchorless objective with no dated changes yet.
442 */
443 causalChainRead(): ChainStatus | null {
444 const footholds = [
445 this.currentObjective?.anchorYear,
446 ...this.timelineEvents.map(e => e.whenSigned)
447 ]
448 return chainStatus(this.contactWhen, footholds)
449 }
450 },
451 
452 actions: {
453 // ---------- message helpers ----------
454 addUserMessage(text: string, figureName?: string) {
455 if (this.gameStatus !== 'playing') return
456 this.messageHistory.push({
457 text,
458 sender: 'user',
459 timestamp: new Date(),
460 figureName
461 })
462 },
463 
464 addAIMessage(text: string, figureName?: string) {
465 this.messageHistory.push({
466 text,
467 sender: 'ai',
468 timestamp: new Date(),
469 figureName
470 })
471 },
472 
473 addAIMessageWithData(messageData: Partial<Message> & { text: string, sender: 'ai' }) {
474 this.messageHistory.push({
475 text: messageData.text,
476 sender: messageData.sender,
477 timestamp: messageData.timestamp || new Date(),
478 figureName: messageData.figureName,
479 diceRoll: messageData.diceRoll,
480 diceOutcome: messageData.diceOutcome,
481 naturalRoll: messageData.naturalRoll,
482 rollModifier: messageData.rollModifier,
483 craft: messageData.craft,
484 craftReason: messageData.craftReason,
485 continuity: messageData.continuity,
486 characterAction: messageData.characterAction,
487 timelineImpact: messageData.timelineImpact,
488 progressChange: messageData.progressChange,
489 baseProgressChange: messageData.baseProgressChange,
490 momentumAtSwing: messageData.momentumAtSwing,
491 anachronism: messageData.anachronism,
492 causalChain: messageData.causalChain,
493 staked: messageData.staked
494 })
495 },
496 
497 // ---------- figures ----------
498 registerFigure(figure: HistoricalFigure) {
499 const existing = this.figures.find(f => f.name === figure.name)
500 if (existing) {
501 if (figure.era) existing.era = figure.era
502 if (figure.descriptor) existing.descriptor = figure.descriptor
503 } else {
504 this.figures.push({ ...figure })
505 }
506 },
507 
508 setActiveFigure(name: string) {
509 this.activeFigureName = name
510 },
511 
512 /**
513 * Synchronously clears the dossier the moment the contact NAME changes, so
514 * a send racing the grounding debounce/fetch is gated as ungrounded (#73)
515 * instead of wearing the PREVIOUS figure's facts, year, and liveness gate —
516 * and so the wrong year can never be written into the ledger's whenSigned.
517 */
518 clearGrounding() {
519 this.groundingSeq++ // invalidate any lookup still in flight
520 this.figureGrounding = null
521 this.contactWhen = null
522 this.contactMoment = null
523 this.groundingLoading = false
524 this.figureStudy = null
525 this.studyFor = ''
526 this.studyWhen = null
527 this.studyNotice = null
528 },
529 
530 /**
531 * Resolves the active figure against the grounding service and defaults the
532 * "when" to a point inside any known lifetime. Never throws: an unresolved
533 * figure simply clears grounding; an ungrounded contact is then blocked (#73).
534 */
535 async groundActiveFigure(name: string): Promise<void> {
536 const target = (name || '').trim()
537 // A new contact makes any prior Archive study stale.
538 this.figureStudy = null
539 this.studyFor = ''
540 this.studyWhen = null
541 this.studyNotice = null
542 if (!target) {
543 this.groundingSeq++ // invalidate any lookup still in flight
544 this.figureGrounding = null
545 this.contactWhen = null
546 this.contactMoment = null
547 this.groundingLoading = false
548 return
549 }
550 // Out-of-order guard: only the LATEST lookup may land. Without it, a slow
551 // response for the previous name attaches the wrong dossier (and a wrong
552 // figureContext on a fast send) to whatever the player typed next.
553 const epoch = this.runEpoch
554 const seq = ++this.groundingSeq
555 this.groundingLoading = true
556 try {
557 const grounded = await $fetch('/api/figure', { params: { name: target }, headers: await this.aiHeaders() }) as GroundedFigure
558 if (epoch !== this.runEpoch || seq !== this.groundingSeq) return
559 this.figureGrounding = grounded
560 this.contactMoment = null
561 if (grounded?.resolved && grounded.born) {
562 const latest = grounded.died ? grounded.died.signed : new Date().getFullYear()
563 this.contactWhen = Math.round((grounded.born.signed + latest) / 2)
564 } else {
565 this.contactWhen = null
566 }
567 } catch (error) {
568 if (epoch !== this.runEpoch || seq !== this.groundingSeq) return
569 console.error('Figure grounding failed:', error)
570 this.figureGrounding = null
571 this.contactWhen = null
572 this.contactMoment = null
573 } finally {
574 if (epoch === this.runEpoch && seq === this.groundingSeq) this.groundingLoading = false
575 }
576 },
577 
578 /** The current run's id, minted server-side via POST /api/run on first
579 * need. The run is a server fact (a persisted, charged row); the id then
580 * tags every AI call so cost telemetry groups per run, and the gameplay
581 * gate verifies it. Fails CLOSED: if begin-run can't return an id this
582 * REJECTS (no local fallback) — a run we can't back server-side must not
583 * start, or the paywall gate would reject it mid-run anyway. */
584 async ensureRunId(): Promise<string> {
585 if (this.runId) return this.runId
586 // Collapse concurrent first-calls onto ONE begin-run (two overlapping
587 // calls would otherwise mint two rows). The epoch guards a resetGame
588 // mid-flight — a dead run's id must not become the fresh run's.
589 if (!this.runIdInflight) {
590 const epoch = this.runEpoch
591 this.runIdInflight = (async () => {
592 const res = await $fetch('/api/run', { method: 'POST' }) as { runId?: string }
593 if (!res?.runId) throw new Error('begin-run returned no run id')
594 if (epoch === this.runEpoch) {
595 this.runId = res.runId
596 this.runIdInflight = null
597 }
598 return res.runId
599 })().catch((err) => {
600 if (epoch === this.runEpoch) this.runIdInflight = null
601 throw err
602 })
603 }
604 return this.runIdInflight
605 },
606 
607 /** Headers that tag a server call with the current run (the cost
608 * instrument), minting the run id first if needed. */
609 async aiHeaders(base: Record<string, string> = {}): Promise<Record<string, string>> {
610 return { ...base, 'x-run-id': await this.ensureRunId() }
611 },
612 
613 setContactWhen(year: number | null) {
614 // Scrubbing the year keeps any pinned month/day — the pin refines
615 // whichever year is chosen, it doesn't belong to one.
616 this.contactWhen = year
617 },
618 
619 setContactMoment(moment: ContactMoment | null) {
620 this.contactMoment = moment
621 },
622 
623 /**
624 * Studies the active (grounded) figure via the Archivist — an objective-blind
625 * brief on who they are AT THE CHOSEN MOMENT, so the player can research in the
626 * game instead of a browser tab. The brief is moment-specific, so it's cached
627 * against the figure AND the year: move the contact slider and the player can
628 * re-study them at the new moment. Graceful on failure (leaves the prior brief).
629 * Only resolved figures can be studied — an unresolved name has no record.
630 */
631 async studyActiveFigure(): Promise<void> {
632 const g = this.figureGrounding
633 if (!g?.resolved) {
634 this.figureStudy = null
635 return
636 }
637 // Cache hit only when both the figure AND the studied year still match.
638 if (this.studyFor === g.name && this.studyWhen === this.contactWhen && this.figureStudy) return
639 
640 // Capture the moment being studied NOW: the brief describes this figure at
641 // this year. If the slider moves mid-flight, recording the live value would
642 // mislabel the brief and silently suppress the Re-study button.
643 const epoch = this.runEpoch
644 const studiedName = g.name
645 const studiedWhen = this.contactWhen
646 this.studyLoading = true
647 this.studyNotice = null
648 try {
649 const res = await $fetch('/api/research', {
650 method: 'POST',
651 headers: await this.aiHeaders({ 'Content-Type': 'application/json' }),
652 body: {
653 figureName: studiedName,
654 when: studiedWhen != null ? formatContactYear(studiedWhen) : undefined,
655 description: g.description,
656 extract: g.extract
657 }
658 }) as { success?: boolean; study?: FigureStudy; blocked?: boolean; error?: string }
659 if (epoch !== this.runEpoch) return
660 // If the contact changed mid-flight, the stale-study clear in
661 // groundActiveFigure already ran — don't resurrect the old brief.
662 if (res?.blocked && this.figureGrounding?.name === studiedName) {
663 this.studyNotice = res.error || 'That study was blocked by content moderation.'
664 } else if (res?.success && res.study && this.figureGrounding?.name === studiedName) {
665 this.figureStudy = res.study
666 this.studyFor = studiedName
667 this.studyWhen = studiedWhen
668 }
669 } catch (error) {
670 if (epoch !== this.runEpoch) return
671 console.error('Failed to study the figure:', error)
672 } finally {
673 if (epoch === this.runEpoch) this.studyLoading = false
674 }
675 },
676 
677 /**
678 * Asks the Archive about a freeform topic (the "yellow" layer) — concrete
679 * domain facts: what it is, what it takes, and when it first became known
680 * (the player's anachronism read). Graceful on failure. Topic-scoped, so it
681 * persists across figure changes within a run.
682 */
683 async askArchive(query: string): Promise<void> {
684 const q = (query || '').trim()
685 if (!q) return
686 const epoch = this.runEpoch
687 this.lookupLoading = true
688 this.archiveNotice = null
689 try {
690 const res = await $fetch('/api/lookup', {
691 method: 'POST',
692 headers: await this.aiHeaders({ 'Content-Type': 'application/json' }),
693 body: { query: q }
694 }) as { success?: boolean; lookup?: ArchiveLookup; blocked?: boolean; error?: string }
695 if (epoch !== this.runEpoch) return
696 if (res?.blocked) {
697 // The Archive is the sharpest elicitation vector — a block here is
698 // honest and visible, not a silent empty result.
699 this.archiveNotice = res.error || 'That lookup was blocked by content moderation.'
700 } else if (res?.success && res.lookup) {
701 this.archiveResult = res.lookup
702 }
703 } catch (error) {
704 if (epoch !== this.runEpoch) return
705 console.error('Archive lookup failed:', error)
706 } finally {
707 if (epoch === this.runEpoch) this.lookupLoading = false
708 }
709 },
710 
711 /**
712 * Loads era-relevant figure suggestions for the current objective (the
713 * educational on-ramp). Cached per objective; on any failure it leaves the
714 * list empty so the UI falls back to its generic starters.
715 */
716 async loadSuggestions(): Promise<void> {
717 const objective = this.currentObjective
718 if (!objective) {
719 this.figureSuggestions = []
720 this.suggestionsFor = ''
721 return
722 }
723 if (this.suggestionsFor === objective.title && this.figureSuggestions.length) return
724 
725 const epoch = this.runEpoch
726 const seq = ++this.suggestionsSeq
727 this.suggestionsLoading = true
728 this.suggestionsNotice = null
729 try {
730 const res = await $fetch('/api/suggestions', {
731 method: 'POST',
732 headers: await this.aiHeaders({ 'Content-Type': 'application/json' }),
733 body: {
734 objective: {
735 title: objective.title,
736 description: objective.description,
737 era: objective.era
738 },
739 // Usually empty here (the initial load fires at run start), but a
740 // mid-run remount carries the ledger so even the first paint is
741 // timeline-aware. refreshSuggestions re-sends it after each change.
742 ledger: ledgerForSuggester(this.timelineEvents)
743 }
744 }) as { success?: boolean; suggestions?: FigureSuggestion[]; blocked?: boolean; reason?: string }
745 if (epoch !== this.runEpoch || seq !== this.suggestionsSeq) return
746 // A blocked objective must not masquerade as an empty result — surface it.
747 if (res?.blocked) this.suggestionsNotice = res.reason || 'That objective was blocked by content moderation.'
748 this.figureSuggestions = res?.suggestions ?? []
749 this.suggestionsFor = objective.title
750 } catch (error) {
751 if (epoch !== this.runEpoch || seq !== this.suggestionsSeq) return
752 console.error('Failed to load figure suggestions:', error)
753 this.figureSuggestions = []
754 // Record that this objective WAS asked, even though it failed — the
755 // picker uses suggestionsFor to tell "not yet asked" (skeletons)
756 // from "asked and came up empty" (the honest famous-names fallback).
757 this.suggestionsFor = objective.title
758 } finally {
759 if (epoch === this.runEpoch && seq === this.suggestionsSeq) this.suggestionsLoading = false
760 }
761 },
762 
763 /**
764 * Re-narrate the figure-suggestion blurbs against the altered timeline (issue
765 * #131). The suggester is re-run with the landed ledger so a blurb stops
766 * asserting a fate the player has already overturned. Fired non-blocking after
767 * a landed change, like refreshChronicle — the turn reveal waits on neither.
768 * A failed, blocked, or curated-fallback refresh KEEPS the prior list: a
769 * populated panel never blanks, and a model outage never regresses good
770 * era-specific picks to the generic fallback.
771 *
772 * loadSuggestions and refreshSuggestions share `suggestionsSeq` so the newest
773 * call of EITHER wins. Whichever holds the latest seq therefore owns settling
774 * `suggestionsLoading` + `suggestionsFor` (in the finally), so a refresh fired
775 * while the initial load is still in flight can never strand the skeletons.
776 */
777 async refreshSuggestions(): Promise<void> {
778 const objective = this.currentObjective
779 if (!objective) return
780 const epoch = this.runEpoch
781 const seq = ++this.suggestionsSeq
782 try {
783 const res = await $fetch('/api/suggestions', {
784 method: 'POST',
785 headers: await this.aiHeaders({ 'Content-Type': 'application/json' }),
786 body: {
787 objective: {
788 title: objective.title,
789 description: objective.description,
790 era: objective.era
791 },
792 ledger: ledgerForSuggester(this.timelineEvents)
793 }
794 }) as { success?: boolean; suggestions?: FigureSuggestion[]; fallback?: boolean; blocked?: boolean }
795 if (epoch !== this.runEpoch || seq !== this.suggestionsSeq) return
796 // Only a genuine, model-built list may overwrite the panel — a curated
797 // fallback (model unavailable) or an empty/blocked result keeps the
798 // better existing suggestions rather than regressing to generic names.
799 if (res?.success && res.suggestions?.length && !res.fallback) {
800 this.figureSuggestions = res.suggestions
801 }
802 this.suggestionsFor = objective.title
803 } catch (error) {
804 if (epoch !== this.runEpoch || seq !== this.suggestionsSeq) return
805 console.error('Failed to refresh figure suggestions:', error)
806 // Keep the prior list — a failed refresh never blanks the panel.
807 } finally {
808 // This refresh bumped the shared seq; if it's still the latest, it owns
809 // the resolution — clear a loading flag an invalidated in-flight load
810 // can no longer clear itself (the stuck-skeletons race).
811 if (epoch === this.runEpoch && seq === this.suggestionsSeq) this.suggestionsLoading = false
812 }
813 },
814 
815 // ---------- timeline ledger ----------
816 addTimelineEvent(
817 evt: Omit<TimelineEvent, 'id' | 'timestamp' | 'valence'> & { valence?: Valence; timestamp?: Date }
818 ) {
819 this.timelineEvents.push({
820 id: `evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
821 figureName: evt.figureName,
822 era: evt.era,
823 headline: evt.headline,
824 detail: evt.detail,
825 diceRoll: evt.diceRoll,
826 diceOutcome: evt.diceOutcome,
827 progressChange: evt.progressChange,
828 baseProgressChange: evt.baseProgressChange,
829 valence: evt.valence ?? valenceOf(evt.progressChange),
830 anachronism: evt.anachronism,
831 causalChain: evt.causalChain,
832 craft: evt.craft,
833 whenSigned: evt.whenSigned,
834 staked: evt.staked,
835 timestamp: evt.timestamp ?? new Date()
836 })
837 },
838 
839 // ---------- the living chronicle (Layer 3) ----------
840 /**
841 * Re-narrates the Chronicle from the freshly-bent timeline. Fired
842 * non-blocking after a resolved turn (and at game end): the dice/progress
843 * reveal never waits on prose. The WHOLE account is rewritten each turn —
844 * a later change can re-frame how earlier events read.
845 * Graceful: a failed refresh keeps the prior telling, so the panel never
846 * blanks; an empty timeline clears it (nothing to chronicle yet).
847 */
848 async refreshChronicle(): Promise<void> {
849 if (!this.timelineEvents.length) {
850 this.chronicle = null
851 return
852 }
853 // Sequencing: rewrites overlap (each turn fires one, never awaited), so
854 // only the LATEST issued refresh may land — an earlier telling arriving
855 // late must not regress the account (or worse, the epilogue). The epoch
856 // guard keeps a dead run's telling out of a fresh run entirely.
857 const epoch = this.runEpoch
858 const seq = ++this.chronicleSeq
859 this.chronicleLoading = true
860 try {
861 const res = await $fetch('/api/chronicle', {
862 method: 'POST',
863 headers: await this.aiHeaders({ 'Content-Type': 'application/json' }),
864 body: {
865 objective: this.currentObjective,
866 status: this.gameStatus,
867 progress: this.objectiveProgress,
868 timeline: this.timelineEvents.map(e => ({
869 era: e.era,
870 figureName: e.figureName,
871 headline: e.headline,
872 detail: e.detail,
873 progressChange: e.progressChange
874 }))
875 }
876 }) as { success?: boolean; chronicle?: ChronicleEntry }
877 if (epoch !== this.runEpoch || seq !== this.chronicleSeq) return
878 if (res?.success && res.chronicle) this.chronicle = res.chronicle
879 } catch (error) {
880 if (epoch !== this.runEpoch || seq !== this.chronicleSeq) return
881 console.error('Failed to refresh the chronicle:', error)
882 // Keep the prior chronicle — a failed refresh never blanks the panel.
883 } finally {
884 if (epoch === this.runEpoch && seq === this.chronicleSeq) this.chronicleLoading = false
885 }
886 },
887 
888 // ---------- simple setters ----------
889 setLoading(loading: boolean) { this.isLoading = loading },
890 setError(error: string | null) { this.error = error },
891 clearModerationNotice() { this.moderationNotice = null },
892 clearArchiveNotice() { this.archiveNotice = null },
893 
894 // ---------- rate limiting ----------
895 checkRateLimit(): boolean {
896 const now = Date.now()
897 if (this.lastMessageTime && (now - this.lastMessageTime) < RATE_LIMIT_MS) return false
898 return true
899 },
900 
901 setRateLimit() {
902 this.isRateLimited = true
903 const remainingTime = this.lastMessageTime
904 ? Math.max(0, RATE_LIMIT_MS - (Date.now() - this.lastMessageTime))
905 : 0
906 setTimeout(() => { this.isRateLimited = false }, remainingTime || RATE_LIMIT_MS)
907 },
908 
909 // ---------- counter & status ----------
910 decrementMessages() {
911 if (this.remainingMessages > 0) this.remainingMessages--
912 },
913 
914 /**
915 * Rolls back an unresolved turn: refunds the spent message and drops the
916 * dangling user entry, so the counter and history can't drift out of sync.
917 * Used for BOTH failure shapes — a thrown request and a graceful HTTP-200
918 * `success:false` — an infra hiccup must never burn one of the five
919 * dispatches (or, on the last one, convert into an instant unearned defeat).
920 */
921 refundUnresolvedTurn() {
922 if (this.remainingMessages < TOTAL_MESSAGES) this.remainingMessages++
923 for (let i = this.messageHistory.length - 1; i >= 0; i--) {
924 if (this.messageHistory[i].sender === 'user') {
925 this.messageHistory.splice(i, 1)
926 break
927 }
928 }
929 },
930 
931 applyProgress(progressChange: number) {
932 this.objectiveProgress = Math.max(0, Math.min(MAX_PROGRESS, this.objectiveProgress + progressChange))
933 this.peakProgress = Math.max(this.peakProgress, this.objectiveProgress)
934 },
935 
936 /**
937 * Resolves win/lose AFTER a turn's progress has been applied.
938 *
939 * Victory the instant the objective hits 100%; defeat only once the
940 * final message is spent without reaching it. This fixes the old
941 * premature-defeat bug, where status flipped on the last decrement
942 * BEFORE that turn's progress had a chance to land.
943 */
944 resolveGameStatus() {
945 if (this.objectiveProgress >= MAX_PROGRESS) {
946 this.gameStatus = 'victory'
947 } else if (this.remainingMessages <= 0) {
948 this.gameStatus = 'defeat'
949 }
950 },
951 
952 /** The player-facing reason a contact is currently blocked (unresolved /
953 * before-birth / living / after-death) — pure derivation, lifted out of
954 * sendMessage's guard so the action reads as flow, not message copy. */
955 contactBlockedMessage(target: string): string {
956 const who = this.figureGrounding?.name || target
957 return this.contactLiveness === 'unresolved'
958 ? (this.figureGrounding?.transient
959 ? `Couldn't reach the record to verify "${target}" — try again in a moment.`
960 : `No historical record found for "${target}" — reach for a real figure (pick a match as you type).`)
961 : this.contactLiveness === 'before-birth'
962 ? `${who} isn't born yet in that year — choose a later moment to reach them.`
963 : this.contactLiveness === 'living'
964 ? (this.figureGrounding?.born
965 ? `${who} is still living — Everwhen only reaches figures from history.`
966 : `${who} couldn't be dated — Everwhen can only reach figures it can place in history.`)
967 : `${who} has died by that year — choose an earlier moment to reach them.`
968 },
969 
970 /** Shape the /api/send-message request body — pure field assembly, lifted out
971 * of sendMessage so the action's flow isn't buried under plumbing. `sentWhen`/
972 * `sentMoment` are captured at send time (the slider can move mid-request). */
973 buildSendBody(text: string, target: string, sentWhen: number | null, sentMoment: ContactMoment | null, stake: boolean) {
974 return {
975 message: text,
976 figureName: target,
977 when: sentWhen != null ? formatContactMoment(sentWhen, sentMoment) : undefined,
978 whenSigned: sentWhen ?? undefined,
979 // The pinned moment travels as validated integers; the server
980 // re-derives the display string rather than trusting ours.
981 whenMonth: sentWhen != null ? sentMoment?.month : undefined,
982 whenDay: sentWhen != null ? sentMoment?.day : undefined,
983 stake,
984 // The pre-turn momentum the server amplifies the swing by, and
985 // returns advanced (the client stays the system of record).
986 momentum: this.momentum,
987 figureContext: this.figureGrounding?.resolved
988 ? {
989 description: this.figureGrounding.description,
990 lifespan: lifespanText(this.figureGrounding)
991 }
992 : undefined,
993 objective: this.currentObjective,
994 timeline: this.timelineEvents.map(e => ({
995 era: e.era,
996 figureName: e.figureName,
997 headline: e.headline,
998 detail: e.detail,
999 progressChange: e.progressChange,
1000 whenSigned: e.whenSigned
1001 })),
1002 conversationHistory: this.conversationWith(target)
1004 },
1006 // ---------- the turn ----------
1007 /**
1008 * Sends a 160-char message to a chosen figure and folds the result back
1009 * into the world: the figure replies + acts, the dice decide the swing,
1010 * and the Timeline Engine records how history bent.
1012 * Returns `true` only when the turn actually RESOLVED (a ledger entry
1013 * landed). A blocked or failed send returns `false` so the composer can
1014 * keep the player's crafted words instead of wiping them.
1016 * `opts.stake` arms the last stand — honored only when `canStake` holds
1017 * (final message, win out of normal reach; the server doubles the resolved
1018 * swing, both ways, past the usual cap).
1019 */
1020 async sendMessage(text: string, figureName?: string, opts?: { stake?: boolean }): Promise<boolean> {
1021 const target = (figureName ?? this.activeFigureName ?? '').trim()
1022 const stake = opts?.stake === true && this.canStake
1024 if (!this.canSendMessage) return false
1025 if (!target) {
1026 this.setError('Choose who in history to send your message to first.')
1027 return false
1029 if (!this.checkRateLimit()) {
1030 this.setRateLimit()
1031 this.setError('The timeline needs a moment — wait before sending again.')
1032 return false
1034 if (!this.canContact) {
1035 this.setError(this.contactBlockedMessage(target))
1036 return false
1039 this.setError(null)
1040 this.moderationNotice = null
1041 this.setActiveFigure(target)
1042 this.addUserMessage(text, target)
1043 this.decrementMessages()
1044 this.setLoading(true)
1045 this.lastMessageTime = Date.now()
1047 // If the run is reset while this request is in flight, every write below
1048 // would land in a world that no longer exists — the epoch guard drops the
1049 // response (no fold-in, no refund: the new run's counter is not ours).
1050 const epoch = this.runEpoch
1051 const ledgerBefore = this.timelineEvents.length
1052 // Capture the contact year NOW: the slider can move while the request is
1053 // in flight, and the ledger must record the year this turn was SENT to.
1054 const sentWhen = this.contactWhen
1055 const sentMoment = this.contactMoment
1056 let resolved = false
1057 // Decide once whether to stagger the reveal (browser + motion allowed).
1058 const animate = canAnimateReveal()
1060 try {
1061 const response = await $fetch<SendMessageApiResponse>('/api/send-message', {
1062 method: 'POST',
1063 headers: await this.aiHeaders({ 'Content-Type': 'application/json' }),
1064 body: this.buildSendBody(text, target, sentWhen, sentMoment, stake)
1065 })
1067 if (epoch !== this.runEpoch) return false
1069 if (response?.success && response.data?.characterResponse) {
1070 const { figure, characterResponse, diceRoll, diceOutcome, timeline } = response.data
1072 if (figure?.name) {
1073 this.registerFigure({
1074 name: figure.name,
1075 era: figure.era || '',
1076 descriptor: figure.descriptor || ''
1077 })
1080 // ---- conducted reveal ----
1081 // A beat of suspense (the die keeps shaking) before the result
1082 // lands, so the resolution has a moment of anticipation.
1083 if (animate) {
1084 await wait(REVEAL.suspense)
1085 if (epoch !== this.runEpoch) return false
1088 // Beat 1 — the die lands and the figure's reply writes itself in
1089 // (its parts stage over ~0.55s via CSS). Flipping loading here
1090 // (mid-sequence) is what lands the die now; the finally's flip
1091 // becomes a no-op.
1092 this.addAIMessageWithData({
1093 text: characterResponse.message,
1094 sender: 'ai',
1095 figureName: target,
1096 timestamp: new Date(),
1097 diceRoll,
1098 diceOutcome,
1099 naturalRoll: response.data.naturalRoll ?? diceRoll,
1100 rollModifier: response.data.rollModifier ?? 0,
1101 craft: response.data.craft,
1102 craftReason: response.data.craftReason,
1103 continuity: response.data.continuity,
1104 characterAction: characterResponse.action,
1105 timelineImpact: timeline?.detail,
1106 progressChange: timeline?.progressChange,
1107 baseProgressChange: timeline?.baseProgressChange,
1108 momentumAtSwing: timeline?.momentumAtSwing,
1109 anachronism: timeline?.anachronism,
1110 causalChain: timeline?.causalChain,
1111 staked: response.data.staked
1112 })
1113 if (animate) this.setLoading(false)
1115 if (timeline) {
1116 // Beat 2 — the timeline shift (the % gauge flies its delta),
1117 // timed to land with the reply's own "ripple" line.
1118 if (animate) {
1119 await wait(REVEAL.swing)
1120 if (epoch !== this.runEpoch) return false
1122 // Use !== undefined so a genuine neutral (0%) turn still
1123 // registers instead of silently vanishing.
1124 if (timeline.progressChange !== undefined) {
1125 this.applyProgress(timeline.progressChange)
1127 // The arc meter is server-authoritative for the result; advance
1128 // it WITH the swing it amplified — and only past the epoch check
1129 // above, so a stale turn never moves the meter (issue #62).
1130 this.momentum = response.data.momentum ?? this.momentum
1132 // Beat 3 — the change drops onto the Spine ledger.
1133 if (animate) {
1134 await wait(REVEAL.node)
1135 if (epoch !== this.runEpoch) return false
1137 this.addTimelineEvent({
1138 figureName: target,
1139 era: timeline.era || figure?.era || (this.currentObjective?.era ?? ''),
1140 headline: timeline.headline,
1141 detail: timeline.detail,
1142 diceRoll,
1143 diceOutcome,
1144 progressChange: timeline.progressChange ?? 0,
1145 baseProgressChange: timeline.baseProgressChange,
1146 valence: timeline.valence,
1147 anachronism: timeline.anachronism,
1148 causalChain: timeline.causalChain,
1149 craft: response.data.craft,
1150 whenSigned: sentWhen ?? undefined,
1151 staked: response.data.staked
1152 })
1153 resolved = true
1155 } else {
1156 // A graceful failure (HTTP 200, success:false): an AI layer gave
1157 // out mid-turn. No ripple landed, so the dispatch is refunded —
1158 // exactly like the thrown path below.
1159 // Narrow to the failure variant (its data carries `blocked`).
1160 const failed = response && !response.success ? response.data : undefined
1161 if (failed?.blocked) {
1162 // A content-moderation block, not an infra hiccup — show an
1163 // honest, distinct banner (not "try again"). The composer
1164 // keeps the player's words (sendMessage returns false).
1165 this.moderationNotice = failed.moderationReason || 'That dispatch was blocked by content moderation and cannot be sent.'
1166 } else {
1167 this.setError(failed?.error || 'History did not answer. Try again.')
1169 this.refundUnresolvedTurn()
1171 } catch (error) {
1172 if (epoch !== this.runEpoch) return false
1173 console.error('Error sending message:', error)
1174 this.setError('The timeline resisted your message. Please try again.')
1175 this.refundUnresolvedTurn()
1176 } finally {
1177 if (epoch === this.runEpoch) {
1178 this.setLoading(false)
1179 this.resolveGameStatus()
1183 // The living chronicle re-narrates the world from the new timeline — but
1184 // only when this turn actually added a change, and never awaited: the turn
1185 // reveal must not wait on prose. By here the status is resolved, so the
1186 // final turn's chronicle carries the victory/defeat framing (the epilogue).
1187 let refresh: Promise<void> | null = null
1188 if (resolved && this.timelineEvents.length > ledgerBefore) {
1189 refresh = this.refreshChronicle()
1190 void refresh
1191 // The on-ramp blurbs re-narrate against the altered timeline (issue
1192 // #131), non-blocking like the chronicle — neither holds the reveal.
1193 void this.refreshSuggestions()
1195 // The run just ended: persist it (best-effort) so it survives reload, then
1196 // re-save once the epilogue's telling lands — the immediate save guards
1197 // against that refresh failing, the second captures the final Chronicle.
1198 if (this.gameStatus === 'victory' || this.gameStatus === 'defeat') {
1199 // This turn's refresh writes the EPILOGUE — the terminal telling that
1200 // carries the verdict. Mark it pending so the end screen shows "the
1201 // final account…" instead of the prior turn's stale telling. Cleared
1202 // when the telling lands OR the refresh fails (refreshChronicle keeps
1203 // the prior telling on failure) — so the panel reveals or falls back,
1204 // never hanging on the loading state.
1205 if (refresh) {
1206 this.epiloguePending = true
1207 void refresh.finally(() => { if (epoch === this.runEpoch) this.epiloguePending = false })
1209 void this.saveRunSnapshot()
1210 if (refresh) void refresh.finally(() => { void this.saveRunSnapshot() })
1211 // The terminal snapshot is the record now — drop the in-progress draft
1212 // so a reload can't resume a finished run (#152).
1213 void this.clearRunDraft()
1214 } else if (resolved) {
1215 // Still playing: autosave the live state (best-effort, fire-and-forget)
1216 // so a refresh or crash resumes exactly here (#152). The immediate save
1217 // guards against the chronicle refresh hanging; the post-refresh re-save
1218 // captures this turn's freshly-narrated Chronicle — same two-beat shape
1219 // as the terminal saveRunSnapshot above.
1220 void this.saveRunDraft()
1221 if (refresh) void refresh.finally(() => { void this.saveRunDraft() })
1223 return resolved
1224 },
1226 /**
1227 * Seed a replay (issue #89): start a fresh run on a shared run's captured
1228 * objective. Resets first (the share page may follow an abandoned run in the
1229 * same tab) so the run starts clean, THEN records the seed — so the mission
1230 * briefing opens focused on this objective, and the lineage + credit ride
1231 * through to the end. No generation: the objective is reused verbatim, and the
1232 * player still commits (and is charged) by pressing Begin.
1233 */
1234 preseedReplay(objective: GameObjective, sourceToken: string, sourceAttribution: string | null): void {
1235 this.resetGame()
1236 this.replayState = { objective, sourceToken, sourceAttribution }
1237 },
1239 /** Drop the replay seed — the player chose to start from scratch instead, so this
1240 * run is an original (no "remixed from" lineage). */
1241 clearReplayState(): void {
1242 this.replayState = null
1243 },
1245 /**
1246 * Commits a chosen objective and starts the run from a clean slate of
1247 * progress. Used by the mission-select screen for both curated picks and
1248 * freshly composed ones.
1249 */
1250 async chooseObjective(objective: GameObjective): Promise<boolean> {
1251 // Commit = the run is charged here. Mint the run id server-side, then
1252 // spend one run from the device's balance. Fails CLOSED: out of runs →
1253 // raise the paywall; begin-run or commit failing → surface an error and
1254 // do NOT start. A run that isn't a charged, server-backed run would be
1255 // rejected by the gameplay gate anyway, so starting it only strands the
1256 // player mid-run. The spend cap (not free play) is the outage backstop.
1257 let runId: string
1258 try {
1259 runId = await this.ensureRunId()
1260 } catch (error) {
1261 console.error('Could not begin the run:', error)
1262 this.error = 'Could not start the run. Please try again.'
1263 return false
1265 try {
1266 const res = await $fetch('/api/run-commit', {
1267 method: 'POST',
1268 headers: { 'Content-Type': 'application/json' },
1269 body: { runId }
1270 }) as { runsRemaining?: number }
1271 this.outOfRuns = false
1272 // Keep the header gauge live: the commit returns the post-charge
1273 // balance (−1 marks a degraded, ungated run — leave the gauge as is).
1274 if (typeof res?.runsRemaining === 'number' && res.runsRemaining >= 0) {
1275 this.runsRemaining = res.runsRemaining
1277 } catch (error) {
1278 if (isPaymentRequired(error)) {
1279 this.outOfRuns = true
1280 this.openBuyModal()
1281 return false
1283 if (isAtCapacity(error)) {
1284 this.atCapacity = true
1285 return false
1287 console.error('Could not charge the run:', error)
1288 this.error = 'Could not start the run. Please try again.'
1289 return false
1291 this.currentObjective = objective
1292 this.objectiveProgress = 0
1293 this.peakProgress = 0
1294 this.momentum = 0
1295 this.error = null
1296 return true
1297 },
1299 /**
1300 * Asks the server to compose a fresh objective with the model, WITHOUT
1301 * committing it — the caller previews it, then commits via chooseObjective.
1302 * Generation lives server-side because it needs the OpenAI key (the client
1303 * has no access to it). An optional steer (era + theme, closed enums) biases
1304 * the composition; the server re-validates it against the enums, so a bad
1305 * value is harmless. `avoid` is the titles already composed this session, so
1306 * a reroll lands somewhere new (#95) — the stateless server only knows what
1307 * the client supplies.
1309 * Returns the objective (or null, never throwing, so the UI can quietly fall
1310 * back to the curated pool) alongside `fellBackToCurated` (#127): true only
1311 * when the player steered and the server still returned a curated objective —
1312 * the signal the caller surfaces so a dropped steer doesn't read as a bug.
1313 */
1314 async fetchAIObjective(steer: ObjectiveSteer = {}, avoid: string[] = []): Promise<{ objective: GameObjective | null; fellBackToCurated: boolean }> {
1315 try {
1316 const query: Record<string, string | string[]> = {}
1317 if (steer.era) query.era = steer.era
1318 if (steer.theme) query.theme = steer.theme
1319 if (avoid.length) query.avoid = avoid
1320 const res = await $fetch('/api/objective', { method: 'GET', query, headers: await this.aiHeaders() }) as {
1321 success?: boolean
1322 objective?: GameObjective
1323 fellBackToCurated?: boolean
1325 const objective = res?.success && res.objective ? res.objective : null
1326 // Only report a dropped steer when we actually have an objective; a hard
1327 // failure (null) is the louder compose-error path, not this quiet notice.
1328 return { objective, fellBackToCurated: objective ? res.fellBackToCurated === true : false }
1329 } catch (error) {
1330 console.error('Failed to compose a fresh objective:', error)
1331 return { objective: null, fellBackToCurated: false }
1333 },
1335 /**
1336 * Loads the device's run balance for the header gauge + account popover.
1337 * Grants the free trial on a brand-new device (server-side). Graceful: on
1338 * failure it leaves the prior value (the gauge simply doesn't update).
1339 */
1340 async loadBalance(): Promise<void> {
1341 try {
1342 const res = await $fetch('/api/balance') as { runsRemaining?: number; freeRuns?: number; deviceRef?: string; email?: string | null; isAnonymous?: boolean; referralCredits?: number; referralCount?: number }
1343 if (typeof res?.runsRemaining === 'number') this.runsRemaining = res.runsRemaining
1344 if (typeof res?.freeRuns === 'number') this.freeRuns = res.freeRuns
1345 if (typeof res?.deviceRef === 'string') this.deviceRef = res.deviceRef
1346 this.accountEmail = res?.email ?? null
1347 this.accountAnonymous = res?.isAnonymous === true
1348 if (typeof res?.referralCredits === 'number') this.referralCredits = res.referralCredits
1349 if (typeof res?.referralCount === 'number') this.referralCount = res.referralCount
1350 } catch (error) {
1351 console.error('Failed to load balance:', error)
1353 },
1355 /**
1356 * Claims a pending referral after an email signup (issue #96). Best-effort and
1357 * fire-and-forget by design: the credit lands on the SHARER, not this player, so
1358 * there's nothing here to surface — the server decides idempotently whether it
1359 * qualifies. Never throws into the sign-in flow.
1360 */
1361 async claimReferral(): Promise<void> {
1362 try {
1363 await $fetch('/api/referral/claim', { method: 'POST' })
1364 } catch (error) {
1365 console.error('Failed to claim referral:', error)
1367 },
⋯ 391 lines hidden (lines 1368–1758)
1369 /** Opens the run-packs sales modal, loading the catalog first. */
1370 async openBuyModal(): Promise<void> {
1371 this.buyModalOpen = true
1372 await this.loadPacks()
1373 },
1375 /** Closes the sales modal. */
1376 closeBuyModal(): void {
1377 this.buyModalOpen = false
1378 },
1380 /** Records the Stripe-return outcome and refreshes the balance on success
1381 * (the webhook credits asynchronously; re-read so the gauge reflects it). */
1382 async notePurchaseReturn(outcome: 'success' | 'cancel'): Promise<void> {
1383 this.purchaseNotice = outcome
1384 this.buyModalOpen = false
1385 if (outcome === 'success') {
1386 this.outOfRuns = false
1387 await this.loadBalance()
1389 },
1391 /** Dismisses the post-purchase notice. */
1392 clearPurchaseNotice(): void {
1393 this.purchaseNotice = null
1394 },
1396 /** Loads the run-pack catalog for the paywall. Caches only a NON-empty
1397 * result: a transient empty/failed read must not poison the cache and
1398 * strand the modal with zero packs for the rest of the session. */
1399 async loadPacks(): Promise<void> {
1400 if (this.packs.length) return
1401 try {
1402 const res = await $fetch('/api/packs') as { packs?: Pack[] }
1403 const packs = res?.packs ?? []
1404 if (packs.length) this.packs = packs
1405 } catch (error) {
1406 console.error('Failed to load packs:', error)
1408 },
1410 /** Starts checkout for a pack; returns the Stripe Checkout URL to redirect
1411 * to (null on failure). The caller does the redirect. */
1412 async buyPack(packId: string): Promise<string | null> {
1413 try {
1414 const res = await $fetch('/api/checkout', {
1415 method: 'POST',
1416 headers: { 'Content-Type': 'application/json' },
1417 body: { packId }
1418 }) as { url?: string }
1419 return res?.url ?? null
1420 } catch (error) {
1421 console.error('Failed to start checkout:', error)
1422 return null
1424 },
1426 getVictoryEfficiency() {
1427 if (this.gameStatus === 'victory') {
1428 const messagesSaved = this.remainingMessages
1429 const messagesUsed = TOTAL_MESSAGES - this.remainingMessages
1430 const efficiencyPercentage = Math.round((messagesSaved / TOTAL_MESSAGES) * 100)
1432 const efficiencyRating = rateEfficiency(messagesSaved)
1434 return {
1435 messagesSaved,
1436 messagesUsed,
1437 efficiencyPercentage,
1438 efficiencyRating,
1439 isEarlyVictory: messagesSaved > 0
1442 return null
1443 },
1445 /**
1446 * Persist the finished run's snapshot to the player's account, best-effort.
1447 * Captures exactly what the end screen shows — objective, verdict, ledger,
1448 * dispatches, rolls, and the Chronicle epilogue — keyed by the run id, so the
1449 * run survives reload and feeds the read-only "your runs" view. Fires and
1450 * forgets like refreshChronicle: a save failure must never disturb the end
1451 * screen. Only a completed (victory/defeat), server-backed (uuid run id) run
1452 * is saved; a degraded local id is dropped server-side.
1453 */
1454 async saveRunSnapshot(): Promise<void> {
1455 const epoch = this.runEpoch
1456 const status = this.gameStatus
1457 if (status !== 'victory' && status !== 'defeat') return
1458 const objective = this.currentObjective
1459 const runId = this.runId
1460 if (!objective || !runId) return
1462 const summary = this.gameSummary
1463 const events = this.timelineEvents
1464 // The dispatch keepsake — the player's verbatim messages, paired with
1465 // whom/when they reached (the same zip the end screen renders).
1466 const dispatches = this.messageHistory
1467 .filter((m) => m.sender === 'user')
1468 .map((m, i) => ({
1469 text: m.text,
1470 figure: m.figureName || events[i]?.figureName || 'the past',
1471 era: events[i]?.era || ''
1472 }))
1473 const mark = (m: typeof summary.bestRoll) =>
1474 m && m.diceRoll != null && m.diceOutcome != null
1475 ? { diceRoll: m.diceRoll, diceOutcome: m.diceOutcome }
1476 : null
1478 const snapshot: RunSnapshot = {
1479 version: RUN_SNAPSHOT_VERSION,
1480 runId,
1481 status,
1482 objective,
1483 objectiveProgress: this.objectiveProgress,
1484 // The high-water mark, so a snapshot-backed view can show "Peaked at X%"
1485 // (the live store zeroes it on reset, so it's only meaningful here at end).
1486 peakProgress: this.peakProgress,
1487 messagesUsed: TOTAL_MESSAGES - this.remainingMessages,
1488 totalMessages: TOTAL_MESSAGES,
1489 bestRoll: mark(summary.bestRoll),
1490 worstRoll: mark(summary.worstRoll),
1491 efficiency: this.getVictoryEfficiency(),
1492 dispatches,
1493 timeline: events.map((e) => ({
1494 id: e.id,
1495 figureName: e.figureName,
1496 era: e.era,
1497 headline: e.headline,
1498 detail: e.detail,
1499 diceRoll: e.diceRoll,
1500 diceOutcome: e.diceOutcome,
1501 progressChange: e.progressChange,
1502 baseProgressChange: e.baseProgressChange,
1503 valence: e.valence,
1504 craft: e.craft,
1505 anachronism: e.anachronism,
1506 staked: e.staked
1507 })),
1508 chronicle: this.chronicle
1511 // A replay (issue #89) rides its source's share token alongside the snapshot
1512 // (a sibling field, not part of the immutable blob); the server resolves it to
1513 // the parent run id and stamps the lineage pointer. replayState set ⟺ this run
1514 // committed the seeded objective (the briefing clears it on any other choice).
1515 const body = this.replayState
1516 ? { ...snapshot, remixedFromToken: this.replayState.sourceToken }
1517 : snapshot
1519 try {
1520 const res = (await $fetch('/api/run-save', {
1521 method: 'POST',
1522 headers: { 'Content-Type': 'application/json' },
1523 body
1524 })) as { saved?: boolean }
1525 // Mark saved (for the share affordance) only if this run is still current
1526 // and the server actually persisted it (a degraded run returns saved:false).
1527 if (epoch === this.runEpoch && res?.saved) this.runSnapshotSaved = true
1528 } catch (error) {
1529 // Saving the run is a nicety; a failure must not break the end screen.
1530 if (epoch === this.runEpoch) console.error('Could not save the run:', error)
1532 },
1534 /**
1535 * Autosave the in-progress run's draft, best-effort (#152). The live mirror of
1536 * saveRunSnapshot: it serializes the DURABLE live state — totals, the full
1537 * thread (both sides), the un-trimmed ledger (with whenSigned + causalChain),
1538 * the contacted figures, the active contact, the Chronicle, and any replay
1539 * lineage — keyed by the run id, so a refresh or a crash resumes exactly here.
1540 * Fires and forgets: only an in-progress, server-backed (uuid) run is saved;
1541 * the server drops a degraded local id, and any failure is swallowed.
1542 */
1543 async saveRunDraft(): Promise<void> {
1544 if (this.gameStatus !== 'playing') return
1545 const runId = this.runId
1546 const objective = this.currentObjective
1547 if (!runId || !objective) return
1549 const draft: RunDraft = {
1550 version: RUN_DRAFT_VERSION,
1551 runId,
1552 currentObjective: objective,
1553 objectiveProgress: this.objectiveProgress,
1554 peakProgress: this.peakProgress,
1555 momentum: this.momentum,
1556 remainingMessages: this.remainingMessages,
1557 // The full thread, both sides — the in-memory Date stamp serialized for
1558 // jsonb (revived on resume). The AI replies cost a model call to redo.
1559 messageHistory: this.messageHistory.map((m) => ({
1560 text: m.text,
1561 sender: m.sender,
1562 timestamp: m.timestamp.toISOString(),
1563 figureName: m.figureName,
1564 diceRoll: m.diceRoll,
1565 diceOutcome: m.diceOutcome,
1566 naturalRoll: m.naturalRoll,
1567 rollModifier: m.rollModifier,
1568 craft: m.craft,
1569 craftReason: m.craftReason,
1570 continuity: m.continuity,
1571 characterAction: m.characterAction,
1572 timelineImpact: m.timelineImpact,
1573 progressChange: m.progressChange,
1574 baseProgressChange: m.baseProgressChange,
1575 momentumAtSwing: m.momentumAtSwing,
1576 anachronism: m.anachronism,
1577 causalChain: m.causalChain,
1578 staked: m.staked
1579 })),
1580 // The UN-trimmed ledger: keeps whenSigned + causalChain (the footholds
1581 // the next turn's causal chain and the world-brief read back).
1582 timelineEvents: this.timelineEvents.map((e) => ({
1583 id: e.id,
1584 figureName: e.figureName,
1585 era: e.era,
1586 headline: e.headline,
1587 detail: e.detail,
1588 diceRoll: e.diceRoll,
1589 diceOutcome: e.diceOutcome,
1590 progressChange: e.progressChange,
1591 baseProgressChange: e.baseProgressChange,
1592 valence: e.valence,
1593 anachronism: e.anachronism,
1594 causalChain: e.causalChain,
1595 craft: e.craft,
1596 whenSigned: e.whenSigned,
1597 staked: e.staked,
1598 timestamp: e.timestamp.toISOString()
1599 })),
1600 figures: this.figures.map((f) => ({ name: f.name, era: f.era, descriptor: f.descriptor })),
1601 activeFigureName: this.activeFigureName,
1602 contactWhen: this.contactWhen,
1603 contactMoment: this.contactMoment,
1604 chronicle: this.chronicle,
1605 replayState: this.replayState
1608 try {
1609 await $fetch('/api/run-draft', {
1610 method: 'POST',
1611 headers: { 'Content-Type': 'application/json' },
1612 body: draft
1613 })
1614 } catch (error) {
1615 // Autosave is a safety net; a failure must never disturb the turn.
1616 console.error('Could not save the run draft:', error)
1618 },
1620 /**
1621 * Clear the run's server-side draft (#152) — fired on terminal save and on
1622 * resetGame (abandon). Takes an explicit run id so resetGame can clear the
1623 * run it is tearing down (runId is nulled there). Idempotent + best-effort.
1624 */
1625 async clearRunDraft(runId?: string): Promise<void> {
1626 const id = runId ?? this.runId
1627 if (!id) return
1628 try {
1629 await $fetch('/api/run-draft', { method: 'DELETE', query: { runId: id } })
1630 } catch (error) {
1631 console.error('Could not clear the run draft:', error)
1633 },
1635 /**
1636 * Resume an in-progress run from its server-side draft (#152) — the counterpart
1637 * to saveRunDraft, called on page load when the board is empty. Reads the
1638 * subject's latest draft and, when there is one, rehydrates the store so the
1639 * player drops straight back onto the in-progress board: the SAME run id (no new
1640 * credit charged), the totals, the full thread, the ledger, and the figures.
1642 * The active figure is RE-grounded from the live record (the draft carries no
1643 * dossier), then the saved contact year/moment are re-applied — groundActiveFigure
1644 * defaults contactWhen to mid-lifetime, which would otherwise clobber the player's
1645 * chosen year. Best-effort: any failure simply leaves the mission briefing.
1646 */
1647 async resumeDraft(): Promise<boolean> {
1648 // currentObjective set ⟹ a run is already on the board. A replayState seed
1649 // (preseedReplay, from a /r/<token> share link) is an intent to start a FRESH
1650 // charged run — never let a pre-existing server draft clobber it (#152/#89).
1651 if (this.currentObjective || this.replayState) return false
1652 let draft: RunDraft | null
1653 try {
1654 const res = (await $fetch('/api/run-draft')) as { draft?: RunDraft | null }
1655 draft = res?.draft ?? null
1656 } catch (error) {
1657 console.error('Could not load the run draft:', error)
1658 return false
1660 if (!draft || !draft.currentObjective || !draft.runId) return false
1661 // A reset, a fresh run, or a replay seed may have begun while the draft was
1662 // loading — never drop a stale run onto a board the player has moved on from.
1663 if (this.currentObjective || this.replayState) return false
1665 // jsonb is dateless: revive the serialized ISO stamps, falling back to now()
1666 // for any unparseable value rather than minting an Invalid Date.
1667 const revive = (ts: string): Date => {
1668 const d = new Date(ts)
1669 return Number.isNaN(d.getTime()) ? new Date() : d
1672 // Guard the async tail like every other state-writing action: the board (and
1673 // its ↻ reset) appears the instant we rehydrate, so a reset can fire during
1674 // the re-ground below — its stale contact must not land on the fresh run.
1675 const epoch = this.runEpoch
1676 this.runId = draft.runId
1677 this.gameStatus = 'playing'
1678 this.currentObjective = draft.currentObjective
1679 this.objectiveProgress = draft.objectiveProgress
1680 this.peakProgress = draft.peakProgress
1681 this.momentum = draft.momentum
1682 this.remainingMessages = draft.remainingMessages
1683 this.messageHistory = draft.messageHistory.map((m) => ({ ...m, timestamp: revive(m.timestamp) }))
1684 this.timelineEvents = draft.timelineEvents.map((e) => ({ ...e, timestamp: revive(e.timestamp) }))
1685 this.figures = draft.figures.map((f) => ({ ...f }))
1686 this.activeFigureName = draft.activeFigureName
1687 this.chronicle = draft.chronicle
1688 this.replayState = draft.replayState
1690 // Re-ground the active figure (the draft carries no dossier), then restore the
1691 // saved year/moment OVER the mid-lifetime default grounding picks — unless a
1692 // reset tore the board mid-ground, in which case abort without touching it.
1693 if (this.activeFigureName) {
1694 await this.groundActiveFigure(this.activeFigureName)
1695 if (epoch !== this.runEpoch) return false
1697 this.contactWhen = draft.contactWhen
1698 this.contactMoment = draft.contactMoment
1699 return true
1700 },
1702 resetGame() {
1703 // Clear the abandoned run's server draft so a reload can't resume it (#152).
1704 // Capture the id before the epoch tear nulls runId below; fire-and-forget.
1705 const abandonedRunId = this.runId
1706 // Tear the epoch first: anything still in flight belongs to the old run
1707 // and must find no purchase here. (The seq counters deliberately survive.)
1708 this.runEpoch++
1709 this.remainingMessages = TOTAL_MESSAGES
1710 this.messageHistory = []
1711 this.gameStatus = 'playing'
1712 this.isLoading = false
1713 this.error = null
1714 this.lastMessageTime = null
1715 this.isRateLimited = false
1716 // A new run gets a fresh id on its next server call; abandon any
1717 // in-flight mint so a dead run's id can't land in the new run.
1718 this.runId = null
1719 this.runIdInflight = null
1720 this.runSnapshotSaved = false
1721 // The paywall / capacity notices re-check on the next commit.
1722 this.outOfRuns = false
1723 this.atCapacity = false
1724 this.currentObjective = null
1725 // A fresh run carries no replay lineage (preseedReplay re-sets it after).
1726 this.replayState = null
1727 this.objectiveProgress = 0
1728 this.peakProgress = 0
1729 this.momentum = 0
1730 this.timelineEvents = []
1731 this.figures = []
1732 this.activeFigureName = ''
1733 this.figureGrounding = null
1734 this.groundingLoading = false
1735 this.contactWhen = null
1736 this.contactMoment = null
1737 this.figureSuggestions = []
1738 this.suggestionsLoading = false
1739 this.suggestionsFor = ''
1740 this.chronicle = null
1741 this.chronicleLoading = false
1742 this.epiloguePending = false
1743 this.figureStudy = null
1744 this.studyLoading = false
1745 this.studyFor = ''
1746 this.studyWhen = null
1747 this.archiveResult = null
1748 this.lookupLoading = false
1749 this.moderationNotice = null
1750 this.archiveNotice = null
1751 this.studyNotice = null
1752 this.suggestionsNotice = null
1753 // Drop the abandoned run's draft last, off the fresh (already-torn) epoch —
1754 // a no-op when there was no run yet (#152).
1755 if (abandonedRunId) void this.clearRunDraft(abandonedRunId)

Notification — batched, never per-referral

The primary notice is passive and zero-spam: the account popover shows "+N from sharing", pulled from the balance endpoint. No push, no interruption.

For the additive digest, the decision is a pure function: send when enough credits have piled up (the threshold) or the oldest un-notified credit has waited long enough (the window). It's claim-triggered, so the window clause means "something's waiting and it's been a while" without needing a background timer. Keeping it pure makes the throttle provable in a unit test, independent of any delivery channel.

The throttle: threshold OR elapsed-window, debounced below the threshold.

utils/referral-digest.ts · 44 lines
utils/referral-digest.ts44 lines · TypeScript
⋯ 29 lines hidden (lines 1–29)
1/**
2 * The batched referral-notice throttle (issue #96) — the rule that turns a stream of
3 * one-off referral credits into at most one DIGEST, never a ping per referral.
4 *
5 * A digest fires when EITHER enough new credits have piled up (the threshold) OR the
6 * oldest un-notified credit has waited long enough (the window) — whichever comes
7 * first. It's claim-triggered: re-evaluated each time a NEW referral is credited, so
8 * the window clause means "there's something waiting and it's been a while, send now"
9 * rather than needing a background timer. Pure + dependency-free so the policy is
10 * provable in a unit test, independent of any delivery channel (the in-app tally, an
11 * email/web-push for opted-in accounts, etc.).
12 */
13 
14export interface ReferralDigestState {
15 /** Credited referrals for this referrer not yet covered by a notice. */
16 unnotifiedCount: number
17 /** Epoch ms of the OLDEST un-notified credit, or null when there are none. */
18 oldestUnnotifiedAt: number | null
20 
21export interface ReferralDigestDecision extends ReferralDigestState {
22 /** Now, epoch ms (passed in so the decision stays pure / time-injectable). */
23 now: number
24 /** Send once this many un-notified credits accumulate. */
25 threshold: number
26 /** ...or once the oldest un-notified credit has waited this long. */
27 windowMs: number
29 
30/** Defaults the config layer falls back to. A digest at 3 accrued credits, or daily. */
31export const REFERRAL_DIGEST_THRESHOLD = 3
32export const REFERRAL_DIGEST_WINDOW_MS = 24 * 60 * 60 * 1000
33 
34/**
35 * Should a batched referral digest go out now? True on the threshold (enough piled up)
36 * OR the window (the oldest has waited long enough); false when nothing is waiting.
37 * Deliberately debounced: a single fresh credit below the threshold waits, so the
38 * notice is a digest, never one-per-referral.
39 */
40export function shouldSendReferralDigest(d: ReferralDigestDecision): boolean {
41 if (d.unnotifiedCount <= 0 || d.oldestUnnotifiedAt == null) return false
42 if (d.unnotifiedCount >= d.threshold) return true
43 return d.now - d.oldestUnnotifiedAt >= d.windowMs

The gauge read surfaces the accrued referral credits (read-safe).

server/api/balance.get.ts · 44 lines
server/api/balance.get.ts44 lines · TypeScript
⋯ 21 lines hidden (lines 1–21)
1/**
2 * GET /api/balance — the device's run balance, for the header gauge + account
3 * popover. Reads (and, on a brand-new device, grants) the free trial via
4 * ensureDevice, so a first-time visitor's gauge shows their free run rather than
5 * zero. Server-side only; keyed by the opaque device cookie.
6 */
7import { currentUser, type Account } from '~/server/utils/auth'
8import { deviceId } from '~/server/utils/device'
9import { balanceStore, FREE_RUNS } from '~/server/utils/balance-store'
10 
11export default defineEventHandler(async (event) => {
12 // Resolve the account ONCE (read-safe: a transient auth error falls back to the
13 // device id rather than failing this gauge poll), then derive the subject.
14 let user: Account | null = null
15 try {
16 user = await currentUser(event)
17 } catch {
18 user = null
19 }
20 const subject = user?.id ?? deviceId(event)
21 const runsRemaining = await balanceStore().ensureDevice(subject)
22 // Accrued referral credits, for the in-app "+N from sharing" tally (issue #96).
23 // Read-safe: a store hiccup degrades the tally to zero rather than failing the gauge.
24 let referral = { creditsEarned: 0, count: 0 }
25 try {
26 referral = await balanceStore().referralStats(subject)
27 } catch {
28 referral = { creditsEarned: 0, count: 0 }
29 }
30 return {
31 runsRemaining,
32 freeRuns: FREE_RUNS,
33 // A short, non-secret support handle for the current subject.
34 deviceRef: subject.slice(0, 8),
35 // Account state for the client UI: the email when signed in, and whether
36 // the current user is anonymous (→ must sign in before buying). No user =
37 // device-fallback mode → treated as not-anonymous (buying works as before).
38 email: user?.email ?? null,
39 isAnonymous: user?.isAnonymous ?? false,
40 // Referral credits earned from sharing (passive, pull-based notice).
41 referralCredits: referral.creditsEarned,
42 referralCount: referral.count
43 }
44})

The passive '+N from sharing' line, shown only once something's earned.

components/RunsBalance.vue · 145 lines
components/RunsBalance.vue145 lines · Vue
⋯ 25 lines hidden (lines 1–25)
1<template>
2 <!-- The run balance, as a header gauge (◈ N runs), doubling as the entry to a
3 small account popover. Mirrors MessagesCounter's mono-gauge idiom. -->
4 <div ref="rootRef" data-testid="runs-balance" class="relative">
5 <button type="button" data-testid="runs-balance-toggle"
6 class="inline-flex items-baseline gap-1 ew-mono text-sm ew-tap ew-hover-fg"
7 :aria-expanded="open" aria-haspopup="dialog" :aria-label="aria" @click="toggle">
8 <span class="ew-faint" aria-hidden="true"></span>
9 <span class="ew-fg font-semibold">{{ display }}</span>
10 <span class="ew-faint text-xs hidden sm:inline">{{ runs === 1 ? 'run' : 'runs' }}</span>
11 </button>
12 
13 <Transition name="ew-pop">
14 <div v-if="open" data-testid="account-popover" role="dialog" aria-label="Your account"
15 class="absolute right-0 mt-2 w-72 ew-bg border ew-line rounded-md shadow-xl z-40 p-3.5 text-left">
16 <p class="ew-label">Your runs</p>
17 <div class="flex items-baseline gap-1.5 mt-0.5">
18 <span class="ew-mono text-2xl font-extrabold ew-fg leading-none">{{ runs }}</span>
19 <span class="ew-muted text-xs">{{ runs === 1 ? 'run left' : 'runs left' }}</span>
20 </div>
21 <p class="ew-faint text-[11px] leading-relaxed mt-2">
22 <template v-if="runs <= 0">Out of runs — grab a pack to keep playing.</template>
23 <template v-else>Each run is one full game. Packs are one-time and never expire.</template>
24 </p>
25 
26 <!-- Referral credits earned from sharing (issue #96): a passive, pull-based
27 notice — it never interrupts, it just shows what your shares have earned. -->
28 <p v-if="gameStore.referralCredits > 0" data-testid="referral-credits"
29 class="ew-ok text-[11px] leading-relaxed mt-1.5">
30 <span aria-hidden="true"></span> +{{ gameStore.referralCredits }} from sharing —
31 {{ gameStore.referralCount === 1 ? 'a player you brought in signed up' : `${gameStore.referralCount} players you brought in signed up` }}.
32 </p>
33 
⋯ 112 lines hidden (lines 34–145)
34 <button type="button" data-testid="account-get-runs" class="ew-btn ew-btn--primary w-full mt-3 text-sm justify-center"
35 @click="getRuns">Get more runs</button>
36 
37 <div class="mt-3 pt-2.5 border-t ew-line">
38 <template v-if="gameStore.accountEmail">
39 <p class="ew-faint text-[11px] leading-relaxed">Signed in as <span class="ew-fg">{{ gameStore.accountEmail }}</span></p>
40 <button type="button" data-testid="account-signout" class="ew-tap ew-hover-fg text-[11px] mt-1 underline" @click="signOut">Sign out</button>
41 </template>
42 <SignInForm v-else-if="gameStore.accountAnonymous"
43 hint="Save your runs to an account — keep them across devices, and unlock buying." />
44 <p v-else-if="gameStore.deviceRef" class="ew-faint text-[10px]">
45 Anonymous play · support id <span class="ew-mono">{{ gameStore.deviceRef }}</span>
46 </p>
47 </div>
48 
49 <!-- Your saved runs — every finished run, kept and revisitable. Shown to
50 anonymous players too: they own their runs from turn one. -->
51 <div class="mt-3 pt-2.5 border-t ew-line">
52 <NuxtLink to="/runs" data-testid="account-past-runs" class="ew-tap ew-hover-fg text-[11px] underline" @click="close">
53 <span aria-hidden="true"></span> Your past runs
54 </NuxtLink>
55 </div>
56 
57 <!-- Replay the guided first run on demand. Only on a live board, where the
58 tour has controls to point at — skipping it disables it for good
59 (completion is remembered); this brings it back. -->
60 <div v-if="canReplayTour" class="mt-3 pt-2.5 border-t ew-line">
61 <button type="button" data-testid="account-replay-tour" class="ew-tap ew-hover-fg text-[11px] underline"
62 @click="replayTour"><span aria-hidden="true"></span> Replay the guided tour</button>
63 </div>
64 </div>
65 </Transition>
66 </div>
67</template>
68 
69<script setup lang="ts">
70/**
71 * RunsBalance — the persistent header gauge for runs remaining, and the entry to a
72 * lightweight account popover (runs left, a one-line explainer, "get more runs",
73 * and a short support id). Play is anonymous (device-keyed) until real accounts
74 * exist, so "account" is deliberately minimal. Closes on outside-click / Escape.
75 */
76import { ref, computed, onMounted, onUnmounted } from 'vue'
77import { useGameStore } from '~/stores/game'
78import { useCoachingStore } from '~/stores/coaching'
79import { resetToAnonymous } from '~/utils/sign-out'
80 
81const gameStore = useGameStore()
82const coaching = useCoachingStore()
83const open = ref(false)
84const rootRef = ref<HTMLElement | null>(null)
85 
86const runs = computed(() => gameStore.runsRemaining ?? 0)
87// A dash until the balance has loaded, so the gauge never flashes a misleading 0.
88const display = computed(() => (gameStore.runsRemaining == null ? '–' : String(runs.value)))
89const aria = computed(() =>
90 gameStore.runsRemaining == null ? 'Loading your runs' : `${runs.value} ${runs.value === 1 ? 'run' : 'runs'} remaining; open account`
92 
93function toggle() { open.value = !open.value }
94function close() { open.value = false }
95 
96function getRuns() {
97 close()
98 void gameStore.openBuyModal()
100 
101// The tour anchors to live board controls, so replay is offered only during a
102// playing run (on the briefing screen there's nothing to point at).
103const canReplayTour = computed(() => !!gameStore.currentObjective && gameStore.gameStatus === 'playing')
104function replayTour() {
105 close()
106 coaching.replay()
108 
109const supabase = useSupabaseClient()
110async function signOut() {
111 // Reset to anonymous resiliently (no failure-prone global revoke that could
112 // abort the whole sign-out), then reload the balance. The popover stays open so
113 // it re-renders to the signed-out state — visible feedback that the click landed.
114 await resetToAnonymous({
115 signOut: (options) => supabase.auth.signOut(options),
116 signInAnonymously: () => supabase.auth.signInAnonymously()
117 })
118 await gameStore.loadBalance()
120 
121function onDocClick(e: MouseEvent) {
122 if (open.value && rootRef.value && !rootRef.value.contains(e.target as Node)) close()
124function onKeydown(e: KeyboardEvent) { if (e.key === 'Escape') close() }
125 
126onMounted(() => {
127 document.addEventListener('click', onDocClick)
128 document.addEventListener('keydown', onKeydown)
129})
130onUnmounted(() => {
131 document.removeEventListener('click', onDocClick)
132 document.removeEventListener('keydown', onKeydown)
133})
134</script>
135 
136<style scoped>
137.ew-pop-enter-active,
138.ew-pop-leave-active { transition: opacity var(--ew-dur-1) var(--ew-ease); }
139.ew-pop-enter-from,
140.ew-pop-leave-to { opacity: 0; }
141@media (prefers-reduced-motion: reduce) {
142 .ew-pop-enter-active,
143 .ew-pop-leave-active { transition: none; }
145</style>

How it was verified

npx vitest run is green and npx nuxt typecheck is clean. The tests cover the acceptance criteria directly, at the layer each behavior lives:

- Attribution + credit, idempotency, self-referral, capclaimReferral driven end to end over the real in-memory stores, with the Supabase session and device id mocked. - The ledger guardscreditReferral at the store level: idempotent per subject and per device, the cap, and the "add onto existing balance" rule. - The throttle — the pure digest decision, including the threshold, the window, and the window=0 edge. - The middleware — stamps for a valid token, and never throws on malformed percent-encoding.

An independent adversarial review (four lenses: correctness, anti-abuse, the SSR attribution flow, test gaps) ran against the diff; its three confirmed findings — the middleware decode crash, the reward=0 foot-gun, and the window=0 test gap — are all fixed in this PR.