- 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>
251 lines
9.0 KiB
TypeScript
251 lines
9.0 KiB
TypeScript
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);
|
||
});
|
||
});
|