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:
2026-10-18 22:02:25 +00:00
parent 9d5a046b40
commit 54444a77f0
45 changed files with 8168 additions and 643 deletions

View File

@@ -0,0 +1,250 @@
import { afterEach, beforeEach, describe, expect, setSystemTime, test } from 'bun:test';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { tournamentConfig } from '../tourney';
import type { Follow } from '../link';
import type { OcrLine } from './desktop';
import { FakeDesktop, fakeTosu, tourneyFrame, windowInfo } from './fakes';
import {
findRoomLine,
parseRoomName,
pickManagerWindow,
savedLogin,
teamsMatch,
TourneyController
} from './tourney';
const line = (text: string, y: number): OcrLine => ({ text, x: 10, y, width: 600, height: 30 });
describe('room names and matching', () => {
test('parses the acronym and both team names', () => {
expect(parseRoomName('VASH: (Red Team) vs (Blue: Team)')).toEqual({
acronym: 'VASH',
teams: ['Red Team', 'Blue: Team']
});
expect(parseRoomName('not a tourney room')).toBeNull();
expect(parseRoomName(null)).toBeNull();
});
test('finds the room by its lobby id, even with OCR look-alike letters', () => {
const lines = [
line('118000001 : @VASH: (Alpha) vs (Beta) () 0/16', 0),
line('11800OO42 : @VASH: (Alpha) vs (Beta) () 2/16', 40)
];
const match = findRoomLine(lines, 118000042, 'VASH: (Alpha) vs (Beta)');
expect(match).toMatchObject({ found: true, by: 'lobby id', line: { y: 40 } });
});
test('falls back to the room name only when exactly one room has it', () => {
const one = [line('VASH: (Other) vs (Team)', 0), line('VASH: (Alpha) vs (Beta)', 40)];
expect(findRoomLine(one, 5, 'VASH: (Alpha) vs (Beta)')).toMatchObject({
found: true,
by: 'room name',
line: { y: 40 }
});
const two = [line('VASH: (Alpha) vs (Beta)', 0), line('VASH: (Alpha) vs (Beta)', 40)];
const ambiguous = findRoomLine(two, 5, 'VASH: (Alpha) vs (Beta)');
expect(ambiguous.found).toBe(false);
expect(ambiguous.found || ambiguous.reason).toContain('2 rooms are named');
expect(
findRoomLine([line('Make a multiplayer room with format:', 0)], 5, 'VASH: (A) vs (B)')
).toMatchObject({
found: false,
reason: 'the room is not listed yet'
});
});
test('confirms the room from the team names tosu reads', () => {
expect(teamsMatch({ left: 'Alpha', right: 'Beta' }, 'VASH: (Alpha) vs (Beta)')).toBe(true);
expect(teamsMatch({ left: 'beta', right: 'ALPHA' }, 'VASH: (Alpha) vs (Beta)')).toBe(true);
expect(teamsMatch({ left: 'Alpha', right: 'Gamma' }, 'VASH: (Alpha) vs (Beta)')).toBe(false);
expect(teamsMatch({ left: '', right: '' }, 'VASH: ( ) vs ( )')).toBe(false);
expect(teamsMatch(null, 'VASH: (Alpha) vs (Beta)')).toBe(false);
});
test('picks the manager window (control panel below 16:9), not a spectator window', () => {
const spectator = windowInfo({ hwnd: 1, clientWidth: 640, clientHeight: 360 });
const manager = windowInfo({ hwnd: 2, clientWidth: 1280, clientHeight: 920 });
const hidden = windowInfo({ hwnd: 3, clientWidth: 1920, clientHeight: 1300, visible: false });
expect(pickManagerWindow([spectator, hidden, manager])?.hwnd).toBe(2);
expect(pickManagerWindow([spectator])).toBeNull();
});
test('reads a remembered login from osu!.<user>.cfg', () => {
expect(savedLogin('Username = spectator\r\nPassword = AQAAANCM\r\nSavePassword = 1')).toEqual({
username: 'spectator',
saved: true
});
expect(savedLogin('Username = spectator\nPassword = \n')).toEqual({
username: 'spectator',
saved: false
});
expect(savedLogin('')).toEqual({ username: null, saved: false });
});
test('tournament.cfg keeps other keys and sets ours', () => {
expect(
tournamentConfig('privateserver = 0\r\nTeamSize = 4\r\nacronym = OWC\r\n', {
teamSize: 2,
height: 720,
acronym: 'VASH'
})
).toBe('privateserver = 0\nTeamSize = 2\nacronym = VASH\nHeight = 720\n');
});
});
describe('TourneyController', () => {
let dir: string;
let desktop: FakeDesktop;
let tosu: ReturnType<typeof fakeTosu>;
let launches: number;
let tourney: TourneyController;
let now: number;
const advance = (ms: number) => {
now += ms;
setSystemTime(new Date(now));
};
const follow = (overrides: Partial<Follow> = {}): Follow => ({
scene: 'gameplay',
matchId: 'm1',
lobbyId: 118000042,
roomName: 'VASH: (Alpha) vs (Beta)',
teamSize: 2,
title: 'Alpha vs Beta',
...overrides
});
beforeEach(async () => {
now = Date.parse('2026-09-29T12:00:00Z');
setSystemTime(new Date(now));
dir = await mkdtemp(join(tmpdir(), 'vash-tourney-'));
await writeFile(join(dir, 'osu!.stan.cfg'), 'Username = spectator\nPassword = secret\n');
desktop = new FakeDesktop();
tosu = fakeTosu();
launches = 0;
tourney = new TourneyController({
osuDir: dir,
snapshots: join(dir, 'snapshots'),
height: 720,
windowTitle: 'Vash osu!tourney (test)',
desktop,
tosu,
log: () => {},
launch: () => {
launches++;
desktop.running.push({ pid: 10 + launches, path: join(dir, 'osu!.exe') });
}
});
});
afterEach(async () => {
setSystemTime();
await rm(dir, { recursive: true, force: true });
});
test('launches with the match team size, selects the room by lobby id and confirms it', async () => {
tourney.follow(follow());
await tourney.tick(desktop.sessionInfo);
expect(launches).toBe(1);
expect(tourney.state).toBe('starting');
expect(await readFile(join(dir, 'tournament.cfg'), 'utf8')).toBe(
'TeamSize = 2\nacronym = VASH\nHeight = 720\n'
);
desktop.windowList = [windowInfo({ pid: 11 })];
await tourney.tick(desktop.sessionInfo);
expect(desktop.called('title')[0].args).toEqual([100, 'Vash osu!tourney (test)']);
expect(desktop.called('click')).toHaveLength(0);
advance(9000);
desktop.ocrLines = [
line('118000001 : @VASH: (Other) vs (Team) () 0/16', 20),
line('118000042 : @VASH: (Alpha) vs (Beta) () 2/16', 60)
];
await tourney.tick(desktop.sessionInfo);
// Panel at client (8, 31 + 720), captured at 2.5×: the second line's start.
expect(desktop.called('click')[0].args).toEqual([52, 781]);
expect(tourney.state).toBe('selecting');
expect(tourney.verified).toBe(false);
tosu.frame = tourneyFrame('Alpha', 'Beta');
advance(2000);
await tourney.tick(desktop.sessionInfo);
expect(tourney.state).toBe('spectating');
expect(tourney.verified).toBe(true);
});
test('restarts osu!tourney for a different team size, and not for an idle channel', async () => {
tourney.follow(follow());
await tourney.tick(desktop.sessionInfo);
desktop.windowList = [windowInfo({ pid: 11 })];
tourney.follow(
follow({ matchId: null, lobbyId: null, roomName: null, teamSize: null, title: null })
);
await tourney.tick(desktop.sessionInfo);
expect(launches).toBe(1);
expect(tourney.state).toBe('idle');
tourney.follow(follow({ lobbyId: 5, teamSize: 1, roomName: 'VASH: (C) vs (D)' }));
await tourney.tick(desktop.sessionInfo);
expect(desktop.called('close')).toHaveLength(1);
expect(launches).toBe(2);
expect(await readFile(join(dir, 'tournament.cfg'), 'utf8')).toContain('TeamSize = 1');
});
test('does not click while Windows is locked', async () => {
tourney.follow(follow());
await tourney.tick(desktop.sessionInfo);
desktop.windowList = [windowInfo({ pid: 11 })];
await tourney.tick(desktop.sessionInfo);
advance(9000);
desktop.sessionInfo = { ...desktop.sessionInfo, inputDesktop: 'Winlogon' };
await tourney.tick(desktop.sessionInfo);
expect(tourney.state).toBe('desktop-locked');
expect(desktop.called('click')).toHaveLength(0);
});
test('selects again when tosu shows a different room after the click', async () => {
tourney.follow(follow());
await tourney.tick(desktop.sessionInfo);
desktop.windowList = [windowInfo({ pid: 11 })];
await tourney.tick(desktop.sessionInfo);
advance(9000);
desktop.ocrLines = [line('118000042 : @VASH: (Alpha) vs (Beta) () 2/16', 60)];
await tourney.tick(desktop.sessionInfo);
expect(desktop.called('click')).toHaveLength(1);
tosu.frame = tourneyFrame('Other', 'Team');
advance(5000);
await tourney.tick(desktop.sessionInfo);
expect(tourney.state).toBe('selecting');
advance(13_000);
await tourney.tick(desktop.sessionInfo);
expect(tourney.verified).toBe(false);
advance(11_000);
await tourney.tick(desktop.sessionInfo);
expect(desktop.called('click')).toHaveLength(2);
});
test('waits for the room when it is not listed, and never clicks another room', async () => {
tourney.follow(follow());
await tourney.tick(desktop.sessionInfo);
desktop.windowList = [windowInfo({ pid: 11 })];
await tourney.tick(desktop.sessionInfo);
advance(9000);
desktop.ocrLines = [line('118000001 : @VASH: (Other) vs (Team) () 0/16', 20)];
await tourney.tick(desktop.sessionInfo);
expect(tourney.state).toBe('waiting-for-lobby');
expect(tourney.detail).toContain('1 room listed');
expect(desktop.called('click')).toHaveLength(0);
});
test('asks for the one-time sign-in instead of launching without a saved login', async () => {
await writeFile(join(dir, 'osu!.stan.cfg'), 'Username = \n');
tourney.follow(follow());
await tourney.tick(desktop.sessionInfo);
expect(tourney.state).toBe('login-required');
expect(launches).toBe(0);
});
});