feat: redesign match play and add broadcast tooling
This commit is contained in:
157
streamer/tourney.ts
Normal file
157
streamer/tourney.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/** 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;
|
||||
}
|
||||
|
||||
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<string, Word[]>();
|
||||
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;
|
||||
}
|
||||
|
||||
function tournamentConfig(existing: string, teamSize: number, height: number) {
|
||||
const lines = existing
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => !/^\s*(TeamSize|acronym|Height)\s*=/i.test(line));
|
||||
return [
|
||||
...lines.filter(Boolean),
|
||||
`TeamSize = ${teamSize}`,
|
||||
'acronym = VASH',
|
||||
`Height = ${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, 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<boolean> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user