POST /difficulty — set the board from outside the game

First command in what will presumably be several, so the shape matters more
than the feature: name the OUTCOME, verify it against the world, and report
what the game now IS rather than that a click was sent.

    POST /api/minesweeper/difficulty  { "level": "expert" }
    -> { "ok": true, "level": "expert", "changed": true }

The proxy forwards <prefix>/*, so this needed no platform-side change.

── clicks, not keys, and that was measured ──

The obvious mechanism is `alt+g` then the underlined letter. It does not work
reliably: the same sequence gave a different difficulty depending on what was
selected before, and `alt+g e` landed on Intermediate more than once. Arrow
navigation was no better. 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 keys are wrong here. With no window manager, X input
focus is PointerRoot — keys go to whatever the pointer is over — so a sequence
that RESIZES the window can move it out from under the pointer and silently
drop the rest of its own keystrokes. That is exactly what the first attempt
did, and it looked like a mapping problem.

── two things that make it a command rather than a click ──

`withStableWindow` holds off the re-centring watcher for the duration. A menu
interaction is two clicks ~450ms apart and the watcher ticks every second, so
it WILL move the window between them, and the second click then lands where
the item used to be — which on this game is the board. A stray click on a live
board reveals a cell and can lose the game.

And the check is the window's SIZE, not that the click was sent: 152x214,
280x342, 504x342 are distinct, so the result is verifiable. That is what makes
retrying safe, and what makes a repeat a no-op (`changed: false`) rather than a
second difficulty change.

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:02:59 +00:00
co-authored by Claude Opus 5
parent 5c8c7937f5
commit 977448868c
3 changed files with 154 additions and 2 deletions
+21 -1
View File
@@ -2,7 +2,8 @@ import type { ServerWebSocket, Socket } from 'bun';
import { createSidecarConnector } from '@@/sidecar/connect'; import { createSidecarConnector } from '@@/sidecar/connect';
import { API_URL } from '@@/officer-url.mjs'; import { API_URL } from '@@/officer-url.mjs';
import { VNC_SOCKET, ensureDirs } from './paths'; import { VNC_SOCKET, ensureDirs } from './paths';
import { isRunning, start, status, stop } from './session'; import { activeDisplay, isRunning, start, status, stop } from './session';
import { DIFFICULTIES, isDifficulty, selectDifficulty } from './menu';
/* /*
officer-minesweeper — owns the game session, and serves both halves of the plugin from one listener. officer-minesweeper — owns the game session, and serves both halves of the plugin from one listener.
@@ -13,6 +14,7 @@ import { isRunning, start, status, stop } from './session';
GET /status { installed, running, window, error? } GET /status { installed, running, window, error? }
POST /start brings the session up; idempotent, and builds the wine prefix on a cold install POST /start brings the session up; idempotent, and builds the wine prefix on a cold install
POST /stop takes it down POST /stop takes it down
POST /difficulty { level: beginner | intermediate | expert } — drives the game's own Game menu
GET /ws the display — an RFB stream, relayed frame-for-byte onto x11vnc's unix socket GET /ws the display — an RFB stream, relayed frame-for-byte onto x11vnc's unix socket
───────────────────────────────────────────────────────────────────────────────────────────────── ─────────────────────────────────────────────────────────────────────────────────────────────────
@@ -106,6 +108,24 @@ const server = Bun.serve({
return Response.json(result, { status: result.running ? 200 : 503 }); return Response.json(result, { status: result.running ? 200 : 503 });
} }
if (req.method === 'POST' && url.pathname === '/stop') return Response.json(await stop()); if (req.method === 'POST' && url.pathname === '/stop') return Response.json(await stop());
// The one command so far, and the shape the rest should follow: name the OUTCOME, verify it, and
// report what the game actually is now rather than that a click was sent.
if (req.method === 'POST' && url.pathname === '/difficulty') {
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 { level?: unknown } | null;
if (!isDifficulty(body?.level)) {
return Response.json(
{ error: `level must be one of ${Object.keys(DIFFICULTIES).join(', ')}` },
{ status: 400 },
);
}
const result = await selectDifficulty(body.level, display);
return Response.json(result, { status: result.ok ? 200 : 502 });
}
} catch (err) { } catch (err) {
console.error(`[${NAME}] ${req.method} ${url.pathname} failed`, err); console.error(`[${NAME}] ${req.method} ${url.pathname} failed`, err);
return Response.json({ error: 'internal error' }, { status: 500 }); return Response.json({ error: 'internal error' }, { status: 500 });
+103
View File
@@ -0,0 +1,103 @@
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.
*/
/** Offset of the "Game" menu bar item from the window's top-left. */
const GAME_MENU = { x: 26, y: 11 };
/** 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,
};
});
}
+30 -1
View File
@@ -124,7 +124,7 @@ type FoundWindow = WindowGeometry & { id: string };
* commands and prints a block per window. That matters because this now runs on a timer rather than once * commands and prints a block per window. That matters because this now runs on a timer rather than once
* at startup, and the old shape spawned five processes a tick. * at startup, and the old shape spawned five processes a tick.
*/ */
async function findWindow(): Promise<FoundWindow | null> { export async function findWindow(): Promise<FoundWindow | null> {
if (!display) return null; if (!display) return null;
const found = await run( const found = await run(
['xdotool', 'search', '--name', 'Minesweeper X', 'getwindowgeometry', '--shell', '%@'], ['xdotool', 'search', '--name', 'Minesweeper X', 'getwindowgeometry', '--shell', '%@'],
@@ -202,6 +202,32 @@ function watchForResize(): void {
centring = setTimeout(tick, CENTRE_INTERVAL_MS); centring = setTimeout(tick, CENTRE_INTERVAL_MS);
} }
/**
* Drive the game's own UI with the window held still.
*
* Anything that opens a menu clicks twice — once on the menu bar, once on the item — and the re-centring
* watcher runs between those two clicks often enough to matter. If the window moves after the first
* click, the second lands wherever the item used to be, which on this game means somewhere on the BOARD.
* A stray click on a live board reveals a cell, and can lose a game outright, so this is not a tidiness
* fix.
*
* Re-centres before handing the watcher back, so the window is not left off-centre for up to a second
* after a difficulty change resizes it.
*/
export async function withStableWindow<T>(fn: () => Promise<T>): Promise<T> {
const wasWatching = centring !== null;
if (centring) {
clearTimeout(centring);
centring = null;
}
try {
return await fn();
} finally {
await centre().catch(() => undefined);
if (wasWatching) watchForResize();
}
}
/** /**
* Whether the session is up, decided by the socket file plus a live x11vnc. * Whether the session is up, decided by the socket file plus a live x11vnc.
* *
@@ -421,5 +447,8 @@ export async function stop(): Promise<SessionStatus> {
return status(); return status();
} }
/** The display the session is on, for anything that has to drive the game's own UI. Null when it is down. */
export const activeDisplay = (): string | null => display;
export const runDir = RUN_DIR; export const runDir = RUN_DIR;
export const prefixIsReady = prefixReady; export const prefixIsReady = prefixReady;