- 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>
438 lines
16 KiB
TypeScript
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);
|
|
}
|
|
};
|
|
}
|