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
169 lines
7.6 KiB
TypeScript
169 lines
7.6 KiB
TypeScript
import { run } from './run';
|
|
import { findWindow, withStableWindow, type WindowGeometry } from './session';
|
|
|
|
/**
|
|
* Driving the game's own Game menu from outside, so difficulty is not only reachable by hand.
|
|
*
|
|
* ── Why clicks and not keys ──
|
|
*
|
|
* The obvious approach is keyboard: `alt+g` then the underlined letter, or `alt+g` then arrows. Both were
|
|
* tried and both are unreliable here, measured rather than assumed — the same sequence produced a
|
|
* different difficulty depending on what was selected before, and `alt+g e` landed on Intermediate more
|
|
* than once. A menu keystroke is a SEQUENCE, so it depends on timing and on where the highlight already
|
|
* was; a click is one event that names its target. Six scrambled clicks in a row hit six correct items.
|
|
*
|
|
* There is a second reason. With no window manager, X input focus is `PointerRoot` — keys go to whatever
|
|
* the pointer is over. So a key sequence that resizes the window can move the window out from under the
|
|
* pointer and silently drop the rest of its own keystrokes, which is exactly what happened.
|
|
*
|
|
* ── Why the offsets are constants ──
|
|
*
|
|
* The menu drops from the menu bar at a fixed offset from the window's top-left, and the item heights do
|
|
* not change with the board. So the only variable is where the window is, which is read fresh each time.
|
|
* They were measured off a screenshot at a known window position; if a future build of the game reorders
|
|
* the Game menu these are the numbers that break, and `verify` below is what will tell you.
|
|
*/
|
|
|
|
/** 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;
|
|
|
|
/**
|
|
* The three standard difficulties: where the item is, and the window size that PROVES it was applied.
|
|
*
|
|
* The sizes are measured on this machine and they are distinct, which is what makes verification
|
|
* possible at all — intermediate and expert share a height, so width is doing the work.
|
|
*/
|
|
export const DIFFICULTIES = {
|
|
beginner: { itemY: 56, width: 152, height: 214 },
|
|
intermediate: { itemY: 73, width: 280, height: 342 },
|
|
expert: { itemY: 90, width: 504, height: 342 },
|
|
} as const;
|
|
|
|
export type Difficulty = keyof typeof DIFFICULTIES;
|
|
|
|
export const isDifficulty = (value: unknown): value is Difficulty =>
|
|
typeof value === 'string' && value in DIFFICULTIES;
|
|
|
|
/** Which difficulty the window's size says we are on, or null for a custom board. */
|
|
export function currentDifficulty(window: WindowGeometry): Difficulty | null {
|
|
for (const [name, spec] of Object.entries(DIFFICULTIES)) {
|
|
if (window.width === spec.width && window.height === spec.height) return name as Difficulty;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export type SelectResult =
|
|
| { ok: true; level: Difficulty; changed: boolean }
|
|
| { ok: false; reason: string; window: WindowGeometry | null };
|
|
|
|
/**
|
|
* Put the game on a difficulty, and confirm it took.
|
|
*
|
|
* Attempted up to three times because the primitive underneath is a synthetic click, not a function call:
|
|
* the menu can fail to open, and a click can land a frame early. Retrying is safe precisely because the
|
|
* check is the window's size rather than "we sent the click" — an attempt that already worked is a no-op
|
|
* on the next pass rather than a second difficulty change.
|
|
*/
|
|
export async function selectDifficulty(level: Difficulty, display: string): Promise<SelectResult> {
|
|
const spec = DIFFICULTIES[level];
|
|
|
|
return withStableWindow(async () => {
|
|
let last: WindowGeometry | null = null;
|
|
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
const win = await findWindow();
|
|
if (!win) return { ok: false, reason: 'the game window is not there', window: null };
|
|
last = win;
|
|
|
|
if (win.width === spec.width && win.height === spec.height) {
|
|
return { ok: true, level, changed: attempt > 0 };
|
|
}
|
|
|
|
// Open the menu, then pick the item. Both positions come from THIS reading of the window, because
|
|
// the previous attempt may have resized it.
|
|
const click = (x: number, y: number) =>
|
|
run(['xdotool', 'mousemove', String(x), String(y), 'click', '1'], { DISPLAY: display }, 5_000);
|
|
|
|
await click(win.x + GAME_MENU.x, win.y + GAME_MENU.y);
|
|
await Bun.sleep(450);
|
|
await click(win.x + ITEM_X, win.y + spec.itemY);
|
|
await Bun.sleep(600);
|
|
}
|
|
|
|
const after = await findWindow();
|
|
return {
|
|
ok: false,
|
|
reason: `the menu did not take after three attempts; the window is ${after?.width ?? '?'}x${after?.height ?? '?'} rather than ${spec.width}x${spec.height}`,
|
|
window: after ?? last,
|
|
};
|
|
});
|
|
}
|
|
|
|
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<PlayResult> {
|
|
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;
|
|
}
|