69607fbfe9
engine.ts used mulberry32 and engine.py used random.Random(seed). Same seed, different word — so every ?seed= permalink on the site would have shown a different puzzle than the recorded run it claimed to be replaying, and nobody would have noticed until someone checked one by hand. Both now derive the index from FNV-1a 32-bit over the decimal seed. A hash rather than a PRNG because there is no honest one-line JavaScript equivalent of Mersenne Twister, and this way there is nothing to keep in step: both sides compute the same integer from the same string. Math.imul on the JS side is load-bearing — a plain multiply overflows into a double and diverges after the first few bytes. Twelve seeds are pinned as a vector in both test suites. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
315 lines
10 KiB
TypeScript
315 lines
10 KiB
TypeScript
/**
|
|
* The replay transport.
|
|
*
|
|
* A recorded rollout is played back on the timings the model actually took.
|
|
* That is not decoration: "the second guess took four seconds and 900 tokens"
|
|
* is one of the few things on this page an executive can feel rather than read.
|
|
*
|
|
* So the hard rule here is that **no code path invents a duration silently**.
|
|
* When a step's `call.durationMs` is null the player falls back to
|
|
* `FALLBACK_STEP_MS` and says so — `timingIsReal` goes false and `invented[i]`
|
|
* marks the step — so the UI can label the timeline as approximate instead of
|
|
* quietly presenting a made-up number as a measurement.
|
|
*
|
|
* Playback always starts PAUSED. A board that animates itself the moment the
|
|
* page loads has already played its best moment to a visitor who was still
|
|
* reading the headline.
|
|
*/
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
import type { DemoStep } from './types';
|
|
|
|
/** 1x, 2x, 4x, or straight to the end. */
|
|
export type PlaybackSpeed = 1 | 2 | 4 | 'instant';
|
|
|
|
export const PLAYBACK_SPEEDS: readonly PlaybackSpeed[] = [1, 2, 4, 'instant'];
|
|
|
|
export function isPlaybackSpeed(value: unknown): value is PlaybackSpeed {
|
|
return value === 1 || value === 2 || value === 4 || value === 'instant';
|
|
}
|
|
|
|
/**
|
|
* Dwell for a step whose real duration was not recorded. Exported and named so
|
|
* that when it appears on screen it can be labelled as the estimate it is.
|
|
*/
|
|
export const FALLBACK_STEP_MS = 900;
|
|
|
|
/**
|
|
* How often the fractional progress within a step is pushed into React state.
|
|
*
|
|
* Not every frame, on purpose: `progress` re-renders the whole demo page, and
|
|
* at 60 Hz on a phone that is the difference between a smooth board and a warm
|
|
* one. ~15 Hz is plenty as long as whatever consumes it has a CSS transition on
|
|
* the property it drives — give your progress bar `transition-[width]
|
|
* duration-1` and the gaps disappear.
|
|
*/
|
|
const PROGRESS_TICK_MS = 66;
|
|
|
|
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
|
|
|
|
function subscribeReducedMotion(onChange: () => void): () => void {
|
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
|
return () => undefined;
|
|
}
|
|
const query = window.matchMedia(REDUCED_MOTION_QUERY);
|
|
query.addEventListener('change', onChange);
|
|
return () => query.removeEventListener('change', onChange);
|
|
}
|
|
|
|
function readReducedMotion(): boolean {
|
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
|
|
return window.matchMedia(REDUCED_MOTION_QUERY).matches;
|
|
}
|
|
|
|
/** Live `prefers-reduced-motion`. Re-renders when the OS setting changes. */
|
|
export function usePrefersReducedMotion(): boolean {
|
|
return useSyncExternalStore(subscribeReducedMotion, readReducedMotion, () => false);
|
|
}
|
|
|
|
/** The recorded duration, or `null` when the trace did not carry a usable one. */
|
|
function recordedDurationMs(step: DemoStep<unknown> | undefined): number | null {
|
|
const recorded = step?.call?.durationMs;
|
|
if (recorded === null || recorded === undefined) return null;
|
|
// A zero or negative duration is a recording artefact, not a measurement.
|
|
if (!Number.isFinite(recorded) || recorded <= 0) return null;
|
|
return recorded;
|
|
}
|
|
|
|
export interface PlayerOptions {
|
|
/** Where to start — typically the `step` URL param. Clamped. */
|
|
initialIndex?: number;
|
|
initialSpeed?: PlaybackSpeed;
|
|
/** Fired on every index change, including seeks. Used to sync the permalink. */
|
|
onIndexChange?: (index: number) => void;
|
|
/** Override the invented dwell. Still reported as invented. */
|
|
fallbackStepMs?: number;
|
|
}
|
|
|
|
export interface Player<TState> {
|
|
index: number;
|
|
/** `undefined` only when there are no steps at all. */
|
|
step: DemoStep<TState> | undefined;
|
|
stepCount: number;
|
|
isPlaying: boolean;
|
|
speed: PlaybackSpeed;
|
|
/** 0..1 through the current step. Pinned to 0 under reduced motion. */
|
|
progress: number;
|
|
elapsedMs: number;
|
|
totalMs: number;
|
|
atStart: boolean;
|
|
atEnd: boolean;
|
|
/** The OS setting, surfaced so surfaces can skip their own animations too. */
|
|
reducedMotion: boolean;
|
|
/** False when ANY step's dwell was invented. Label the timeline when false. */
|
|
timingIsReal: boolean;
|
|
/** Per step: was this dwell invented? */
|
|
invented: readonly boolean[];
|
|
/** The dwell actually used per step, real or invented. */
|
|
durationsMs: readonly number[];
|
|
fallbackStepMs: number;
|
|
play: () => void;
|
|
pause: () => void;
|
|
toggle: () => void;
|
|
/** Jump to a step. Does NOT change play/pause — a scrubber drag keeps playing. */
|
|
seek: (index: number) => void;
|
|
/** Manual stepping pauses: you asked to look at this one. */
|
|
next: () => void;
|
|
prev: () => void;
|
|
restart: () => void;
|
|
setSpeed: (speed: PlaybackSpeed) => void;
|
|
}
|
|
|
|
export function usePlayer<TState>(
|
|
steps: readonly DemoStep<TState>[],
|
|
options: PlayerOptions = {},
|
|
): Player<TState> {
|
|
const fallbackStepMs = options.fallbackStepMs ?? FALLBACK_STEP_MS;
|
|
const stepCount = steps.length;
|
|
const lastIndex = Math.max(0, stepCount - 1);
|
|
const reducedMotion = usePrefersReducedMotion();
|
|
|
|
const [index, setIndexState] = useState(() =>
|
|
clamp(options.initialIndex ?? 0, 0, Math.max(0, steps.length - 1)),
|
|
);
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [speed, setSpeedState] = useState<PlaybackSpeed>(options.initialSpeed ?? 1);
|
|
const [progress, setProgress] = useState(0);
|
|
|
|
const indexRef = useRef(index);
|
|
const elapsedRef = useRef(0);
|
|
const lastProgressPushRef = useRef(0);
|
|
|
|
// Held in a ref so changing the callback never restarts the animation loop.
|
|
const onIndexChangeRef = useRef(options.onIndexChange);
|
|
onIndexChangeRef.current = options.onIndexChange;
|
|
|
|
const durationsMs = useMemo(
|
|
() => steps.map((step) => recordedDurationMs(step) ?? fallbackStepMs),
|
|
[steps, fallbackStepMs],
|
|
);
|
|
const invented = useMemo(() => steps.map((step) => recordedDurationMs(step) === null), [steps]);
|
|
const timingIsReal = useMemo(() => !invented.includes(true), [invented]);
|
|
|
|
const cumulativeMs = useMemo(() => {
|
|
let running = 0;
|
|
return durationsMs.map((duration) => {
|
|
const start = running;
|
|
running += duration;
|
|
return start;
|
|
});
|
|
}, [durationsMs]);
|
|
const totalMs = useMemo(() => durationsMs.reduce((sum, d) => sum + d, 0), [durationsMs]);
|
|
|
|
const dwellAt = useCallback(
|
|
(at: number) => durationsMs[at] ?? fallbackStepMs,
|
|
[durationsMs, fallbackStepMs],
|
|
);
|
|
|
|
const commitIndex = useCallback((next: number) => {
|
|
if (indexRef.current === next) return;
|
|
indexRef.current = next;
|
|
setIndexState(next);
|
|
onIndexChangeRef.current?.(next);
|
|
}, []);
|
|
|
|
// A new steps array means a new run. Rewind rather than leaving the transport
|
|
// pointing at step 7 of a rollout that only has four turns.
|
|
const stepsRef = useRef(steps);
|
|
useEffect(() => {
|
|
if (stepsRef.current === steps) return;
|
|
stepsRef.current = steps;
|
|
elapsedRef.current = 0;
|
|
setIsPlaying(false);
|
|
setProgress(0);
|
|
commitIndex(0);
|
|
}, [steps, commitIndex]);
|
|
|
|
const seek = useCallback(
|
|
(to: number) => {
|
|
elapsedRef.current = 0;
|
|
setProgress(0);
|
|
commitIndex(clamp(to, 0, Math.max(0, stepsRef.current.length - 1)));
|
|
},
|
|
[commitIndex],
|
|
);
|
|
|
|
const play = useCallback(() => {
|
|
if (stepsRef.current.length === 0) return;
|
|
// Pressing play on the final step replays from the top; the alternative is
|
|
// a button that visibly does nothing.
|
|
if (indexRef.current >= stepsRef.current.length - 1) {
|
|
elapsedRef.current = 0;
|
|
setProgress(0);
|
|
commitIndex(0);
|
|
}
|
|
setIsPlaying(true);
|
|
}, [commitIndex]);
|
|
|
|
const pause = useCallback(() => setIsPlaying(false), []);
|
|
const toggle = useCallback(() => {
|
|
if (isPlaying) pause();
|
|
else play();
|
|
}, [isPlaying, pause, play]);
|
|
|
|
const next = useCallback(() => {
|
|
setIsPlaying(false);
|
|
seek(indexRef.current + 1);
|
|
}, [seek]);
|
|
|
|
const prev = useCallback(() => {
|
|
setIsPlaying(false);
|
|
seek(indexRef.current - 1);
|
|
}, [seek]);
|
|
|
|
const restart = useCallback(() => {
|
|
setIsPlaying(false);
|
|
seek(0);
|
|
}, [seek]);
|
|
|
|
const setSpeed = useCallback((nextSpeed: PlaybackSpeed) => setSpeedState(nextSpeed), []);
|
|
|
|
useEffect(() => {
|
|
if (!isPlaying || stepCount === 0) return;
|
|
|
|
let raf = 0;
|
|
let previous = performance.now();
|
|
|
|
const frame = (now: number): void => {
|
|
// `instant` is expressed as infinite elapsed time rather than an infinite
|
|
// rate: `(now - previous) * Infinity` is NaN on the very first frame,
|
|
// where `now === previous`, and NaN would freeze the transport forever.
|
|
const gained = speed === 'instant' ? Number.POSITIVE_INFINITY : (now - previous) * speed;
|
|
previous = now;
|
|
|
|
const startIndex = indexRef.current;
|
|
let elapsed = elapsedRef.current + gained;
|
|
let at = startIndex;
|
|
|
|
while (at < lastIndex && elapsed >= dwellAt(at)) {
|
|
elapsed -= dwellAt(at);
|
|
at += 1;
|
|
}
|
|
|
|
if (at >= lastIndex && elapsed >= dwellAt(lastIndex)) {
|
|
elapsedRef.current = dwellAt(lastIndex);
|
|
commitIndex(lastIndex);
|
|
setProgress(1);
|
|
setIsPlaying(false);
|
|
return; // Run over. Deliberately not scheduling another frame.
|
|
}
|
|
|
|
elapsedRef.current = elapsed;
|
|
|
|
if (at !== startIndex) {
|
|
commitIndex(at);
|
|
lastProgressPushRef.current = now;
|
|
// Reduced motion still advances on real time — the content is not the
|
|
// animation — but no fractional progress is emitted, so nothing on the
|
|
// page is being driven frame by frame.
|
|
setProgress(reducedMotion ? 0 : Math.min(1, elapsed / dwellAt(at)));
|
|
} else if (!reducedMotion && now - lastProgressPushRef.current >= PROGRESS_TICK_MS) {
|
|
lastProgressPushRef.current = now;
|
|
setProgress(Math.min(1, elapsed / dwellAt(at)));
|
|
}
|
|
|
|
raf = requestAnimationFrame(frame);
|
|
};
|
|
|
|
raf = requestAnimationFrame(frame);
|
|
return () => cancelAnimationFrame(raf);
|
|
}, [isPlaying, speed, stepCount, lastIndex, dwellAt, reducedMotion, commitIndex]);
|
|
|
|
const elapsedMs = (cumulativeMs[index] ?? 0) + progress * dwellAt(index);
|
|
|
|
return {
|
|
index,
|
|
step: steps[index],
|
|
stepCount,
|
|
isPlaying,
|
|
speed,
|
|
progress,
|
|
elapsedMs,
|
|
totalMs,
|
|
atStart: index === 0,
|
|
atEnd: stepCount === 0 || index >= lastIndex,
|
|
reducedMotion,
|
|
timingIsReal,
|
|
invented,
|
|
durationsMs,
|
|
fallbackStepMs,
|
|
play,
|
|
pause,
|
|
toggle,
|
|
seek,
|
|
next,
|
|
prev,
|
|
restart,
|
|
setSpeed,
|
|
};
|
|
}
|
|
|
|
function clamp(value: number, min: number, max: number): number {
|
|
if (!Number.isFinite(value)) return min;
|
|
return Math.min(max, Math.max(min, Math.trunc(value)));
|
|
}
|