quit the game cleanly on stop, so best times survive

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
This commit is contained in:
2026-08-23 23:50:37 +00:00
co-authored by Claude Opus 5
parent 34fad8be35
commit 035c374563
3 changed files with 86 additions and 0 deletions
+45
View File
@@ -30,6 +30,9 @@ 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. */
@@ -202,3 +205,45 @@ async function findDialog(display: string): Promise<{ x: number; y: number } | n
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;
}
}