Where they live: `HKCU\Software\Brightsoft\Minesweeper X` in the prefix's `user.reg`. `Time1/2/3` are beginner, intermediate and expert; `Name1/2/3` are the names, which is why the name you type sticks as the default. Nothing is stored beside the executable except the recordings. Why they were always empty: the game holds that whole key in memory and commits it ONLY on a clean exit. `stop()` sent SIGTERM. So every restart of this sidecar threw away every record set since the last one — six wins in one evening left a Best Times table reading 9999.99, which is what started this. `stop()` now drives Game > Exit and waits for the process to go before reaping. It runs FIRST, ahead of even reaping x11vnc, because it needs the display alive and it is racing PM2's clock. ── how it was pinned down ── Not by reading. `user.reg` grew by 848 bytes at the exact moment the game exited, which named the file. Writing `Time1="12.34"` / `Name1="Pastilhas"` into it by hand and restarting made the game display them, which proved the key and the load path. Then switching difficulty and stopping cleanly wrote Width/Height/Mines through from 30/16/99 to 8/8/10, which proved the write path carries in-memory state — beginner is 8x8 here, not the 9x9 I had assumed. ── the part this does NOT fix ── `pm2 restart` will usually still cut it short: PM2 SIGKILLs 1600ms after SIGTERM by default and a full stop takes about 5.6 seconds. That is not new — this sidecar's teardown has always exceeded that budget — but it does mean the fix only reliably applies to an explicit POST /stop. Raising it needs a `kill_timeout` on `Pm2App` in the platform's plugins/ecosystem.ts, which has no such field. Left alone rather than decided quietly; it is in the README. Also reverted: the 12.34 "Pastilhas" beginner record was my probe, not a game. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
250 lines
11 KiB
TypeScript
250 lines
11 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;
|
|
|
|
/** "Exit" at the bottom of the Game menu. The only way the game writes its settings — see `quitGame`. */
|
|
const EXIT_Y = 176;
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Any window that is not the game itself — a dialog, and therefore something in the way.
|
|
*
|
|
* This guard exists because of what happens WITHOUT it, which is worse than a failed command. A modal
|
|
* dialog swallows the click meant for the menu bar, the menu never opens, and the second click — aimed at
|
|
* where a menu item would have been — lands on the BOARD. Retrying does it three times. On a live game
|
|
* that reveals cells and can lose it.
|
|
*
|
|
* Found the honest way: `POST /difficulty` failed three times against a "you have the fastest time"
|
|
* prompt that had been waiting for a name, and the fix is to refuse rather than to click harder.
|
|
*
|
|
* Named windows only. wine litters the display with 1x1 stubs, and the game's own title is the one thing
|
|
* that is never a dialog.
|
|
*/
|
|
export async function blockingDialog(display: string): Promise<string | null> {
|
|
const found = await run(['xdotool', 'search', '--name', '.'], { DISPLAY: display }, 5_000);
|
|
if (!found.ok) return null;
|
|
|
|
for (const id of found.stdout.trim().split('\n').filter(Boolean)) {
|
|
const geom = await run(['xdotool', 'getwindowgeometry', '--shell', id], { DISPLAY: display }, 5_000);
|
|
if (!geom.ok || Number(/^WIDTH=(\d+)$/m.exec(geom.stdout)?.[1] ?? 1) <= 1) continue;
|
|
const name = await run(['xdotool', 'getwindowname', id], { DISPLAY: display }, 5_000);
|
|
const title = name.stdout.trim();
|
|
if (title && title !== 'Minesweeper X') return title;
|
|
}
|
|
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 () => {
|
|
const blocked = await blockingDialog(display);
|
|
if (blocked) {
|
|
return { ok: false, reason: `"${blocked}" is open on the board — dismiss it first`, window: null };
|
|
}
|
|
|
|
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 blocked = await blockingDialog(display);
|
|
if (blocked) return { ok: false, reason: `"${blocked}" is open on the board — dismiss it first` };
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Quit the game the way it expects to be quit, so it writes its settings down.
|
|
*
|
|
* THIS IS WHY BEST TIMES KEPT VANISHING. Minesweeper X holds its whole settings key in memory and commits
|
|
* it to `HKCU\Software\Brightsoft\Minesweeper X` — `Time1/2/3`, `Name1/2/3` and the rest — only when it
|
|
* exits cleanly. `stop()` sent SIGTERM, so every restart of this sidecar threw away every record set since
|
|
* the last one. Six wins in one evening left a Best Times table reading 9999.99.
|
|
*
|
|
* Diagnosed by watching `user.reg` grow by 848 bytes at the exact moment the game exited, and confirmed by
|
|
* writing a time into that key by hand and seeing the game display it on the next start.
|
|
*
|
|
* Best effort by design. It returns false rather than throwing if the window is gone, a dialog is in the
|
|
* way, or the game ignores the click — `stop()` then reaps as it always did. Losing the settings is bad;
|
|
* failing to stop is worse.
|
|
*/
|
|
export async function quitGame(display: string, isGameAlive: () => boolean): Promise<boolean> {
|
|
try {
|
|
if (await blockingDialog(display)) return false;
|
|
const win = await findWindow();
|
|
if (!win) return false;
|
|
|
|
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(400);
|
|
await click(win.x + ITEM_X, win.y + EXIT_Y);
|
|
|
|
// Wine writes the registry a moment after the process goes, so the caller must not race ahead.
|
|
for (let i = 0; i < 20; i++) {
|
|
await Bun.sleep(250);
|
|
if (!isGameAlive()) {
|
|
await Bun.sleep(750);
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|