import { toast } from 'svelte-sonner'; import type { MatchCommand, MatchView, SideView } from '#lib/match-view.js'; import type { Crowd } from '#lib/predictions.js'; import { connectTopics } from '#lib/topic-socket.js'; type SimCommand = Record & { type: string }; /** * The live state of one match on the page: the latest snapshot from the * server, a clock corrected for skew, and helpers to act. Snapshots replace * the view wholesale; the page never refetches to update. */ export class MatchLive { view = $state() as MatchView; connected = $state(false); /** A recent snapshot arrived through the fallback when the socket is unavailable. */ fallbackConnected = $state(false); viewers = $state(0); /** Latest crowd split, pushed whenever someone predicts. */ crowd = $state(null); /** Server time, ticking a few times a second, for countdowns. */ now = $state(Date.now()); busy = $state(false); private offset = 0; private ticker: ReturnType | undefined; /** Plain, not reactive: connect() runs inside an effect, and reading the view there would reconnect on every snapshot. */ readonly matchId: string; constructor( initial: MatchView, readonly userId: string | null ) { this.matchId = initial.id; this.apply(initial); } private apply(view: MatchView) { this.offset = view.serverNow - Date.now(); this.view = view; this.now = Date.now() + this.offset; } /** The side the signed-in user plays for, if any. */ get mySide(): SideView | null { return ( this.view.sides.find((side) => side.players.some((player) => player.userId === this.userId) ) ?? null ); } get myTurn(): boolean { const prompt = this.view.prompt; const mine = this.mySide; if (!mine || !prompt.kind) return false; if (prompt.kind === 'roll') return mine.roll == null; return prompt.side === mine.id; } secondsLeft(deadline: number | null = this.view.deadlineAt): number | null { if (deadline == null) return null; return Math.max(0, Math.ceil((deadline - this.now) / 1000)); } side(id: string | null | undefined) { return this.view.sides.find((side) => side.id === id) ?? null; } slot(id: string | null | undefined) { return this.view.slots.find((slot) => slot.id === id) ?? null; } connect() { this.ticker = setInterval(() => (this.now = Date.now() + this.offset), 250); let stopped = false; let polling = false; const poll = async () => { if ( stopped || this.connected || polling || ['finished', 'cancelled'].includes(this.view.phase) ) return; polling = true; try { const response = await fetch(`/api/matches/${this.matchId}`, { cache: 'no-store' }); if (!response.ok) throw new Error('Could not refresh match'); const view = (await response.json()) as MatchView; if (!stopped && !this.connected) { this.apply(view); this.fallbackConnected = true; } } catch { if (!stopped && !this.connected) this.fallbackConnected = false; } finally { polling = false; } }; const fallback = setInterval(() => void poll(), 5000); const disconnect = connectTopics({ topics: [`match:${this.matchId}`], onStatus: (connected) => { this.connected = connected; if (connected) this.fallbackConnected = false; else void poll(); }, onMessage: (message) => { if (message.matchId !== this.matchId) return; if (message.type === 'match') this.apply(message.view as MatchView); if (message.type === 'viewers') this.viewers = message.count as number; if (message.type === 'crowd') this.crowd = message.crowd as Crowd; } }); return () => { stopped = true; clearInterval(this.ticker); clearInterval(fallback); disconnect(); }; } /** Sends a player or referee action. Errors show as a toast; returns whether it worked. */ async send(command: MatchCommand & { side?: string }): Promise { this.busy = true; try { const response = await fetch(`/api/matches/${this.view.id}/action`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(command) }); if (!response.ok) { const body = await response.json().catch(() => null); toast.error(body?.message ?? 'That did not work. Try again.'); return false; } return true; } catch { toast.error('Could not reach the server.'); return false; } finally { this.busy = false; } } async simulate(command: SimCommand) { const response = await fetch(`/api/matches/${this.view.id}/sim`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(command) }); if (!response.ok) { const body = await response.json().catch(() => null); toast.error(body?.message ?? 'Simulator command failed'); } } }