/** * osu!tourney on Windows: keeps the instance's own tourney client running * with the right tournament.cfg, finds its window, and spectates the lobby * Vash asks for, without anyone clicking. * * Selecting a room: osu!tourney lists rooms named ": (A) vs (B)" in * its control panel as " : …". There is no command or * IPC for choosing one, so the worker reads the panel with Windows' own OCR, * clicks the line with the lobby's match id (or, failing that, the only line * with both team names), and then checks tosu: osu!tourney's current team * names must be the ones in the requested room before it counts as selected. */ import { existsSync } from 'node:fs'; import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { plain, tournamentConfig } from '../tourney'; import type { Follow } from '../link'; import type { TosuFeed } from '../tosu'; import type { Desktop, OcrLine, SessionInfo, WindowInfo } from './desktop'; // ── Pure helpers (tested) ──────────────────────────────────────────────── /** "VASH: (Red) vs (Blue)" → acronym and team names. */ export function parseRoomName(roomName: string | null) { const match = roomName ? /^(.+?): \((.*)\) vs \((.*)\)\s*$/.exec(roomName) : null; if (!match || !match[1].trim()) return null; return { acronym: match[1].trim(), teams: [match[2], match[3]] as [string, string] }; } /** The streamable area is 16:9 across the window's width; the control panel sits below it. */ export function gameplayHeight(clientWidth: number) { return Math.round((clientWidth * 9) / 16); } /** The manager window is taller than 16:9 by its control panel; spectator windows are not. */ export function pickManagerWindow(windows: WindowInfo[]): WindowInfo | null { const candidates = windows.filter( (window) => window.visible && window.clientWidth >= 400 && window.clientHeight > gameplayHeight(window.clientWidth) + 60 ); candidates.sort((a, b) => b.clientWidth * b.clientHeight - a.clientWidth * a.clientHeight); return candidates[0] ?? null; } /** The biggest visible window of the osu! installation (for sign-in, when there is no panel). */ export function pickMainWindow(windows: WindowInfo[]): WindowInfo | null { const visible = windows.filter((window) => window.visible && window.clientWidth > 0); visible.sort((a, b) => b.clientWidth * b.clientHeight - a.clientWidth * a.clientHeight); return visible[0] ?? null; } /** OCR misreads digits as look-alike letters; read a leading "match id :" generously. */ function leadingNumber(text: string) { const match = /^\s*([0-9OoIl|SB]{5,})\s*[:;.]/.exec(text); if (!match) return null; return match[1] .replace(/[Oo]/g, '0') .replace(/[Il|]/g, '1') .replace(/S/g, '5') .replace(/B/g, '8'); } export type RoomMatch = | { found: true; line: OcrLine; by: 'lobby id' | 'room name' } | { found: false; reason: string; rooms: number }; /** * Finds the requested room in the control panel's OCR lines. The lobby's * match id is the reliable key; the room name is used only when exactly one * listed room has both team names, never "the first room". */ export function findRoomLine( lines: OcrLine[], lobbyId: number | null, roomName: string | null ): RoomMatch { const rooms = lines.filter((line) => /\bvs\b/i.test(line.text) || leadingNumber(line.text)); if (lobbyId != null) { const id = String(lobbyId); const byId = lines.find( (line) => leadingNumber(line.text) === id || line.text.replace(/\s+/g, '').includes(`${id}:`) ); if (byId) return { found: true, line: byId, by: 'lobby id' }; } const room = parseRoomName(roomName); if (!room) return { found: false, reason: 'no room name to look for', rooms: rooms.length }; const wanted = plain(`${room.acronym}${room.teams[0]}vs${room.teams[1]}`); const byName = lines.filter((line) => plain(line.text).includes(wanted)); if (byName.length === 1) return { found: true, line: byName[0], by: 'room name' }; if (byName.length > 1) return { found: false, reason: `${byName.length} rooms are named ${roomName}; waiting for the lobby id to be readable`, rooms: rooms.length }; return { found: false, reason: 'the room is not listed yet', rooms: rooms.length }; } /** Whether osu!tourney (via tosu) is spectating a room with these team names. */ export function teamsMatch( actual: { left: string; right: string } | null | undefined, roomName: string | null ) { const room = parseRoomName(roomName); if (!room || !actual) return false; const [a, b] = room.teams.map(plain); const left = plain(actual.left); const right = plain(actual.right); if (!left && !right) return false; return (left === a && right === b) || (left === b && right === a); } /** osu! stable remembers a login in osu!..cfg. */ export function savedLogin(cfg: string) { const value = (key: string) => new RegExp(`^\\s*${key}\\s*=\\s*(.*?)\\s*$`, 'mi').exec(cfg)?.[1] ?? ''; const username = value('Username'); return { username: username || null, saved: !!username && !!value('Password') }; } export async function readSavedLogin(osuDir: string) { const files = (await readdir(osuDir).catch(() => [] as string[])).filter((name) => /^osu!\..+\.cfg$/i.test(name) ); for (const name of files) { const login = savedLogin(await readFile(join(osuDir, name), 'utf8').catch(() => '')); if (login.saved) return login; } return { username: null, saved: false }; } // ── The controller ────────────────────────────────────────────────────── export type TourneyState = | 'stopped' | 'login-required' | 'starting' | 'desktop-locked' | 'idle' | 'waiting-for-lobby' | 'selecting' | 'spectating' | 'error'; export interface TourneyOptions { osuDir: string; snapshots: string; height: number; /** Shown in the window title so OBS captures exactly this window. */ windowTitle: string; desktop: Desktop; tosu: TosuFeed; log: (...parts: unknown[]) => void; /** Starts osu!.exe; defaults to Bun.spawn. */ launch?: (exe: string, cwd: string) => void; } export class TourneyController { state: TourneyState = 'stopped'; detail: string | null = null; window: WindowInfo | null = null; /** The room was clicked and tosu confirmed it (or tosu is down and could not). */ verified = false; private want: Follow | null = null; private launchedWith: string | null = null; private launchedAt = 0; private windowSeenAt = 0; private selected: { lobbyId: number; at: number } | null = null; private mismatchSince = 0; private nextAttempt = 0; private failures = 0; private busy = false; /** Kept between matches, so an idle channel does not restart osu!tourney. */ private teamSize = 1; private acronym = 'VASH'; constructor(private options: TourneyOptions) {} private get log() { return this.options.log; } /** What Vash wants shown; a new lobby or team size takes effect on the next tick. */ follow(follow: Follow) { const lobbyChanged = follow.lobbyId !== this.want?.lobbyId; this.want = follow; if (follow.teamSize) this.teamSize = Math.min(8, Math.max(1, follow.teamSize)); const room = parseRoomName(follow.roomName); if (room) this.acronym = room.acronym; if (lobbyChanged) { this.selected = null; this.verified = false; this.failures = 0; this.nextAttempt = 0; } } /** The tournament.cfg the wanted match needs. */ private desiredConfig() { return { teamSize: this.teamSize, acronym: this.acronym, height: this.options.height }; } private set(state: TourneyState, detail: string | null = null) { if (state !== this.state || detail !== this.detail) { if (state !== this.state) this.log(`osu!tourney: ${state}${detail ? ` (${detail})` : ''}`); this.state = state; this.detail = detail; } } /** Advances launch, window discovery and room selection. Call every couple of seconds. */ async tick(session: SessionInfo | null) { if (this.busy) return; this.busy = true; try { await this.step(session); } catch (err) { this.set('error', (err as Error).message); } finally { this.busy = false; } } private async step(session: SessionInfo | null) { const { desktop, osuDir } = this.options; const login = await readSavedLogin(osuDir); if (!login.saved) { this.set('login-required', 'Run vash-broadcast login once to sign in the spectator account'); return; } const desired = this.desiredConfig(); const key = JSON.stringify(desired); const processes = await desktop.processes(osuDir); if (!processes.length || this.launchedWith !== key) { if (processes.length) { this.log( `Restarting osu!tourney for ${desired.teamSize}v${desired.teamSize} (${desired.acronym})` ); await this.stopProcesses(); } await this.writeConfig(desired); this.launch(); this.launchedWith = key; this.launchedAt = Date.now(); this.window = null; this.windowSeenAt = 0; this.selected = null; this.verified = false; this.set('starting', 'launching osu!tourney'); return; } const windows = await desktop.windows(osuDir); const manager = pickManagerWindow(windows); if (!manager) { // A minimized manager has no client area to capture; bring it back. Only the window // already known as the manager (it carries our title), never a spectator window. const minimized = windows.find( (window) => window.minimized && window.title === this.options.windowTitle ); if (minimized) { this.log('osu!tourney was minimized; restoring it'); await desktop.focus(minimized.hwnd); return; } this.window = null; const waited = Date.now() - this.launchedAt; // A first start may update osu! itself for a few minutes. if (waited > 5 * 60_000) { this.log('osu!tourney window did not appear; restarting it'); await this.stopProcesses(); this.launchedWith = null; this.set('error', 'osu!tourney did not open its window in 5 minutes'); } else this.set('starting', 'waiting for the osu!tourney window'); return; } if (!this.windowSeenAt) this.windowSeenAt = Date.now(); this.window = manager; if (manager.title !== this.options.windowTitle) await desktop.setTitle(manager.hwnd, this.options.windowTitle); if (session && (manager.x < -8 || manager.y < -8 || manager.x + 100 > session.screenWidth)) { await desktop.move(manager.hwnd, 0, 0); this.window = (await desktop.window(manager.hwnd)) ?? manager; } const want = this.want; if (!want?.lobbyId || !want.roomName) { this.verified = false; this.set('idle', want?.matchId ? 'the match has no osu! lobby to spectate' : null); return; } const frame = this.options.tosu.fresh(); if (teamsMatch(frame?.teams, want.roomName)) { this.mismatchSince = 0; if (!this.verified) this.log(`Spectating ${want.roomName} (mp/${want.lobbyId}), confirmed by tosu`); this.selected = { lobbyId: want.lobbyId, at: this.selected?.at ?? Date.now() }; this.verified = true; this.failures = 0; this.set('spectating', null); return; } if (this.selected?.lobbyId === want.lobbyId) { if (!frame) { // Nothing can confirm the click while tosu is down; say so rather than claim success. this.verified = true; this.set('spectating', 'room selected; tosu is not answering, so it is unconfirmed'); return; } this.mismatchSince ||= Date.now(); if (Date.now() - this.mismatchSince < 12_000) { this.set('selecting', `waiting for osu!tourney to join ${want.roomName}`); return; } this.log( `osu!tourney shows "${frame.teams.left}" vs "${frame.teams.right}", not ${want.roomName}; selecting again` ); this.selected = null; this.verified = false; this.failures++; } this.verified = false; // osu!tourney needs a moment after its window appears before the room list fills. if (Date.now() - this.windowSeenAt < 8000 || Date.now() < this.nextAttempt) return; await this.select(want, manager, session); } private async select(want: Follow, manager: WindowInfo, session: SessionInfo | null) { const { desktop, snapshots } = this.options; if (session && session.inputDesktop !== 'Default') { this.set( 'desktop-locked', 'Windows is locked; unlock it (or disable the lock screen) so the room can be selected' ); this.nextAttempt = Date.now() + 5000; return; } this.nextAttempt = Date.now() + Math.min(30_000, 5000 * 2 ** Math.min(this.failures, 3)); if (!(await desktop.focus(manager.hwnd))) { this.set('selecting', 'could not bring osu!tourney to the front'); this.failures++; return; } const window = (await desktop.window(manager.hwnd)) ?? manager; const top = gameplayHeight(window.clientWidth); const region = { x: window.clientX, y: window.clientY + top, width: Math.round(window.clientWidth * 0.7), height: window.clientHeight - top }; const limit = await desktop .ocrReady() .then((ready) => ready.maxDimension) .catch(() => 2600); const scale = Math.max(1, Math.min(2.5, limit / region.width)); await mkdir(snapshots, { recursive: true }); const image = join(snapshots, 'tourney-panel.png'); await desktop.capture({ ...region, path: image, scale, invert: true }); const lines = await desktop.ocr(image); await writeFile( join(snapshots, 'tourney-panel.json'), JSON.stringify({ at: new Date().toISOString(), scale, region, lines }, null, '\t') ); const match = findRoomLine(lines, want.lobbyId, want.roomName); if (!match.found) { this.set( 'waiting-for-lobby', `${match.reason} (${match.rooms} room${match.rooms === 1 ? '' : 's'} listed)` ); return; } const x = Math.round(region.x + (match.line.x + Math.min(match.line.width, 200) / 2) / scale); const y = Math.round(region.y + (match.line.y + match.line.height / 2) / scale); this.log(`Clicking ${want.roomName} (mp/${want.lobbyId}), found by ${match.by} at ${x},${y}`); if (!(await desktop.click(x, y))) { this.set('selecting', 'Windows refused the click'); this.failures++; return; } this.selected = { lobbyId: want.lobbyId!, at: Date.now() }; this.mismatchSince = Date.now(); this.set('selecting', `joining ${want.roomName}`); } private async writeConfig(desired: { teamSize: number; acronym: string; height: number }) { const path = join(this.options.osuDir, 'tournament.cfg'); const existing = await readFile(path, 'utf8').catch(() => ''); await writeFile(path, tournamentConfig(existing, desired)); } private launch() { const exe = join(this.options.osuDir, 'osu!.exe'); if (this.options.launch) return this.options.launch(exe, this.options.osuDir); Bun.spawn([exe], { cwd: this.options.osuDir, stdout: 'ignore', stderr: 'ignore' }); } /** Closes every osu! process of this installation (manager and spectator clients). */ async stopProcesses() { const { desktop, osuDir } = this.options; const processes = await desktop.processes(osuDir); for (const process of processes) await desktop.close(process.pid).catch(() => 0); for (let waited = 0; waited < 8000; waited += 500) { if (!(await desktop.processes(osuDir)).length) return; await Bun.sleep(500); } await desktop.kill((await desktop.processes(osuDir)).map((process) => process.pid)); } async stop() { this.launchedWith = null; this.window = null; this.verified = false; await this.stopProcesses(); this.set('stopped'); } } // ── One-time sign-in ──────────────────────────────────────────────────── /** * Moves tournament.cfg aside so osu! starts normally (for signing in), and * puts it back afterwards. Returns a function that restores it. */ export async function normalModeFor(osuDir: string) { const cfg = join(osuDir, 'tournament.cfg'); const aside = join(osuDir, 'tournament.cfg.vash-signin'); if (existsSync(cfg)) await rename(cfg, aside); return async () => { if (existsSync(aside)) { await rm(cfg, { force: true }); await rename(aside, cfg); } }; }