diff --git a/sidecar/index.ts b/sidecar/index.ts index f811fe3..7b5db5c 100644 --- a/sidecar/index.ts +++ b/sidecar/index.ts @@ -2,7 +2,8 @@ import type { ServerWebSocket, Socket } from 'bun'; import { createSidecarConnector } from '@@/sidecar/connect'; import { API_URL } from '@@/officer-url.mjs'; 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. @@ -13,6 +14,7 @@ import { isRunning, start, status, stop } from './session'; GET /status { installed, running, window, error? } POST /start brings the session up; idempotent, and builds the wine prefix on a cold install 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 ───────────────────────────────────────────────────────────────────────────────────────────────── @@ -106,6 +108,24 @@ const server = Bun.serve({ return Response.json(result, { status: result.running ? 200 : 503 }); } 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) { console.error(`[${NAME}] ${req.method} ${url.pathname} failed`, err); return Response.json({ error: 'internal error' }, { status: 500 }); diff --git a/sidecar/menu.ts b/sidecar/menu.ts new file mode 100644 index 0000000..b0b2a9d --- /dev/null +++ b/sidecar/menu.ts @@ -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 { + 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, + }; + }); +} diff --git a/sidecar/session.ts b/sidecar/session.ts index d14c3ec..4a7dd09 100644 --- a/sidecar/session.ts +++ b/sidecar/session.ts @@ -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 * at startup, and the old shape spawned five processes a tick. */ -async function findWindow(): Promise { +export async function findWindow(): Promise { if (!display) return null; const found = await run( ['xdotool', 'search', '--name', 'Minesweeper X', 'getwindowgeometry', '--shell', '%@'], @@ -202,6 +202,32 @@ function watchForResize(): void { 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(fn: () => Promise): Promise { + 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. * @@ -421,5 +447,8 @@ export async function stop(): Promise { 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 prefixIsReady = prefixReady;