From 5d626688832ff98a5384f7a4632be2ba82f8c3ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 23 Aug 2026 23:23:44 +0000 Subject: [PATCH] difficulty and replay panels in the left column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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 ` - .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 Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b --- sidecar/index.ts | 38 +++++++++++++++++++-- sidecar/menu.ts | 67 +++++++++++++++++++++++++++++++++++- sidecar/replays.ts | 61 +++++++++++++++++++++++++++++++++ web/LevelsPanel.tsx | 75 ++++++++++++++++++++++++++++++++++++++++ web/ReplaysPanel.tsx | 81 ++++++++++++++++++++++++++++++++++++++++++++ web/layout.ts | 14 ++++---- web/levels.ts | 13 +++++++ web/panels.ts | 6 +++- 8 files changed, 343 insertions(+), 12 deletions(-) create mode 100644 sidecar/replays.ts create mode 100644 web/LevelsPanel.tsx create mode 100644 web/ReplaysPanel.tsx create mode 100644 web/levels.ts diff --git a/sidecar/index.ts b/sidecar/index.ts index 7b5db5c..33d55d0 100644 --- a/sidecar/index.ts +++ b/sidecar/index.ts @@ -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= — 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 }); diff --git a/sidecar/menu.ts b/sidecar/menu.ts index b0b2a9d..52cf8e8 100644 --- a/sidecar/menu.ts +++ b/sidecar/menu.ts @@ -24,8 +24,13 @@ import { findWindow, withStableWindow, type WindowGeometry } from './session'; * the Game menu these are the numbers that break, and `verify` below is what will tell you. */ -/** Offset of the "Game" menu bar item from the window's top-left. */ +/** Offsets of the menu bar items from the window's top-left. Left-aligned, so board size does not move them. */ const GAME_MENU = { x: 26, y: 11 }; +const EXTRAS_MENU = { x: 90, y: 11 }; + +/** "Play Recording…" in the Extras menu, and the File name box inside the dialog it opens. */ +const PLAY_RECORDING_Y = 62; +const FILE_DIALOG = { name: 'Open Recording', nameBox: { x: 206, y: 190 } }; /** How far into an item to click horizontally — anywhere inside its width will do. */ const ITEM_X = 45; @@ -101,3 +106,63 @@ export async function selectDifficulty(level: Difficulty, display: string): Prom }; }); } + +export type PlayResult = { ok: true; file: string } | { ok: false; reason: string }; + +/** + * Open a saved recording, by driving Extras -> Play Recording... and its file dialog. + * + * The filename is TYPED into a dialog, which is why `index.ts` only accepts one that `isKnownReplay` + * matched against the directory first. A client-supplied string reaching `xdotool type` unchecked is a + * string that can carry a path, a wildcard, or a newline that submits something else entirely. + * + * The dialog opens in C:\Minesweeper because that is the process's working directory, so a bare filename + * is enough and no path is ever typed. + * + * Verified by the dialog going away. Checking the window size instead would be wrong: a replay of a + * beginner game leaves the window at beginner size, which is also what it looks like when nothing + * happened at all. + */ +export async function playReplay(file: string, display: string): Promise { + return withStableWindow(async () => { + const win = await findWindow(); + if (!win) return { ok: false, reason: 'the game window is not there' }; + + const click = (x: number, y: number) => + run(['xdotool', 'mousemove', String(x), String(y), 'click', '1'], { DISPLAY: display }, 5_000); + + await click(win.x + EXTRAS_MENU.x, win.y + EXTRAS_MENU.y); + await Bun.sleep(450); + await click(win.x + ITEM_X + 80, win.y + PLAY_RECORDING_Y); + await Bun.sleep(900); + + const dialog = await findDialog(display); + if (!dialog) return { ok: false, reason: 'the file dialog did not open' }; + + await click(dialog.x + FILE_DIALOG.nameBox.x, dialog.y + FILE_DIALOG.nameBox.y); + await Bun.sleep(250); + await run(['xdotool', 'type', '--delay', '20', file], { DISPLAY: display }, 15_000); + await Bun.sleep(250); + await run(['xdotool', 'key', 'Return'], { DISPLAY: display }, 5_000); + + // The dialog closing is the game accepting the name. It stays open on one it cannot find. + for (let i = 0; i < 12; i++) { + await Bun.sleep(250); + if (!(await findDialog(display))) return { ok: true, file }; + } + return { ok: false, reason: 'the file dialog stayed open, so the game did not accept that name' }; + }); +} + +/** The Open Recording dialog, if it is up. Its own window, so it is found by name rather than guessed at. */ +async function findDialog(display: string): Promise<{ x: number; y: number } | null> { + const found = await run( + ['xdotool', 'search', '--name', FILE_DIALOG.name, 'getwindowgeometry', '--shell', '%@'], + { DISPLAY: display }, + 5_000, + ); + if (!found.ok) return null; + const x = /^X=(\d+)$/m.exec(found.stdout); + const y = /^Y=(\d+)$/m.exec(found.stdout); + return x && y ? { x: Number(x[1]), y: Number(y[1]) } : null; +} diff --git a/sidecar/replays.ts b/sidecar/replays.ts new file mode 100644 index 0000000..e0a7554 --- /dev/null +++ b/sidecar/replays.ts @@ -0,0 +1,61 @@ +import { readdirSync } from 'node:fs'; +import { GAME_DIR } from './paths'; +import type { Difficulty } from './menu'; + +/** + * The saved games, read off disk rather than out of the game. + * + * Minesweeper X names a recording ` -