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>
This commit is contained in:
437
streamer/windows/tourney.ts
Normal file
437
streamer/windows/tourney.ts
Normal file
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user