Files
esports/streamer/windows/tourney.ts
Stan Runge 54444a77f0 feat: add native Windows broadcaster and live score modules
- streamer/windows: vash-broadcast.exe (setup/login/start/stop/status/doctor)
  with per-instance OBS, tosu and FFmpeg, a supervised worker, OCR room
  selection and OBS scene reconciliation; built and tested on Linux only
- streamer/relay.ts shared with Vash TV; Cloudflare Access proxy for dev/staging
- worker status validation and live score helpers for the site
- STREAMING_HANDOFF.md and streamer/ALPHA_TEST.md record what is unverified

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:02:25 +00:00

438 lines
16 KiB
TypeScript

/**
* 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 "<acronym>: (A) vs (B)" in
* its control panel as "<match id> : <room name> …". 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!.<Windows user>.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);
}
};
}