Make the page playable: interactive board, live solver, and a usable run picker
Three things the build was quietly missing. The interactive board was never mounted. The contract has `interactive.init` and `interactive.Controls`, the demo implemented both, and the shell's `split-play` beat rendered only the replay — so the beat titled "you and the model get the same word" showed one board. PlayYourself now renders the visitor's attempt from the same seed as the run beside it, generically: it knows only the contract, so any demo shipping an interactive mode gets it and one that does not renders nothing rather than an empty pane. solver.worker.ts was dead code — nothing constructed it, which is how CI caught it: `new Worker(` appeared nowhere in the bundle. It is wired now behind "what would the best player guess?", and it answers in 92ms from a real worker on boards no recording covers. That is the difference between a demo and a video. It also surfaces the moment the solver picks a word that CANNOT win, which is the counterweight visible in one line instead of explained in a paragraph. The CI check that found it was itself wrong: it grepped every bundled file for `blob:`, which React's own code contains in a scheme check, so it failed on a risk that was not present. It now greps for worker construction from a blob, which is the thing production CSP actually blocks in silence. And the run switcher was thirty buttons carrying four distinct labels. Split into arm and seed, holding the seed across an arm change — comparing two agents means comparing them on the same hidden word, and silently jumping seeds would break that while looking fine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
This commit is contained in:
@@ -3,7 +3,8 @@ import { useEffect, useMemo } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { play, type BoardState } from './engine';
|
||||
import { allowedNow, primeGuessList } from './words';
|
||||
import { useSolver } from './useSolver';
|
||||
import { allowedNow, ANSWERS, primeGuessList } from './words';
|
||||
|
||||
const ROWS = ['qwertyuiop', 'asdfghjkl', 'zxcvbnm'];
|
||||
|
||||
@@ -37,6 +38,7 @@ export function Keyboard({
|
||||
}) {
|
||||
const states = useMemo(() => letterStates(state), [state]);
|
||||
const done = state.status !== 'playing';
|
||||
const solver = useSolver(ANSWERS);
|
||||
|
||||
useEffect(() => {
|
||||
void primeGuessList();
|
||||
@@ -78,8 +80,46 @@ export function Keyboard({
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
});
|
||||
|
||||
const hint = solver.state;
|
||||
|
||||
return (
|
||||
<div className="grid gap-1 sm:gap-1.5" style={{ paddingBottom: 'max(0px, var(--safe-bottom))' }}>
|
||||
{/* The solver, live, on whatever word you picked — not a recording. This
|
||||
is the difference between a demo and a video: it answers for boards no
|
||||
rollout on this site ever covered. */}
|
||||
{hint.status !== 'unavailable' && !done ? (
|
||||
<div className="mb-1 flex min-h-9 flex-wrap items-center gap-2 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => solver.ask(state.rows)}
|
||||
disabled={hint.status === 'thinking'}
|
||||
className="tap inline-flex items-center rounded-md border border-border px-2.5 py-1 font-medium transition-colors duration-1 hover:bg-accent-subtle disabled:opacity-50"
|
||||
>
|
||||
{hint.status === 'thinking' ? 'Thinking…' : 'What would the best player guess?'}
|
||||
</button>
|
||||
<span aria-live="polite" className="text-muted">
|
||||
{hint.status === 'ready' ? (
|
||||
<>
|
||||
<span className="font-mono font-semibold uppercase text-fg">
|
||||
{hint.suggestion.guess}
|
||||
</span>{' '}
|
||||
— <span className="nums">{hint.suggestion.bits.toFixed(2)}</span> bits from{' '}
|
||||
<span className="nums">{hint.suggestion.candidatesBefore.toLocaleString('en-US')}</span>{' '}
|
||||
remaining
|
||||
{hint.suggestion.viable ? null : (
|
||||
<>
|
||||
{' '}
|
||||
·{' '}
|
||||
<span className="text-warning">
|
||||
cannot win — it is buying information
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{ROWS.map((row, index) => (
|
||||
<div key={row} className="flex justify-center gap-1 sm:gap-1.5">
|
||||
{index === 2 ? (
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { Pattern } from './engine';
|
||||
import type { SolverRequest, SolverResponse } from './solver.worker';
|
||||
|
||||
/**
|
||||
* The entropy solver, on a worker, with a main-thread fallback.
|
||||
*
|
||||
* This is what lets the page answer for ANY word the visitor picks rather than
|
||||
* only the handful the recordings cover — the difference between a demo and a
|
||||
* video. The opening scan is roughly 4,600 x 4,600 pattern computations, which
|
||||
* visibly freezes a phone if it runs on the main thread at the exact moment
|
||||
* somebody first touches the board.
|
||||
*
|
||||
* `new URL(..., import.meta.url)` gives a same-origin module in the build.
|
||||
* Never Vite's `?worker&inline`: that yields a blob: URL, and production CSP has
|
||||
* no `worker-src`, so it falls back to `default-src 'self'` and the worker is
|
||||
* blocked with no console error — in production only. CI greps the bundle for
|
||||
* exactly that construction.
|
||||
*/
|
||||
export interface Suggestion {
|
||||
guess: string;
|
||||
bits: number;
|
||||
viable: boolean;
|
||||
candidatesBefore: number;
|
||||
}
|
||||
|
||||
export type SolverState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'thinking' }
|
||||
| { status: 'ready'; suggestion: Suggestion }
|
||||
| { status: 'unavailable' };
|
||||
|
||||
export function useSolver(pool: readonly string[]) {
|
||||
const workerRef = useRef<Worker | null>(null);
|
||||
const nextId = useRef(0);
|
||||
const pending = useRef<number | null>(null);
|
||||
const [state, setState] = useState<SolverState>({ status: 'idle' });
|
||||
|
||||
useEffect(() => {
|
||||
let worker: Worker | null = null;
|
||||
try {
|
||||
worker = new Worker(new URL('./solver.worker.ts', import.meta.url), { type: 'module' });
|
||||
worker.onmessage = (event: MessageEvent<SolverResponse>) => {
|
||||
// Ignore everything but the most recent request, or a slow first scan
|
||||
// lands after a later, faster one and the hint goes backwards.
|
||||
if (event.data.id !== pending.current) return;
|
||||
const { guess, bits, viable, candidatesBefore } = event.data;
|
||||
setState({ status: 'ready', suggestion: { guess, bits, viable, candidatesBefore } });
|
||||
};
|
||||
worker.onerror = () => setState({ status: 'unavailable' });
|
||||
workerRef.current = worker;
|
||||
} catch {
|
||||
// Some embedded browsers refuse module workers outright. The button is
|
||||
// hidden rather than shown broken.
|
||||
setState({ status: 'unavailable' });
|
||||
}
|
||||
return () => {
|
||||
worker?.terminate();
|
||||
workerRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const ask = useCallback(
|
||||
(history: readonly { guess: string; pattern: Pattern }[]) => {
|
||||
const worker = workerRef.current;
|
||||
if (!worker) {
|
||||
setState({ status: 'unavailable' });
|
||||
return;
|
||||
}
|
||||
const id = (nextId.current += 1);
|
||||
pending.current = id;
|
||||
setState({ status: 'thinking' });
|
||||
const request: SolverRequest = {
|
||||
id,
|
||||
pool: pool as string[],
|
||||
history: history.map((h) => ({ guess: h.guess, pattern: h.pattern })),
|
||||
};
|
||||
worker.postMessage(request);
|
||||
},
|
||||
[pool],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
pending.current = null;
|
||||
setState({ status: 'idle' });
|
||||
}, []);
|
||||
|
||||
return { state, ask, reset };
|
||||
}
|
||||
Reference in New Issue
Block a user