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, }; }); }