/** * tosu (https://github.com/tosuapp/tosu) reads the osu! tourney client's * memory and serves it over a websocket. We need each spectated player's * live score, how far through the map they are, and the team names of the * room being spectated (to confirm the right room was selected). */ export interface TosuClientScore { osuId: number; name: string; score: number; /** Percent. */ accuracy: number; combo: number; failed: boolean; } export interface TosuFrame { /** 1 idle, 2 waiting for clients, 3 playing, 4 results (tosu's ipcState). */ ipcState: number | null; beatmapId: number | null; /** 0–1 through the map. */ progress: number | null; /** Team names osu!tourney parsed from the spectated room's name; empty when none. */ teams: { left: string; right: string }; clients: TosuClientScore[]; } /** Picks what Vash needs out of one tosu v2 message. Returns null outside tourney mode. */ export function parseTosu(message: unknown): TosuFrame | null { const root = message as { tourney?: { ipcState?: number; team?: { left?: string; right?: string }; clients?: { user?: { id?: number; name?: string }; play?: { playerName?: string; score?: number; accuracy?: number; failed?: boolean; combo?: { current?: number }; }; }[]; }; beatmap?: { id?: number; time?: { live?: number; firstObject?: number; lastObject?: number } }; }; if (!root?.tourney || !Array.isArray(root.tourney.clients)) return null; const time = root.beatmap?.time; const span = time && time.lastObject != null && time.firstObject != null ? time.lastObject - time.firstObject : 0; return { ipcState: root.tourney.ipcState ?? null, beatmapId: root.beatmap?.id ?? null, progress: span > 0 && time?.live != null ? Math.max(0, Math.min(1, (time.live - time.firstObject!) / span)) : null, teams: { left: String(root.tourney.team?.left ?? '').trim(), right: String(root.tourney.team?.right ?? '').trim() }, clients: root.tourney.clients .filter( (client) => (client.user?.id ?? 0) > 0 || client.user?.name?.trim() || client.play?.playerName?.trim() ) .map((client) => ({ osuId: client.user?.id ?? 0, name: client.user?.name?.trim() || client.play?.playerName?.trim() || '', score: client.play?.score ?? 0, accuracy: client.play?.accuracy ?? 0, combo: client.play?.combo?.current ?? 0, failed: client.play?.failed ?? false })) }; } /** Keeps a websocket to tosu open and remembers its latest tourney frame. */ export class TosuFeed { latest: TosuFrame | null = null; latestAt = 0; connected = false; private socket: WebSocket | null = null; private attempts = 0; private stopped = false; private timer: ReturnType | null = null; constructor( private url: string, private onFrame: (frame: TosuFrame) => void, private log: (...parts: unknown[]) => void = () => {} ) {} start() { this.stopped = false; this.connect(); } private connect() { if (this.stopped) return; const socket = new WebSocket(this.url); this.socket = socket; socket.onopen = () => { this.attempts = 0; this.connected = true; this.log('tosu connected'); }; socket.onmessage = (event) => { let frame: TosuFrame | null; try { frame = parseTosu(JSON.parse(String(event.data))); } catch { return; } if (!frame) return; this.latest = frame; this.latestAt = Date.now(); this.onFrame(frame); }; socket.onclose = () => { if (this.socket !== socket) return; if (this.connected) this.log('tosu disconnected'); this.connected = false; this.socket = null; if (this.stopped) return; // tosu often starts after us; keep trying quietly. const wait = Math.min(15_000, 1000 * 2 ** this.attempts++); if (this.attempts === 4) this.log(`tosu is not answering at ${this.url}; live scores start when it does.`); this.timer = setTimeout(() => this.connect(), wait); }; } /** The latest frame if it is recent enough to trust. */ fresh(maxAgeMs = 5000) { return this.connected && Date.now() - this.latestAt <= maxAgeMs ? this.latest : null; } stop() { this.stopped = true; if (this.timer) clearTimeout(this.timer); const socket = this.socket; this.socket = null; this.connected = false; socket?.close(); } }