refuse a command when a dialog is in the way

Found by `POST /difficulty` failing three times against a "you have the fastest
time for intermediate level" prompt that had been sitting there waiting for a
name.

Failing was not the problem. What the three attempts DID was: 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. Three
times. On a live game that reveals cells and can lose it.

So both commands now look for any named top-level window that is not the game,
and refuse with its title rather than clicking harder. Named windows only, since
wine litters the display with 1x1 stubs.

Verified against a real one: with the Open Recording dialog up, both
/difficulty and /replay return `"Open Recording" is open on the board — dismiss
it first`, and the board is untouched.

The lesson generalises past this plugin: driving another program's UI by
synthetic clicks means every command needs to check the world is still the shape
it assumed. A retry loop without that check is not resilience, it is the same
mistake three times.

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:34:05 +00:00
co-authored by Claude Opus 5
parent 5d62668883
commit 34fad8be35
+36
View File
@@ -60,6 +60,34 @@ export function currentDifficulty(window: WindowGeometry): Difficulty | null {
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 };
@@ -76,6 +104,11 @@ export async function selectDifficulty(level: Difficulty, display: string): Prom
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++) {
@@ -125,6 +158,9 @@ export type PlayResult = { ok: true; file: string } | { ok: false; reason: strin
*/
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' };