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 ` -