stage: auto-recovery splash over the game, polled from flystage-web

The loop-recovery helper's notice (/run/fly/wd/recovery-notice.json,
FLY_RECOVERY_NOTICE) is served by serve.mjs at /recovery-notice.json and
polled once a second by the page, independent of flysim. Countdown shows a
Game Boy text box over the bottom of the game; acting covers the game while
flysim is down; done/failed show briefly. Stale, malformed or oversized
notices are ignored and helper strings are clipped to a closed charset.
Adds ?recovery=, __stage.recovery(), the recovery-notice dev tool, unit
tests (model, poller, serve.mjs route) and an e2e spec.
This commit is contained in:
acamilo 2026-09-28 21:34:37 +00:00
parent 1bd2223431
commit 15f2e6dbae
14 changed files with 1376 additions and 4 deletions

View file

@ -14,7 +14,8 @@
"test:e2e": "playwright test", "test:e2e": "playwright test",
"mockups": "tsx tools/mockup.mts", "mockups": "tsx tools/mockup.mts",
"fonts": "tsx tools/font-compare.mts", "fonts": "tsx tools/font-compare.mts",
"record": "tsx tools/record-fixture.mts" "record": "tsx tools/record-fixture.mts",
"recovery-notice": "tsx tools/recovery-notice.mts"
}, },
"dependencies": { "dependencies": {
"@flybrain/brain": "*", "@flybrain/brain": "*",

View file

@ -50,6 +50,8 @@ import {
boxStyle, boxStyle,
} from '@/lib/geometry'; } from '@/lib/geometry';
import { stageOptions } from '@/lib/query'; import { stageOptions } from '@/lib/query';
import { parseRecoveryNotice, recoveryView, type RecoveryView } from '@/lib/recovery';
import { RecoveryPoller } from '@/lib/recovery-poll';
import { Director, FLY_HEAD_ANCHOR } from '@/motion/director'; import { Director, FLY_HEAD_ANCHOR } from '@/motion/director';
import { MotionEngine } from '@/motion/engine'; import { MotionEngine } from '@/motion/engine';
import type { MomentType } from '@/motion/moments'; import type { MomentType } from '@/motion/moments';
@ -68,6 +70,7 @@ import { FlyStrip } from '@/panels/FlyStrip';
import { GamePanel } from '@/panels/GamePanel'; import { GamePanel } from '@/panels/GamePanel';
import { MomentLayer } from '@/panels/MomentLayer'; import { MomentLayer } from '@/panels/MomentLayer';
import { ProgressCluster } from '@/panels/ProgressCluster'; import { ProgressCluster } from '@/panels/ProgressCluster';
import { RecoverySplash, nowSeconds, useRecovery } from '@/panels/RecoverySplash';
import { StaleBanner } from '@/panels/StaleBanner'; import { StaleBanner } from '@/panels/StaleBanner';
import { TabSlot } from '@/panels/TabSlot'; import { TabSlot } from '@/panels/TabSlot';
import { TitleStrip } from '@/panels/TitleStrip'; import { TitleStrip } from '@/panels/TitleStrip';
@ -632,6 +635,14 @@ export function App() {
loop.start(); loop.start();
// -- The auto-recovery splash's notice ----------------------------------------------------
// Polled from `flystage-web`, not the feed: flysim is what the helper restarts
// (`src/lib/recovery-poll.ts`). Off in player mode unless `?recovery=1`.
const recoveryPoller = options.recovery
? new RecoveryPoller({ onChange: (notice) => useRecovery.setState({ notice }) })
: null;
recoveryPoller?.start();
// -- Test and operator surface ------------------------------------------------------------ // -- Test and operator surface ------------------------------------------------------------
window.__stage = { window.__stage = {
options, options,
@ -687,6 +698,19 @@ export function App() {
ingest.commit(hot.lastSnapshotMs, true); ingest.commit(hot.lastSnapshotMs, true);
return useStage.getState().chat.length; return useStage.getState().chat.length;
}, },
/**
* Put a notice on the recovery splash by hand, the same way `fire` drives a moment: for the
* splash mockups and e2e assertions. The raw object goes through the same validation as a
* polled file, the poller stops so it cannot overwrite it, and `nowS` pins the splash's clock
* so a countdown screenshot is reproducible. `null` clears it. Returns what the splash shows.
*/
recovery: (raw, nowS) => {
recoveryPoller?.stop();
const notice = raw === null ? null : parseRecoveryNotice(raw);
const pinnedNowS = nowS ?? null;
useRecovery.setState({ notice, pinnedNowS });
return recoveryView(notice, nowSeconds(pinnedNowS));
},
fly: () => ({ fly: () => ({
// The renderer that is actually drawing, which is not always the one that was asked for: // The renderer that is actually drawing, which is not always the one that was asked for:
// `webgl` falls back to `paper` on a host with no usable GL context (`src/fly/index.ts`). // `webgl` falls back to `paper` on a host with no usable GL context (`src/fly/index.ts`).
@ -714,6 +738,7 @@ export function App() {
if (readyTimer !== null) clearTimeout(readyTimer); if (readyTimer !== null) clearTimeout(readyTimer);
loop.stop(); loop.stop();
source?.stop(); source?.stop();
recoveryPoller?.stop();
fly?.dispose(); fly?.dispose();
worker.terminate(); worker.terminate();
void engine?.stop(); void engine?.stop();
@ -739,6 +764,7 @@ export function App() {
<ChatPanel source={options.chat} /> <ChatPanel source={options.chat} />
<MomentLayer particleRef={particleCanvas} /> <MomentLayer particleRef={particleCanvas} />
<RecoverySplash />
<StaleBanner /> <StaleBanner />
</div> </div>
); );
@ -764,6 +790,8 @@ declare global {
fire: (type: MomentType, label?: string, detail?: string) => number | null; fire: (type: MomentType, label?: string, detail?: string) => number | null;
/** Replace the held feed's chat ring by hand; returns how many lines the panel accepted. */ /** Replace the held feed's chat ring by hand; returns how many lines the panel accepted. */
chat: (lines: readonly { by: string; text: string; bot?: boolean }[]) => number; chat: (lines: readonly { by: string; text: string; bot?: boolean }[]) => number;
/** Show a recovery notice by hand (validated like a polled one); null clears it. */
recovery: (notice: unknown, nowS?: number) => RecoveryView | null;
fly: () => { fly: () => {
mode: string; mode: string;
requested: string; requested: string;

View file

@ -3,6 +3,7 @@
@import './theme/panels.css'; @import './theme/panels.css';
@import './theme/rail.css'; @import './theme/rail.css';
@import './theme/motion.css'; @import './theme/motion.css';
@import './theme/recovery.css';
/** /**
* Self-hosted OFL faces. `font-display: block` with the default 3 s block period is deliberate: * Self-hosted OFL faces. `font-display: block` with the default 3 s block period is deliberate:

View file

@ -57,6 +57,12 @@ export interface StageOptions {
audio: boolean; audio: boolean;
/** `?metrics=1` keeps the paint-stage histogram and prints it on demand. */ /** `?metrics=1` keeps the paint-stage histogram and prints it on demand. */
metrics: boolean; metrics: boolean;
/**
* Poll `flystage-web` for the auto-recovery notice (`src/lib/recovery.ts`). On by default in
* `live` mode and off in `player` mode, so a fixture screenshot can never pick up a notice file
* that happens to exist on the machine; `?recovery=1` / `?recovery=0` force it either way.
*/
recovery: boolean;
} }
const THEMES: readonly StageTheme[] = ['t1', 't2', 't3']; const THEMES: readonly StageTheme[] = ['t1', 't2', 't3'];
@ -114,6 +120,7 @@ export function parseStageOptions(search: string, defaultFeedUrl = 'ws://127.0.0
}, },
audio: flag(params, 'audio', true), audio: flag(params, 'audio', true),
metrics: flag(params, 'metrics', false), metrics: flag(params, 'metrics', false),
recovery: flag(params, 'recovery', mode === 'live'),
}; };
} }

View file

@ -0,0 +1,88 @@
/**
* Polls `flystage-web` for the recovery notice (`src/lib/recovery.ts`).
*
* Deliberately not the feed: flysim is the thing being restarted, so during `acting` there is no
* feed and no control API to ask. `flystage-web` is the page's own static server, in the same
* container and independent of flysim, and serves the helper's file at
* {@link RECOVERY_NOTICE_ROUTE} (`infra/config/serve.mjs`).
*
* Once a second, with a short timeout, and every failure — no server, a 404 from an older
* `serve.mjs`, a 204 for "no file", a half-written body, a hung request — is "no notice". The
* poller never throws into the page and never retries faster than its interval.
*/
import { RECOVERY_NOTICE_ROUTE, parseRecoveryBody, type RecoveryNotice } from './recovery';
export const RECOVERY_POLL_MS = 1000;
const TIMEOUT_MS = 2500;
type Fetch = (input: string, init?: RequestInit) => Promise<Response>;
export interface RecoveryPollerOptions {
url?: string;
intervalMs?: number;
fetch?: Fetch;
onChange: (notice: RecoveryNotice | null) => void;
}
export class RecoveryPoller {
private readonly url: string;
private readonly intervalMs: number;
private readonly fetchFn: Fetch;
private readonly onChange: (notice: RecoveryNotice | null) => void;
private timer: ReturnType<typeof setTimeout> | null = null;
private stopped = true;
/** The last body seen, so an unchanged file does not re-render the panel. */
private lastKey = '\u0000';
constructor(options: RecoveryPollerOptions) {
this.url = options.url ?? RECOVERY_NOTICE_ROUTE;
this.intervalMs = options.intervalMs ?? RECOVERY_POLL_MS;
this.fetchFn = options.fetch ?? ((input, init) => fetch(input, init));
this.onChange = options.onChange;
}
start(): void {
if (!this.stopped) return;
this.stopped = false;
void this.tick();
}
stop(): void {
this.stopped = true;
if (this.timer !== null) clearTimeout(this.timer);
this.timer = null;
}
/** One poll. Public so the unit tests can drive it without timers. */
async poll(): Promise<RecoveryNotice | null> {
let body = '';
const abort = new AbortController();
const timeout = setTimeout(() => abort.abort(), TIMEOUT_MS);
try {
const response = await this.fetchFn(this.url, { cache: 'no-store', signal: abort.signal });
if (response.status === 200) body = await response.text();
} catch {
body = '';
} finally {
clearTimeout(timeout);
}
const notice = parseRecoveryBody(body);
const key = notice === null ? '' : JSON.stringify(notice);
if (key !== this.lastKey) {
this.lastKey = key;
try {
this.onChange(notice);
} catch (error) {
console.warn(`recovery splash: ${(error as Error).message}`);
}
}
return notice;
}
private async tick(): Promise<void> {
if (this.stopped) return;
await this.poll();
if (this.stopped) return;
this.timer = setTimeout(() => void this.tick(), this.intervalMs);
}
}

View file

@ -0,0 +1,286 @@
/**
* The recovery splash's model: the notice file's contract, its validation, and what the splash
* shows at a given wall-clock second.
*
* `infra/bin/fly-loop-recover` (the auto-unstick helper) writes one small JSON file while it acts
* on a confirmed macro loop — restarting flysim, or resetting the run to an earlier rung — and
* `flystage-web` serves it to this page at {@link RECOVERY_NOTICE_ROUTE}. Viewers otherwise see
* the game freeze or jump for no reason; the splash says what is happening.
*
* Everything here is pure: the poller (`src/lib/recovery-poll.ts`) fetches, the panel
* (`src/panels/RecoverySplash.tsx`) renders, and this file decides. The one rule it exists to
* keep is that **the notice can never break the stream**: a missing, malformed, oversized, future
* or stale file is no notice at all, and every string the helper wrote is clipped to a closed
* character set before it can reach the screen.
*/
/** Where `flystage-web` (and the Vite dev/preview servers) serve the notice file. */
export const RECOVERY_NOTICE_ROUTE = '/recovery-notice.json';
/** Ignore a notice whose `updatedAt` is older than this (the contract: 15 minutes). */
export const RECOVERY_STALE_S = 15 * 60;
/** How long `done` stays up after the helper wrote it. */
export const RECOVERY_DONE_S = 8;
/** How long `failed` stays up after the helper wrote it. */
export const RECOVERY_FAILED_S = 20;
/**
* Stop covering the game this long after an `acting` write, even if the helper never follows up.
* A helper that died mid-recovery must not leave the game hidden behind a splash; with the game
* uncovered again the page's own STALE FEED banner is the honest state.
*/
export const RECOVERY_ACTING_MAX_S = 10 * 60;
/** Tolerated clock skew between the helper's `updatedAt` and the page's clock. */
const FUTURE_SKEW_S = 120;
export type RecoveryPhase = 'countdown' | 'acting' | 'done' | 'failed';
export type RecoveryAction = 'restart' | 'reset';
/** A validated notice. Field names are the file's own; see `infra/docs/loop-recovery.md`. */
export interface RecoveryNotice {
id: string;
phase: RecoveryPhase;
action: RecoveryAction;
fromRung: number | null;
fromLabel: string;
/** Only for `reset`; null otherwise. */
toRung: number | null;
toLabel: string;
reason: string;
loop: string[];
stuckSeconds: number | null;
announcedAt: number;
executeAt: number;
updatedAt: number;
}
const PHASES: readonly RecoveryPhase[] = ['countdown', 'acting', 'done', 'failed'];
const ACTIONS: readonly RecoveryAction[] = ['restart', 'reset'];
/** Longest place name kept (the ladder's longest is 15 characters). */
const LABEL_MAX = 24;
/** Longest macro name kept (the pad's are at most 12, e.g. `GO OBJECTIVE`). */
const MACRO_MAX = 16;
/** At most this many macros of the loop are shown. */
const LOOP_MAX = 4;
/**
* Clip a helper-written string to what the splash may draw: printable ASCII letters, digits,
* spaces and a little punctuation (place names like `MT. MOON` or `S.S. ANNE`, `ROUTE 22`), with
* runs of whitespace collapsed, capped at `max` characters. Anything else is dropped, so a notice
* cannot put markup, control characters or an unbounded line on air.
*/
export function cleanText(value: unknown, max: number): string {
if (typeof value !== 'string') return '';
return value
.replace(/\s+/g, ' ')
.replace(/[^A-Za-z0-9 .,'&:#/-]/g, '')
.replace(/ {2,}/g, ' ')
.trim()
.slice(0, max)
.trim();
}
function finite(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function rung(value: unknown): number | null {
const n = finite(value);
return n !== null && Number.isInteger(n) && n >= 0 && n < 1000 ? n : null;
}
/**
* Validate a parsed JSON value as a v1 notice, or return null.
*
* Required: `v === 1`, `id`, a known `phase` and `action`, and finite `announcedAt`, `executeAt`,
* `updatedAt`. Everything else is optional and degrades the copy rather than the notice: a reset
* without a `toLabel` still says it is rewinding, just not to where.
*/
export function parseRecoveryNotice(raw: unknown): RecoveryNotice | null {
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null;
const o = raw as Record<string, unknown>;
if (o.v !== 1) return null;
const id = typeof o.id === 'string' ? o.id.slice(0, 64) : '';
if (id === '') return null;
if (!PHASES.includes(o.phase as RecoveryPhase)) return null;
if (!ACTIONS.includes(o.action as RecoveryAction)) return null;
const announcedAt = finite(o.announcedAt);
const executeAt = finite(o.executeAt);
const updatedAt = finite(o.updatedAt);
if (announcedAt === null || executeAt === null || updatedAt === null) return null;
const action = o.action as RecoveryAction;
const loop = Array.isArray(o.loop)
? o.loop
.map((step) => cleanText(step, MACRO_MAX).toUpperCase())
.filter((step) => step !== '')
.slice(0, LOOP_MAX)
: [];
const stuck = finite(o.stuckSeconds);
return {
id,
phase: o.phase as RecoveryPhase,
action,
fromRung: rung(o.fromRung),
fromLabel: cleanText(o.fromLabel, LABEL_MAX).toUpperCase(),
toRung: action === 'reset' ? rung(o.toRung) : null,
toLabel: action === 'reset' ? cleanText(o.toLabel, LABEL_MAX).toUpperCase() : '',
reason: cleanText(o.reason, 32),
loop,
stuckSeconds: stuck !== null && stuck >= 0 ? stuck : null,
announcedAt,
executeAt,
updatedAt,
};
}
/** Parse the body the server returned. Never throws. */
export function parseRecoveryBody(body: string): RecoveryNotice | null {
if (body.length === 0 || body.length > 16_384) return null;
try {
return parseRecoveryNotice(JSON.parse(body));
} catch {
return null;
}
}
/**
* How the splash sits on the stage: `box` is the Pokémon-style text box along the bottom of the
* game, leaving the stuck loop visible above it; `cover` fills the game panel, which is frozen or
* blank while flysim restarts.
*/
export type RecoveryLayout = 'box' | 'cover';
/** What the splash draws. Every string is already final copy. */
export interface RecoveryView {
id: string;
phase: RecoveryPhase;
action: RecoveryAction;
layout: RecoveryLayout;
/** The small chip in the frame's top edge. */
chip: string;
/** The first line, Pokémon-dialogue style. */
headline: string;
/** The second line. */
body: string;
/** `M:SS` during the countdown, otherwise empty. */
countdown: string;
/** `GO OBJECTIVE > GO WARP`, or empty. */
loop: string;
/** `STUCK 30 MIN`, or empty. */
stuck: string;
/** Whether to animate the trailing dots / blinking arrow. */
busy: boolean;
}
/** `0:42`, `1:00`, `12:05`. Negative clamps to `0:00`. */
export function formatCountdown(seconds: number): string {
const s = Math.max(0, Math.ceil(seconds));
const m = Math.floor(s / 60);
return `${m}:${String(s % 60).padStart(2, '0')}`;
}
/** `STUCK 30 MIN`, `STUCK 2 H`, `STUCK 1 H 5 MIN`; empty under a minute or when unknown. */
export function formatStuck(seconds: number | null): string {
if (seconds === null || seconds < 60) return '';
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `STUCK ${minutes} MIN`;
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return m === 0 ? `STUCK ${h} H` : `STUCK ${h} H ${m} MIN`;
}
/**
* Whether the notice is showing at `nowS` (epoch seconds), per the contract's lifetimes.
*
* Stateless on purpose: a page that reloads mid-recovery, or a Chromium the watchdog restarted,
* draws exactly what a page that watched the whole thing would, from the file alone.
*/
export function recoveryVisible(notice: RecoveryNotice, nowS: number): boolean {
const age = nowS - notice.updatedAt;
if (age > RECOVERY_STALE_S) return false;
if (age < -FUTURE_SKEW_S) return false;
switch (notice.phase) {
case 'countdown':
return true;
case 'acting':
return age <= RECOVERY_ACTING_MAX_S;
case 'done':
return age <= RECOVERY_DONE_S;
case 'failed':
return age <= RECOVERY_FAILED_S;
}
}
/** The splash at `nowS`, or null when nothing should be on screen. */
export function recoveryView(notice: RecoveryNotice | null, nowS: number): RecoveryView | null {
if (notice === null || !recoveryVisible(notice, nowS)) return null;
const reset = notice.action === 'reset';
const to = notice.toLabel;
const base = {
id: notice.id,
phase: notice.phase,
action: notice.action,
loop: notice.loop.join(' > '),
stuck: formatStuck(notice.stuckSeconds),
};
switch (notice.phase) {
case 'countdown': {
const remaining = notice.executeAt - nowS;
const now = remaining <= 0;
return {
...base,
layout: 'box',
chip: 'AUTO RECOVERY',
headline: 'The fly is stuck in a loop!',
body: reset
? now
? to !== '' ? `Rewinding to ${to} now` : 'Rewinding now'
: to !== '' ? `Rewinding to ${to} in` : 'Rewinding to the last milestone in'
: now
? 'Shaking it off now'
: 'Shaking it off in',
countdown: formatCountdown(remaining),
busy: now,
};
}
case 'acting':
return {
...base,
layout: 'cover',
chip: 'AUTO RECOVERY',
headline: reset ? 'Rewinding' : 'Shaking it off',
body: reset
? to !== '' ? `Back to ${to}` : 'Back to the last milestone'
: 'Same place, fresh start',
countdown: '',
busy: true,
};
case 'done':
return {
...base,
layout: 'box',
chip: 'AUTO RECOVERY',
headline: reset ? (to !== '' ? `Back at ${to}!` : 'Rewound!') : 'All shaken off!',
body: "Go get 'em, little fly",
countdown: '',
loop: '',
stuck: '',
busy: false,
};
case 'failed':
return {
...base,
layout: 'box',
chip: 'AUTO RECOVERY',
headline: "That didn't work",
body: 'A human will take a look',
countdown: '',
loop: '',
stuck: '',
busy: false,
};
}
}

View file

@ -0,0 +1,129 @@
import { useEffect, useState } from 'react';
import { create } from 'zustand';
import { LAYOUT, boxStyle } from '@/lib/geometry';
import { recoveryView, type RecoveryNotice, type RecoveryView } from '@/lib/recovery';
/**
* The auto-recovery splash, over the game panel.
*
* `infra/bin/fly-loop-recover` announces a recovery a minute before it acts, then restarts flysim
* or resets the run to an earlier rung; without this, viewers see the game freeze and jump for no
* reason. The model is `src/lib/recovery.ts`, the data path `src/lib/recovery-poll.ts`.
*
* Two layouts, both in the Game Boy's own four greens, so it reads as the cartridge's text box
* rather than as one more rail panel:
*
* - **box** (countdown, done, failed): a Pokémon-style text box across the bottom of the game,
* with the countdown beside it. The top two thirds of the game stay visible, because the stuck
* loop is the thing the countdown is about.
* - **cover** (acting): the whole game panel, because the picture there is frozen or blank while
* flysim restarts.
*
* It re-renders at 4 Hz while a notice is held — the stage's own React cadence — and not at all
* otherwise. The countdown is wall-clock arithmetic against the helper's `executeAt`, which is why
* the page needs nothing but the file: it is right after a reload, and while the feed is down.
*/
export const useRecovery = create<{ notice: RecoveryNotice | null; pinnedNowS: number | null }>(() => ({
notice: null,
pinnedNowS: null,
}));
const TICK_MS = 250;
export function nowSeconds(pinned: number | null): number {
return pinned ?? Date.now() / 1000;
}
export function RecoverySplash() {
const notice = useRecovery((state) => state.notice);
const pinned = useRecovery((state) => state.pinnedNowS);
const [nowS, setNowS] = useState(() => nowSeconds(pinned));
useEffect(() => {
setNowS(nowSeconds(pinned));
if (notice === null || pinned !== null) return;
const timer = setInterval(() => setNowS(nowSeconds(null)), TICK_MS);
return () => clearInterval(timer);
}, [notice, pinned]);
let view: RecoveryView | null = null;
try {
view = recoveryView(notice, nowS);
} catch {
view = null;
}
if (view === null) return null;
return (
<div
className="recovery"
data-testid="recovery-splash"
data-phase={view.phase}
data-action={view.action}
data-layout={view.layout}
style={{ ...boxStyle(LAYOUT.game), zIndex: 50 }}
>
{view.layout === 'cover' ? <Cover view={view} /> : <Box view={view} />}
</div>
);
}
function LoopLine({ view }: { view: RecoveryView }) {
if (view.loop === '' && view.stuck === '') return null;
return (
<div className="recovery__loop" data-testid="recovery-loop">
{view.loop !== '' ? <span className="recovery__loop-macros">{view.loop}</span> : null}
{view.loop !== '' && view.stuck !== '' ? <span className="recovery__sep" aria-hidden /> : null}
{view.stuck !== '' ? <span>{view.stuck}</span> : null}
</div>
);
}
function Box({ view }: { view: RecoveryView }) {
return (
<div className="recovery__box">
<span className="recovery__chip">{view.chip}</span>
<div className="recovery__row">
<div className="recovery__text">
<p className="recovery__headline" data-testid="recovery-headline">
{view.headline}
</p>
<p className="recovery__body" data-testid="recovery-body">
{view.body}
{view.busy ? <span className="recovery__dots" aria-hidden /> : null}
</p>
</div>
{view.countdown !== '' ? (
<div className="recovery__countdown" data-testid="recovery-countdown">
{view.countdown}
</div>
) : null}
</div>
<LoopLine view={view} />
<span className="recovery__arrow" aria-hidden />
</div>
);
}
function Cover({ view }: { view: RecoveryView }) {
return (
<div className="recovery__cover">
<span className="recovery__chip recovery__chip--cover">{view.chip}</span>
<p className="recovery__big" data-testid="recovery-headline">
{view.headline}
<span className="recovery__dots" aria-hidden />
</p>
<p className="recovery__body recovery__body--cover" data-testid="recovery-body">
{view.body}
</p>
<div className={`recovery__bar recovery__bar--${view.action}`} aria-hidden>
{Array.from({ length: 8 }, (_, index) => (
<span key={index} style={{ animationDelay: `${index * 150}ms` }} />
))}
</div>
<div className="recovery__was">{view.loop !== '' ? 'IT WAS LOOPING ON' : null}</div>
<LoopLine view={view} />
</div>
);
}

View file

@ -0,0 +1,230 @@
/**
* The auto-recovery splash (`src/panels/RecoverySplash.tsx`), over the 800x720 game panel.
*
* Its own palette on purpose: the Game Boy's four greens, so the splash reads as the cartridge's
* own text box / screen and never as a rail panel or an alarm. Square corners, a 4 px outer frame
* and a 2 px inner line, like every other box on the page (`tokens.css`). Type sits on the page's
* floors: Press Start 2P for the dialogue line and the countdown, VT323 for the rest.
*/
.recovery {
--gb-0: #e0f0c8; /* lightest: the paper */
--gb-1: #8bac0f;
--gb-2: #306230;
--gb-3: #0f380f; /* darkest: the ink */
pointer-events: none;
}
/* -- Box: countdown, done, failed ---------------------------------------------------------- */
.recovery__box {
position: absolute;
left: 16px;
right: 16px;
bottom: 16px;
padding: 30px 28px 20px;
background: var(--gb-0);
color: var(--gb-3);
border: var(--border-w) solid var(--gb-3);
outline: var(--border-inner-w) solid var(--gb-0);
outline-offset: calc(-1 * var(--border-w) - 6px);
box-shadow: inset 0 0 0 10px var(--gb-0), inset 0 0 0 12px var(--gb-2);
animation: recovery-rise 320ms var(--ease-pixel, steps(8, end)) both;
}
.recovery__chip {
position: absolute;
top: -18px;
left: 24px;
padding: 2px 12px;
font-family: var(--font-label);
font-size: var(--fs-body);
line-height: 28px;
background: var(--gb-3);
color: var(--gb-0);
letter-spacing: 0.04em;
}
.recovery__row {
display: flex;
align-items: center;
gap: 24px;
}
.recovery__text {
flex: 1 1 auto;
min-width: 0;
}
.recovery__headline {
margin: 0 0 14px;
font-family: var(--font-pixel);
font-size: 24px;
line-height: 1.45;
}
.recovery__body {
margin: 0;
font-family: var(--font-text);
font-size: 40px;
line-height: 1.05;
color: var(--gb-2);
}
.recovery__countdown {
flex: 0 0 auto;
font-family: var(--font-pixel);
font-size: 52px;
line-height: 1;
padding: 16px 14px 12px;
background: var(--gb-3);
color: var(--gb-0);
font-variant-numeric: tabular-nums;
}
.recovery__loop {
margin-top: 14px;
font-family: var(--font-text);
font-size: 30px;
line-height: 1;
color: var(--gb-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.recovery__loop-macros {
color: var(--gb-3);
}
.recovery__sep {
display: inline-block;
width: 8px;
height: 8px;
margin: 0 14px;
vertical-align: middle;
background: var(--gb-1);
}
/* The Pokémon "more text" arrow, blinking in the corner. Drawn, not typed: no glyph to miss. */
.recovery__arrow {
position: absolute;
right: 22px;
bottom: 16px;
width: 0;
height: 0;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-top: 12px solid var(--gb-3);
animation: recovery-blink 1s steps(1, end) infinite;
}
/* Three dots that count up, for "working on it". Steps, not a fade. */
.recovery__dots::after {
content: '...';
display: inline-block;
width: 3ch;
overflow: hidden;
vertical-align: bottom;
text-align: left;
animation: recovery-dots 1.2s steps(4, jump-none) infinite;
}
/* -- Cover: acting --------------------------------------------------------------------------- */
.recovery__cover {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 28px;
padding: 40px;
background: var(--gb-0);
color: var(--gb-3);
border: var(--border-w) solid var(--gb-3);
box-shadow: inset 0 0 0 12px var(--gb-0), inset 0 0 0 16px var(--gb-2);
text-align: center;
}
.recovery__chip--cover {
position: static;
}
.recovery__big {
margin: 0;
font-family: var(--font-pixel);
font-size: 44px;
line-height: 1.3;
}
.recovery__body--cover {
font-size: 48px;
}
.recovery__was {
margin-top: 8px;
font-family: var(--font-label);
font-size: var(--fs-body);
color: var(--gb-2);
min-height: 1px;
}
.recovery__cover .recovery__loop {
margin-top: 0;
max-width: 100%;
}
/* Eight cells lighting in turn: right-to-left for a rewind, left-to-right for a restart. */
.recovery__bar {
display: flex;
gap: 8px;
}
.recovery__bar--reset {
flex-direction: row-reverse;
}
.recovery__bar span {
width: 40px;
height: 24px;
background: var(--gb-1);
animation: recovery-cell 1.2s steps(1, end) infinite;
}
@keyframes recovery-rise {
from {
transform: translateY(24px);
opacity: 0;
}
to {
transform: none;
opacity: 1;
}
}
@keyframes recovery-blink {
50% {
visibility: hidden;
}
}
@keyframes recovery-dots {
from {
width: 0;
}
to {
width: 3ch;
}
}
@keyframes recovery-cell {
0%,
12% {
background: var(--gb-3);
}
13%,
100% {
background: var(--gb-1);
}
}

View file

@ -0,0 +1,138 @@
/**
* The auto-recovery splash, against the real build.
*
* The first two tests go through the page's real data path — the poller fetching
* `/recovery-notice.json` — with Playwright answering the route, which is exactly what
* `flystage-web` does with the helper's file. The rest drive the splash through
* `window.__stage.recovery` with a pinned clock, for the geometry and the text floor.
*/
import { expect, test, type Page } from '@playwright/test';
import { stageUrl } from './stage';
const now = (): number => Math.floor(Date.now() / 1000);
function notice(overrides: Record<string, unknown> = {}): Record<string, unknown> {
const t = now();
return {
v: 1,
id: `${t}-reset`,
phase: 'countdown',
action: 'reset',
fromRung: 12,
fromLabel: 'MT. MOON',
toRung: 11,
toLabel: 'PEWTER CITY',
reason: 'unrewarded',
loop: ['GO OBJECTIVE', 'GO WARP'],
stuckSeconds: 1800,
announcedAt: t,
executeAt: t + 45,
updatedAt: t,
...overrides,
};
}
async function open(page: Page): Promise<void> {
await page.goto(`${stageUrl({ t: 95, tab: 'senses', audio: false })}&recovery=1`);
await page.waitForSelector('html[data-ready="1"]', { timeout: 60_000 });
}
test('a polled notice shows the countdown, follows the phases and clears', async ({ page }) => {
let body: string | null = JSON.stringify(notice());
await page.route('**/recovery-notice.json', (route) =>
body === null
? route.fulfill({ status: 204 })
: route.fulfill({ status: 200, contentType: 'application/json', body }),
);
await open(page);
const splash = page.getByTestId('recovery-splash');
await expect(splash).toBeVisible({ timeout: 10_000 });
await expect(splash).toHaveAttribute('data-phase', 'countdown');
await expect(splash).toHaveAttribute('data-layout', 'box');
await expect(page.getByTestId('recovery-countdown')).toHaveText(/^0:4\d$/);
await expect(page.getByTestId('recovery-body')).toContainText('Rewinding to PEWTER CITY');
await expect(page.getByTestId('recovery-loop')).toContainText('GO OBJECTIVE > GO WARP');
body = JSON.stringify(notice({ phase: 'acting' }));
await expect(splash).toHaveAttribute('data-layout', 'cover', { timeout: 5_000 });
body = JSON.stringify(notice({ phase: 'done' }));
await expect(splash).toHaveAttribute('data-phase', 'done', { timeout: 5_000 });
// The file going away is also an end: the page never holds a notice the server no longer has.
body = null;
await expect(splash).toHaveCount(0, { timeout: 5_000 });
});
test('stale, malformed and missing notices never show', async ({ page }) => {
const bodies = [
JSON.stringify(notice({ updatedAt: now() - 16 * 60 })),
'{"v":1,"phase":"countdown"',
JSON.stringify(notice({ v: 2 })),
JSON.stringify(notice({ phase: 'done', updatedAt: now() - 60 })),
];
let index = 0;
await page.route('**/recovery-notice.json', (route) => {
const body = bodies[Math.min(index, bodies.length - 1)] as string;
index += 1;
return route.fulfill({ status: 200, contentType: 'application/json', body });
});
await open(page);
await expect.poll(() => index, { timeout: 10_000 }).toBeGreaterThan(bodies.length);
await expect(page.getByTestId('recovery-splash')).toHaveCount(0);
});
test('player mode does not poll unless asked', async ({ page }) => {
let hits = 0;
await page.route('**/recovery-notice.json', (route) => {
hits += 1;
return route.fulfill({ status: 204 });
});
await page.goto(stageUrl({ t: 95, tab: 'senses', audio: false }));
await page.waitForSelector('html[data-ready="1"]', { timeout: 60_000 });
await page.waitForTimeout(2500);
expect(hits).toBe(0);
});
test('the box leaves the top of the game clear; the cover stays inside the game', async ({ page }) => {
await open(page);
const game = await page.getByTestId('game').boundingBox();
if (!game) throw new Error('no game box');
const t = 1_790_629_095;
const base = notice({ announcedAt: t, executeAt: t + 60, updatedAt: t });
await page.evaluate(([raw, nowS]) => window.__stage?.recovery(raw, nowS as number), [base, t + 18] as const);
// Measure after the 320 ms rise, not during it.
await page.locator('.recovery__box').evaluate((element) =>
Promise.all(element.getAnimations().map((animation) => animation.finished)).then(() => undefined),
);
const box = await page.locator('.recovery__box').boundingBox();
if (!box) throw new Error('no text box');
expect(box.y).toBeGreaterThan(game.y + game.height / 2);
expect(box.x).toBeGreaterThanOrEqual(game.x);
expect(box.x + box.width).toBeLessThanOrEqual(game.x + game.width);
expect(box.y + box.height).toBeLessThanOrEqual(game.y + game.height);
await expect(page.getByTestId('recovery-countdown')).toHaveText('0:42');
await page.evaluate(
([raw, nowS]) => window.__stage?.recovery(raw, nowS as number),
[{ ...base, phase: 'acting', updatedAt: t + 60 }, t + 70] as const,
);
const cover = await page.locator('.recovery__cover').boundingBox();
if (!cover) throw new Error('no cover');
expect(cover).toEqual(game);
// Every run of text on the splash sits on the page's body floor.
const sizes = await page.evaluate(() =>
[...document.querySelectorAll('[data-testid="recovery-splash"] *')]
.filter((element) => [...element.childNodes].some((node) => node.nodeType === 3 && node.textContent?.trim()))
.map((element) => Number.parseFloat(getComputedStyle(element).fontSize)),
);
expect(sizes.length).toBeGreaterThan(3);
for (const size of sizes) expect(size).toBeGreaterThanOrEqual(24);
await page.evaluate(() => window.__stage?.recovery(null));
await expect(page.getByTestId('recovery-splash')).toHaveCount(0);
});

View file

@ -0,0 +1,261 @@
/**
* The recovery splash's model, poller and server route.
*
* The one property every test here protects: the notice file can never break the stream. A
* missing, malformed, stale or hostile file is no splash, and a good one shows exactly the
* contract's lifetimes (countdown until acting, acting while flysim is down, done ~8 s, failed
* briefly) from the file alone.
*/
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { parseStageOptions } from '../../src/lib/query';
import {
RECOVERY_ACTING_MAX_S,
RECOVERY_DONE_S,
RECOVERY_FAILED_S,
RECOVERY_STALE_S,
cleanText,
formatCountdown,
formatStuck,
parseRecoveryBody,
parseRecoveryNotice,
recoveryView,
} from '../../src/lib/recovery';
import { RecoveryPoller } from '../../src/lib/recovery-poll';
const T = 1_790_629_095;
function notice(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
v: 1,
id: `${T}-reset`,
phase: 'countdown',
action: 'reset',
fromRung: 12,
fromLabel: 'MT. MOON',
toRung: 11,
toLabel: 'PEWTER CITY',
reason: 'unrewarded',
loop: ['GO OBJECTIVE', 'GO WARP'],
stuckSeconds: 1800,
announcedAt: T,
executeAt: T + 60,
updatedAt: T,
...overrides,
};
}
const view = (raw: Record<string, unknown>, now: number) => recoveryView(parseRecoveryNotice(raw), now);
test('the contract example parses', () => {
const parsed = parseRecoveryNotice(notice());
assert.ok(parsed);
assert.equal(parsed.phase, 'countdown');
assert.equal(parsed.action, 'reset');
assert.equal(parsed.toLabel, 'PEWTER CITY');
assert.equal(parsed.fromLabel, 'MT. MOON');
assert.deepEqual(parsed.loop, ['GO OBJECTIVE', 'GO WARP']);
});
test('anything malformed is no notice at all', () => {
const bad: unknown[] = [
null,
42,
'x',
[],
notice({ v: 2 }),
notice({ v: '1' }),
notice({ id: '' }),
notice({ id: 7 }),
notice({ phase: 'panic' }),
notice({ action: 'reboot' }),
notice({ executeAt: 'soon' }),
notice({ updatedAt: Number.NaN }),
notice({ announcedAt: undefined }),
];
for (const raw of bad) assert.equal(parseRecoveryNotice(raw), null, JSON.stringify(raw));
for (const body of ['', '{', 'null', '{"v":1}', 'x'.repeat(20_000)]) assert.equal(parseRecoveryBody(body), null);
assert.ok(parseRecoveryBody(JSON.stringify(notice())));
});
test('optional fields degrade the copy, not the notice', () => {
const bare = view({ v: 1, id: 'a', phase: 'countdown', action: 'reset', announcedAt: T, executeAt: T + 30, updatedAt: T }, T);
assert.ok(bare);
assert.equal(bare.body, 'Rewinding to the last milestone in');
assert.equal(bare.loop, '');
assert.equal(bare.stuck, '');
});
test('helper strings are clipped to a closed character set', () => {
assert.equal(cleanText('<img src=x onerror=alert(1)>', 24), 'img srcx onerroralert1');
assert.equal(cleanText('S.S. ANNE', 24), 'S.S. ANNE');
assert.equal(cleanText(' ROUTE\n\t22 ', 24), 'ROUTE 22');
assert.equal(cleanText('A'.repeat(100), 24).length, 24);
assert.equal(cleanText(12, 24), '');
const parsed = parseRecoveryNotice(notice({ loop: ['go objective', 5, '', 'A', 'B', 'C', 'D'], toLabel: 'pewter\u0000city' }));
assert.ok(parsed);
assert.deepEqual(parsed.loop, ['GO OBJECTIVE', 'A', 'B', 'C']);
assert.equal(parsed.toLabel, 'PEWTERCITY');
// A restart has no destination, whatever the file says.
assert.equal(parseRecoveryNotice(notice({ action: 'restart' }))?.toLabel, '');
});
test('countdown: the text box with M:SS to executeAt, reset and restart copy differ', () => {
const reset = view(notice(), T + 18);
assert.ok(reset);
assert.equal(reset.layout, 'box');
assert.equal(reset.countdown, '0:42');
assert.equal(reset.headline, 'The fly is stuck in a loop!');
assert.equal(reset.body, 'Rewinding to PEWTER CITY in');
assert.equal(reset.loop, 'GO OBJECTIVE > GO WARP');
assert.equal(reset.stuck, 'STUCK 30 MIN');
const restart = view(notice({ action: 'restart' }), T);
assert.ok(restart);
assert.equal(restart.body, 'Shaking it off in');
assert.equal(restart.countdown, '1:00');
// Past executeAt but not yet acting: say "now", never a negative clock.
const late = view(notice(), T + 75);
assert.ok(late);
assert.equal(late.countdown, '0:00');
assert.equal(late.body, 'Rewinding to PEWTER CITY now');
assert.equal(late.busy, true);
});
test('acting: covers the game while flysim is down, and gives up after a cap', () => {
const acting = notice({ phase: 'acting', updatedAt: T + 60 });
const reset = view(acting, T + 90);
assert.ok(reset);
assert.equal(reset.layout, 'cover');
assert.equal(reset.headline, 'Rewinding');
assert.equal(reset.body, 'Back to PEWTER CITY');
const restart = view({ ...acting, action: 'restart' }, T + 90);
assert.equal(restart?.headline, 'Shaking it off');
assert.equal(view(acting, T + 60 + RECOVERY_ACTING_MAX_S + 1), null);
});
test('done shows about 8 s, failed a little longer, then both clear', () => {
const done = notice({ phase: 'done', updatedAt: T + 120 });
assert.equal(view(done, T + 120)?.headline, 'Back at PEWTER CITY!');
assert.equal(view({ ...done, action: 'restart' }, T + 121)?.headline, 'All shaken off!');
assert.ok(view(done, T + 120 + RECOVERY_DONE_S));
assert.equal(view(done, T + 120 + RECOVERY_DONE_S + 1), null);
const failed = notice({ phase: 'failed', updatedAt: T + 120 });
assert.equal(view(failed, T + 125)?.body, 'A human will take a look');
assert.equal(view(failed, T + 120 + RECOVERY_FAILED_S + 1), null);
});
test('a notice older than 15 minutes, or from the far future, is ignored', () => {
assert.ok(view(notice(), T + RECOVERY_STALE_S));
assert.equal(view(notice(), T + RECOVERY_STALE_S + 1), null);
assert.equal(view(notice(), T - 3600), null);
});
test('formatting', () => {
assert.equal(formatCountdown(59.2), '1:00');
assert.equal(formatCountdown(5), '0:05');
assert.equal(formatCountdown(725), '12:05');
assert.equal(formatCountdown(-3), '0:00');
assert.equal(formatStuck(null), '');
assert.equal(formatStuck(30), '');
assert.equal(formatStuck(1800), 'STUCK 30 MIN');
assert.equal(formatStuck(7200), 'STUCK 2 H');
assert.equal(formatStuck(3900), 'STUCK 1 H 5 MIN');
});
test('the page polls in live mode, not in player mode, unless told', () => {
assert.equal(parseStageOptions('?mode=live').recovery, true);
assert.equal(parseStageOptions('').recovery, false);
assert.equal(parseStageOptions('?recovery=1').recovery, true);
assert.equal(parseStageOptions('?mode=live&recovery=0').recovery, false);
});
test('the poller turns every failure into "no notice" and reports changes once', async () => {
const seen: (string | null)[] = [];
let reply: () => Promise<Response> = () => Promise.resolve(new Response(JSON.stringify(notice()), { status: 200 }));
const poller = new RecoveryPoller({
fetch: () => reply(),
onChange: (n) => seen.push(n === null ? null : n.phase),
});
assert.equal((await poller.poll())?.phase, 'countdown');
await poller.poll(); // unchanged: no second callback
reply = () => Promise.resolve(new Response(null, { status: 204 }));
assert.equal(await poller.poll(), null);
reply = () => Promise.resolve(new Response('{"v":1,', { status: 200 }));
assert.equal(await poller.poll(), null);
reply = () => Promise.resolve(new Response('not found', { status: 404 }));
assert.equal(await poller.poll(), null);
reply = () => Promise.reject(new Error('ECONNREFUSED'));
assert.equal(await poller.poll(), null);
reply = () => Promise.resolve(new Response(JSON.stringify(notice({ phase: 'acting' })), { status: 200 }));
assert.equal((await poller.poll())?.phase, 'acting');
assert.deepEqual(seen, ['countdown', null, 'acting']);
});
test('the poller survives a throwing listener', async () => {
const poller = new RecoveryPoller({
fetch: () => Promise.resolve(new Response(JSON.stringify(notice()), { status: 200 })),
onChange: () => {
throw new Error('boom');
},
});
const warn = console.warn;
console.warn = () => {};
try {
assert.equal((await poller.poll())?.phase, 'countdown');
} finally {
console.warn = warn;
}
});
test('flystage-web serves the notice file at /recovery-notice.json, 204 without one', async () => {
const here = dirname(fileURLToPath(import.meta.url));
const serve = resolve(here, '../../../../infra/config/serve.mjs');
const dir = await mkdtemp(join(tmpdir(), 'recovery-notice-'));
const file = join(dir, 'recovery-notice.json');
const child = spawn(process.execPath, [serve, dir, '127.0.0.1', '0'], {
env: { ...process.env, FLY_RECOVERY_NOTICE: file },
stdio: ['ignore', 'pipe', 'inherit'],
});
try {
const base = await new Promise<string>((done, fail) => {
let out = '';
child.stdout.on('data', (chunk: Buffer) => {
out += chunk.toString();
const match = /(http:\/\/127\.0\.0\.1:\d+)\//.exec(out);
if (match?.[1]) done(match[1]);
});
child.on('exit', () => fail(new Error(`serve.mjs exited: ${out}`)));
});
const missing = await fetch(`${base}/recovery-notice.json`);
assert.equal(missing.status, 204);
assert.equal(missing.headers.get('cache-control'), 'no-store');
const body = JSON.stringify(notice());
await writeFile(file, body);
const present = await fetch(`${base}/recovery-notice.json?t=1`);
assert.equal(present.status, 200);
assert.equal(await present.text(), body);
await writeFile(file, 'x'.repeat(20_000));
assert.equal((await fetch(`${base}/recovery-notice.json`)).status, 204, 'oversized files are not served');
// The static half still works, and the notice is not reachable any other way.
await writeFile(join(dir, 'index.html'), '<p>stage</p>');
assert.equal(await (await fetch(`${base}/`)).text(), '<p>stage</p>');
} finally {
child.kill('SIGTERM');
await rm(dir, { recursive: true, force: true });
}
});

View file

@ -24,6 +24,8 @@
* macros-center.png wide pad with both of its columns filled * macros-center.png wide pad with both of its columns filled
* macros-tab.png * macros-tab.png
* pad-strip.png * pad-strip.png
* recovery-{countdown,acting,done,failed}-{reset,restart}.png
* the auto-recovery splash, each phase and both actions
* *
* T1 Instrument is the chosen theme (`docs/stream-mvp-plan.md`, decisions 2026-09-15 evening), so * T1 Instrument is the chosen theme (`docs/stream-mvp-plan.md`, decisions 2026-09-15 evening), so
* the theme sweep the first gate needed is gone: what these images are for now is the *layout* and * the theme sweep the first gate needed is gone: what these images are for now is the *layout* and
@ -142,6 +144,34 @@ const MOMENTS: { name: string; type: string; label: string; detail: string; tab?
{ name: 'moment-rollback', type: 'rollback', label: 'Rolled back to the archived frame', detail: 'try 3' }, { name: 'moment-rollback', type: 'rollback', label: 'Rolled back to the archived frame', detail: 'try 3' },
]; ];
/**
* The recovery splash shots (`src/panels/RecoverySplash.tsx`): every phase for both actions,
* driven through `window.__stage.recovery` with the splash's clock pinned 18 s into the countdown,
* so the clock reads 0:42 on every machine. The notice is the contract's own example.
*/
const RECOVERY_T = 1_790_629_095;
const RECOVERY_SHOTS = (['countdown', 'acting', 'done', 'failed'] as const).flatMap((phase) =>
(['reset', 'restart'] as const).map((action) => ({
name: `recovery-${phase}-${action}`,
nowS: phase === 'countdown' ? RECOVERY_T + 18 : RECOVERY_T + 63,
notice: {
v: 1,
id: `${RECOVERY_T}-${action}`,
phase,
action,
fromRung: 12,
fromLabel: 'MT. MOON',
...(action === 'reset' ? { toRung: 11, toLabel: 'PEWTER CITY' } : {}),
reason: 'unrewarded',
loop: ['GO OBJECTIVE', 'GO WARP'],
stuckSeconds: 1800,
announcedAt: RECOVERY_T,
executeAt: RECOVERY_T + 60,
updatedAt: phase === 'countdown' ? RECOVERY_T : RECOVERY_T + 60,
},
})),
);
function run(command: string, args: string[]): Promise<void> { function run(command: string, args: string[]): Promise<void> {
return new Promise((done, fail) => { return new Promise((done, fail) => {
const child = spawn(command, args, { cwd: appDir, stdio: 'inherit' }); const child = spawn(command, args, { cwd: appDir, stdio: 'inherit' });
@ -264,6 +294,23 @@ async function main(): Promise<void> {
console.log(` ${path} (${String(state?.moment)} ${String(state?.momentPhase)}, ${String(state?.particles)} particles, tab ${String(state?.tab)})`); console.log(` ${path} (${String(state?.moment)} ${String(state?.momentPhase)}, ${String(state?.particles)} particles, tab ${String(state?.tab)})`);
} }
for (const shot of RECOVERY_SHOTS) {
if (!wanted(shot.name)) continue;
await page.goto(url(baseUrl, { fixture: 'steady', t: 95, tab: 'senses' }));
await settle(page);
const shown = await page.evaluate(([notice, nowS]) => window.__stage?.recovery(notice, nowS as number) ?? null, [
shot.notice,
shot.nowS,
] as const);
if (shown === null) throw new Error(`${shot.name}: the splash refused its notice`);
// The text box rises in over 320 ms (`src/theme/recovery.css`).
await page.waitForTimeout(500);
const path = resolve(outDir, `${shot.name}.png`);
await page.screenshot({ path, clip: FRAME });
written.push(path);
console.log(` ${path} (${shown.layout}: ${shown.headline} / ${shown.body} ${shown.countdown})`);
}
// Two extra review shots: the fly strip alone, at 2x, once per renderer. The strip is 800x220 // Two extra review shots: the fly strip alone, at 2x, once per renderer. The strip is 800x220
// in a 1920x1080 frame, which is too small to judge an animal in; these are what a human looks // in a 1920x1080 frame, which is too small to judge an animal in; these are what a human looks
// at to decide whether it reads. // at to decide whether it reads.

View file

@ -0,0 +1,92 @@
/**
* Write a fake auto-recovery notice, for looking at the recovery splash by hand.
*
* The file is the one `infra/bin/fly-loop-recover` writes (contract: `infra/docs/loop-recovery.md`,
* "Stream notice"), written the same way — a temporary file renamed over the target, mode 0644 —
* to `--out`, else `$FLY_RECOVERY_NOTICE`. Point the page's server at the same path:
*
* ```sh
* export FLY_RECOVERY_NOTICE=$PWD/.recovery-notice.json # any writable path
* npm run dev -w @flybrain/stage # serves it at /recovery-notice.json
* # http://127.0.0.1:5273/?mode=player&recovery=1
*
* npm run recovery-notice -w @flybrain/stage -- --phase countdown --action reset --in 45
* npm run recovery-notice -w @flybrain/stage -- --phase acting --action restart
* npm run recovery-notice -w @flybrain/stage -- --demo reset # countdown 20 s, acting 15 s, done
* npm run recovery-notice -w @flybrain/stage -- --clear
* ```
*
* On the release container the deployed `serve.mjs` reads `/run/fly/wd/recovery-notice.json`
* unless its unit sets `FLY_RECOVERY_NOTICE`; this tool is for a dev machine, not for faking a
* recovery on air.
*/
import { chmod, rename, rm, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
type Phase = 'countdown' | 'acting' | 'done' | 'failed';
type Action = 'restart' | 'reset';
function arg(name: string): string | null {
const at = process.argv.indexOf(`--${name}`);
return at === -1 ? null : (process.argv[at + 1] ?? null);
}
const out = arg('out') ?? process.env.FLY_RECOVERY_NOTICE ?? '';
if (out === '') {
console.error('recovery-notice: pass --out <path> or set FLY_RECOVERY_NOTICE');
process.exit(2);
}
async function write(phase: Phase, action: Action, id: string, announcedAt: number, executeAt: number): Promise<void> {
const notice = {
v: 1,
id,
phase,
action,
fromRung: Number(arg('from-rung') ?? 12),
fromLabel: arg('from-label') ?? 'MT. MOON',
...(action === 'reset' ? { toRung: Number(arg('to-rung') ?? 11), toLabel: arg('to-label') ?? 'PEWTER CITY' } : {}),
reason: arg('reason') ?? 'unrewarded',
loop: (arg('loop') ?? 'GO OBJECTIVE,GO WARP').split(',').map((step) => step.trim()),
stuckSeconds: Number(arg('stuck') ?? 1800),
announcedAt,
executeAt,
updatedAt: Math.floor(Date.now() / 1000),
};
const tmp = join(dirname(out), `.recovery-notice.${process.pid}.tmp`);
await writeFile(tmp, `${JSON.stringify(notice, null, 2)}\n`);
await chmod(tmp, 0o644);
await rename(tmp, out);
console.log(`${phase} ${action} -> ${out}`);
}
const sleep = (s: number): Promise<void> => new Promise((done) => setTimeout(done, s * 1000));
async function main(): Promise<void> {
if (process.argv.includes('--clear')) {
await rm(out, { force: true });
console.log(`removed ${out}`);
return;
}
const now = Math.floor(Date.now() / 1000);
const demo = arg('demo');
if (demo !== null) {
const action = (demo === 'restart' ? 'restart' : 'reset') as Action;
const id = `${now}-${action}`;
await write('countdown', action, id, now, now + 20);
await sleep(20);
await write('acting', action, id, now, now + 20);
await sleep(15);
await write(process.argv.includes('--fail') ? 'failed' : 'done', action, id, now, now + 20);
return;
}
const phase = (arg('phase') ?? 'countdown') as Phase;
const action = (arg('action') ?? 'reset') as Action;
const lead = Number(arg('in') ?? 60);
await write(phase, action, `${now}-${action}`, now, now + lead);
}
main().catch((error: unknown) => {
console.error(error);
process.exit(1);
});

View file

@ -16,7 +16,8 @@
*/ */
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { createReadStream } from 'node:fs'; import { createReadStream } from 'node:fs';
import { cp, stat } from 'node:fs/promises'; import { cp, readFile, stat } from 'node:fs/promises';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { dirname, join, normalize, resolve } from 'node:path'; import { dirname, join, normalize, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
@ -87,8 +88,41 @@ function datasetArtifacts(): Plugin {
}; };
} }
/**
* `GET /recovery-notice.json` in dev and preview, the same route `infra/config/serve.mjs` gives
* the deployed page: the auto-recovery helper's notice file (`FLY_RECOVERY_NOTICE`, default
* `/run/fly/wd/recovery-notice.json`), or 204 when there is none. What makes
* `npm run recovery-notice` (`tools/recovery-notice.mts`) work against a local page.
*/
function recoveryNotice(): Plugin {
const serve = async (req: IncomingMessage, res: ServerResponse, next: () => void): Promise<void> => {
if ((req.url ?? '').split('?')[0] !== '/recovery-notice.json') return next();
const path = process.env.FLY_RECOVERY_NOTICE || '/run/fly/wd/recovery-notice.json';
res.setHeader('cache-control', 'no-store');
try {
const info = await stat(path);
if (!info.isFile() || info.size === 0 || info.size > 16_384) throw new Error('no notice');
const body = await readFile(path);
res.setHeader('content-type', 'application/json; charset=utf-8');
res.end(body);
} catch {
res.statusCode = 204;
res.end();
}
};
return {
name: 'flystage-recovery-notice',
configureServer(server) {
server.middlewares.use((req, res, next) => void serve(req, res, next));
},
configurePreviewServer(server) {
server.middlewares.use((req, res, next) => void serve(req, res, next));
},
};
}
export default defineConfig(({ command }) => ({ export default defineConfig(({ command }) => ({
plugins: [react(), tailwindcss(), datasetArtifacts()], plugins: [react(), tailwindcss(), datasetArtifacts(), recoveryNotice()],
define: { define: {
__STAGE_VERSION__: JSON.stringify(stageVersion(command)), __STAGE_VERSION__: JSON.stringify(stageVersion(command)),
}, },

View file

@ -9,6 +9,14 @@
// on flystage-web.service). Deliberately not a general-purpose server: // on flystage-web.service). Deliberately not a general-purpose server:
// no directory listing, no symlink following outside root, no range // no directory listing, no symlink following outside root, no range
// requests (the page is a handful of static assets, not video). // requests (the page is a handful of static assets, not video).
//
// One route is not a file under root: GET /recovery-notice.json returns the
// auto-recovery helper's notice (infra/bin/fly-loop-recover writes it to
// /run/fly/wd/recovery-notice.json, FLY_RECOVERY_NOTICE overrides), which the
// page's recovery splash polls once a second. It is served from here and not
// from flysim because flysim is the thing being restarted. 200 with the file's
// bytes, or 204 when there is no (readable, small, regular) file; the page does
// all of the validation (apps/stage/src/lib/recovery.ts).
import { createServer } from 'node:http'; import { createServer } from 'node:http';
import { readFile, stat } from 'node:fs/promises'; import { readFile, stat } from 'node:fs/promises';
import { join, normalize, extname } from 'node:path'; import { join, normalize, extname } from 'node:path';
@ -17,6 +25,27 @@ const root = process.argv[2] ?? new URL('.', import.meta.url).pathname;
const host = process.argv[3] ?? '127.0.0.1'; const host = process.argv[3] ?? '127.0.0.1';
const port = Number(process.argv[4] ?? 7402); const port = Number(process.argv[4] ?? 7402);
const RECOVERY_ROUTE = '/recovery-notice.json';
const RECOVERY_PATH = process.env.FLY_RECOVERY_NOTICE || '/run/fly/wd/recovery-notice.json';
const RECOVERY_MAX_BYTES = 16384;
async function serveRecoveryNotice(res) {
const headers = { 'cache-control': 'no-store' };
const st = await stat(RECOVERY_PATH).catch(() => null);
if (!st?.isFile() || st.size === 0 || st.size > RECOVERY_MAX_BYTES) {
res.writeHead(204, headers).end();
return;
}
const body = await readFile(RECOVERY_PATH).catch(() => null);
if (!body) { res.writeHead(204, headers).end(); return; }
res.writeHead(200, {
...headers,
'content-type': 'application/json; charset=utf-8',
'content-length': body.length,
});
res.end(body);
}
const TYPES = { const TYPES = {
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8',
@ -27,6 +56,7 @@ const TYPES = {
const server = createServer(async (req, res) => { const server = createServer(async (req, res) => {
try { try {
if (req.url.split('?')[0] === RECOVERY_ROUTE) { await serveRecoveryNotice(res); return; }
const urlPath = normalize(decodeURIComponent(req.url.split('?')[0])); const urlPath = normalize(decodeURIComponent(req.url.split('?')[0]));
if (urlPath.includes('..')) { res.writeHead(400).end('bad path'); return; } if (urlPath.includes('..')) { res.writeHead(400).end('bad path'); return; }
let path = join(root, urlPath === '/' ? 'index.html' : urlPath); let path = join(root, urlPath === '/' ? 'index.html' : urlPath);
@ -47,5 +77,5 @@ const server = createServer(async (req, res) => {
}); });
server.listen(port, host, () => { server.listen(port, host, () => {
console.log(`flystage-web: serving ${root} on http://${host}:${port}/`); console.log(`flystage-web: serving ${root} on http://${host}:${server.address().port}/`);
}); });