difficulty and replay panels in the left column

Top panel: Expert / Intermediate / Beginner. Bottom: that difficulty's saved
games, best first, click to play one on the board.

── the selection lives in the URL ──

`/minesweeper/expert`, not a channel and not panel state. It survives a
refresh, it can be linked, and the two panels never pass anything between
themselves — both read `useParams`. The plugin section route already existed;
nothing had used it.

The side effect hangs off the URL too: an effect on the selected level posts
the difficulty, so arriving at `/minesweeper/expert` by click, deep link or
back button all leave the game on expert. `POST /difficulty` verifies and is
idempotent, so a repeat changes nothing. One `<Navigate replace>` after the
hooks canonicalises both the bare route and a bogus section, and it waits for
`/status` so the URL lands on what the game IS rather than a guess — which is
why `difficulty` is now part of that payload.

Levels are NavLinks so cmd-click works and the active style comes from the
router. Replay rows are buttons, because they do not navigate anywhere, they
make the game do something. That is the line navigation-audit.md draws.

── two things on the server ──

`GET /replays` reads the directory rather than any index, because the filename
IS the metadata — Minesweeper X names a recording `<Lvl> <secs> - <date>.mvr`
and keeps no catalogue. A name that does not parse is skipped rather than
guessed at. Ordering is the server's: a loss is never "best" however short it
was, so wins rank by time and losses fall to the end, still watchable.

`POST /replay` drives Extras > Play Recording... and TYPES the filename into a
file dialog, which is why it only accepts a name `isKnownReplay` just matched
against the directory. An unchecked client string reaching `xdotool type` can
carry a path, a wildcard, or a newline that submits something else. Verified by
the dialog closing, not by the window size — a beginner replay leaves the
window beginner-sized, which is also what nothing-happened looks like.

Verified end to end against the running session: the expert list comes back
ranked 58.63 through 82.06 with losses after, playing 58.63 starts on the
board, and `{"file":"../../../etc/passwd"}` is a 404.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
This commit is contained in:
2026-08-23 23:23:44 +00:00
co-authored by Claude Opus 5
parent b94c1a6557
commit 5d62668883
8 changed files with 343 additions and 12 deletions
+35 -3
View File
@@ -3,7 +3,8 @@ import { createSidecarConnector } from '@@/sidecar/connect';
import { API_URL } from '@@/officer-url.mjs';
import { VNC_SOCKET, ensureDirs } from './paths';
import { activeDisplay, isRunning, start, status, stop } from './session';
import { DIFFICULTIES, isDifficulty, selectDifficulty } from './menu';
import { DIFFICULTIES, currentDifficulty, isDifficulty, playReplay, selectDifficulty } from './menu';
import { isKnownReplay, listReplays } from './replays';
/*
officer-minesweeper — owns the game session, and serves both halves of the plugin from one listener.
@@ -11,10 +12,12 @@ import { DIFFICULTIES, isDifficulty, selectDifficulty } from './menu';
─────────────────────────────────────────────────────────────────────────────────────────────────
HTTP CONTRACT — the platform strips its /api/minesweeper mount prefix before forwarding.
GET /status { installed, running, window, error? }
GET /status { installed, running, window, difficulty, error? }
POST /start brings the session up; idempotent, and builds the wine prefix on a cold install
POST /stop takes it down
POST /difficulty { level: beginner | intermediate | expert } — drives the game's own Game menu
GET /replays ?level=<one of those> — the saved games, best first
POST /replay { file } — opens one, by name, and only a name /replays just returned
GET /ws the display — an RFB stream, relayed frame-for-byte onto x11vnc's unix socket
─────────────────────────────────────────────────────────────────────────────────────────────────
@@ -100,7 +103,12 @@ const server = Bun.serve({
}
try {
if (req.method === 'GET' && url.pathname === '/status') return Response.json(await status());
if (req.method === 'GET' && url.pathname === '/status') {
// `difficulty` is derived here rather than in `status()` so that `session.ts` does not have to
// import from `menu.ts`, which imports from it — the cycle would work and would still be a trap.
const current = await status();
return Response.json({ ...current, difficulty: current.window ? currentDifficulty(current.window) : null });
}
if (req.method === 'POST' && url.pathname === '/start') {
const result = await start(log);
// 503 rather than 500: the session is unavailable and the reason is in the body, which is what the
@@ -126,6 +134,30 @@ const server = Bun.serve({
const result = await selectDifficulty(body.level, display);
return Response.json(result, { status: result.ok ? 200 : 502 });
}
// Read off the directory the game writes to, so it needs no running session.
if (req.method === 'GET' && url.pathname === '/replays') {
const level = url.searchParams.get('level');
if (level !== null && !isDifficulty(level)) {
return Response.json({ error: `level must be one of ${Object.keys(DIFFICULTIES).join(', ')}` }, { status: 400 });
}
return Response.json({ replays: listReplays(level ?? undefined) });
}
if (req.method === 'POST' && url.pathname === '/replay') {
const display = activeDisplay();
if (!isRunning() || !display) {
return Response.json({ error: 'the session is not running' }, { status: 409 });
}
const body = (await req.json().catch(() => null)) as { file?: unknown } | null;
// Checked against the DIRECTORY, not against a pattern. The name is about to be typed into a
// file dialog, and the only string safe to type there is one that names a file we just listed.
if (!isKnownReplay(body?.file)) {
return Response.json({ error: 'no such replay' }, { status: 404 });
}
const result = await playReplay(body.file, display);
return Response.json(result, { status: result.ok ? 200 : 502 });
}
} catch (err) {
console.error(`[${NAME}] ${req.method} ${url.pathname} failed`, err);
return Response.json({ error: 'internal error' }, { status: 500 });