What changed, and why

The reveal equation showed which levers bent a turn's swing but not by how much. It named the anachronism wager (⚑) and the causal chain (⏳), yet the two biggest scalers β€” craft and momentum β€” were off-screen entirely. A vague-graded Success quietly shrank to about 40% of the swing it earned (Γ—0.4) with nothing on screen to account for it, so "+22 β†’ +9%" read as random and a sharp-looking move could pay almost nothing for no visible reason (issue #184).

This change writes the numeric multiplier onto each scaler token, so the printed equation now reconciles: a reader multiplies the base by the shown factors and lands on the final. The bug case reads "+22 βœ’ vague Γ—0.4 β†’ +9%" β€” the 60% cut is right there. Three small moves carry it: a factor on each scaler in the one shared token builder; momentum plumbed the last mile so the ledger and codex leaf can show it too; and the ledger detail strip folded onto that same builder, retiring its own parallel badges. The blast radius is the reveal surfaces only β€” no scoring math moves.

The single source: a factor on each scaler token

equationTokens is the one place every surface β€” the thread, the codex reveal leaf, the ledger β€” turns a turn's record into the labeled glyphs between base and final. The change appends the multiplier to the three SCALER tokens. Craft reads its two-sided factor (the gain table on a win, the inverted loss table on a miss, so a masterful loss shows the Γ—0.6 shield). Momentum shows the factor its level applies via momentumFactor, not the bare level β€” that's the number the equation multiplies by, and the level moves to the hover hint. Chain shows its exact carried decay factor. A short formatFactor trims each to at most two decimals so "Γ—0.4" and "Γ—1.45" read clean.

Each scaler appends Γ—factor; formatFactor trims it. Anachronism is untouched.

utils/swing-equation.ts Β· 95 lines
utils/swing-equation.ts95 lines Β· TypeScript
β‹― 51 lines hidden (lines 1–51)
1/**
2 * The swing equation's amplifier tokens β€” the labeled glyphs that disclose WHY a
3 * turn's base swing became its final one: anachronism widened it (⚑), the causal
4 * chain decayed it (⏳), craft scaled it (βœ’), momentum lifted it (✦), the stake
5 * doubled it (βš‘). Named ONCE here so every surface that prints the base β†’ final
6 * equation renders the SAME breakdown from the SAME source β€” the thread
7 * (MessageHistory), the codex reveal leaf (LeafRipple), and the ledger detail
8 * (TimelineLedger) β€” so a lever can never explain a swing in one place and vanish
9 * in another (the gap behind "+22 β†’ +9%, how?").
10 *
11 * The SCALER levers carry their numeric multiplier so the equation reconciles to
12 * the result (issue #184): `βœ’ vague Γ—0.4`, `✦ momentum Γ—1.3`, `⏳ diffuse Γ—0.41`.
13 * Without it a craft- or chain-shrunk swing showed a grade word but no magnitude,
14 * so "+22 β†’ +9%" still read as random. Anachronism stays the lone QUALITATIVE
15 * token β€” its ⚑-pip count already encodes the tier and its factor is "qualitative
16 * on purpose" (server/utils/anachronism.ts), the dramatic wager kept off the math.
17 */
18import { ANACHRONISM_HINT, ANACHRONISM_LABEL, type Anachronism } from '~/server/utils/anachronism'
19import { CHAIN_HINT, CHAIN_LABEL, type ChainStatus } from '~/utils/causal-chain'
20import { CRAFT_GAIN_FACTOR, CRAFT_LABEL, CRAFT_LOSS_FACTOR, type Craft } from '~/utils/craft'
21import { MOMENTUM_MAX, momentumFactor } from '~/utils/momentum'
22import { STAKE_HINT } from '~/utils/swing-bands'
23 
24/**
25 * The fields a swing's breakdown reads β€” all optional, so `Message`, `TimelineEvent`,
26 * and the ripple payload all satisfy it structurally (each carries the same turn-level
27 * record, `momentumAtSwing` included, so the breakdown renders identically on every
28 * surface). A lever that stayed idle this turn (in-period reach, at-hinge chain, sound
29 * craft, zero momentum) simply renders no token.
30 */
31export interface SwingBreakdown {
32 progressChange?: number
33 anachronism?: Anachronism
34 causalChain?: ChainStatus
35 craft?: Craft
36 momentumAtSwing?: number
37 staked?: boolean
39 
40export interface EquationToken {
41 kind: 'anachronism' | 'chain' | 'craft' | 'momentum' | 'staked'
42 label: string
43 hint: string
45 
46/**
47 * The amplifier tokens between the base and final swing, each a labeled glyph carrying
48 * its own hover hint for what it did. Emitted in the order the server applies them
49 * (anachronism β†’ chain β†’ craft β†’ momentum β†’ stake), so the printed equation reads
50 * left-to-right as the math ran; absent levers drop their token.
51 */
52export function equationTokens(m: SwingBreakdown): EquationToken[] {
53 const tokens: EquationToken[] = []
54 if (m.anachronism && m.anachronism !== 'in-period') {
55 const pips = m.anachronism === 'impossible' ? '⚑⚑⚑' : m.anachronism === 'far-ahead' ? '⚑⚑' : '⚑'
56 tokens.push({ kind: 'anachronism', label: `${pips} ${ANACHRONISM_LABEL[m.anachronism].toLowerCase()}`, hint: ANACHRONISM_HINT[m.anachronism] })
57 }
58 if (m.causalChain && m.causalChain.tier !== 'at-hinge') {
59 tokens.push({ kind: 'chain', label: `⏳ ${CHAIN_LABEL[m.causalChain.tier].toLowerCase()} ${formatFactor(m.causalChain.factor)}`, hint: CHAIN_HINT[m.causalChain.tier] })
60 }
61 // Craft drives the magnitude on BOTH signs (issue #166): on a gain it banks more
62 // (or less) of the band the roll earned; on a loss it works INVERTED β€” good craft
63 // shields the blow, poor craft deepens it. Skip the neutral 'sound' (no tilt) and a
64 // zero swing (nothing to attribute).
65 if (m.progressChange !== undefined && m.progressChange !== 0 && m.craft && m.craft !== 'sound') {
66 const grade = CRAFT_LABEL[m.craft].toLowerCase()
67 // The factor mirrors the server's two-sided craft amplifier: a gain banks
68 // CRAFT_GAIN_FACTOR of the band, a loss is bent by CRAFT_LOSS_FACTOR. Sign of
69 // the final swing == sign at the craft step (every later factor is positive).
70 const factor = m.progressChange > 0 ? CRAFT_GAIN_FACTOR[m.craft] : CRAFT_LOSS_FACTOR[m.craft]
71 tokens.push({
72 kind: 'craft',
73 label: `βœ’ ${grade} ${formatFactor(factor)}`,
74 hint: m.progressChange > 0
75 ? `a ${grade} dispatch scales the gain it earned β€” craft drives the swing`
76 : `a ${grade} dispatch bends the loss it took β€” good craft shields it, poor craft deepens it`
77 })
78 }
79 // Momentum is the other gains-only amplifier (the arc meter): show it whenever it
80 // lifted this turn, so the printed equation reconciles to the result (issue #62).
81 // The label is the MULTIPLIER it applied (momentumFactor of the level), not the bare
82 // level β€” that's the number the equation multiplies by; the level rides in the hint.
83 if (m.progressChange !== undefined && m.progressChange > 0 && m.momentumAtSwing && m.momentumAtSwing >= 1) {
84 tokens.push({ kind: 'momentum', label: `✦ momentum ${formatFactor(momentumFactor(m.momentumAtSwing))}`, hint: `momentum ${m.momentumAtSwing} of ${MOMENTUM_MAX} β€” a coherent arc amplifies every gain` })
85 }
86 if (m.staked) tokens.push({ kind: 'staked', label: 'βš‘ staked', hint: STAKE_HINT })
87 return tokens
89 
90/** A swing multiplier as the player reads it in the equation β€” `Γ—` then the factor
91 * trimmed to at most two decimals with trailing zeros dropped (Γ—0.4, Γ—1.2, Γ—0.41),
92 * so `base Γ— factor` reads clean and reconciles to the shown final. */
93function formatFactor(factor: number): string {
94 return `Γ—${Number(factor.toFixed(2))}`

Momentum, carried the last mile to the ledger

Momentum already rode the thread's Message, which is why the thread could show it. The ledger event and the ripple payload never carried it, so the Spine and the codex leaf showed nothing. This adds momentumAtSwing to TimelineEvent and threads it through every carrier: the ripple frame that lands a resolved turn onto the ledger node, both addTimelineEvent sites, the snapshot and draft builders, and the two saved-run contracts. So live, end-screen, and reloaded runs disclose the same breakdown.

The new TimelineEvent field, and the ripple frame copying it onto the ledger payload.

stores/game.ts Β· 2354 lines
stores/game.ts2354 lines Β· TypeScript
β‹― 52 lines hidden (lines 1–52)
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 /** The PRE-turn momentum that amplified this swing β€” the Spine reads it to show
58 * the ✦ momentum factor in the reveal equation, the same as the thread. */
59 momentumAtSwing?: number
60 /** Signed year of the intervention, when the contact was grounded. */
61 whenSigned?: number
62 /** True when this was the run's staked last stand (doubled swing). */
β‹― 1482 lines hidden (lines 63–1544)
63 staked?: boolean
64 timestamp: Date
66 
67/**
68 * Message Interface β€” one line in the conversation with a figure.
69 * Dice / outcome / action / impact / progress are recorded on the AI (figure)
70 * turn, since that is the resolved result of the roll.
71 */
72export interface Message {
73 text: string
74 sender: 'user' | 'ai' | 'system'
75 timestamp: Date
76 figureName?: string
77 /** The figure's Wikipedia portrait (the picture shown at selection), snapshotted
78 * at send time so the reply leaf can show it. Absent when the figure has no
79 * Wikipedia image β€” the reply leaf then falls back to the monogram. */
80 figureThumbnail?: string
81 /** The effective (craft-tilted) roll β€” what the bands judged. */
82 diceRoll?: number
83 diceOutcome?: DiceOutcome
84 /** The die as it actually landed, before the craft modifier. */
85 naturalRoll?: number
86 /** The Judge's tilt on the roll (Β±2..0), and the grade + reason behind it. */
87 rollModifier?: number
88 craft?: Craft
89 craftReason?: string
90 /** Whether the dispatch built on the run's thread (issue #62) β€” drives the
91 * momentum meter and the 'builds' badge in the reveal. */
92 continuity?: Continuity
93 characterAction?: string
94 timelineImpact?: string
95 progressChange?: number
96 /** The pre-amplifier swing + the wager level, for the reveal's equation. */
97 baseProgressChange?: number
98 /** The PRE-turn momentum that amplified this swing β€” for the reveal's equation. */
99 momentumAtSwing?: number
100 anachronism?: Anachronism
101 /** The causal-chain decay applied to this swing (for the reveal's equation). */
102 causalChain?: ChainStatus
103 /** True when this turn was the staked last stand. */
104 staked?: boolean
106 
107export type GameStatus = 'playing' | 'victory' | 'defeat'
108 
109/** The two deferred turn writes, as the exact payloads their store actions take. */
110type AIMessagePayload = Partial<Message> & { text: string; sender: 'ai' }
111type RipplePayload = Omit<TimelineEvent, 'id' | 'timestamp' | 'valence'> & { valence?: Valence; timestamp?: Date }
112 
113/**
114 * A resolved-but-not-yet-revealed turn β€” held between `resolveTurn` (the network
115 * round-trip, the Sending leaf) and the conductor's per-leaf commits
116 * (`commitReveal` on the Dice leaf, `commitRipple` on the Ripple leaf). It decouples
117 * DATA-presence from the timing of the animation/SFX-bearing writes, so the
118 * page-by-page reveal can fire each store write at its own leaf β€” preserving the
119 * soundscape's "write == beat" lockstep (and every component's animate-on-change)
120 * for free. Holds plain payloads, never the response type: the deferred writes are
121 * exactly the ones `sendMessage` makes inline today.
122 */
123export interface PendingTurn {
124 epoch: number
125 target: string
126 sentWhen: number | null
127 ledgerBefore: number
128 aiMessage: AIMessagePayload
129 ripple: RipplePayload | null
130 progressChange: number | undefined
131 momentum: number
132 /** Precomputed at resolve (from the shared win/lose rule) so the conductor can
133 * route the Ripple leaf straight to the End screen on a terminal turn β€” the same
134 * verdict the live commit will set, so the two can never diverge. */
135 terminal: boolean
136 /** The Chronicle/suggestion re-narration, fired EARLY (at resolve, leaf II) on the
137 * animated path so it composes during the III→VI reading and is ready by VII —
138 * stashed here so commitRipple's two-beat persistence can re-save once it lands.
139 * Null on the inline (!animate) path, where commitRipple fires it against live state. */
140 refresh?: Promise<void> | null
141 /** Streaming: resolves once the WHOLE turn (reply + ripple + final) has landed in
142 * this pending turn β€” or it was blocked/errored. The conductor's commits await it,
143 * so the reveal can START on the early `dice` frame while the slow Character +
144 * Timeline calls finish; reaching a commit before its data arrived simply waits
145 * here (a graceful micro-state), never an empty leaf. */
146 ready: Promise<void>
147 /** True once the whole turn has landed (the `final` frame). The commits skip the
148 * `await ready` when this is set, so they run SYNCHRONOUSLY in the common case (and
149 * in tests / the inline path) β€” an async fn with no await runs its body in the same
150 * tick, preserving the "state present the moment the commit is called" contract. */
151 resolved: boolean
152 /** Set by a moderation block / failure mid-stream (after the early reveal): the
153 * store has already refunded + bannered, so the commits must NOT write and the
154 * conductor halts the reveal back to the dispatch. */
155 blocked: boolean
157 
158/**
159 * Shape returned by `POST /api/send-message` (see `server/api/send-message.post.ts`).
160 * Discriminated by `success`: when `true`, all fields downstream of the dice roll are
161 * guaranteed; when `false`, only `userMessage` + `error` are guaranteed (and the
162 * Character-AI-failure path doesn't even surface a dice roll). The discriminant lets
163 * the consumer destructure without optional chaining or null gymnastics.
164 */
165type SendMessageApiResponse =
166 | {
167 success: true
168 message: string
169 data: {
170 userMessage: string
171 figure: { name: string; era: string; descriptor: string }
172 diceRoll: number
173 diceOutcome: DiceOutcome
174 // Judge-of-craft fields (optional so older mocks/fixtures stay valid;
175 // the live server always sends them).
176 naturalRoll?: number
177 rollModifier?: number
178 craft?: Craft
179 craftReason?: string
180 continuity?: Continuity
181 momentum?: number
182 staked?: boolean
183 characterResponse: { message: string; action: string }
184 timeline: {
185 headline: string
186 detail: string
187 era: string
188 progressChange: number
189 baseProgressChange?: number
190 momentumAtSwing?: number
191 valence: Valence
192 anachronism?: Anachronism
193 causalChain?: ChainStatus
194 }
195 error: null
196 }
197 }
198 | {
199 success: false
200 message: string
201 data: {
202 userMessage: string
203 diceRoll?: number
204 diceOutcome?: DiceOutcome
205 characterResponse?: { message: string; action: string }
206 error: string
207 /** A content-moderation block (not an infra failure): the dispatch was
208 * refused by the detector, the Sentinel, or the model itself. */
209 blocked?: boolean
210 moderationReason?: string
211 }
212 }
213 
214const RATE_LIMIT_MS = 1000 // message cooldown window, in ms
215 
216// Turn-reveal pacing. A resolved turn releases its beats one at a time β€” a beat of
217// suspense, then the die lands and the figure's reply writes itself in, then the
218// timeline shift, then the ledger node β€” so the resolution reads as one conducted
219// moment instead of everything firing in the same tick. Each component already
220// animates when its own slice of state changes; sequencing the WRITES sequences the
221// animations, with no component coordination. Tuned so the swing lands with the
222// reply's own "ripple" line (~0.55s into its staged reveal).
223export const REVEAL = { suspense: 450, swing: 550, node: 250 } as const
224const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
225/** Stagger the reveal only in a real browser with motion allowed. SSR and the test
226 * env (no `matchMedia`) collapse to an instant, atomic apply β€” so the store's
227 * contract (state present the moment sendMessage resolves) is unchanged for tests. */
228function canAnimateReveal(): boolean {
229 return typeof window !== 'undefined'
230 && typeof window.matchMedia === 'function'
231 && !window.matchMedia('(prefers-reduced-motion: reduce)').matches
233 
234function valenceOf(progressChange: number): Valence {
235 if (progressChange > 0) return 'positive'
236 if (progressChange < 0) return 'negative'
237 return 'neutral'
239 
240/** The single win/lose rule. Shared by `resolveGameStatus` (the live flip at
241 * commitRipple) and the resolve-time `terminal` precompute, so a turn's verdict
242 * and any epilogue framing derived from it can never diverge. Mirrors the original
243 * resolveGameStatus exactly: victory at/over the cap, else defeat once the messages
244 * are spent, else still playing. */
245function deriveStatus(progress: number, remaining: number): GameStatus {
246 if (progress >= MAX_PROGRESS) return 'victory'
247 if (remaining <= 0) return 'defeat'
248 return 'playing'
250 
251/** Formats a signed timeline year (AD positive, BCE negative) for display. */
252export function formatContactYear(signed: number): string {
253 return signed < 0 ? `${-signed} BC` : `${signed}`
255 
256/** The landed ledger as {era, headline} for the suggester's altered-timeline brief
257 * (issue #131) β€” the server bounds + caps it. Drops changes without a headline. */
258function ledgerForSuggester(events: TimelineEvent[]): { era: string; headline: string }[] {
259 return events.filter(e => e.headline).map(e => ({ era: e.era, headline: e.headline }))
261 
262/** Human lifespan line for a grounded figure, e.g. "69 BC – 30 BC" or "1942 – present". */
263function lifespanText(g: GroundedFigure): string | undefined {
264 if (!g.born) return undefined
265 if (g.died) return `${g.born.display} – ${g.died.display}`
266 return g.living ? `${g.born.display} – present` : g.born.display
268 
269/** True when a $fetch error is a 402 (out of runs) β€” the paywall signal. ofetch
270 * surfaces the status as statusCode and on the response, so check both. */
271function isPaymentRequired(error: unknown): boolean {
272 const e = error as { statusCode?: number; status?: number; response?: { status?: number } }
273 return e?.statusCode === 402 || e?.status === 402 || e?.response?.status === 402
275 
276/** True when a $fetch error is a 503 / at-capacity β€” the spend cap paused new
277 * runs (distinct from the per-device 402 paywall). */
278function isAtCapacity(error: unknown): boolean {
279 const e = error as { statusCode?: number; status?: number; response?: { status?: number }; data?: { atCapacity?: boolean } }
280 return e?.statusCode === 503 || e?.status === 503 || e?.response?.status === 503 || e?.data?.atCapacity === true
282 
283export type ContactLiveness = 'ok' | 'before-birth' | 'after-death' | 'living' | 'unresolved' | 'unknown'
284 
285/**
286 * A replay seed (issue #89) β€” when a player starts a run from a shared run's captured
287 * objective. It carries the objective to seed (verbatim, no generation), the SOURCE run's
288 * public share token (rides along to /api/run-save to stamp the lineage pointer), and the
289 * source's display name at seed time (for the "remixed from" credit shown before/after the
290 * run; the public page re-derives the credit live, honoring the source's current opt-in).
291 */
292export interface ReplaySeed {
293 objective: GameObjective
294 sourceToken: string
295 sourceAttribution: string | null
297 
298/**
299 * Game Store β€” core state for a session of freeform timeline editing.
300 */
301export const useGameStore = defineStore('game', {
302 state: () => ({
303 remainingMessages: TOTAL_MESSAGES,
304 messageHistory: [] as Message[],
305 gameStatus: 'playing' as GameStatus,
306 isLoading: false,
307 error: null as string | null,
308 lastMessageTime: null as number | null,
309 isRateLimited: false,
310 // Staleness plumbing, not run state. runEpoch increments on every reset so an
311 // async result from a dead run can never write into a fresh one; the seq
312 // counters order overlapping requests of the same kind so only the latest
313 // lands (an earlier chronicle rewrite must not overwrite a later one).
314 // Deliberately NOT restored by resetGame β€” they must survive it to work.
315 runEpoch: 0,
316 chronicleSeq: 0,
317 suggestionsSeq: 0,
318 groundingSeq: 0,
319 // The current run's id β€” lazily minted on the run's first server call and
320 // cleared by resetGame so each run gets a fresh one. Tags every AI call
321 // (via the x-run-id header) so the gateway's cost telemetry groups per
322 // run: the GTM cost instrument.
323 runId: null as string | null,
324 // The in-flight begin-run, so concurrent first-calls of a run share one
325 // mint (one POST /api/run, one runs row) instead of racing two. Cleared
326 // by resetGame. (Vue leaves a Promise unproxied, so awaiting it is safe.)
327 runIdInflight: null as Promise<string> | null,
328 // True once this run's snapshot is durably saved (POST /api/run-save returned
329 // saved). Gates the end-screen Share affordance (issue #88) so it appears only for
330 // a run that actually exists server-side to share β€” never for a degraded local id.
331 // Reset per run.
332 runSnapshotSaved: false,
333 // outOfRuns gates the mission screen's paywall (set when a commit is
334 // refused with 402). The run packs offered there are loaded into `packs`.
335 outOfRuns: false,
336 // atCapacity gates the "at capacity" notice (set when a commit is refused
337 // with 503 because the global spend cap paused new runs).
338 atCapacity: false,
339 packs: [] as Pack[],
340 // The device's run balance, for the header gauge + account popover. null
341 // until first loaded (GET /api/balance); the run-commit response also
342 // refreshes it so the gauge stays live without a second round-trip.
343 runsRemaining: null as number | null,
344 freeRuns: 1,
345 deviceRef: '' as string,
346 // Account state (from /api/balance): the signed-in email, and whether the
347 // current user is anonymous (β†’ must sign in before buying). Drives the
348 // account UI + the sign-in-to-buy gate. false/null in device-fallback mode.
349 accountEmail: null as string | null,
350 accountAnonymous: false,
351 // Referral credits earned from sharing (issue #96), from /api/balance β€” the
352 // passive "+N from sharing" tally in the account popover. Pull-based, never a ping.
353 referralCredits: 0,
354 referralCount: 0,
355 // The account's generated, reroll-only username (#117), from /api/profile. null
356 // until first loaded; lazily minted server-side on that first read. Reroll is the
357 // only way to change it β€” there is no typed-name path anywhere.
358 username: null as string | null,
359 // The run-packs sales modal β€” opened on demand (header) or automatically
360 // when a commit is refused for being out of runs.
361 buyModalOpen: false,
362 // A one-shot notice after returning from Stripe Checkout ('success' shows a
363 // credited confirmation; 'cancel' a gentle "no charge" note). Cleared by the UI.
364 purchaseNotice: null as 'success' | 'cancel' | null,
365 currentObjective: null as GameObjective | null,
366 // Set when this run is a REPLAY of a shared run's objective (issue #89): seeded
367 // verbatim from the public projection, no generation. Survives chooseObjective so
368 // it can stamp the lineage pointer at save time and credit the source on the end
369 // screen; cleared by resetGame (a fresh "new timeline" carries no lineage).
370 replayState: null as ReplaySeed | null,
371 objectiveProgress: 0,
372 // The run's high-water mark (0..MAX_PROGRESS): the highest objectiveProgress ever
373 // reached this run. Tracked so the end screen can show "peaked at X%" when a run
374 // loses ground from its peak β€” classically a staked final dispatch that crit-fails
375 // to 0% β€” instead of the floored final % erasing the player's real effort (#129).
376 peakProgress: 0,
377 // The run-level momentum meter (0..MOMENTUM_MAX): a coherent arc compounds its
378 // gains amplifier; a reset or catastrophe shatters it back to 0 (issue #62).
379 momentum: 0,
380 timelineEvents: [] as TimelineEvent[],
381 figures: [] as HistoricalFigure[],
382 activeFigureName: '' as string,
383 // Grounding for the active contact: real facts + the chosen year to reach them.
384 figureGrounding: null as GroundedFigure | null,
385 groundingLoading: false,
386 contactWhen: null as number | null,
387 /** Optional sub-year refinement of contactWhen β€” display/prompt flavor
388 * only; every mechanic keeps doing arithmetic on the YEAR (issue #32). */
389 contactMoment: null as ContactMoment | null,
390 // Era-relevant figure suggestions for the current objective (the on-ramp).
391 figureSuggestions: [] as FigureSuggestion[],
392 suggestionsLoading: false,
393 suggestionsFor: '' as string,
394 // The objective title an initial load is in flight FOR (''=idle). Set the moment
395 // loadSuggestions begins so the prefetch fired at chooseObjective and the later
396 // FigurePicker onMounted call can't both hit the suggester for one objective.
397 suggestionsLoadingFor: '' as string,
398 // The living Chronicle (Layer 3): a prose telling of the altered timeline,
399 // rewritten each turn. Non-blocking β€” see refreshChronicle.
400 chronicle: null as ChronicleEntry | null,
401 chronicleLoading: false,
402 // The committed-ledger length the current `chronicle` was generated for. The
403 // Chronicle PAGE shows a loader (not the stale prior telling) until this
404 // matches the live ledger β€” under the explicit-Continue model a fast player can
405 // reach the page before the ~10s compose finishes, and a one-turn-stale telling
406 // on a focused page reads as disjointed.
407 chronicleForLedgerLen: 0,
408 // Live streaming buffer: the telling as it's being WRITTEN, delta by delta,
409 // so the Chronicle page (and the End screen) can show the prose appearing in
410 // realtime instead of a static loader. `chronicleStreamLen` is the ledger
411 // length the active stream narrates β€” a render only shows the live buffer
412 // when it matches the live ledger (never a stale prior turn's stream).
413 chronicleStream: '',
414 chronicleStreaming: false,
415 chronicleStreamLen: 0,
416 // The run's epilogue β€” the TERMINAL telling carrying the verdict β€” is being
417 // written. Set when the final turn fires its refresh and cleared when that
418 // telling lands or the refresh fails. Distinct from chronicleLoading (any
419 // refresh): it lets the end screen show "the final account…" instead of
420 // presenting the prior turn's stale, pre-ending telling as the epilogue.
421 epiloguePending: false,
422 // A turn resolved by `resolveTurn` but not yet revealed leaf-by-leaf by the
423 // conductor β€” its deferred writes wait here for commitReveal/commitRipple.
424 // Null except mid-reveal; the legacy `sendMessage` path never touches it.
425 pendingTurn: null as PendingTurn | null,
426 // The Archive (prototype): an objective-blind brief on the active figure, so
427 // the player can research who they're reaching without leaving the game. The
428 // brief is moment-specific, so it's cached against the figure AND the year.
429 figureStudy: null as FigureStudy | null,
430 studyLoading: false,
431 studyFor: '' as string,
432 studyWhen: null as number | null,
433 // Archive topic lookup (the "yellow" layer): concrete domain facts on demand.
434 archiveResult: null as ArchiveLookup | null,
435 lookupLoading: false,
436 // A content-moderation block β€” distinct from `error` (an infra hiccup) so
437 // the UI shows an honest, visibly different banner. One per surface that
438 // takes untrusted input into a model: the dispatch/turn, the Archive lookup,
439 // the Archivist study, and the figure-suggestions step.
440 moderationNotice: null as string | null,
441 archiveNotice: null as string | null,
442 studyNotice: null as string | null,
443 suggestionsNotice: null as string | null
444 }),
445 
446 getters: {
447 /**
448 * Can the player send right now? (messages left, still playing, not
449 * mid-request, not rate-limited)
450 */
451 canSendMessage(): boolean {
452 return this.remainingMessages > 0 &&
453 this.gameStatus === 'playing' &&
454 !this.isLoading &&
455 !this.isRateLimited
456 },
457 
458 /**
459 * The conversation thread with a single figure (their turns + the
460 * player's turns addressed to them). Each figure keeps a coherent,
461 * independent thread.
462 */
463 conversationWith(): (name: string) => Message[] {
464 return (name: string) => this.messageHistory.filter(
465 m => m.figureName === name && m.sender !== 'system'
466 )
467 },
468 
469 /** Total progress, net of setbacks, the player has clawed back. */
470 gameSummary(): GameSummary {
471 return generateGameSummary(this)
472 },
473 
474 /**
475 * Whether the active figure can be reached at the chosen `when`.
476 *
477 * Require grounding (#73): an UNRESOLVED name can't be reached at all β€”
478 * 'unresolved' (free-form contact is removed; the picker guides you to a real
479 * match). Deceased-only floor (#72): a RESOLVED figure with no confirmed
480 * death is living/undatable and never contactable β€” 'living', fail closed. A
481 * resolved, deceased figure is gated to their lifetime (before-birth /
482 * after-death). 'unknown' remains only for a resolved, deceased figure we
483 * can't fully place (no birth year, or no contact year chosen yet), which
484 * stays permissible.
485 */
486 contactLiveness(): ContactLiveness {
487 const g = this.figureGrounding
488 if (!g || !g.resolved) return 'unresolved'
489 if (!g.died) return 'living'
490 if (!g.born || this.contactWhen == null) return 'unknown'
491 if (this.contactWhen < g.born.signed) return 'before-birth'
492 if (this.contactWhen > g.died.signed) return 'after-death'
493 return 'ok'
494 },
495 
496 /** Can the chosen contact + when actually be reached? */
497 canContact(): boolean {
498 return this.contactLiveness === 'ok' || this.contactLiveness === 'unknown'
499 },
500 
501 /**
502 * The last stand is offered ONLY when the final dispatch can no longer win
503 * inside the normal per-turn fuse β€” from any nearer position an unstaked
504 * throw can still land it, and offering the (strictly win-probability-
505 * increasing) doubling there would be a dominance trap, not a decision.
506 */
507 canStake(): boolean {
508 return this.remainingMessages === 1 &&
509 this.gameStatus === 'playing' &&
510 (MAX_PROGRESS - this.objectiveProgress) > MAX_PROGRESS_SWING
511 },
512 
513 /**
514 * The active figure's age at the chosen `when` β€” so the player never has to
515 * do lifetime math in their head. Null when we lack a birth year or a chosen
516 * year (a resolved figure we can't date, or before a year is chosen). Corrects for the missing year zero
517 * when a life spans the BC→AD boundary (1 BC → AD 1 is one year, not two).
518 */
519 contactAge(): number | null {
520 const g = this.figureGrounding
521 if (!g?.resolved || !g.born || this.contactWhen == null) return null
522 const bornSigned = g.born.signed
523 const whenSigned = this.contactWhen
524 if (whenSigned < bornSigned) return null // before birth β€” no meaningful age
525 const crossedZero = bornSigned < 0 && whenSigned > 0 ? 1 : 0
526 return whenSigned - bornSigned - crossedZero
527 },
528 
529 /**
530 * The causal-chain read for the CURRENT contact β€” the pre-send ⏳ chip's
531 * source, mirroring what the Timeline Engine will compute. Footholds are the
532 * objective's anchor year plus every dated change already on the ledger; the
533 * gap to the nearest one decays the swing. Null (no chip) for ungrounded
534 * contacts or an anchorless objective with no dated changes yet.
535 */
536 causalChainRead(): ChainStatus | null {
537 const footholds = [
538 this.currentObjective?.anchorYear,
539 ...this.timelineEvents.map(e => e.whenSigned)
540 ]
541 return chainStatus(this.contactWhen, footholds)
542 }
543 },
544 
545 actions: {
546 // ---------- message helpers ----------
547 addUserMessage(text: string, figureName?: string) {
548 if (this.gameStatus !== 'playing') return
549 this.messageHistory.push({
550 text,
551 sender: 'user',
552 timestamp: new Date(),
553 figureName
554 })
555 },
556 
557 addAIMessage(text: string, figureName?: string) {
558 this.messageHistory.push({
559 text,
560 sender: 'ai',
561 timestamp: new Date(),
562 figureName
563 })
564 },
565 
566 addAIMessageWithData(messageData: Partial<Message> & { text: string, sender: 'ai' }) {
567 this.messageHistory.push({
568 text: messageData.text,
569 sender: messageData.sender,
570 timestamp: messageData.timestamp || new Date(),
571 figureName: messageData.figureName,
572 figureThumbnail: messageData.figureThumbnail,
573 diceRoll: messageData.diceRoll,
574 diceOutcome: messageData.diceOutcome,
575 naturalRoll: messageData.naturalRoll,
576 rollModifier: messageData.rollModifier,
577 craft: messageData.craft,
578 craftReason: messageData.craftReason,
579 continuity: messageData.continuity,
580 characterAction: messageData.characterAction,
581 timelineImpact: messageData.timelineImpact,
582 progressChange: messageData.progressChange,
583 baseProgressChange: messageData.baseProgressChange,
584 momentumAtSwing: messageData.momentumAtSwing,
585 anachronism: messageData.anachronism,
586 causalChain: messageData.causalChain,
587 staked: messageData.staked
588 })
589 },
590 
591 // ---------- figures ----------
592 registerFigure(figure: HistoricalFigure) {
593 const existing = this.figures.find(f => f.name === figure.name)
594 if (existing) {
595 if (figure.era) existing.era = figure.era
596 if (figure.descriptor) existing.descriptor = figure.descriptor
597 } else {
598 this.figures.push({ ...figure })
599 }
600 },
601 
602 setActiveFigure(name: string) {
603 this.activeFigureName = name
604 },
605 
606 /**
607 * Synchronously clears the dossier the moment the contact NAME changes, so
608 * a send racing the grounding debounce/fetch is gated as ungrounded (#73)
609 * instead of wearing the PREVIOUS figure's facts, year, and liveness gate β€”
610 * and so the wrong year can never be written into the ledger's whenSigned.
611 */
612 clearGrounding() {
613 this.groundingSeq++ // invalidate any lookup still in flight
614 this.figureGrounding = null
615 this.contactWhen = null
616 this.contactMoment = null
617 this.groundingLoading = false
618 this.figureStudy = null
619 this.studyFor = ''
620 this.studyWhen = null
621 this.studyNotice = null
622 },
623 
624 /**
625 * Resolves the active figure against the grounding service and defaults the
626 * "when" to a point inside any known lifetime. Never throws: an unresolved
627 * figure simply clears grounding; an ungrounded contact is then blocked (#73).
628 */
629 async groundActiveFigure(name: string): Promise<void> {
630 const target = (name || '').trim()
631 // A new contact makes any prior Archive study stale.
632 this.figureStudy = null
633 this.studyFor = ''
634 this.studyWhen = null
635 this.studyNotice = null
636 if (!target) {
637 this.groundingSeq++ // invalidate any lookup still in flight
638 this.figureGrounding = null
639 this.contactWhen = null
640 this.contactMoment = null
641 this.groundingLoading = false
642 return
643 }
644 // Out-of-order guard: only the LATEST lookup may land. Without it, a slow
645 // response for the previous name attaches the wrong dossier (and a wrong
646 // figureContext on a fast send) to whatever the player typed next.
647 const epoch = this.runEpoch
648 const seq = ++this.groundingSeq
649 this.groundingLoading = true
650 try {
651 const grounded = await $fetch('/api/figure', { params: { name: target }, headers: await this.aiHeaders() }) as GroundedFigure
652 if (epoch !== this.runEpoch || seq !== this.groundingSeq) return
653 this.figureGrounding = grounded
654 this.contactMoment = null
655 if (grounded?.resolved && grounded.born) {
656 const latest = grounded.died ? grounded.died.signed : new Date().getFullYear()
657 this.contactWhen = Math.round((grounded.born.signed + latest) / 2)
658 } else {
659 this.contactWhen = null
660 }
661 } catch (error) {
662 if (epoch !== this.runEpoch || seq !== this.groundingSeq) return
663 console.error('Figure grounding failed:', error)
664 this.figureGrounding = null
665 this.contactWhen = null
666 this.contactMoment = null
667 } finally {
668 if (epoch === this.runEpoch && seq === this.groundingSeq) this.groundingLoading = false
669 }
670 },
671 
672 /** The current run's id, minted server-side via POST /api/run on first
673 * need. The run is a server fact (a persisted, charged row); the id then
674 * tags every AI call so cost telemetry groups per run, and the gameplay
675 * gate verifies it. Fails CLOSED: if begin-run can't return an id this
676 * REJECTS (no local fallback) β€” a run we can't back server-side must not
677 * start, or the paywall gate would reject it mid-run anyway. */
678 async ensureRunId(): Promise<string> {
679 if (this.runId) return this.runId
680 // Collapse concurrent first-calls onto ONE begin-run (two overlapping
681 // calls would otherwise mint two rows). The epoch guards a resetGame
682 // mid-flight β€” a dead run's id must not become the fresh run's.
683 if (!this.runIdInflight) {
684 const epoch = this.runEpoch
685 this.runIdInflight = (async () => {
686 const res = await $fetch('/api/run', { method: 'POST' }) as { runId?: string }
687 if (!res?.runId) throw new Error('begin-run returned no run id')
688 if (epoch === this.runEpoch) {
689 this.runId = res.runId
690 this.runIdInflight = null
691 }
692 return res.runId
693 })().catch((err) => {
694 if (epoch === this.runEpoch) this.runIdInflight = null
695 throw err
696 })
697 }
698 return this.runIdInflight
699 },
700 
701 /** Headers that tag a server call with the current run (the cost
702 * instrument), minting the run id first if needed. */
703 async aiHeaders(base: Record<string, string> = {}): Promise<Record<string, string>> {
704 return { ...base, 'x-run-id': await this.ensureRunId() }
705 },
706 
707 setContactWhen(year: number | null) {
708 // Scrubbing the year keeps any pinned month/day β€” the pin refines
709 // whichever year is chosen, it doesn't belong to one.
710 this.contactWhen = year
711 },
712 
713 setContactMoment(moment: ContactMoment | null) {
714 this.contactMoment = moment
715 },
716 
717 /**
718 * Studies the active (grounded) figure via the Archivist β€” an objective-blind
719 * brief on who they are AT THE CHOSEN MOMENT, so the player can research in the
720 * game instead of a browser tab. The brief is moment-specific, so it's cached
721 * against the figure AND the year: move the contact slider and the player can
722 * re-study them at the new moment. Graceful on failure (leaves the prior brief).
723 * Only resolved figures can be studied β€” an unresolved name has no record.
724 */
725 async studyActiveFigure(): Promise<void> {
726 const g = this.figureGrounding
727 if (!g?.resolved) {
728 this.figureStudy = null
729 return
730 }
731 // Cache hit only when both the figure AND the studied year still match.
732 if (this.studyFor === g.name && this.studyWhen === this.contactWhen && this.figureStudy) return
733 
734 // Capture the moment being studied NOW: the brief describes this figure at
735 // this year. If the slider moves mid-flight, recording the live value would
736 // mislabel the brief and silently suppress the Re-study button.
737 const epoch = this.runEpoch
738 const studiedName = g.name
739 const studiedWhen = this.contactWhen
740 this.studyLoading = true
741 this.studyNotice = null
742 try {
743 const res = await $fetch('/api/research', {
744 method: 'POST',
745 headers: await this.aiHeaders({ 'Content-Type': 'application/json' }),
746 body: {
747 figureName: studiedName,
748 when: studiedWhen != null ? formatContactYear(studiedWhen) : undefined,
749 description: g.description,
750 extract: g.extract
751 }
752 }) as { success?: boolean; study?: FigureStudy; blocked?: boolean; error?: string }
753 if (epoch !== this.runEpoch) return
754 // If the contact changed mid-flight, the stale-study clear in
755 // groundActiveFigure already ran β€” don't resurrect the old brief.
756 if (res?.blocked && this.figureGrounding?.name === studiedName) {
757 this.studyNotice = res.error || 'That study was blocked by content moderation.'
758 } else if (res?.success && res.study && this.figureGrounding?.name === studiedName) {
759 this.figureStudy = res.study
760 this.studyFor = studiedName
761 this.studyWhen = studiedWhen
762 }
763 } catch (error) {
764 if (epoch !== this.runEpoch) return
765 console.error('Failed to study the figure:', error)
766 } finally {
767 if (epoch === this.runEpoch) this.studyLoading = false
768 }
769 },
770 
771 /**
772 * Asks the Archive about a freeform topic (the "yellow" layer) β€” concrete
773 * domain facts: what it is, what it takes, and when it first became known
774 * (the player's anachronism read). Graceful on failure. The result stays put
775 * across figure changes while you compose one dispatch, then clears the moment
776 * that dispatch is sent (`clearArchive`) β€” each turn starts from fresh research.
777 */
778 async askArchive(query: string): Promise<void> {
779 const q = (query || '').trim()
780 if (!q) return
781 const epoch = this.runEpoch
782 this.lookupLoading = true
783 this.archiveNotice = null
784 try {
785 const res = await $fetch('/api/lookup', {
786 method: 'POST',
787 headers: await this.aiHeaders({ 'Content-Type': 'application/json' }),
788 body: { query: q }
789 }) as { success?: boolean; lookup?: ArchiveLookup; blocked?: boolean; error?: string }
790 if (epoch !== this.runEpoch) return
791 if (res?.blocked) {
792 // The Archive is the sharpest elicitation vector β€” a block here is
793 // honest and visible, not a silent empty result.
794 this.archiveNotice = res.error || 'That lookup was blocked by content moderation.'
795 } else if (res?.success && res.lookup) {
796 this.archiveResult = res.lookup
797 }
798 } catch (error) {
799 if (epoch !== this.runEpoch) return
800 console.error('Archive lookup failed:', error)
801 } finally {
802 if (epoch === this.runEpoch) this.lookupLoading = false
803 }
804 },
805 
806 /**
807 * Loads era-relevant figure suggestions for the current objective (the
808 * educational on-ramp). Cached per objective; on any failure it leaves the
809 * list empty so the UI falls back to its generic starters.
810 */
811 async loadSuggestions(): Promise<void> {
812 const objective = this.currentObjective
813 if (!objective) {
814 this.figureSuggestions = []
815 this.suggestionsFor = ''
816 return
817 }
818 if (this.suggestionsFor === objective.title && this.figureSuggestions.length) return
819 // Prefetch already running for this objective (chooseObjective started it;
820 // FigurePicker's onMounted is calling again) β€” let the one in flight finish.
821 if (this.suggestionsLoadingFor === objective.title) return
822 
823 const epoch = this.runEpoch
824 const seq = ++this.suggestionsSeq
825 this.suggestionsLoadingFor = objective.title
826 this.suggestionsLoading = true
827 this.suggestionsNotice = null
828 try {
829 const res = await $fetch('/api/suggestions', {
830 method: 'POST',
831 headers: await this.aiHeaders({ 'Content-Type': 'application/json' }),
832 body: {
833 objective: {
834 title: objective.title,
835 description: objective.description,
836 era: objective.era
837 },
838 // Usually empty here (the initial load fires at run start), but a
839 // mid-run remount carries the ledger so even the first paint is
840 // timeline-aware. refreshSuggestions re-sends it after each change.
841 ledger: ledgerForSuggester(this.timelineEvents)
842 }
843 }) as { success?: boolean; suggestions?: FigureSuggestion[]; blocked?: boolean; reason?: string }
844 if (epoch !== this.runEpoch || seq !== this.suggestionsSeq) return
845 // A blocked objective must not masquerade as an empty result β€” surface it.
846 if (res?.blocked) this.suggestionsNotice = res.reason || 'That objective was blocked by content moderation.'
847 this.figureSuggestions = res?.suggestions ?? []
848 this.suggestionsFor = objective.title
849 } catch (error) {
850 if (epoch !== this.runEpoch || seq !== this.suggestionsSeq) return
851 console.error('Failed to load figure suggestions:', error)
852 this.figureSuggestions = []
853 // Record that this objective WAS asked, even though it failed β€” the
854 // picker uses suggestionsFor to tell "not yet asked" (skeletons)
855 // from "asked and came up empty" (the honest famous-names fallback).
856 this.suggestionsFor = objective.title
857 } finally {
858 if (epoch === this.runEpoch && seq === this.suggestionsSeq) this.suggestionsLoading = false
859 // Always release the in-flight marker for this load, even if a refresh
860 // superseded it β€” otherwise a future load for this objective is blocked.
861 if (this.suggestionsLoadingFor === objective.title) this.suggestionsLoadingFor = ''
862 }
863 },
864 
865 /**
866 * Re-narrate the figure-suggestion blurbs against the altered timeline (issue
867 * #131). The suggester is re-run with the landed ledger so a blurb stops
868 * asserting a fate the player has already overturned. Fired non-blocking after
869 * a landed change, like refreshChronicle β€” the turn reveal waits on neither.
870 * A failed, blocked, or curated-fallback refresh KEEPS the prior list: a
871 * populated panel never blanks, and a model outage never regresses good
872 * era-specific picks to the generic fallback.
873 *
874 * loadSuggestions and refreshSuggestions share `suggestionsSeq` so the newest
875 * call of EITHER wins. Whichever holds the latest seq therefore owns settling
876 * `suggestionsLoading` + `suggestionsFor` (in the finally), so a refresh fired
877 * while the initial load is still in flight can never strand the skeletons.
878 */
879 async refreshSuggestions(ledgerOverride?: TimelineEvent[]): Promise<void> {
880 const objective = this.currentObjective
881 if (!objective) return
882 const epoch = this.runEpoch
883 const seq = ++this.suggestionsSeq
884 try {
885 const res = await $fetch('/api/suggestions', {
886 method: 'POST',
887 headers: await this.aiHeaders({ 'Content-Type': 'application/json' }),
888 body: {
889 objective: {
890 title: objective.title,
891 description: objective.description,
892 era: objective.era
893 },
894 ledger: ledgerForSuggester(ledgerOverride ?? this.timelineEvents)
895 }
896 }) as { success?: boolean; suggestions?: FigureSuggestion[]; fallback?: boolean; blocked?: boolean }
897 if (epoch !== this.runEpoch || seq !== this.suggestionsSeq) return
898 // Only a genuine, model-built list may overwrite the panel β€” a curated
899 // fallback (model unavailable) or an empty/blocked result keeps the
900 // better existing suggestions rather than regressing to generic names.
901 if (res?.success && res.suggestions?.length && !res.fallback) {
902 this.figureSuggestions = res.suggestions
903 }
904 this.suggestionsFor = objective.title
905 } catch (error) {
906 if (epoch !== this.runEpoch || seq !== this.suggestionsSeq) return
907 console.error('Failed to refresh figure suggestions:', error)
908 // Keep the prior list β€” a failed refresh never blanks the panel.
909 } finally {
910 // This refresh bumped the shared seq; if it's still the latest, it owns
911 // the resolution β€” clear a loading flag an invalidated in-flight load
912 // can no longer clear itself (the stuck-skeletons race).
913 if (epoch === this.runEpoch && seq === this.suggestionsSeq) this.suggestionsLoading = false
914 }
915 },
916 
917 // ---------- timeline ledger ----------
918 addTimelineEvent(
919 evt: Omit<TimelineEvent, 'id' | 'timestamp' | 'valence'> & { valence?: Valence; timestamp?: Date }
920 ) {
921 this.timelineEvents.push({
922 id: `evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
923 figureName: evt.figureName,
924 era: evt.era,
925 headline: evt.headline,
926 detail: evt.detail,
927 diceRoll: evt.diceRoll,
928 diceOutcome: evt.diceOutcome,
929 progressChange: evt.progressChange,
930 baseProgressChange: evt.baseProgressChange,
931 valence: evt.valence ?? valenceOf(evt.progressChange),
932 anachronism: evt.anachronism,
933 causalChain: evt.causalChain,
934 craft: evt.craft,
935 momentumAtSwing: evt.momentumAtSwing,
936 whenSigned: evt.whenSigned,
937 staked: evt.staked,
938 timestamp: evt.timestamp ?? new Date()
939 })
940 },
941 
942 // ---------- the living chronicle (Layer 3) ----------
943 /**
944 * Re-narrates the Chronicle from the freshly-bent timeline. Fired
945 * non-blocking after a resolved turn (and at game end): the dice/progress
946 * reveal never waits on prose. The WHOLE account is rewritten each turn β€”
947 * a later change can re-frame how earlier events read.
948 * Graceful: a failed refresh keeps the prior telling, so the panel never
949 * blanks; an empty timeline clears it (nothing to chronicle yet).
950 */
951 async refreshChronicle(override?: { ledger: TimelineEvent[]; progress: number; status: GameStatus }): Promise<void> {
952 // The conductor can fire this at resolve with the resolved-but-not-yet-committed
953 // snapshot (ledger/progress/status) so the telling narrates the very turn it
954 // is about; the legacy tail passes nothing and reads the live, just-committed
955 // state. Byte-identical when no override is given.
956 const ledger = override?.ledger ?? this.timelineEvents
957 const progress = override?.progress ?? this.objectiveProgress
958 const status = override?.status ?? this.gameStatus
959 if (!ledger.length) {
960 this.chronicle = null
961 this.chronicleForLedgerLen = 0
962 this.chronicleStream = ''
963 this.chronicleStreaming = false
964 return
965 }
966 // Sequencing: rewrites overlap (each turn fires one, never awaited), so
967 // only the LATEST issued refresh may land β€” an earlier telling arriving
968 // late must not regress the account (or worse, the epilogue). The epoch
969 // guard keeps a dead run's telling out of a fresh run entirely.
970 const epoch = this.runEpoch
971 const seq = ++this.chronicleSeq
972 // True only while THIS issued refresh is still the newest (and same run).
973 const current = () => epoch === this.runEpoch && seq === this.chronicleSeq
974 this.chronicleLoading = true
975 try {
976 const res = await $fetch('/api/chronicle', {
977 method: 'POST',
978 headers: await this.aiHeaders({ 'Content-Type': 'application/json' }),
979 // The real endpoint streams the prose as SSE; ask for the raw body
980 // so we can render it being written. A non-streaming mock ignores
981 // this and returns a plain { success, chronicle } object β€” handled
982 // below β€” so tests and the e2e suite need no streaming machinery.
983 responseType: 'stream',
984 body: {
985 objective: this.currentObjective,
986 status,
987 progress,
988 timeline: ledger.map(e => ({
989 era: e.era,
990 figureName: e.figureName,
991 headline: e.headline,
992 detail: e.detail,
993 progressChange: e.progressChange
994 }))
995 }
996 }) as unknown
997 
998 const isStream = !!res && typeof (res as ReadableStream<Uint8Array>).getReader === 'function'
999 if (isStream) {
1000 // SSE path: accumulate prose deltas into the live buffer (so the
1001 // page shows it being written), then settle on the terminal frame.
1002 if (!current()) { try { await (res as ReadableStream).cancel() } catch { /* already gone */ } return }
1003 this.chronicleStream = ''
1004 this.chronicleStreaming = true
1005 this.chronicleStreamLen = ledger.length
1006 const reader = (res as ReadableStream<Uint8Array>).getReader()
1007 const decoder = new TextDecoder()
1008 let buf = ''
1009 let final: ChronicleEntry | null = null
1010 while (true) {
1011 const { done, value } = await reader.read()
1012 if (done) break
1013 // A newer refresh superseded this one β€” stop, drop, free the
1014 // connection. The newest stream owns the buffer now.
1015 if (!current()) { try { await reader.cancel() } catch { /* noop */ } return }
1016 buf += decoder.decode(value, { stream: true })
1017 let nl: number
1018 while ((nl = buf.indexOf('\n\n')) >= 0) {
1019 const frame = buf.slice(0, nl); buf = buf.slice(nl + 2)
1020 const dataLine = frame.split('\n').map(l => l.trim()).find(l => l.startsWith('data:'))
1021 if (!dataLine) continue
1022 let msg: { t?: string; x?: string; chronicle?: ChronicleEntry } | null = null
1023 try { msg = JSON.parse(dataLine.slice(5).trim()) } catch { continue }
1024 if (msg?.t === 'd' && typeof msg.x === 'string') {
1025 if (current() && this.chronicleStreamLen === ledger.length) this.chronicleStream += msg.x
1026 } else if (msg?.t === 'final' && msg.chronicle) {
1027 final = msg.chronicle
1028 } // t:'error' β†’ leave final null; the prior telling is kept
1031 if (!current()) return
1032 if (!final) {
1033 // No SSE `final` frame β€” tolerate a plain { success, chronicle }
1034 // body that arrived as a stream (a non-streaming mock or proxy:
1035 // responseType:'stream' returns the raw body for ANY response).
1036 // The leftover buffer holds the whole un-framed body.
1037 try {
1038 const j = JSON.parse(buf.trim()) as { success?: boolean; chronicle?: ChronicleEntry }
1039 if (j?.chronicle && Array.isArray(j.chronicle.paragraphs)) final = j.chronicle
1040 } catch { /* not JSON either β€” keep the prior telling */ }
1042 if (final && Array.isArray(final.paragraphs) && final.paragraphs.length) {
1043 this.chronicle = final
1044 this.chronicleForLedgerLen = ledger.length
1046 } else {
1047 // Plain JSON path (the test/e2e mock, or any non-streamed response).
1048 const json = res as { success?: boolean; chronicle?: ChronicleEntry }
1049 if (!current()) return
1050 if (json?.success && json.chronicle) {
1051 this.chronicle = json.chronicle
1052 // Stamp the ledger length this telling narrates, so the Chronicle
1053 // page can tell a current telling from a stale prior one.
1054 this.chronicleForLedgerLen = ledger.length
1057 } catch (error) {
1058 if (!current()) return
1059 console.error('Failed to refresh the chronicle:', error)
1060 // Keep the prior chronicle β€” a failed refresh never blanks the panel.
1061 } finally {
1062 if (current()) {
1063 this.chronicleLoading = false
1064 this.chronicleStreaming = false
1067 },
1069 // ---------- simple setters ----------
1070 setLoading(loading: boolean) { this.isLoading = loading },
1071 setError(error: string | null) { this.error = error },
1072 clearModerationNotice() { this.moderationNotice = null },
1073 clearArchiveNotice() { this.archiveNotice = null },
1074 // Reset the Archive lookup panel β€” both the last result and any block banner.
1075 // Fired on each dispatch so a new turn starts from fresh research, not the
1076 // previous turn's stale lookup.
1077 clearArchive() { this.archiveResult = null; this.archiveNotice = null },
1079 // ---------- rate limiting ----------
1080 checkRateLimit(): boolean {
1081 const now = Date.now()
1082 if (this.lastMessageTime && (now - this.lastMessageTime) < RATE_LIMIT_MS) return false
1083 return true
1084 },
1086 setRateLimit() {
1087 this.isRateLimited = true
1088 const remainingTime = this.lastMessageTime
1089 ? Math.max(0, RATE_LIMIT_MS - (Date.now() - this.lastMessageTime))
1090 : 0
1091 setTimeout(() => { this.isRateLimited = false }, remainingTime || RATE_LIMIT_MS)
1092 },
1094 // ---------- counter & status ----------
1095 decrementMessages() {
1096 if (this.remainingMessages > 0) this.remainingMessages--
1097 },
1099 /**
1100 * Rolls back an unresolved turn: refunds the spent message and drops the
1101 * dangling user entry, so the counter and history can't drift out of sync.
1102 * Used for BOTH failure shapes β€” a thrown request and a graceful HTTP-200
1103 * `success:false` β€” an infra hiccup must never burn one of the five
1104 * dispatches (or, on the last one, convert into an instant unearned defeat).
1105 */
1106 refundUnresolvedTurn() {
1107 if (this.remainingMessages < TOTAL_MESSAGES) this.remainingMessages++
1108 for (let i = this.messageHistory.length - 1; i >= 0; i--) {
1109 if (this.messageHistory[i].sender === 'user') {
1110 this.messageHistory.splice(i, 1)
1111 break
1114 },
1116 applyProgress(progressChange: number) {
1117 this.objectiveProgress = Math.max(0, Math.min(MAX_PROGRESS, this.objectiveProgress + progressChange))
1118 this.peakProgress = Math.max(this.peakProgress, this.objectiveProgress)
1119 },
1121 /**
1122 * Resolves win/lose AFTER a turn's progress has been applied.
1124 * Victory the instant the objective hits 100%; defeat only once the
1125 * final message is spent without reaching it. This fixes the old
1126 * premature-defeat bug, where status flipped on the last decrement
1127 * BEFORE that turn's progress had a chance to land.
1128 */
1129 resolveGameStatus() {
1130 // The single win/lose rule lives in `deriveStatus` (shared with the
1131 // resolve-time precompute). Only ever flips TO a terminal status β€”
1132 // never back to playing β€” so an already-resolved run stays resolved.
1133 const status = deriveStatus(this.objectiveProgress, this.remainingMessages)
1134 if (status !== 'playing') this.gameStatus = status
1135 },
1137 /** The player-facing reason a contact is currently blocked (unresolved /
1138 * before-birth / living / after-death) β€” pure derivation, lifted out of
1139 * sendMessage's guard so the action reads as flow, not message copy. */
1140 contactBlockedMessage(target: string): string {
1141 const who = this.figureGrounding?.name || target
1142 return this.contactLiveness === 'unresolved'
1143 ? (this.figureGrounding?.transient
1144 ? `Couldn't reach the record to verify "${target}" β€” try again in a moment.`
1145 : `No historical record found for "${target}" β€” reach for a real figure (pick a match as you type).`)
1146 : this.contactLiveness === 'before-birth'
1147 ? `${who} isn't born yet in that year β€” choose a later moment to reach them.`
1148 : this.contactLiveness === 'living'
1149 ? (this.figureGrounding?.born
1150 ? `${who} is still living β€” Everwhen only reaches figures from history.`
1151 : `${who} couldn't be dated β€” Everwhen can only reach figures it can place in history.`)
1152 : `${who} has died by that year β€” choose an earlier moment to reach them.`
1153 },
1155 /** Shape the /api/send-message request body β€” pure field assembly, lifted out
1156 * of sendMessage so the action's flow isn't buried under plumbing. `sentWhen`/
1157 * `sentMoment` are captured at send time (the slider can move mid-request). */
1158 buildSendBody(text: string, target: string, sentWhen: number | null, sentMoment: ContactMoment | null, stake: boolean) {
1159 return {
1160 message: text,
1161 figureName: target,
1162 when: sentWhen != null ? formatContactMoment(sentWhen, sentMoment) : undefined,
1163 whenSigned: sentWhen ?? undefined,
1164 // The pinned moment travels as validated integers; the server
1165 // re-derives the display string rather than trusting ours.
1166 whenMonth: sentWhen != null ? sentMoment?.month : undefined,
1167 whenDay: sentWhen != null ? sentMoment?.day : undefined,
1168 stake,
1169 // The pre-turn momentum the server amplifies the swing by, and
1170 // returns advanced (the client stays the system of record).
1171 momentum: this.momentum,
1172 figureContext: this.figureGrounding?.resolved
1173 ? {
1174 description: this.figureGrounding.description,
1175 lifespan: lifespanText(this.figureGrounding)
1177 : undefined,
1178 objective: this.currentObjective,
1179 timeline: this.timelineEvents.map(e => ({
1180 era: e.era,
1181 figureName: e.figureName,
1182 headline: e.headline,
1183 detail: e.detail,
1184 progressChange: e.progressChange,
1185 whenSigned: e.whenSigned
1186 })),
1187 conversationHistory: this.conversationWith(target)
1189 },
1191 // ---------- the turn ----------
1192 /**
1193 * Sends a 160-char message to a chosen figure and folds the result back
1194 * into the world: the figure replies + acts, the dice decide the swing,
1195 * and the Timeline Engine records how history bent.
1197 * Returns `true` only when the turn actually RESOLVED (a ledger entry
1198 * landed). A blocked or failed send returns `false` so the composer can
1199 * keep the player's crafted words instead of wiping them.
1201 * `opts.stake` arms the last stand β€” honored only when `canStake` holds
1202 * (final message, win out of normal reach; the server doubles the resolved
1203 * swing, both ways, past the usual cap).
1204 */
1205 async sendMessage(text: string, figureName?: string, opts?: { stake?: boolean }): Promise<boolean> {
1206 const target = (figureName ?? this.activeFigureName ?? '').trim()
1207 const stake = opts?.stake === true && this.canStake
1209 if (!this.canSendMessage) return false
1210 if (!target) {
1211 this.setError('Choose who in history to send your message to first.')
1212 return false
1214 if (!this.checkRateLimit()) {
1215 this.setRateLimit()
1216 this.setError('The timeline needs a moment β€” wait before sending again.')
1217 return false
1219 if (!this.canContact) {
1220 this.setError(this.contactBlockedMessage(target))
1221 return false
1224 this.setError(null)
1225 this.moderationNotice = null
1226 this.clearArchive()
1227 this.setActiveFigure(target)
1228 this.addUserMessage(text, target)
1229 this.decrementMessages()
1230 this.setLoading(true)
1231 this.lastMessageTime = Date.now()
1233 // If the run is reset while this request is in flight, every write below
1234 // would land in a world that no longer exists β€” the epoch guard drops the
1235 // response (no fold-in, no refund: the new run's counter is not ours).
1236 const epoch = this.runEpoch
1237 const ledgerBefore = this.timelineEvents.length
1238 // Capture the contact year NOW: the slider can move while the request is
1239 // in flight, and the ledger must record the year this turn was SENT to.
1240 const sentWhen = this.contactWhen
1241 const sentMoment = this.contactMoment
1242 // The figure's Wikipedia portrait shown at selection (if any) β€” see resolveTurn.
1243 const sentThumbnail = this.figureGrounding?.thumbnail
1244 let resolved = false
1245 // Decide once whether to stagger the reveal (browser + motion allowed).
1246 const animate = canAnimateReveal()
1248 try {
1249 const response = await $fetch<SendMessageApiResponse>('/api/send-message', {
1250 method: 'POST',
1251 headers: await this.aiHeaders({ 'Content-Type': 'application/json' }),
1252 body: this.buildSendBody(text, target, sentWhen, sentMoment, stake)
1253 })
1255 if (epoch !== this.runEpoch) return false
1257 if (response?.success && response.data?.characterResponse) {
1258 const { figure, characterResponse, diceRoll, diceOutcome, timeline } = response.data
1260 if (figure?.name) {
1261 this.registerFigure({
1262 name: figure.name,
1263 era: figure.era || '',
1264 descriptor: figure.descriptor || ''
1265 })
1268 // ---- conducted reveal ----
1269 // A beat of suspense (the die keeps shaking) before the result
1270 // lands, so the resolution has a moment of anticipation.
1271 if (animate) {
1272 await wait(REVEAL.suspense)
1273 if (epoch !== this.runEpoch) return false
1276 // Beat 1 β€” the die lands and the figure's reply writes itself in
1277 // (its parts stage over ~0.55s via CSS). Flipping loading here
1278 // (mid-sequence) is what lands the die now; the finally's flip
1279 // becomes a no-op.
1280 this.addAIMessageWithData({
1281 text: characterResponse.message,
1282 sender: 'ai',
1283 figureName: target,
1284 figureThumbnail: sentThumbnail,
1285 timestamp: new Date(),
1286 diceRoll,
1287 diceOutcome,
1288 naturalRoll: response.data.naturalRoll ?? diceRoll,
1289 rollModifier: response.data.rollModifier ?? 0,
1290 craft: response.data.craft,
1291 craftReason: response.data.craftReason,
1292 continuity: response.data.continuity,
1293 characterAction: characterResponse.action,
1294 timelineImpact: timeline?.detail,
1295 progressChange: timeline?.progressChange,
1296 baseProgressChange: timeline?.baseProgressChange,
1297 momentumAtSwing: timeline?.momentumAtSwing,
1298 anachronism: timeline?.anachronism,
1299 causalChain: timeline?.causalChain,
1300 staked: response.data.staked
1301 })
1302 if (animate) this.setLoading(false)
1304 if (timeline) {
1305 // Beat 2 β€” the timeline shift (the % gauge flies its delta),
1306 // timed to land with the reply's own "ripple" line.
1307 if (animate) {
1308 await wait(REVEAL.swing)
1309 if (epoch !== this.runEpoch) return false
1311 // Use !== undefined so a genuine neutral (0%) turn still
1312 // registers instead of silently vanishing.
1313 if (timeline.progressChange !== undefined) {
1314 this.applyProgress(timeline.progressChange)
1316 // The arc meter is server-authoritative for the result; advance
1317 // it WITH the swing it amplified β€” and only past the epoch check
1318 // above, so a stale turn never moves the meter (issue #62).
1319 this.momentum = response.data.momentum ?? this.momentum
1321 // Beat 3 β€” the change drops onto the Spine ledger.
1322 if (animate) {
1323 await wait(REVEAL.node)
1324 if (epoch !== this.runEpoch) return false
1326 this.addTimelineEvent({
1327 figureName: target,
1328 era: timeline.era || figure?.era || (this.currentObjective?.era ?? ''),
1329 headline: timeline.headline,
1330 detail: timeline.detail,
1331 diceRoll,
1332 diceOutcome,
1333 progressChange: timeline.progressChange ?? 0,
1334 baseProgressChange: timeline.baseProgressChange,
1335 valence: timeline.valence,
1336 anachronism: timeline.anachronism,
1337 causalChain: timeline.causalChain,
1338 craft: response.data.craft,
1339 momentumAtSwing: timeline.momentumAtSwing,
1340 whenSigned: sentWhen ?? undefined,
1341 staked: response.data.staked
1342 })
1343 resolved = true
1345 } else {
1346 // A graceful failure (HTTP 200, success:false): an AI layer gave
1347 // out mid-turn. No ripple landed, so the dispatch is refunded β€”
1348 // exactly like the thrown path below.
1349 // Narrow to the failure variant (its data carries `blocked`).
1350 const failed = response && !response.success ? response.data : undefined
1351 if (failed?.blocked) {
1352 // A content-moderation block, not an infra hiccup β€” show an
1353 // honest, distinct banner (not "try again"). The composer
1354 // keeps the player's words (sendMessage returns false).
1355 this.moderationNotice = failed.moderationReason || 'That dispatch was blocked by content moderation and cannot be sent.'
1356 } else {
1357 this.setError(failed?.error || 'History did not answer. Try again.')
1359 this.refundUnresolvedTurn()
1361 } catch (error) {
1362 if (epoch !== this.runEpoch) return false
1363 console.error('Error sending message:', error)
1364 this.setError('The timeline resisted your message. Please try again.')
1365 this.refundUnresolvedTurn()
1366 } finally {
1367 if (epoch === this.runEpoch) {
1368 this.setLoading(false)
1369 this.resolveGameStatus()
1373 // The living chronicle re-narrates the world from the new timeline β€” but
1374 // only when this turn actually added a change, and never awaited: the turn
1375 // reveal must not wait on prose. By here the status is resolved, so the
1376 // final turn's chronicle carries the victory/defeat framing (the epilogue).
1377 let refresh: Promise<void> | null = null
1378 if (resolved && this.timelineEvents.length > ledgerBefore) {
1379 refresh = this.refreshChronicle()
1380 void refresh
1381 // The on-ramp blurbs re-narrate against the altered timeline (issue
1382 // #131), non-blocking like the chronicle β€” neither holds the reveal.
1383 void this.refreshSuggestions()
1385 // The run just ended: persist it (best-effort) so it survives reload, then
1386 // re-save once the epilogue's telling lands β€” the immediate save guards
1387 // against that refresh failing, the second captures the final Chronicle.
1388 if (this.gameStatus === 'victory' || this.gameStatus === 'defeat') {
1389 // This turn's refresh writes the EPILOGUE β€” the terminal telling that
1390 // carries the verdict. Mark it pending so the end screen shows "the
1391 // final account…" instead of the prior turn's stale telling. Cleared
1392 // when the telling lands OR the refresh fails (refreshChronicle keeps
1393 // the prior telling on failure) β€” so the panel reveals or falls back,
1394 // never hanging on the loading state.
1395 if (refresh) {
1396 this.epiloguePending = true
1397 void refresh.finally(() => { if (epoch === this.runEpoch) this.epiloguePending = false })
1399 void this.saveRunSnapshot()
1400 if (refresh) void refresh.finally(() => { void this.saveRunSnapshot() })
1401 // The terminal snapshot is the record now β€” drop the in-progress draft
1402 // so a reload can't resume a finished run (#152).
1403 void this.clearRunDraft()
1404 } else if (resolved) {
1405 // Still playing: autosave the live state (best-effort, fire-and-forget)
1406 // so a refresh or crash resumes exactly here (#152). The immediate save
1407 // guards against the chronicle refresh hanging; the post-refresh re-save
1408 // captures this turn's freshly-narrated Chronicle β€” same two-beat shape
1409 // as the terminal saveRunSnapshot above.
1410 void this.saveRunDraft()
1411 if (refresh) void refresh.finally(() => { void this.saveRunDraft() })
1413 return resolved
1414 },
1416 /**
1417 * The conducted turn β€” the two-phase twin of `sendMessage`. It runs the SAME
1418 * gates + network round-trip, but instead of staggering the reveal itself with
1419 * `await wait()`, it holds the resolved turn in `pendingTurn` and lets the
1420 * client leaf conductor fire the animation/SFX-bearing writes leaf by leaf
1421 * (`commitReveal` on the Dice leaf, `commitRipple` on the Ripple leaf). So the
1422 * page-by-page reveal paces on the player's reading, never artificial waits,
1423 * while the soundscape's "write == beat" lockstep is preserved untouched.
1425 * Returns `true` once a turn is RESOLVED and ready to reveal (animated path),
1426 * OR β€” under SSR / tests / reduced-motion (`!canAnimateReveal`) β€” once it has
1427 * committed atomically inline, exactly like `sendMessage`'s instant path, so
1428 * the store's "all turn state present the moment the promise resolves" contract
1429 * is unchanged. A blocked / failed send returns `false` (composer keeps words).
1430 */
1431 async resolveTurn(text: string, figureName?: string, opts?: { stake?: boolean }): Promise<boolean> {
1432 const target = (figureName ?? this.activeFigureName ?? '').trim()
1433 const stake = opts?.stake === true && this.canStake
1435 if (!this.canSendMessage) return false
1436 if (!target) {
1437 this.setError('Choose who in history to send your message to first.')
1438 return false
1440 if (!this.checkRateLimit()) {
1441 this.setRateLimit()
1442 this.setError('The timeline needs a moment β€” wait before sending again.')
1443 return false
1445 if (!this.canContact) {
1446 this.setError(this.contactBlockedMessage(target))
1447 return false
1450 this.setError(null)
1451 this.moderationNotice = null
1452 this.clearArchive()
1453 this.setActiveFigure(target)
1454 // Enqueue the player's line while the run is still 'playing' β€” addUserMessage
1455 // no-ops on a terminal status, and refundUnresolvedTurn would then splice the
1456 // wrong entry. No status flip happens before here (#Fix9).
1457 this.addUserMessage(text, target)
1458 this.decrementMessages()
1459 this.setLoading(true)
1460 this.lastMessageTime = Date.now()
1462 // Same staleness contract as sendMessage: a reset mid-flight bumps runEpoch,
1463 // so every write below finds no purchase (the response is dropped whole).
1464 const epoch = this.runEpoch
1465 const ledgerBefore = this.timelineEvents.length
1466 // Capture the contact year NOW (the slider can move while the request is in
1467 // flight) β€” the ledger must record the year this turn was SENT to.
1468 const sentWhen = this.contactWhen
1469 const sentMoment = this.contactMoment
1470 // The figure's Wikipedia portrait shown at selection (if any), captured NOW
1471 // so the reply leaf shows it even after the active figure changes mid-reveal.
1472 const sentThumbnail = this.figureGrounding?.thumbnail
1473 const animate = canAnimateReveal()
1475 // Streaming reveal (see PendingTurn.ready). The server emits the turn as SSE
1476 // frames (dice β†’ reply β†’ ripple β†’ final), so the reveal STARTS on the early
1477 // `dice` frame while the slow Character + Timeline calls finish behind the
1478 // player reading the Judgment/Dice beats. resolveTurn resolves on the dice
1479 // frame (the conductor's data-ready); the rest streams into pendingTurn, and
1480 // the commits await `ready`. A non-streaming mock returns the old plain JSON,
1481 // converted to the same frames by applyPlain β€” so tests need no streaming.
1482 let releaseDice: () => void = () => {}
1483 let releaseReady: () => void = () => {}
1484 const diceReady = new Promise<void>((r) => { releaseDice = r })
1485 const ready = new Promise<void>((r) => { releaseReady = r })
1486 let dicedReleased = false
1487 let readyReleased = false
1488 const settleDice = () => { if (!dicedReleased) { dicedReleased = true; releaseDice() } }
1489 const settleReady = () => { if (!readyReleased) { readyReleased = true; pending.resolved = true; releaseReady() } }
1491 const pending: PendingTurn = {
1492 epoch, target, sentWhen, ledgerBefore,
1493 aiMessage: { text: '', sender: 'ai', figureName: target, figureThumbnail: sentThumbnail, timestamp: new Date() } as AIMessagePayload,
1494 ripple: null, progressChange: undefined, momentum: this.momentum, terminal: false,
1495 ready, resolved: false, blocked: false
1498 // Refund + banner mid-stream, mirroring the old graceful-failure / thrown paths.
1499 const fail = (o: { blocked?: boolean; reason?: string; error?: string }) => {
1500 if (epoch !== this.runEpoch) { settleDice(); settleReady(); return }
1501 if (o.blocked) {
1502 pending.blocked = true
1503 this.moderationNotice = o.reason || 'That dispatch was blocked by content moderation and cannot be sent.'
1504 } else {
1505 this.setError(o.error || 'History did not answer. Try again.')
1507 this.refundUnresolvedTurn()
1508 this.setLoading(false)
1509 this.pendingTurn = null
1510 settleDice(); settleReady()
1513 // Apply one parsed frame onto the pending turn (mutating the REACTIVE copy after
1514 // the dice frame publishes it, so the leaves fill live as data lands).
1515 const onFrame = (msg: Record<string, unknown> | null) => {
1516 if (!msg || epoch !== this.runEpoch) return
1517 const t = msg.t
1518 if (t === 'dice') {
1519 Object.assign(pending.aiMessage, {
1520 diceRoll: msg.diceRoll, diceOutcome: msg.diceOutcome,
1521 naturalRoll: (msg.naturalRoll as number) ?? (msg.diceRoll as number),
1522 rollModifier: (msg.rollModifier as number) ?? 0,
1523 craft: msg.craft, craftReason: msg.craftReason, continuity: msg.continuity
1524 })
1525 this.pendingTurn = pending // the Judgment + Dice leaves can read now (early reveal)
1526 settleDice()
1527 return
1529 const pt = this.pendingTurn
1530 if (!pt || pt.epoch !== epoch) return
1531 switch (t) {
1532 case 'reply': {
1533 pt.aiMessage.text = (msg.message as string) ?? ''
1534 pt.aiMessage.characterAction = msg.action as string
1535 break
1537 case 'ripple': {
1538 const pc = typeof msg.progressChange === 'number' ? (msg.progressChange as number) : 0
1539 Object.assign(pt.aiMessage, {
1540 timelineImpact: msg.detail, progressChange: msg.progressChange,
1541 baseProgressChange: msg.baseProgressChange, momentumAtSwing: msg.momentumAtSwing,
1542 anachronism: msg.anachronism, causalChain: msg.causalChain
1543 })
1544 pt.ripple = {
1545 figureName: target,
1546 era: (msg.era as string) || (this.currentObjective?.era ?? ''),
1547 headline: msg.headline as string,
1548 detail: msg.detail as string,
1549 diceRoll: pt.aiMessage.diceRoll!,
1550 diceOutcome: pt.aiMessage.diceOutcome!,
1551 progressChange: pc,
1552 baseProgressChange: msg.baseProgressChange as number | undefined,
1553 valence: msg.valence as Valence,
1554 anachronism: msg.anachronism as Anachronism | undefined,
1555 causalChain: msg.causalChain as ChainStatus | undefined,
1556 craft: pt.aiMessage.craft,
1557 momentumAtSwing: msg.momentumAtSwing as number | undefined,
1558 whenSigned: sentWhen ?? undefined,
1559 staked: pt.aiMessage.staked
β‹― 795 lines hidden (lines 1560–2354)
1561 pt.progressChange = msg.progressChange !== undefined ? (msg.progressChange as number) : undefined
1562 const newProgress = pt.progressChange !== undefined
1563 ? Math.max(0, Math.min(MAX_PROGRESS, this.objectiveProgress + pt.progressChange))
1564 : this.objectiveProgress
1565 pt.terminal = deriveStatus(newProgress, this.remainingMessages) !== 'playing'
1566 // Fire the Chronicle/suggestion re-narration from the resolved SNAPSHOT
1567 // (the not-yet-committed node) so it composes during the reveal reading
1568 // β€” animated path only (the inline path fires from commitRipple).
1569 if (animate && pt.ripple) {
1570 const node: TimelineEvent = {
1571 ...pt.ripple,
1572 id: `evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
1573 valence: pt.ripple.valence ?? valenceOf(pt.ripple.progressChange),
1574 timestamp: new Date()
1576 const snapshot = {
1577 ledger: [...this.timelineEvents, node],
1578 progress: newProgress,
1579 status: deriveStatus(newProgress, this.remainingMessages)
1581 const refresh = this.refreshChronicle(snapshot)
1582 void refresh
1583 void this.refreshSuggestions(snapshot.ledger)
1584 pt.refresh = refresh
1585 if (pt.terminal) {
1586 this.epiloguePending = true
1587 void refresh.finally(() => { if (epoch === this.runEpoch) this.epiloguePending = false })
1590 break
1592 case 'final': {
1593 pt.momentum = typeof msg.momentum === 'number' ? (msg.momentum as number) : this.momentum
1594 pt.aiMessage.staked = msg.staked as boolean | undefined
1595 if (pt.ripple) pt.ripple.staked = msg.staked as boolean | undefined
1596 const fig = msg.figure as { name?: string; era?: string; descriptor?: string } | undefined
1597 if (fig?.name) this.registerFigure({ name: fig.name, era: fig.era || '', descriptor: fig.descriptor || '' })
1598 settleReady()
1599 break
1601 case 'blocked': fail({ blocked: true, reason: msg.reason as string }); break
1602 case 'error': fail({ error: msg.error as string }); break
1606 // Convert the old plain `{ success, data }` JSON (a non-streaming mock) into the
1607 // same frame sequence, so unit/e2e mocks that return JSON keep working.
1608 const applyPlain = (res: SendMessageApiResponse | null) => {
1609 if (res?.success && res.data?.characterResponse) {
1610 const d = res.data
1611 onFrame({ t: 'dice', diceRoll: d.diceRoll, diceOutcome: d.diceOutcome, naturalRoll: d.naturalRoll, rollModifier: d.rollModifier, craft: d.craft, craftReason: d.craftReason, continuity: d.continuity })
1612 onFrame({ t: 'reply', message: d.characterResponse.message, action: d.characterResponse.action })
1613 if (d.timeline) onFrame({ t: 'ripple', headline: d.timeline.headline, detail: d.timeline.detail, era: d.timeline.era, progressChange: d.timeline.progressChange, baseProgressChange: d.timeline.baseProgressChange, momentumAtSwing: d.timeline.momentumAtSwing, valence: d.timeline.valence, anachronism: d.timeline.anachronism, causalChain: d.timeline.causalChain })
1614 onFrame({ t: 'final', momentum: d.momentum, staked: d.staked, figure: d.figure })
1615 } else {
1616 const failed = res && !res.success ? res.data : undefined
1617 if (failed?.blocked) fail({ blocked: true, reason: failed.moderationReason })
1618 else fail({ error: failed?.error })
1622 const consume = async (): Promise<void> => {
1623 try {
1624 const res = await $fetch('/api/send-message', {
1625 method: 'POST',
1626 headers: await this.aiHeaders({ 'Content-Type': 'application/json' }),
1627 responseType: 'stream',
1628 body: this.buildSendBody(text, target, sentWhen, sentMoment, stake)
1629 }) as unknown
1630 if (epoch !== this.runEpoch) { settleDice(); settleReady(); return }
1631 const isStream = !!res && typeof (res as ReadableStream<Uint8Array>).getReader === 'function'
1632 if (!isStream) { applyPlain(res as SendMessageApiResponse); return }
1633 const reader = (res as ReadableStream<Uint8Array>).getReader()
1634 const decoder = new TextDecoder()
1635 let buf = ''
1636 while (true) {
1637 const { done, value } = await reader.read()
1638 if (done) break
1639 if (epoch !== this.runEpoch) { try { await reader.cancel() } catch { /* gone */ } settleDice(); settleReady(); return }
1640 buf += decoder.decode(value, { stream: true })
1641 let nl: number
1642 while ((nl = buf.indexOf('\n\n')) >= 0) {
1643 const frame = buf.slice(0, nl); buf = buf.slice(nl + 2)
1644 const dataLine = frame.split('\n').map(l => l.trim()).find(l => l.startsWith('data:'))
1645 if (!dataLine) continue
1646 try { onFrame(JSON.parse(dataLine.slice(5).trim())) } catch { /* skip a partial/garbled frame */ }
1649 // Stream ended. Normal completion / handled block β†’ done. No frames at all
1650 // β†’ a plain-JSON body delivered as a stream. Some frames but no terminal
1651 // (a dropped connection mid-turn) β†’ refund, like the thrown path.
1652 if (readyReleased || pending.blocked) return
1653 if (!this.pendingTurn) {
1654 try { applyPlain(JSON.parse(buf.trim())) } catch { fail({ error: 'History did not answer. Try again.' }) }
1655 } else {
1656 fail({ error: 'The timeline resisted your message. Please try again.' })
1658 } catch (error) {
1659 if (epoch !== this.runEpoch) { settleDice(); settleReady(); return }
1660 console.error('Error sending message:', error)
1661 fail({ error: 'The timeline resisted your message. Please try again.' })
1665 if (!animate) {
1666 // SSR / tests / reduced-motion: drain the whole stream, then commit
1667 // atomically β€” the "all state present the moment resolveTurn resolves"
1668 // contract is unchanged.
1669 await consume()
1670 if (epoch !== this.runEpoch) return false
1671 if (pending.blocked || !this.pendingTurn) return false
1672 await this.commitReveal()
1673 await this.commitRipple()
1674 return pending.ripple != null
1677 // Animated path: consume in the background; resolve on the `dice` frame so the
1678 // conductor reveals immediately while reply/ripple/final keep streaming in.
1679 void consume()
1680 await diceReady
1681 if (epoch !== this.runEpoch) return false
1682 if (pending.blocked || !this.pendingTurn) return false
1683 return true
1684 },
1686 /**
1687 * Reveal beat β€” the figure's reply writes itself in and the die lands
1688 * (`setLoading(false)` is the falling edge DiceRoller + the soundscape watch).
1689 * Fired by the conductor on the Dice leaf. A no-op once the run has moved on
1690 * (epoch guard), so a stale turn never lands in a fresh run.
1691 */
1692 async commitReveal(): Promise<boolean> {
1693 const p = this.pendingTurn
1694 if (!p || p.epoch !== this.runEpoch) return true // nothing to commit β€” benign
1695 // Wait for the reply (and the rest of the turn) to stream in β€” a graceful
1696 // micro-wait if the player outran the Character/Timeline calls, usually already
1697 // landed by here (skipped once resolved, so this stays synchronous in tests).
1698 if (!p.resolved && !p.blocked) await p.ready
1699 if (p.blocked) return false // a real moderation block β€” the conductor halts the reveal
1700 const cur = this.pendingTurn
1701 if (!cur || cur !== p || cur.epoch !== this.runEpoch) return true // reset/cleared β€” benign
1702 // addAIMessageWithData BEFORE setLoading(false): the soundscape's diceLand fires
1703 // on the loading falling edge and reads the new message's roll/outcome/craft.
1704 this.addAIMessageWithData(cur.aiMessage)
1705 this.setLoading(false)
1706 return true
1707 },
1709 /**
1710 * Ripple beat β€” the % gauge flies its delta, momentum advances, and the change
1711 * drops onto the Spine ledger; then win/lose resolves and (reading the now-live,
1712 * just-committed state) the run persists and the Chronicle/suggestions re-narrate
1713 * non-blocking. Fired by the conductor on the Ripple leaf, AFTER commitReveal, so
1714 * the writes land in exactly the order sendMessage makes them. No-op under a
1715 * bumped epoch. This is the mirror of sendMessage's beats 2-3 + its tail.
1716 */
1717 async commitRipple(): Promise<boolean> {
1718 const p = this.pendingTurn
1719 if (!p || p.epoch !== this.runEpoch) return true // nothing to commit β€” benign
1720 if (!p.resolved && !p.blocked) await p.ready
1721 if (p.blocked) return false // a real moderation block β€” the conductor halts the reveal
1722 const cur = this.pendingTurn
1723 if (!cur || cur !== p || cur.epoch !== this.runEpoch) return true // reset/cleared β€” benign
1724 const { epoch, ledgerBefore } = p
1725 if (p.progressChange !== undefined) this.applyProgress(p.progressChange)
1726 // The arc meter is server-authoritative for the result; advance it WITH the
1727 // swing it amplified (only past the epoch guard above).
1728 this.momentum = p.momentum
1729 if (p.ripple) this.addTimelineEvent(p.ripple)
1730 this.resolveGameStatus()
1731 const resolved = p.ripple != null
1732 // The re-narration fired EARLY (animate path, at resolve) is carried on the
1733 // pending turn; capture it before we clear the slot. Persistence reads LIVE
1734 // (now-committed) state, so it stays here regardless.
1735 const earlyRefresh = p.refresh ?? null
1736 this.pendingTurn = null
1738 // Reuse the early-fired telling if there is one; otherwise (the inline path)
1739 // fire it now β€” never awaited (the reveal waits on neither), reading the
1740 // just-committed live state. By here the status is resolved, so a final turn's
1741 // chronicle carries the verdict (the epilogue).
1742 let refresh: Promise<void> | null = earlyRefresh
1743 if (!earlyRefresh && resolved && this.timelineEvents.length > ledgerBefore) {
1744 refresh = this.refreshChronicle()
1745 void refresh
1746 void this.refreshSuggestions()
1748 // Persist AFTER the commit (the draft/snapshot serialize the now-committed
1749 // ledger/progress/momentum/history). Two beats: the immediate save guards a
1750 // hanging refresh; the post-refresh re-save captures the fresh telling.
1751 if (this.gameStatus === 'victory' || this.gameStatus === 'defeat') {
1752 // The animate path already marked epiloguePending at resolve; the inline
1753 // path marks it here (only when it fired the telling itself).
1754 if (refresh && !earlyRefresh) {
1755 this.epiloguePending = true
1756 void refresh.finally(() => { if (epoch === this.runEpoch) this.epiloguePending = false })
1758 void this.saveRunSnapshot()
1759 if (refresh) void refresh.finally(() => { void this.saveRunSnapshot() })
1760 // The terminal snapshot is the record now β€” drop the in-progress draft.
1761 void this.clearRunDraft()
1762 } else if (resolved) {
1763 void this.saveRunDraft()
1764 if (refresh) void refresh.finally(() => { void this.saveRunDraft() })
1766 return true
1767 },
1769 /**
1770 * Seed a replay (issue #89): start a fresh run on a shared run's captured
1771 * objective. Resets first (the share page may follow an abandoned run in the
1772 * same tab) so the run starts clean, THEN records the seed β€” so the mission
1773 * briefing opens focused on this objective, and the lineage + credit ride
1774 * through to the end. No generation: the objective is reused verbatim, and the
1775 * player still commits (and is charged) by pressing Begin.
1776 */
1777 preseedReplay(objective: GameObjective, sourceToken: string, sourceAttribution: string | null): void {
1778 this.resetGame()
1779 this.replayState = { objective, sourceToken, sourceAttribution }
1780 },
1782 /** Drop the replay seed β€” the player chose to start from scratch instead, so this
1783 * run is an original (no "remixed from" lineage). */
1784 clearReplayState(): void {
1785 this.replayState = null
1786 },
1788 /**
1789 * Commits a chosen objective and starts the run from a clean slate of
1790 * progress. Used by the mission-select screen for both curated picks and
1791 * freshly composed ones.
1792 */
1793 async chooseObjective(objective: GameObjective): Promise<boolean> {
1794 // Commit = the run is charged here. Mint the run id server-side, then
1795 // spend one run from the device's balance. Fails CLOSED: out of runs β†’
1796 // raise the paywall; begin-run or commit failing β†’ surface an error and
1797 // do NOT start. A run that isn't a charged, server-backed run would be
1798 // rejected by the gameplay gate anyway, so starting it only strands the
1799 // player mid-run. The spend cap (not free play) is the outage backstop.
1800 let runId: string
1801 try {
1802 runId = await this.ensureRunId()
1803 } catch (error) {
1804 console.error('Could not begin the run:', error)
1805 this.error = 'Could not start the run. Please try again.'
1806 return false
1808 try {
1809 const res = await $fetch('/api/run-commit', {
1810 method: 'POST',
1811 headers: { 'Content-Type': 'application/json' },
1812 body: { runId }
1813 }) as { runsRemaining?: number }
1814 this.outOfRuns = false
1815 // Keep the header gauge live: the commit returns the post-charge
1816 // balance (βˆ’1 marks a degraded, ungated run β€” leave the gauge as is).
1817 if (typeof res?.runsRemaining === 'number' && res.runsRemaining >= 0) {
1818 this.runsRemaining = res.runsRemaining
1820 } catch (error) {
1821 if (isPaymentRequired(error)) {
1822 this.outOfRuns = true
1823 this.openBuyModal()
1824 return false
1826 if (isAtCapacity(error)) {
1827 this.atCapacity = true
1828 return false
1830 console.error('Could not charge the run:', error)
1831 this.error = 'Could not start the run. Please try again.'
1832 return false
1834 this.currentObjective = objective
1835 this.objectiveProgress = 0
1836 this.peakProgress = 0
1837 this.momentum = 0
1838 this.error = null
1839 // Prefetch the era-relevant figures the instant the objective commits, so
1840 // they're already in flight (often landed) by the time the player reaches
1841 // the dispatch page β€” not a cold wait as their first impression. The
1842 // FigurePicker onMounted call no-ops while this is in flight (see the
1843 // suggestionsLoadingFor guard in loadSuggestions).
1844 void this.loadSuggestions()
1845 return true
1846 },
1848 /**
1849 * Asks the server to compose a fresh objective with the model, WITHOUT
1850 * committing it β€” the caller previews it, then commits via chooseObjective.
1851 * Generation lives server-side because it needs the OpenAI key (the client
1852 * has no access to it). An optional steer (era + theme, closed enums) biases
1853 * the composition; the server re-validates it against the enums, so a bad
1854 * value is harmless. `avoid` is the titles already composed this session, so
1855 * a reroll lands somewhere new (#95) β€” the stateless server only knows what
1856 * the client supplies.
1858 * Returns the objective (or null, never throwing, so the UI can quietly fall
1859 * back to the curated pool) alongside `fellBackToCurated` (#127): true only
1860 * when the player steered and the server still returned a curated objective β€”
1861 * the signal the caller surfaces so a dropped steer doesn't read as a bug.
1862 */
1863 async fetchAIObjective(steer: ObjectiveSteer = {}, avoid: string[] = []): Promise<{ objective: GameObjective | null; fellBackToCurated: boolean }> {
1864 try {
1865 const query: Record<string, string | string[]> = {}
1866 if (steer.era) query.era = steer.era
1867 if (steer.theme) query.theme = steer.theme
1868 if (avoid.length) query.avoid = avoid
1869 const res = await $fetch('/api/objective', { method: 'GET', query, headers: await this.aiHeaders() }) as {
1870 success?: boolean
1871 objective?: GameObjective
1872 fellBackToCurated?: boolean
1874 const objective = res?.success && res.objective ? res.objective : null
1875 // Only report a dropped steer when we actually have an objective; a hard
1876 // failure (null) is the louder compose-error path, not this quiet notice.
1877 return { objective, fellBackToCurated: objective ? res.fellBackToCurated === true : false }
1878 } catch (error) {
1879 console.error('Failed to compose a fresh objective:', error)
1880 return { objective: null, fellBackToCurated: false }
1882 },
1884 /**
1885 * Loads the device's run balance for the header gauge + account popover.
1886 * Grants the free trial on a brand-new device (server-side). Graceful: on
1887 * failure it leaves the prior value (the gauge simply doesn't update).
1888 */
1889 async loadBalance(): Promise<void> {
1890 try {
1891 const res = await $fetch('/api/balance') as { runsRemaining?: number; freeRuns?: number; deviceRef?: string; email?: string | null; isAnonymous?: boolean; referralCredits?: number; referralCount?: number }
1892 if (typeof res?.runsRemaining === 'number') this.runsRemaining = res.runsRemaining
1893 if (typeof res?.freeRuns === 'number') this.freeRuns = res.freeRuns
1894 if (typeof res?.deviceRef === 'string') this.deviceRef = res.deviceRef
1895 this.accountEmail = res?.email ?? null
1896 this.accountAnonymous = res?.isAnonymous === true
1897 if (typeof res?.referralCredits === 'number') this.referralCredits = res.referralCredits
1898 if (typeof res?.referralCount === 'number') this.referralCount = res.referralCount
1899 } catch (error) {
1900 console.error('Failed to load balance:', error)
1902 },
1904 /**
1905 * Claims a pending referral after an email signup (issue #96). Best-effort and
1906 * fire-and-forget by design: the credit lands on the SHARER, not this player, so
1907 * there's nothing here to surface β€” the server decides idempotently whether it
1908 * qualifies. Never throws into the sign-in flow.
1909 */
1910 async claimReferral(): Promise<void> {
1911 try {
1912 await $fetch('/api/referral/claim', { method: 'POST' })
1913 } catch (error) {
1914 console.error('Failed to claim referral:', error)
1916 },
1918 /**
1919 * Loads the account's generated username (#117) for the profile / settings pages
1920 * and the share affordance, minting one server-side on first need. Cached: skips
1921 * the round-trip once loaded (a reroll keeps it current). Graceful on failure β€”
1922 * leaves the prior value.
1923 */
1924 async loadUsername(force = false): Promise<void> {
1925 if (this.username && !force) return
1926 try {
1927 const res = await $fetch('/api/profile') as { username?: string }
1928 if (typeof res?.username === 'string') this.username = res.username
1929 } catch (error) {
1930 console.error('Failed to load username:', error)
1932 },
1934 /**
1935 * Rerolls the account username to a fresh generated one (#117) β€” the only way to
1936 * change it. The server re-stamps it onto the player's attributed shares too, so
1937 * the handle stays consistent everywhere it appears. Returns the new name on
1938 * success, or null on failure so the caller can surface a notice.
1939 */
1940 async rerollUsername(): Promise<string | null> {
1941 try {
1942 const res = await $fetch('/api/profile/reroll', { method: 'POST' }) as { username?: string }
1943 if (typeof res?.username === 'string') {
1944 this.username = res.username
1945 return res.username
1947 return null
1948 } catch (error) {
1949 console.error('Failed to reroll username:', error)
1950 return null
1952 },
1954 /** Opens the run-packs sales modal, loading the catalog first. */
1955 async openBuyModal(): Promise<void> {
1956 this.buyModalOpen = true
1957 await this.loadPacks()
1958 },
1960 /** Closes the sales modal. */
1961 closeBuyModal(): void {
1962 this.buyModalOpen = false
1963 },
1965 /** Records the Stripe-return outcome and refreshes the balance on success
1966 * (the webhook credits asynchronously; re-read so the gauge reflects it). */
1967 async notePurchaseReturn(outcome: 'success' | 'cancel'): Promise<void> {
1968 this.purchaseNotice = outcome
1969 this.buyModalOpen = false
1970 if (outcome === 'success') {
1971 this.outOfRuns = false
1972 await this.loadBalance()
1974 },
1976 /** Dismisses the post-purchase notice. */
1977 clearPurchaseNotice(): void {
1978 this.purchaseNotice = null
1979 },
1981 /** Loads the run-pack catalog for the paywall. Caches only a NON-empty
1982 * result: a transient empty/failed read must not poison the cache and
1983 * strand the modal with zero packs for the rest of the session. */
1984 async loadPacks(): Promise<void> {
1985 if (this.packs.length) return
1986 try {
1987 const res = await $fetch('/api/packs') as { packs?: Pack[] }
1988 const packs = res?.packs ?? []
1989 if (packs.length) this.packs = packs
1990 } catch (error) {
1991 console.error('Failed to load packs:', error)
1993 },
1995 /** Starts checkout for a pack; returns the Stripe Checkout URL to redirect
1996 * to (null on failure). The caller does the redirect. */
1997 async buyPack(packId: string): Promise<string | null> {
1998 try {
1999 const res = await $fetch('/api/checkout', {
2000 method: 'POST',
2001 headers: { 'Content-Type': 'application/json' },
2002 body: { packId }
2003 }) as { url?: string }
2004 return res?.url ?? null
2005 } catch (error) {
2006 console.error('Failed to start checkout:', error)
2007 return null
2009 },
2011 getVictoryEfficiency() {
2012 if (this.gameStatus === 'victory') {
2013 const messagesSaved = this.remainingMessages
2014 const messagesUsed = TOTAL_MESSAGES - this.remainingMessages
2015 const efficiencyPercentage = Math.round((messagesSaved / TOTAL_MESSAGES) * 100)
2017 const efficiencyRating = rateEfficiency(messagesSaved)
2019 return {
2020 messagesSaved,
2021 messagesUsed,
2022 efficiencyPercentage,
2023 efficiencyRating,
2024 isEarlyVictory: messagesSaved > 0
2027 return null
2028 },
2030 /**
2031 * Persist the finished run's snapshot to the player's account, best-effort.
2032 * Captures exactly what the end screen shows β€” objective, verdict, ledger,
2033 * dispatches, rolls, and the Chronicle epilogue β€” keyed by the run id, so the
2034 * run survives reload and feeds the read-only "your runs" view. Fires and
2035 * forgets like refreshChronicle: a save failure must never disturb the end
2036 * screen. Only a completed (victory/defeat), server-backed (uuid run id) run
2037 * is saved; a degraded local id is dropped server-side.
2038 */
2039 async saveRunSnapshot(): Promise<void> {
2040 const epoch = this.runEpoch
2041 const status = this.gameStatus
2042 if (status !== 'victory' && status !== 'defeat') return
2043 const objective = this.currentObjective
2044 const runId = this.runId
2045 if (!objective || !runId) return
2047 const summary = this.gameSummary
2048 const events = this.timelineEvents
2049 // The dispatch keepsake β€” the player's verbatim messages, paired with
2050 // whom/when they reached (the same zip the end screen renders).
2051 const dispatches = this.messageHistory
2052 .filter((m) => m.sender === 'user')
2053 .map((m, i) => ({
2054 text: m.text,
2055 figure: m.figureName || events[i]?.figureName || 'the past',
2056 era: events[i]?.era || ''
2057 }))
2058 const mark = (m: typeof summary.bestRoll) =>
2059 m && m.diceRoll != null && m.diceOutcome != null
2060 ? { diceRoll: m.diceRoll, diceOutcome: m.diceOutcome }
2061 : null
2063 const snapshot: RunSnapshot = {
2064 version: RUN_SNAPSHOT_VERSION,
2065 runId,
2066 status,
2067 objective,
2068 objectiveProgress: this.objectiveProgress,
2069 // The high-water mark, so a snapshot-backed view can show "Peaked at X%"
2070 // (the live store zeroes it on reset, so it's only meaningful here at end).
2071 peakProgress: this.peakProgress,
2072 messagesUsed: TOTAL_MESSAGES - this.remainingMessages,
2073 totalMessages: TOTAL_MESSAGES,
2074 bestRoll: mark(summary.bestRoll),
2075 worstRoll: mark(summary.worstRoll),
2076 efficiency: this.getVictoryEfficiency(),
2077 dispatches,
2078 timeline: events.map((e) => ({
2079 id: e.id,
2080 figureName: e.figureName,
2081 era: e.era,
2082 headline: e.headline,
2083 detail: e.detail,
2084 diceRoll: e.diceRoll,
2085 diceOutcome: e.diceOutcome,
2086 progressChange: e.progressChange,
2087 baseProgressChange: e.baseProgressChange,
2088 valence: e.valence,
2089 craft: e.craft,
2090 momentumAtSwing: e.momentumAtSwing,
2091 anachronism: e.anachronism,
2092 causalChain: e.causalChain,
2093 staked: e.staked
2094 })),
2095 chronicle: this.chronicle
2098 // A replay (issue #89) rides its source's share token alongside the snapshot
2099 // (a sibling field, not part of the immutable blob); the server resolves it to
2100 // the parent run id and stamps the lineage pointer. replayState set ⟺ this run
2101 // committed the seeded objective (the briefing clears it on any other choice).
2102 const body = this.replayState
2103 ? { ...snapshot, remixedFromToken: this.replayState.sourceToken }
2104 : snapshot
2106 try {
2107 const res = (await $fetch('/api/run-save', {
2108 method: 'POST',
2109 headers: { 'Content-Type': 'application/json' },
2110 body
2111 })) as { saved?: boolean }
2112 // Mark saved (for the share affordance) only if this run is still current
2113 // and the server actually persisted it (a degraded run returns saved:false).
2114 if (epoch === this.runEpoch && res?.saved) this.runSnapshotSaved = true
2115 } catch (error) {
2116 // Saving the run is a nicety; a failure must not break the end screen.
2117 if (epoch === this.runEpoch) console.error('Could not save the run:', error)
2119 },
2121 /**
2122 * Autosave the in-progress run's draft, best-effort (#152). The live mirror of
2123 * saveRunSnapshot: it serializes the DURABLE live state β€” totals, the full
2124 * thread (both sides), the un-trimmed ledger (with whenSigned + causalChain),
2125 * the contacted figures, the active contact, the Chronicle, and any replay
2126 * lineage β€” keyed by the run id, so a refresh or a crash resumes exactly here.
2127 * Fires and forgets: only an in-progress, server-backed (uuid) run is saved;
2128 * the server drops a degraded local id, and any failure is swallowed.
2129 */
2130 async saveRunDraft(): Promise<void> {
2131 if (this.gameStatus !== 'playing') return
2132 const runId = this.runId
2133 const objective = this.currentObjective
2134 if (!runId || !objective) return
2136 const draft: RunDraft = {
2137 version: RUN_DRAFT_VERSION,
2138 runId,
2139 currentObjective: objective,
2140 objectiveProgress: this.objectiveProgress,
2141 peakProgress: this.peakProgress,
2142 momentum: this.momentum,
2143 remainingMessages: this.remainingMessages,
2144 // The full thread, both sides β€” the in-memory Date stamp serialized for
2145 // jsonb (revived on resume). The AI replies cost a model call to redo.
2146 messageHistory: this.messageHistory.map((m) => ({
2147 text: m.text,
2148 sender: m.sender,
2149 timestamp: m.timestamp.toISOString(),
2150 figureName: m.figureName,
2151 diceRoll: m.diceRoll,
2152 diceOutcome: m.diceOutcome,
2153 naturalRoll: m.naturalRoll,
2154 rollModifier: m.rollModifier,
2155 craft: m.craft,
2156 craftReason: m.craftReason,
2157 continuity: m.continuity,
2158 characterAction: m.characterAction,
2159 timelineImpact: m.timelineImpact,
2160 progressChange: m.progressChange,
2161 baseProgressChange: m.baseProgressChange,
2162 momentumAtSwing: m.momentumAtSwing,
2163 anachronism: m.anachronism,
2164 causalChain: m.causalChain,
2165 staked: m.staked
2166 })),
2167 // The UN-trimmed ledger: keeps whenSigned + causalChain (the footholds
2168 // the next turn's causal chain and the world-brief read back).
2169 timelineEvents: this.timelineEvents.map((e) => ({
2170 id: e.id,
2171 figureName: e.figureName,
2172 era: e.era,
2173 headline: e.headline,
2174 detail: e.detail,
2175 diceRoll: e.diceRoll,
2176 diceOutcome: e.diceOutcome,
2177 progressChange: e.progressChange,
2178 baseProgressChange: e.baseProgressChange,
2179 valence: e.valence,
2180 anachronism: e.anachronism,
2181 causalChain: e.causalChain,
2182 craft: e.craft,
2183 momentumAtSwing: e.momentumAtSwing,
2184 whenSigned: e.whenSigned,
2185 staked: e.staked,
2186 timestamp: e.timestamp.toISOString()
2187 })),
2188 figures: this.figures.map((f) => ({ name: f.name, era: f.era, descriptor: f.descriptor })),
2189 activeFigureName: this.activeFigureName,
2190 contactWhen: this.contactWhen,
2191 contactMoment: this.contactMoment,
2192 chronicle: this.chronicle,
2193 replayState: this.replayState
2196 try {
2197 await $fetch('/api/run-draft', {
2198 method: 'POST',
2199 headers: { 'Content-Type': 'application/json' },
2200 body: draft
2201 })
2202 } catch (error) {
2203 // Autosave is a safety net; a failure must never disturb the turn.
2204 console.error('Could not save the run draft:', error)
2206 },
2208 /**
2209 * Clear the run's server-side draft (#152) β€” fired on terminal save and on
2210 * resetGame (abandon). Takes an explicit run id so resetGame can clear the
2211 * run it is tearing down (runId is nulled there). Idempotent + best-effort.
2212 */
2213 async clearRunDraft(runId?: string): Promise<void> {
2214 const id = runId ?? this.runId
2215 if (!id) return
2216 try {
2217 await $fetch('/api/run-draft', { method: 'DELETE', query: { runId: id } })
2218 } catch (error) {
2219 console.error('Could not clear the run draft:', error)
2221 },
2223 /**
2224 * Resume an in-progress run from its server-side draft (#152) β€” the counterpart
2225 * to saveRunDraft, called on page load when the board is empty. Reads the
2226 * subject's latest draft and, when there is one, rehydrates the store so the
2227 * player drops straight back onto the in-progress board: the SAME run id (no new
2228 * credit charged), the totals, the full thread, the ledger, and the figures.
2230 * The active figure is RE-grounded from the live record (the draft carries no
2231 * dossier), then the saved contact year/moment are re-applied β€” groundActiveFigure
2232 * defaults contactWhen to mid-lifetime, which would otherwise clobber the player's
2233 * chosen year. Best-effort: any failure simply leaves the mission briefing.
2234 */
2235 async resumeDraft(): Promise<boolean> {
2236 // currentObjective set ⟹ a run is already on the board. A replayState seed
2237 // (preseedReplay, from a /r/<token> share link) is an intent to start a FRESH
2238 // charged run β€” never let a pre-existing server draft clobber it (#152/#89).
2239 if (this.currentObjective || this.replayState) return false
2240 let draft: RunDraft | null
2241 try {
2242 const res = (await $fetch('/api/run-draft')) as { draft?: RunDraft | null }
2243 draft = res?.draft ?? null
2244 } catch (error) {
2245 console.error('Could not load the run draft:', error)
2246 return false
2248 if (!draft || !draft.currentObjective || !draft.runId) return false
2249 // A reset, a fresh run, or a replay seed may have begun while the draft was
2250 // loading β€” never drop a stale run onto a board the player has moved on from.
2251 if (this.currentObjective || this.replayState) return false
2253 // jsonb is dateless: revive the serialized ISO stamps, falling back to now()
2254 // for any unparseable value rather than minting an Invalid Date.
2255 const revive = (ts: string): Date => {
2256 const d = new Date(ts)
2257 return Number.isNaN(d.getTime()) ? new Date() : d
2260 // Guard the async tail like every other state-writing action: the board (and
2261 // its ↻ reset) appears the instant we rehydrate, so a reset can fire during
2262 // the re-ground below β€” its stale contact must not land on the fresh run.
2263 const epoch = this.runEpoch
2264 this.runId = draft.runId
2265 this.gameStatus = 'playing'
2266 this.currentObjective = draft.currentObjective
2267 this.objectiveProgress = draft.objectiveProgress
2268 this.peakProgress = draft.peakProgress
2269 this.momentum = draft.momentum
2270 this.remainingMessages = draft.remainingMessages
2271 this.messageHistory = draft.messageHistory.map((m) => ({ ...m, timestamp: revive(m.timestamp) }))
2272 this.timelineEvents = draft.timelineEvents.map((e) => ({ ...e, timestamp: revive(e.timestamp) }))
2273 this.figures = draft.figures.map((f) => ({ ...f }))
2274 this.activeFigureName = draft.activeFigureName
2275 this.chronicle = draft.chronicle
2276 this.replayState = draft.replayState
2278 // Re-ground the active figure (the draft carries no dossier), then restore the
2279 // saved year/moment OVER the mid-lifetime default grounding picks β€” unless a
2280 // reset tore the board mid-ground, in which case abort without touching it.
2281 if (this.activeFigureName) {
2282 await this.groundActiveFigure(this.activeFigureName)
2283 if (epoch !== this.runEpoch) return false
2285 this.contactWhen = draft.contactWhen
2286 this.contactMoment = draft.contactMoment
2287 return true
2288 },
2290 resetGame() {
2291 // Clear the abandoned run's server draft so a reload can't resume it (#152).
2292 // Capture the id before the epoch tear nulls runId below; fire-and-forget.
2293 const abandonedRunId = this.runId
2294 // Tear the epoch first: anything still in flight belongs to the old run
2295 // and must find no purchase here. (The seq counters deliberately survive.)
2296 this.runEpoch++
2297 this.remainingMessages = TOTAL_MESSAGES
2298 this.messageHistory = []
2299 this.gameStatus = 'playing'
2300 this.isLoading = false
2301 this.error = null
2302 this.lastMessageTime = null
2303 this.isRateLimited = false
2304 // A new run gets a fresh id on its next server call; abandon any
2305 // in-flight mint so a dead run's id can't land in the new run.
2306 this.runId = null
2307 this.runIdInflight = null
2308 this.runSnapshotSaved = false
2309 // The paywall / capacity notices re-check on the next commit.
2310 this.outOfRuns = false
2311 this.atCapacity = false
2312 this.currentObjective = null
2313 // A fresh run carries no replay lineage (preseedReplay re-sets it after).
2314 this.replayState = null
2315 this.objectiveProgress = 0
2316 this.peakProgress = 0
2317 this.momentum = 0
2318 this.timelineEvents = []
2319 this.figures = []
2320 this.activeFigureName = ''
2321 this.figureGrounding = null
2322 this.groundingLoading = false
2323 this.contactWhen = null
2324 this.contactMoment = null
2325 this.figureSuggestions = []
2326 this.suggestionsLoading = false
2327 this.suggestionsFor = ''
2328 this.chronicle = null
2329 this.chronicleForLedgerLen = 0
2330 this.chronicleLoading = false
2331 this.chronicleStream = ''
2332 this.chronicleStreaming = false
2333 this.chronicleStreamLen = 0
2334 this.epiloguePending = false
2335 // Drop any half-revealed turn β€” a reset mid-reveal must leave nothing
2336 // for commitReveal/commitRipple to land in the fresh run (the epoch guard
2337 // also catches it, this is the belt-and-suspenders clear).
2338 this.pendingTurn = null
2339 this.figureStudy = null
2340 this.studyLoading = false
2341 this.studyFor = ''
2342 this.studyWhen = null
2343 this.archiveResult = null
2344 this.lookupLoading = false
2345 this.moderationNotice = null
2346 this.archiveNotice = null
2347 this.studyNotice = null
2348 this.suggestionsNotice = null
2349 // Drop the abandoned run's draft last, off the fresh (already-torn) epoch β€”
2350 // a no-op when there was no run yet (#152).
2351 if (abandonedRunId) void this.clearRunDraft(abandonedRunId)

The server validator that rebuilds a posted snapshot gets the matching guard. A direct POST can send anything, so momentum is coerced to a clamped integer in [0, MOMENTUM_MAX] β€” the same shape every other ledger field is bounded to.

Untrusted momentum clamped to the meter's range before it reaches a saved run.

server/utils/run-snapshot.ts Β· 199 lines
server/utils/run-snapshot.ts199 lines Β· TypeScript
β‹― 148 lines hidden (lines 1–148)
1/**
2 * Server-side boundary validation for a saved run snapshot.
3 *
4 * The shared CONTRACT (types + version) lives in `~/utils/run-snapshot` so both the
5 * client store and the server can import it without pulling server-only code into the
6 * client bundle. This module is the server-only half: it coerces an untrusted POST
7 * body into a valid RunSnapshot (a direct POST can send anything), bounding every
8 * field with the same cleanString/cleanArray/clampInt helpers the other routes use.
9 */
10import { cleanString, cleanArray, clampInt } from './validate'
11import {
12 MAX_OBJECTIVE_TITLE_CHARS,
13 MAX_OBJECTIVE_DESC_CHARS,
14 MAX_ERA_CHARS,
15 MAX_FIGURE_NAME_CHARS,
16 MAX_LEDGER_DETAIL_CHARS,
17 MAX_TIMELINE_ENTRIES,
18 MAX_HISTORY_ENTRIES,
19 MAX_LEDGER_SWING
20} from './validate'
21import { sanitizeRunId } from './run-context'
22import { TOTAL_MESSAGES } from '~/utils/game-config'
23import {
24 RUN_SNAPSHOT_VERSION,
25 type RunSnapshot,
26 type RunOutcome,
27 type RunValence,
28 type RunEfficiency,
29 type RunDispatch,
30 type RunLedgerEntry,
31 type RollMark,
32 type EfficiencyRating
33} from '~/utils/run-snapshot'
34import type { DiceOutcome } from '~/utils/dice'
35import type { Craft } from '~/utils/craft'
36import { MOMENTUM_MAX } from '~/utils/momentum'
37import { CHAIN_TIERS, type ChainStatus, type ChainTier } from '~/utils/causal-chain'
38import type { Anachronism } from '~/server/utils/anachronism'
39import type { GameObjective } from '~/server/utils/objectives'
40import type { ChronicleEntry } from '~/server/utils/openai'
41 
42// Caps specific to a snapshot's own fields (the shared input caps live in validate.ts).
43const MAX_DISPATCH_CHARS = 320
44const MAX_HEADLINE_CHARS = 200
45const MAX_ICON_CHARS = 16
46const MAX_OUTCOME_CHARS = 24
47const MAX_RATING_CHARS = 24
48const MAX_HINT_CHARS = 24
49const MAX_ID_CHARS = 64
50const MAX_TOTAL_MESSAGES = 20
51const MAX_DICE = 40
52 
53const VALENCES: RunValence[] = ['positive', 'negative', 'neutral']
54const coerceValence = (v: unknown): RunValence =>
55 VALENCES.includes(v as RunValence) ? (v as RunValence) : 'neutral'
56 
57// The causal-chain status rides a ledger entry as a nested object β€” validate the tier
58// against the known set and keep gap/factor only if finite; a malformed blob drops to
59// null (no ⏳ badge), never throws.
60const cleanChainStatus = (v: unknown): ChainStatus | null => {
61 if (!v || typeof v !== 'object') return null
62 const c = v as Record<string, unknown>
63 if (!CHAIN_TIERS.includes(c.tier as ChainTier)) return null
64 if (typeof c.gap !== 'number' || !Number.isFinite(c.gap)) return null
65 if (typeof c.factor !== 'number' || !Number.isFinite(c.factor)) return null
66 return { gap: c.gap, factor: c.factor, tier: c.tier as ChainTier }
68 
69const clampNonNeg = (n: number): number => Math.max(0, n)
70 
71const cleanRoll = (v: unknown): RollMark | null => {
72 if (!v || typeof v !== 'object') return null
73 const m = v as Record<string, unknown>
74 const diceOutcome = cleanString(m.diceOutcome, MAX_OUTCOME_CHARS)
75 if (!diceOutcome) return null
76 return { diceRoll: clampNonNeg(clampInt(m.diceRoll, MAX_DICE)), diceOutcome: diceOutcome as DiceOutcome }
78 
79/**
80 * Coerce an untrusted body into a valid RunSnapshot, or null when it isn't a
81 * completed run we can save. Bounds every field, and stamps the server's own
82 * `version` rather than trusting the client's. Returns null when the run isn't
83 * terminal or has no objective / run id β€” there is nothing meaningful to persist then.
84 */
85export function sanitizeSnapshot(raw: unknown): RunSnapshot | null {
86 if (!raw || typeof raw !== 'object') return null
87 const r = raw as Record<string, unknown>
88 
89 const status: RunOutcome | null =
90 r.status === 'victory' || r.status === 'defeat' ? r.status : null
91 if (!status) return null
92 
93 const runId = sanitizeRunId(r.runId)
94 if (!runId) return null
95 
96 if (!r.objective || typeof r.objective !== 'object') return null
97 const o = r.objective as Record<string, unknown>
98 const objective: GameObjective = {
99 title: cleanString(o.title, MAX_OBJECTIVE_TITLE_CHARS),
100 description: cleanString(o.description, MAX_OBJECTIVE_DESC_CHARS),
101 era: cleanString(o.era, MAX_ERA_CHARS),
102 icon: cleanString(o.icon, MAX_ICON_CHARS)
103 }
104 if (typeof o.anchorYear === 'number' && Number.isFinite(o.anchorYear)) {
105 objective.anchorYear = Math.round(o.anchorYear)
106 }
107 
108 const totalMessages = clampNonNeg(clampInt(r.totalMessages, MAX_TOTAL_MESSAGES)) || TOTAL_MESSAGES
109 const messagesUsed = Math.min(totalMessages, clampNonNeg(clampInt(r.messagesUsed, MAX_TOTAL_MESSAGES)))
110 
111 let efficiency: RunEfficiency | null = null
112 if (r.efficiency && typeof r.efficiency === 'object') {
113 const e = r.efficiency as Record<string, unknown>
114 efficiency = {
115 messagesSaved: clampNonNeg(clampInt(e.messagesSaved, MAX_TOTAL_MESSAGES)),
116 messagesUsed: clampNonNeg(clampInt(e.messagesUsed, MAX_TOTAL_MESSAGES)),
117 efficiencyPercentage: clampNonNeg(clampInt(e.efficiencyPercentage, 100)),
118 efficiencyRating: (cleanString(e.efficiencyRating, MAX_RATING_CHARS) || 'Standard') as EfficiencyRating,
119 isEarlyVictory: e.isEarlyVictory === true
120 }
121 }
122 
123 const dispatches: RunDispatch[] = cleanArray(r.dispatches, MAX_HISTORY_ENTRIES).map((raw) => {
124 const d = (raw ?? {}) as Record<string, unknown>
125 return {
126 text: cleanString(d.text, MAX_DISPATCH_CHARS),
127 figure: cleanString(d.figure, MAX_FIGURE_NAME_CHARS),
128 era: cleanString(d.era, MAX_ERA_CHARS)
129 }
130 })
131 
132 const timeline: RunLedgerEntry[] = cleanArray(r.timeline, MAX_TIMELINE_ENTRIES).map((raw) => {
133 const t = (raw ?? {}) as Record<string, unknown>
134 const entry: RunLedgerEntry = {
135 id: cleanString(t.id, MAX_ID_CHARS),
136 figureName: cleanString(t.figureName, MAX_FIGURE_NAME_CHARS),
137 era: cleanString(t.era, MAX_ERA_CHARS),
138 headline: cleanString(t.headline, MAX_HEADLINE_CHARS),
139 detail: cleanString(t.detail, MAX_LEDGER_DETAIL_CHARS),
140 diceRoll: clampNonNeg(clampInt(t.diceRoll, MAX_DICE)),
141 diceOutcome: cleanString(t.diceOutcome, MAX_OUTCOME_CHARS) as DiceOutcome,
142 progressChange: clampInt(t.progressChange, MAX_LEDGER_SWING),
143 valence: coerceValence(t.valence)
144 }
145 // The pre-amplifier swing is signed and only present when it amplified; keep
146 // "absent" distinct from a real 0 so the Spine shows the Ξ” equation faithfully.
147 if (typeof t.baseProgressChange === 'number') {
148 entry.baseProgressChange = clampInt(t.baseProgressChange, MAX_LEDGER_SWING)
149 }
150 const craft = cleanString(t.craft, MAX_HINT_CHARS)
151 if (craft) entry.craft = craft as Craft
152 // The pre-turn momentum that amplified the swing (0..MOMENTUM_MAX) β€” the detail
153 // strip reads it for the ✦ factor, so a saved run discloses momentum too.
154 if (typeof t.momentumAtSwing === 'number') {
155 entry.momentumAtSwing = clampNonNeg(clampInt(t.momentumAtSwing, MOMENTUM_MAX))
156 }
β‹― 43 lines hidden (lines 157–199)
157 const anachronism = cleanString(t.anachronism, MAX_HINT_CHARS)
158 if (anachronism) entry.anachronism = anachronism as Anachronism
159 if (t.staked === true) entry.staked = true
160 const causalChain = cleanChainStatus(t.causalChain)
161 if (causalChain) entry.causalChain = causalChain
162 return entry
163 })
164 
165 let chronicle: ChronicleEntry | null = null
166 if (r.chronicle && typeof r.chronicle === 'object') {
167 const c = r.chronicle as Record<string, unknown>
168 chronicle = {
169 title: cleanString(c.title, MAX_HEADLINE_CHARS),
170 paragraphs: cleanArray(c.paragraphs, MAX_HISTORY_ENTRIES)
171 .map((p) => cleanString(p, MAX_OBJECTIVE_DESC_CHARS))
172 .filter(Boolean)
173 }
174 }
175 
176 const objectiveProgress = clampNonNeg(clampInt(r.objectiveProgress, 100))
177 // The high-water mark is a peak, so it can't sit below the final progress β€” floor it
178 // there. That both defaults an absent peak (an old client / a direct POST) to the
179 // final % (peak == final β†’ no "Peaked at X%") and rejects a malformed peak < final.
180 const peakProgress = Math.max(objectiveProgress, clampNonNeg(clampInt(r.peakProgress, 100)))
181 
182 return {
183 // The server owns the version stamp β€” never trust the client's.
184 version: RUN_SNAPSHOT_VERSION,
185 runId,
186 status,
187 objective,
188 objectiveProgress,
189 peakProgress,
190 messagesUsed,
191 totalMessages,
192 bestRoll: cleanRoll(r.bestRoll),
193 worstRoll: cleanRoll(r.worstRoll),
194 efficiency,
195 dispatches,
196 timeline,
197 chronicle
198 }

One equation everywhere: the ledger folds onto the shared tokens

The ledger detail strip used to disclose levers its own way: a bare "(base β†’ final)" plus standalone craft, anachronism, chain, and staked badges beside it. So the factors, even once added to equationTokens, would not have reached it. This replaces that whole row's tail with the same token-bearing equation the thread and leaf render, and retires the four standalone badges β€” every lever now rides inside the one equation.

The detail strip now renders equationTokens inline; the parallel badges are gone.

components/TimelineLedger.vue Β· 212 lines
components/TimelineLedger.vue212 lines Β· Vue
β‹― 63 lines hidden (lines 1–63)
1<template>
2 <!-- THE SPINE: history as a horizontal era-axis you travel left→right. -->
3 <div data-testid="timeline-ledger">
4 <div v-if="!props.bare" class="border-b ew-line pb-1.5 mb-3">
5 <div class="flex items-center justify-between gap-3">
6 <span class="ew-label">The Spine</span>
7 <span class="ew-mono ew-faint text-[11px] normal-case tracking-normal">
8 {{ events.length }} {{ events.length === 1 ? 'change' : 'changes' }}<template v-if="anachCount"> Β· ⚑{{ anachCount }}</template><span v-if="events.length" class="ml-2">scroll β†’</span>
9 </span>
10 </div>
11 <p class="ew-faint text-[11px] mt-0.5">{{ props.readonly ? 'the history you wrote' : "the timeline you're rewriting" }} β€” every change, in the order you made it<template v-if="events.length"> Β· <span class="ew-accent">tap any node to inspect</span></template></p>
12 </div>
13 
14 <!-- Empty state β€” but the instant a first move is resolving, it leans in rather
15 than still reading "unwritten" (the board never contradicts the act). -->
16 <div v-if="events.length === 0" data-testid="timeline-empty" class="text-center py-12">
17 <template v-if="!props.readonly && gameStore.isLoading">
18 <div class="text-4xl mb-3 opacity-50 ew-accent" aria-hidden="true">βœ’</div>
19 <p class="ew-serif ew-fg text-lg">The timeline trembles…</p>
20 <p class="ew-faint text-xs mt-1">Your message ripples into the past.</p>
21 </template>
22 <template v-else>
23 <div class="text-4xl mb-3 opacity-40" aria-hidden="true">πŸ“œ</div>
24 <p class="ew-serif ew-fg text-lg">History is still unwritten.</p>
25 <p class="ew-faint text-xs mt-1">Send your first message to bend the timeline.</p>
26 </template>
27 </div>
28 
29 <!-- The axis -->
30 <div v-else class="spine-scroll overflow-x-auto px-1 pb-2">
31 <TransitionGroup name="timeline" tag="ol" class="flex items-start min-w-full" aria-label="Timeline of changes">
32 <li v-for="event in events" :key="event.id" data-testid="timeline-event"
33 class="spine-node ew-press" :class="{ 'is-selected': event.id === selectedId }"
34 role="button" tabindex="0" @click="select(event.id)" @keydown.enter="select(event.id)" @keydown.space.prevent="select(event.id)">
35 <span class="sr-only">{{ event.headline }} β€” {{ event.detail }} ({{ signed(event.progressChange) }})</span>
36 <span class="block text-center ew-mono text-[11px] ew-faint">{{ event.era || 'β€”' }}</span>
37 <span class="dot-wrap" aria-hidden="true">
38 <span class="connector" />
39 <span class="ew-dot" :class="event.id === selectedId ? 'ew-dot--accent' : dotClass(event.valence)" />
40 </span>
41 <span class="block text-center ew-mono text-xs ew-fg truncate px-1">{{ event.figureName }}</span>
42 <span class="block text-center ew-mono text-[11px] mt-0.5 tabular-nums">
43 <span class="ew-faint">🎲</span>{{ event.diceRoll }}
44 <span class="delta-cell" :class="deltaClass(event.progressChange)">{{ signed(event.progressChange) }}</span><span
45 v-if="isAnachronistic(event.anachronism)" class="ew-warn ml-0.5" :aria-label="anachronismLabel(event.anachronism)" :title="anachronismHint(event.anachronism)">{{ anachPips(event.anachronism) }}</span><span
46 v-if="event.staked" class="ew-warn ml-0.5" aria-label="Staked β€” the last stand" :title="STAKE_HINT">βš‘</span>
47 </span>
48 </li>
49 
50 <!-- the live present β€” only while the run is live; the verdict has no next move -->
51 <li v-if="!props.readonly" key="now" class="spine-now" aria-hidden="true">
52 <span class="block text-center ew-mono text-[11px] ew-accent font-semibold">β–· now</span>
53 <span class="dot-wrap"><span class="connector connector--half" /></span>
54 <span class="block text-center ew-faint text-[11px]">your move</span>
55 </li>
56 </TransitionGroup>
57 </div>
58 
59 <!-- Node-detail strip: the selected change in full (defaults to the newest).
60 The consequence headline leads β€” the second of the board's two focal peaks. -->
61 <div v-if="selected" data-testid="node-detail" class="border-t ew-line pt-2.5 mt-2">
62 <p class="ew-serif ew-fg text-lg lg:text-xl font-semibold leading-snug">{{ selected.headline }}</p>
63 <p class="ew-muted text-xs lg:text-sm mt-1 leading-relaxed max-w-2xl">{{ selected.detail }}</p>
64 <div class="flex items-center gap-x-2 gap-y-1 flex-wrap text-[11px] ew-mono mt-1.5 tabular-nums">
65 <span class="inline-flex items-center gap-1.5">
66 <span class="ew-dot" :class="dotClass(selected.valence)" aria-hidden="true" />
67 <span class="ew-faint">{{ selected.era || 'β€”' }} Β· {{ selected.figureName }}</span>
68 </span>
69 <span class="ew-hair-c">Β·</span>
70 <span class="ew-muted">🎲 {{ selected.diceRoll }} · {{ selected.diceOutcome }}</span>
71 <span class="ew-hair-c">Β·</span>
72 <span class="font-bold" :class="deltaClass(selected.progressChange)">{{ signed(selected.progressChange) }}%</span>
73 <!-- The full base β†’ final breakdown, in the open on every turn (issue #135), with
74 each scaler disclosing its multiplier so the Ξ” reconciles (issue #184): ⚑
75 wager, ⏳ chain Γ—f, βœ’ craft Γ—f, ✦ momentum Γ—f, βš‘ stake. Rendered from the SAME
76 equationTokens as the thread + codex leaf, so a lever can't explain a swing in
77 one place and vanish here; collapses to (+22 β†’ +22) when nothing moved it. -->
78 <span v-if="selected.baseProgressChange !== undefined" data-testid="swing-equation"
79 class="ew-faint inline-flex items-center flex-wrap gap-x-1">
80 <span>({{ signed(selected.baseProgressChange) }}</span>
81 <SymbolHint v-for="tok in equationTokens(selected)" :key="tok.kind" :text="tok.hint">
82 <span :data-testid="`equation-${tok.kind}`">{{ tok.label }}</span>
83 </SymbolHint>
84 <span>β†’ {{ signed(selected.progressChange) }})</span>
85 </span>
β‹― 127 lines hidden (lines 86–212)
86 </div>
87 </div>
88 </div>
89</template>
90 
91<script setup lang="ts">
92/**
93 * TimelineLedger β€” the Spine. Each resolved turn is a node on a horizontal era-axis
94 * (valence dot + mono year/figure/roll/Ξ” + ⚑), newest on the right with a β–·now marker;
95 * clicking a node snaps its full payload into the node-detail strip, which defaults to
96 * the newest change so the latest turn is always visible. Pure glyphs on the axis; the
97 * only prose lives in the strip. (Re-skin only β€” the store data is unchanged.)
98 */
99import { computed, ref, watch } from 'vue'
100import { useGameStore } from '~/stores/game'
101import type { Valence } from '~/stores/game'
102import { ANACHRONISM_HINT, ANACHRONISM_LABEL, type Anachronism } from '~/server/utils/anachronism'
103import { STAKE_HINT } from '~/utils/swing-bands'
104import { equationTokens } from '~/utils/swing-equation'
105import type { RunLedgerEntry } from '~/utils/run-snapshot'
106 
107const gameStore = useGameStore()
108const props = defineProps<{
109 /** Verdict / past-run view (the end screen): drop the live "β–· now" present
110 * marker and the "the timeline trembles…" loading empty state, and frame the
111 * lead in past tense. In-game usage passes nothing, so its behavior is
112 * unchanged β€” the Spine the player scrolled and tapped renders the same. */
113 readonly?: boolean
114 /** Events to render. Omitted in live play, where the Spine reads the live store.
115 * The saved-run view (RunView) passes a snapshot's ledger so a past run renders
116 * the same Spine from the saved record, not the (empty/irrelevant) live store.
117 * `RunLedgerEntry` is the view-facing projection of the store's `TimelineEvent`,
118 * so the live events satisfy it too β€” the fallback stays byte-identical. */
119 events?: RunLedgerEntry[]
120 /** Drop the "The Spine" header block for inline use where an outer surface (the
121 * record pane) already labels it. Off by default, so the end-screen and
122 * saved-run views keep their own header. */
123 bare?: boolean
124}>()
125const events = computed<RunLedgerEntry[]>(() => props.events ?? gameStore.timelineEvents)
126 
127const selectedId = ref<string | null>(null)
128// Default the detail strip to the newest change, and snap to it whenever one lands.
129watch(() => events.value.length, () => {
130 const last = events.value[events.value.length - 1]
131 if (last) selectedId.value = last.id
132}, { immediate: true })
133 
134const selected = computed(() =>
135 events.value.find(e => e.id === selectedId.value) ?? events.value[events.value.length - 1] ?? null
137const anachCount = computed(() => events.value.filter(e => isAnachronistic(e.anachronism)).length)
138 
139function select(id: string) { selectedId.value = id }
140function signed(n: number) { return `${n > 0 ? '+' : ''}${n}` }
141function isAnachronistic(a?: Anachronism) { return !!a && a !== 'in-period' }
142function anachronismLabel(a?: Anachronism) { return a ? ANACHRONISM_LABEL[a] : '' }
143function anachronismHint(a?: Anachronism) { return a ? ANACHRONISM_HINT[a] : '' }
144/** A quantified, glanceable anachronism tier on the spine node: 1–3 amber pips. */
145function anachPips(a?: Anachronism) {
146 return a === 'impossible' ? '⚑⚑⚑' : a === 'far-ahead' ? '⚑⚑' : a === 'ahead' ? '⚑' : ''
148 
149function dotClass(v: Valence) {
150 return v === 'positive' ? 'ew-dot--ok' : v === 'negative' ? 'ew-dot--bad' : 'ew-dot--mut'
152function deltaClass(change: number) {
153 if (change > 0) return 'ew-ok'
154 if (change < 0) return 'ew-bad'
155 return 'ew-muted'
157</script>
158 
159<style scoped>
160.spine-node,
161.spine-now {
162 flex: 0 0 auto;
163 min-width: 92px;
164 padding: 2px 4px;
165 cursor: pointer;
166 border: none;
167 background: transparent;
168 scroll-snap-align: center;
170.spine-scroll { scroll-snap-type: x proximity; -webkit-overflow-scrolling: touch; }
171/* Reserve a stable sign slot so a column of +/βˆ’ deltas stacks rule-straight. */
172.delta-cell { display: inline-block; min-width: 1.9em; text-align: left; }
173.spine-now { cursor: default; }
174.spine-node:focus-visible { outline: 2px solid var(--ew-accent); outline-offset: 2px; border-radius: 3px; }
175 
176/* The axis line is drawn by a full-width connector behind each node's dot. */
177.dot-wrap {
178 position: relative;
179 display: flex;
180 justify-content: center;
181 align-items: center;
182 height: 16px;
183 margin: 3px 0;
185.connector {
186 position: absolute;
187 top: 50%;
188 left: 0;
189 right: 0;
190 height: 1px;
191 background: var(--ew-line);
192 transform: translateY(-50%);
194.connector--half { right: 50%; }
195.dot-wrap > .ew-dot { position: relative; z-index: 1; }
196.spine-node.is-selected .dot-wrap > .ew-dot {
197 width: 12px;
198 height: 12px;
199 box-shadow: 0 0 0 3px var(--ew-bg), 0 0 0 4px var(--ew-accent);
201.spine-node:hover .dot-wrap > .ew-dot { transform: translateY(-1px); }
202 
203/* Preserved entry/move animation (now driving the right-edge node arrival). */
204.timeline-enter-active { transition: opacity var(--ew-dur-3) var(--ew-ease), transform var(--ew-dur-3) var(--ew-ease); }
205.timeline-enter-from { opacity: 0; transform: translateY(-10px); }
206.timeline-move { transition: transform var(--ew-dur-3) var(--ew-ease); }
207 
208@media (prefers-reduced-motion: reduce) {
209 .timeline-enter-active, .timeline-move { transition: none; }
210 .spine-node:hover .dot-wrap > .ew-dot { transform: none; }
212</style>

The tests reconcile the math

The unit spec pins the behavior that matters: each scaler token carries its factor, anachronism carries none, and the factors multiply out. The second test below is the issue in miniature β€” a chain-decayed, vague gain shows both factors, so 22 Γ— 0.41 Γ— 0.4 reads straight off to the result. The component specs assert the same strings render on the thread, the codex leaf, and the ledger, with data that reconciles (20 Γ— 1.45 = 29, 10 Γ— 1.4 = 14, βˆ’10 Γ— 0.8 = βˆ’8).

Anachronism stays pip-only; every scaler factor is present and multiplies out.

tests/unit/utils/swing-equation.spec.ts Β· 95 lines
tests/unit/utils/swing-equation.spec.ts95 lines Β· TypeScript
β‹― 79 lines hidden (lines 1–79)
1import { describe, it, expect } from 'vitest'
2import { equationTokens, type SwingBreakdown } from '~/utils/swing-equation'
3import type { ChainStatus } from '~/utils/causal-chain'
4 
5// The shared source of truth behind the base β†’ final equation on every surface
6// (thread, codex reveal leaf, ledger). The behavior that matters: a lever that moved
7// the swing earns exactly one token, an idle lever earns none, and the order matches
8// the server's apply order β€” so the printed equation reconciles to the result and the
9// "+22 β†’ +9%, how?" gap can never reappear.
10 
11const chain = (tier: ChainStatus['tier']): ChainStatus => ({ gap: 600, factor: 0.41, tier })
12const kinds = (b: SwingBreakdown) => equationTokens(b).map(t => t.kind)
13 
14describe('equationTokens', () => {
15 it('collapses to no tokens on a plain in-period, sound, full-power gain', () => {
16 expect(equationTokens({ progressChange: 12, anachronism: 'in-period', craft: 'sound' })).toEqual([])
17 })
18 
19 it('emits a ⏳ chain token, with its decay factor, when a far reach shrank the swing', () => {
20 const toks = equationTokens({ progressChange: 9, causalChain: chain('reaching') })
21 expect(toks).toHaveLength(1)
22 expect(toks[0].kind).toBe('chain')
23 expect(toks[0].label).toContain('⏳')
24 expect(toks[0].label).toContain('reaching')
25 expect(toks[0].label).toContain('Γ—0.41') // the multiplier, so base β†’ final reconciles
26 })
27 
28 it('stays silent when the reach landed at the hinge (nothing to explain)', () => {
29 expect(equationTokens({ progressChange: 22, causalChain: chain('at-hinge') })).toEqual([])
30 })
31 
32 it('emits a βœ’ craft token with its gain factor β€” the +22 β†’ +9 culprit, now disclosed', () => {
33 const toks = equationTokens({ progressChange: 9, craft: 'vague' })
34 expect(toks).toHaveLength(1)
35 expect(toks[0].kind).toBe('craft')
36 expect(toks[0].label).toContain('vague')
37 expect(toks[0].label).toContain('Γ—0.4') // CRAFT_GAIN_FACTOR.vague β€” 22 Γ— 0.4 β‰ˆ 9
38 expect(toks[0].hint).toContain('scales the gain') // gain-side wording
39 })
40 
41 it('shows the INVERTED loss factor on a loss β€” craft shields rather than scales', () => {
42 const [tok] = equationTokens({ progressChange: -8, craft: 'masterful' })
43 expect(tok.kind).toBe('craft')
44 expect(tok.label).toContain('Γ—0.6') // CRAFT_LOSS_FACTOR.masterful β€” a softened blow
45 expect(tok.hint).toContain('shields')
46 })
47 
48 it('skips the craft token on a neutral sound grade and on a zero swing', () => {
49 expect(equationTokens({ progressChange: 9, craft: 'sound' })).toEqual([])
50 expect(equationTokens({ progressChange: 0, craft: 'vague' })).toEqual([])
51 })
52 
53 it('scales the ⚑ pips with the anachronism tier', () => {
54 expect(equationTokens({ progressChange: 9, anachronism: 'ahead' })[0].label).toContain('⚑ ')
55 expect(equationTokens({ progressChange: 9, anachronism: 'far-ahead' })[0].label).toContain('⚑⚑')
56 expect(equationTokens({ progressChange: 9, anachronism: 'impossible' })[0].label).toContain('⚑⚑⚑')
57 })
58 
59 it('shows momentum only on a positive swing it actually lifted', () => {
60 expect(kinds({ progressChange: 9, momentumAtSwing: 3 })).toContain('momentum')
61 expect(kinds({ progressChange: -9, momentumAtSwing: 3 })).not.toContain('momentum')
62 expect(kinds({ progressChange: 9, momentumAtSwing: 0 })).not.toContain('momentum')
63 })
64 
65 it('labels momentum with its MULTIPLIER, not the raw level β€” that is what the equation multiplies by', () => {
66 const [tok] = equationTokens({ progressChange: 9, momentumAtSwing: 3 })
67 expect(tok.kind).toBe('momentum')
68 expect(tok.label).toContain('Γ—1.3') // momentumFactor(3) = 1 + 0.1Β·3
69 expect(tok.hint).toContain('3 of') // the level still rides in the hint
70 })
71 
72 it('orders the tokens the way the server applied them: anachronism β†’ chain β†’ craft β†’ momentum β†’ staked', () => {
73 const toks = kinds({
74 progressChange: 14, anachronism: 'far-ahead', causalChain: chain('reaching'),
75 craft: 'sharp', momentumAtSwing: 2, staked: true
76 })
77 expect(toks).toEqual(['anachronism', 'chain', 'craft', 'momentum', 'staked'])
78 })
79 
80 it('keeps anachronism QUALITATIVE β€” pips, no Γ—factor (the wager stays off the math)', () => {
81 const [tok] = equationTokens({ progressChange: 9, anachronism: 'far-ahead' })
82 expect(tok.kind).toBe('anachronism')
83 expect(tok.label).not.toContain('Γ—') // its factor is "qualitative on purpose"
84 })
85 
86 it('discloses every scaler factor so base Γ— factors reconciles to the shown final (issue #184)', () => {
87 // The vague Success the issue filed on: base 22, in-period, chain 1.0 β†’ +9.
88 // The single craft token now shows Γ—0.4, and 22 Γ— 0.4 β‰ˆ 9 reads straight off.
89 const [craftTok] = equationTokens({ progressChange: 9, craft: 'vague' })
90 expect(craftTok.label).toMatch(/βœ’ vague Γ—0\.4/)
91 // A chain-decayed, vague gain: both scalers show their factor (22 Γ— 0.41 Γ— 0.4 β‰ˆ 4).
92 const toks = equationTokens({ progressChange: 4, causalChain: chain('reaching'), craft: 'vague' })
93 expect(toks.map(t => t.label)).toEqual(['⏳ reaching Γ—0.41', 'βœ’ vague Γ—0.4'])
94 })
95})