/** Launches osu!tourney under Wine and selects a room from its control panel. */ import { readFile, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import type { Subprocess } from 'bun'; interface Word { line: string; text: string; x: number; y: number; w: number; h: number; } export const plain = (value: string) => value .normalize('NFKD') .toLowerCase() .replace(/[^\p{L}\p{N}]/gu, ''); /** OCR is imperfect, so require both team names on one control-panel line. */ export function findRoom(tsv: string, roomName: string): { x: number; y: number } | null { const names = [...roomName.matchAll(/\(([^)]+)\)/g)].map((match) => plain(match[1])); if (names.length !== 2 || names.some((name) => !name)) return null; const words: Word[] = tsv .split('\n') .slice(1) .map((row) => row.split('\t')) .filter((row) => row.length >= 12 && row[0] === '5' && Number(row[10]) >= 30) .map((row) => ({ line: row.slice(1, 5).join(':'), text: row.slice(11).join(''), x: Number(row[6]), y: Number(row[7]), w: Number(row[8]), h: Number(row[9]) })); const lines = new Map(); for (const word of words) lines.set(word.line, [...(lines.get(word.line) ?? []), word]); for (const line of lines.values()) { const normalized = plain(line.map((word) => word.text).join('')); if (!normalized.includes(`vash${names[0]}vs${names[1]}`)) continue; const left = Math.min(...line.map((word) => word.x)); const right = Math.max(...line.map((word) => word.x + word.w)); const top = Math.min(...line.map((word) => word.y)); const bottom = Math.max(...line.map((word) => word.y + word.h)); return { x: Math.round((left + right) / 2), y: Math.round((top + bottom) / 2) }; } return null; } /** tournament.cfg with our keys replaced and anything else osu! wrote kept. */ export function tournamentConfig( existing: string, options: { teamSize: number; height: number; acronym?: string } ) { const lines = existing .split(/\r?\n/) .filter((line) => !/^\s*(TeamSize|acronym|Height)\s*=/i.test(line)); return [ ...lines.filter(Boolean), `TeamSize = ${options.teamSize}`, `acronym = ${options.acronym ?? 'VASH'}`, `Height = ${options.height}`, '' ].join('\n'); } export class Tourney { private process: Subprocess | null = null; private size = 0; private startedAt = 0; private readonly exe: string; private readonly directory: string; private readonly display: string; private readonly width: number; private readonly height: number; constructor(exe: string, options: { display: string; width: number; height: number }) { this.exe = exe; this.directory = dirname(exe); this.display = options.display; this.width = options.width; this.height = options.height; } /** Config must be written while osu! is closed; a size change restarts it. */ async ensure(teamSize: number) { if (!Number.isInteger(teamSize) || teamSize < 1 || teamSize > 8) throw new Error(`Unsupported team size ${teamSize}`); if (this.size === teamSize && this.process?.exitCode == null) return false; if (this.process) { this.process.kill(); await Bun.spawn(['wineserver', '-k'], { stdout: 'ignore', stderr: 'ignore' }).exited; await this.process.exited; this.process = null; } const configPath = join(this.directory, 'tournament.cfg'); const existing = await readFile(configPath, 'utf8').catch(() => ''); await writeFile(configPath, tournamentConfig(existing, { teamSize, height: this.height })); this.process = Bun.spawn(['wine', this.exe], { cwd: this.directory, env: { ...process.env, DISPLAY: this.display }, stdout: 'ignore', stderr: 'ignore' }); this.size = teamSize; this.startedAt = Date.now(); return true; } async select(roomName: string): Promise { if (!this.process || this.process.exitCode != null || Date.now() - this.startedAt < 10_000) return false; const capture = Bun.spawn( [ 'ffmpeg', '-hide_banner', '-loglevel', 'error', '-f', 'x11grab', '-video_size', `${this.width}x200`, '-i', `${this.display}+0,${this.height}`, '-frames:v', '1', '-f', 'image2pipe', '-vcodec', 'png', 'pipe:1' ], { stdout: 'pipe', stderr: 'ignore' } ); const png = new Uint8Array(await new Response(capture.stdout).arrayBuffer()); if ((await capture.exited) !== 0 || !png.length) return false; const ocr = Bun.spawn(['tesseract', 'stdin', 'stdout', 'tsv'], { stdin: 'pipe', stdout: 'pipe', stderr: 'ignore' }); ocr.stdin.write(png); ocr.stdin.end(); const result = await new Response(ocr.stdout).text(); if ((await ocr.exited) !== 0) return false; const point = findRoom(result, roomName); if (!point) return false; return ( (await Bun.spawn( ['xdotool', 'mousemove', String(point.x), String(this.height + point.y), 'click', '1'], { env: { ...process.env, DISPLAY: this.display }, stdout: 'ignore', stderr: 'ignore' } ).exited) === 0 ); } stop() { this.process?.kill(); } }