import { toRankOrdinal, type RankOrdinal } from '../ranks' export type AccountRole = 'admin' | 'moderator' | 'player' export type RsvpStatus = 'going' | 'maybe' | 'not_going' export type PlayerRole = 'tank' | 'damage' | 'support' export type TeamSide = 'alpha' | 'beta' export type MapOutcome = 'TeamAWin' | 'TeamBWin' | 'Draw' export interface Ratings { tank: RankOrdinal; damage: RankOrdinal; support: RankOrdinal; updatedAt: string } export interface Player { id: string accountId?: string displayName: string avatarUrl?: string battleTag?: string ratings: Ratings preferredRoles: PlayerRole[] preferredPlayerIds: string[] avoidedPlayerIds: string[] } export interface Session { account: { id: string; discordId: string; displayName: string; avatarUrl?: string; role: AccountRole } player: Player } export interface Registration { id: string player: Player status: RsvpStatus updatedAt: string changedBy?: { id: string; displayName: string; source: 'self' | 'admin' } } export interface MixEvent { id: string title: string description: string startsAt: string endsAt: string registrationDeadline: string status: 'registration' | 'balancing' | 'live' | 'completed' | 'cancelled' rulesetName: string counts: Record myRsvp?: RsvpStatus workflowState: EventWorkflowState version: number rulesetId: string activeSeriesId?: string tournamentId?: string } export type EventWorkflowState = 'RegistrationOpen' | 'RegistrationClosed' | 'Balancing' | 'RostersDraft' | 'RostersConfirmed' | 'BracketDraft' | 'Live' | 'Completed' | 'Cancelled' export interface TeamPlayer { player: Player; assignedRole: PlayerRole } export interface Team { id: string eventId?: string name: string side: TeamSide captainId?: string averageRating: number members: TeamPlayer[] } export interface BalanceCandidate { id: string score: number teams: Team[] reserve: Player[] explanations: string[] payload?: RawBalanceCandidate } export interface DraftOption { id: string name: string role?: PlayerRole mode?: string disabled?: boolean disabledReason?: string } export interface DraftAction { id: string kind: 'coin_toss' | 'map_ban' | 'map_selected' | 'hero_ban' | 'result' summary: string actorName: string createdAt: string } export interface Series { id: string eventId: string tournamentId?: string status: 'scheduled' | 'coin_toss' | 'map_draft' | 'hero_draft' | 'playing' | 'completed' roundLabel: string teamAlpha: Team teamBeta: Team score: { alpha: number; beta: number } version: number currentStep: { title: string instruction: string activeTeam?: TeamSide kind: 'coin_toss' | 'map_ban' | 'map_pick' | 'hero_ban' | 'result' | 'complete' version: number } options: DraftOption[] heroBanHistory: Record maps: Array<{ number: number name: string mode: string status: 'drafting' | 'ready' | 'playing' | 'completed' winner?: TeamSide | 'draw' heroBans: Array<{ hero: string; team: TeamSide; role: PlayerRole }> }> audit: DraftAction[] } export interface BracketMatch { id: string; round: number; slot: number; label: string teamAlpha?: string; teamBeta?: string; teamAlphaId?: string; teamBetaId?: string; scoreAlpha?: number; scoreBeta?: number winner?: TeamSide; seriesId?: string; status: 'pending' | 'ready' | 'live' | 'completed' } export interface Bracket { id: string; title: string; rounds: string[]; matches: BracketMatch[] } export interface EventInput { name: string description: string startsAt: string endsAt: string registrationDeadline: string } export type BracketSlotSource = | { kind: 'Team'; teamId: string; matchId?: never } | { kind: 'Winner' | 'Loser'; matchId: string; teamId?: never } export interface BracketDraftMatch { id: string round: number order: number slotA: BracketSlotSource slotB: BracketSlotSource seriesId?: string teamAId?: string teamBId?: string winnerTeamId?: string } export interface BracketDraft { eventId: string teamIds: string[] matches: BracketDraftMatch[] version: number confirmed: boolean } // Raw backend DTOs mirror Go's current camelCase JSON tags exactly. export interface RawAccount { id: string; discordId: string; username: string; avatarUrl: string role: AccountRole; createdAt: string } export interface RawRatings { tank: number; damage: number; support: number } export interface RawPlayer { id: string; accountId: string; displayName: string; ratings: RawRatings preferredRoles?: Array<'Tank' | 'Damage' | 'Support'> preferredPlayerIds?: string[] avoidedPlayerIds?: string[] createdAt: string; updatedAt: string } export interface RawSession { account: RawAccount; player: RawPlayer } export interface RawEvent { id: string; name: string; description: string; startsAt: string; endsAt: string registrationDeadline: string; createdBy: string; createdAt: string; updatedAt: string state: EventWorkflowState; version: number; rulesetId: string; activeSeriesId: string; tournamentId: string } export interface RawRSVP { eventId: string; playerId: string; actorAccountId: string status: 'Going' | 'Maybe' | 'NotGoing'; source: 'player' | 'admin'; updatedAt: string } export interface RawSlot { playerId: string; role: 'Tank' | 'Damage' | 'Support'; rating: number } export interface RawTeam { id: string; eventId: string; name: string; captainPlayerId: string; slots: RawSlot[] } export interface RawBalanceCandidate { teams: RawTeam[]; reserve: string[]; score: number; explanation?: string[] } export interface RawRosterDraft { eventId: string; teams: RawTeam[]; reserve: string[]; version: number; confirmed: boolean } export interface RawMapResult { mapName: string; actorAccountId: string; outcome: MapOutcome recordedAt: string; correctionOf?: number } export interface RawSeries { id: string; eventId: string; tournamentId: string; teamAId: string; teamBId: string winnerTeamId: string; bestOf: number; rulesetId: string phase: 'CoinTossPending' | 'MapBan' | 'MapPick' | 'HeroBan' | 'Playing' | 'Completed' coinToss?: { seed: string; winnerTeamId: string; performedAt: string } mapDraft?: { pool: string[] | null; banned: string[] | null; firstTeamId: string; teamIds: [string, string]; actions: RawDraftAction[] | null } heroDraft?: { heroes: Array<{ name: string; role: 'Tank' | 'Damage' | 'Support' }> | null; teamIds: [string, string]; firstTeamId: string; bansPerTeam: number; currentBans: RawDraftAction[] | null } currentMap: string; playedMaps: string[] | null; nextMapPickerId: string; availableMaps: string[] | null seriesBans?: Record | null maps: Array<{ number: number; name: string; heroBans: RawDraftAction[] | null; result?: RawMapResult }> | null audit: Array<{ kind: DraftAction['kind']; summary: string; teamId?: string; actorAccountId: string; at: string }> | null results: RawMapResult[] | null; version: number } export interface RawDraftAction { teamId: string; value: string; actorAccountId: string; at: string } export interface RawTournament { id: string; eventId: string; name: string; winnerTeamId: string teamIds: string[]; rounds: RawSeries[][]; matches?: Array; version?: number } export interface RawRealtimeEvent { topic: string; data: unknown } const roleFromRaw = (role: RawSlot['role']): PlayerRole => role === 'Tank' ? 'tank' : role === 'Damage' ? 'damage' : 'support' const statusFromRaw = (status: RawRSVP['status']): RsvpStatus => status === 'Going' ? 'going' : status === 'Maybe' ? 'maybe' : 'not_going' const statusToRaw = (status: RsvpStatus): RawRSVP['status'] => status === 'going' ? 'Going' : status === 'maybe' ? 'Maybe' : 'NotGoing' export function adaptPlayer(raw: RawPlayer): Player { const ratings = { tank: toRankOrdinal(raw.ratings.tank), damage: toRankOrdinal(raw.ratings.damage), support: toRankOrdinal(raw.ratings.support), updatedAt: raw.updatedAt, } const preferredRoles = (raw.preferredRoles ?? []).map((role) => role === 'Tank' ? 'tank' : role === 'Damage' ? 'damage' : 'support', ) return { id: raw.id, accountId: raw.accountId, displayName: raw.displayName, ratings, preferredRoles, preferredPlayerIds: raw.preferredPlayerIds ?? [], avoidedPlayerIds: raw.avoidedPlayerIds ?? [], } } export function adaptSession(raw: RawSession): Session { return { account: { id: raw.account.id, discordId: raw.account.discordId, displayName: raw.account.username, avatarUrl: raw.account.avatarUrl || undefined, role: raw.account.role, }, player: adaptPlayer(raw.player), } } export function adaptEvent(raw: RawEvent, rsvps: RawRSVP[] = [], currentPlayerId?: string): MixEvent { const now = Date.now() const deadline = new Date(raw.registrationDeadline).getTime() const counts = { going: 0, maybe: 0, not_going: 0 } rsvps.forEach((rsvp) => { counts[statusFromRaw(rsvp.status)]++ }) const own = rsvps.find((rsvp) => rsvp.playerId === currentPlayerId) return { id: raw.id, title: raw.name, description: raw.description, startsAt: raw.startsAt, endsAt: raw.endsAt, registrationDeadline: raw.registrationDeadline, status: raw.state === 'Cancelled' ? 'cancelled' : raw.state === 'Completed' ? 'completed' : raw.state === 'Live' ? 'live' : raw.state === 'RegistrationOpen' && now <= deadline ? 'registration' : 'balancing', rulesetName: 'Server ruleset', counts, myRsvp: own ? statusFromRaw(own.status) : undefined, workflowState: raw.state, version: raw.version, rulesetId: raw.rulesetId, activeSeriesId: raw.activeSeriesId || undefined, tournamentId: raw.tournamentId || undefined, } } export function adaptRegistration(raw: RawRSVP, players: Map, session?: Session): Registration { const player = players.get(raw.playerId) ?? { id: raw.playerId, displayName: `Player ${raw.playerId.slice(0, 6)}`, ratings: { tank: 1, damage: 1, support: 1, updatedAt: raw.updatedAt }, preferredRoles: [], preferredPlayerIds: [], avoidedPlayerIds: [], } return { id: `${raw.eventId}:${raw.playerId}`, player, status: statusFromRaw(raw.status), updatedAt: raw.updatedAt, changedBy: { id: raw.actorAccountId, displayName: raw.actorAccountId === session?.account.id ? session.account.displayName : raw.source === 'admin' ? 'Administrator' : player.displayName, source: raw.source === 'admin' ? 'admin' : 'self', }, } } export function adaptTeam(raw: RawTeam, players: Map, index = 0): Team { const members = raw.slots.map((slot) => ({ player: players.get(slot.playerId) ?? { id: slot.playerId, displayName: `Player ${slot.playerId.slice(0, 6)}`, ratings: { tank: toRankOrdinal(slot.rating), damage: toRankOrdinal(slot.rating), support: toRankOrdinal(slot.rating), updatedAt: '', }, preferredRoles: [roleFromRaw(slot.role)], preferredPlayerIds: [], avoidedPlayerIds: [], }, assignedRole: roleFromRaw(slot.role), })) const averageRating = raw.slots.length ? raw.slots.reduce((sum, slot) => sum + slot.rating, 0) / raw.slots.length : 0 return { id: raw.id, eventId: raw.eventId, name: raw.name, side: index % 2 === 0 ? 'alpha' : 'beta', captainId: raw.captainPlayerId || undefined, averageRating: Number(averageRating.toFixed(1)), members, } } export function adaptBalance(raw: RawBalanceCandidate, players: Player[], index: number): BalanceCandidate { const byId = new Map(players.map((player) => [player.id, player])) const teamForPlayer = new Map(raw.teams.flatMap((team) => team.slots.map((slot) => [slot.playerId, team.id] as const))) const preferredRoleAssignments = raw.teams.flatMap((team) => team.slots).filter((slot) => byId.get(slot.playerId)?.preferredRoles.includes(roleFromRaw(slot.role)), ).length const preferredPairs = players.reduce((count, player) => count + player.preferredPlayerIds.filter((preferredId) => teamForPlayer.get(player.id) && teamForPlayer.get(player.id) === teamForPlayer.get(preferredId), ).length, 0) / 2 return { id: `candidate-${index + 1}`, score: raw.score, teams: raw.teams.map((team, teamIndex) => adaptTeam(team, byId, teamIndex)), reserve: raw.reserve.map((id) => byId.get(id)).filter((player): player is Player => Boolean(player)), explanations: raw.explanation ?? [ `${preferredRoleAssignments} preferred role assignments`, `${Math.floor(preferredPairs)} preferred teammate pairs kept together`, ], payload: raw, } } export function adaptSeries(raw: RawSeries, teams: Team[] = []): Series { const alpha = teams.find((team) => team.id === raw.teamAId) ?? emptyTeam(raw.teamAId, 'alpha') const beta = teams.find((team) => team.id === raw.teamBId) ?? emptyTeam(raw.teamBId, 'beta') const results = raw.results ?? [] const activeResults = results.filter((_, index) => !results.some((correction) => correction.correctionOf === index + 1), ) const score = activeResults.reduce((total, result) => ({ alpha: total.alpha + (result.outcome === 'TeamAWin' ? 1 : 0), beta: total.beta + (result.outcome === 'TeamBWin' ? 1 : 0), }), { alpha: 0, beta: 0 }) const complete = raw.phase === 'Completed' || Boolean(raw.winnerTeamId) const phase = raw.phase ?? (complete ? 'Completed' : 'Playing') const status: Series['status'] = phase === 'CoinTossPending' ? 'coin_toss' : phase === 'MapBan' || phase === 'MapPick' ? 'map_draft' : phase === 'HeroBan' ? 'hero_draft' : phase === 'Completed' ? 'completed' : 'playing' const nextMapTeam = raw.mapDraft ? ((raw.mapDraft.actions ?? []).length % 2 === 0 ? raw.mapDraft.firstTeamId : raw.mapDraft.teamIds.find((id) => id !== raw.mapDraft?.firstTeamId)) : undefined const nextHeroTeam = raw.heroDraft ? ((raw.heroDraft.currentBans ?? []).length % 2 === 0 ? raw.heroDraft.firstTeamId : raw.heroDraft.teamIds.find((id) => id !== raw.heroDraft?.firstTeamId)) : undefined const activeTeamId = phase === 'MapBan' ? nextMapTeam : phase === 'MapPick' ? raw.nextMapPickerId : phase === 'HeroBan' ? nextHeroTeam : undefined const kind: Series['currentStep']['kind'] = phase === 'CoinTossPending' ? 'coin_toss' : phase === 'MapBan' ? 'map_ban' : phase === 'MapPick' ? 'map_pick' : phase === 'HeroBan' ? 'hero_ban' : phase === 'Completed' ? 'complete' : 'result' const options: DraftOption[] = phase === 'MapBan' && raw.mapDraft ? (raw.mapDraft.pool ?? []).filter((name) => !(raw.mapDraft?.banned ?? []).includes(name)).map((name) => ({ id: name, name })) : phase === 'MapPick' ? (raw.availableMaps ?? []).map((name) => ({ id: name, name })) : phase === 'HeroBan' && raw.heroDraft ? (raw.heroDraft.heroes ?? []).filter((hero) => !(raw.heroDraft?.currentBans ?? []).some((ban) => ban.value === hero.name)).map((hero) => { const repeated = (raw.seriesBans?.[nextHeroTeam ?? ''] ?? []).includes(hero.name) return { id: hero.name, name: hero.name, role: roleFromRaw(hero.role), disabled: repeated, disabledReason: repeated ? 'Already banned by this team in this Bo3' : undefined } }) : [] return { id: raw.id, eventId: raw.eventId, tournamentId: raw.tournamentId, status, roundLabel: 'Tournament series', teamAlpha: alpha, teamBeta: beta, score, version: raw.version, currentStep: { title: phase === 'CoinTossPending' ? 'Coin toss' : phase === 'MapBan' ? 'Map bans' : phase === 'MapPick' ? 'Loser picks the next map' : phase === 'HeroBan' ? 'Hero bans' : complete ? 'Series complete' : `Record ${raw.currentMap || `map ${activeResults.length + 1}`}`, instruction: complete ? 'The series is complete.' : phase === 'Playing' ? 'Record the authoritative map result.' : 'The active captain or an admin can act.', activeTeam: activeTeamId ? (activeTeamId === raw.teamAId ? 'alpha' : 'beta') : undefined, kind, version: raw.version, }, options, heroBanHistory: { alpha: raw.seriesBans?.[raw.teamAId] ?? [], beta: raw.seriesBans?.[raw.teamBId] ?? [], }, maps: (raw.maps ?? activeResults.map((result, index) => ({ number: index + 1, name: result.mapName, heroBans: [], result }))).map((map) => ({ number: map.number, name: map.name, mode: 'Map', status: map.result ? 'completed' : phase === 'Playing' ? 'playing' : phase === 'HeroBan' ? 'ready' : 'drafting', winner: map.result ? (map.result.outcome === 'TeamAWin' ? 'alpha' : map.result.outcome === 'TeamBWin' ? 'beta' : 'draw') : undefined, heroBans: (map.heroBans ?? []).map((ban) => { const heroRole = raw.heroDraft?.heroes?.find((hero) => hero.name === ban.value)?.role return { hero: ban.value, team: ban.teamId === raw.teamAId ? 'alpha' : 'beta', role: heroRole ? roleFromRaw(heroRole) : 'damage' as PlayerRole } }), })), audit: (raw.audit ?? []).map((action, index) => ({ id: `action-${index}`, kind: action.kind, summary: action.summary, actorName: action.actorAccountId, createdAt: action.at })), } } export function adaptTournament(raw: RawTournament, teams: Team[] = []): Bracket { const names = new Map(teams.map((team) => [team.id, team.name])) if (raw.matches?.length) { const maxRound = Math.max(...raw.matches.map((match) => match.round)) const sourceName = (source: BracketSlotSource) => { if (source.kind === 'Team') return names.get(source.teamId) ?? source.teamId const upstream = raw.matches?.find((match) => match.id === source.matchId) return `${source.kind} of ${upstream ? `R${upstream.round + 1}M${upstream.order + 1}` : 'match'}` } return { id: raw.id, title: raw.name, rounds: Array.from({ length: maxRound + 1 }, (_, index) => index === maxRound ? 'Grand Final' : index === maxRound - 1 ? 'Semifinals' : `Round ${index + 1}`), matches: raw.matches.map((match) => { const adapted = match.series ? adaptSeries(match.series, teams) : undefined return { id: match.id, round: match.round, slot: match.order, label: `Match ${match.order + 1}`, teamAlpha: names.get(match.teamAId ?? '') ?? match.teamAId ?? sourceName(match.slotA), teamBeta: names.get(match.teamBId ?? '') ?? match.teamBId ?? sourceName(match.slotB), teamAlphaId: match.teamAId, teamBetaId: match.teamBId, scoreAlpha: adapted?.score.alpha, scoreBeta: adapted?.score.beta, winner: match.winnerTeamId ? (match.winnerTeamId === match.teamAId ? 'alpha' : 'beta') : undefined, seriesId: match.seriesId ?? match.series?.id, status: match.winnerTeamId ? 'completed' : match.series?.phase ? 'live' : match.teamAId && match.teamBId ? 'ready' : 'pending', } }), } } return { id: raw.id, title: raw.name, rounds: raw.rounds.map((_, index) => index === raw.rounds.length - 1 ? 'Grand Final' : index === raw.rounds.length - 2 ? 'Semifinals' : `Round ${index + 1}`), matches: raw.rounds.flatMap((round, roundIndex) => round.map((series, slot) => { const adapted = adaptSeries(series, teams) return { id: series.id, round: roundIndex, slot, label: `Match ${slot + 1}`, teamAlpha: names.get(series.teamAId) ?? (series.teamAId || undefined), teamBeta: names.get(series.teamBId) ?? (series.teamBId || undefined), teamAlphaId: series.teamAId || undefined, teamBetaId: series.teamBId || undefined, scoreAlpha: adapted.score.alpha, scoreBeta: adapted.score.beta, winner: series.winnerTeamId ? (series.winnerTeamId === series.teamAId ? 'alpha' : 'beta') : undefined, seriesId: series.id, status: series.winnerTeamId ? 'completed' : series.teamAId && series.teamBId && series.phase ? 'live' : series.teamAId && series.teamBId ? 'ready' : 'pending', } satisfies BracketMatch })), } } function emptyTeam(id: string, side: TeamSide): Team { return { id, name: id ? `Team ${id.slice(0, 8)}` : 'TBD', side, averageRating: 0, members: [] } } export class ApiError extends Error { readonly status: number readonly details?: unknown constructor(message: string, status: number, details?: unknown) { super(message) this.name = 'ApiError' this.status = status this.details = details } } const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api' async function request(path: string, init?: RequestInit): Promise { const response = await fetch(`${API_BASE}${path}`, { credentials: 'include', ...init, headers: { Accept: 'application/json', ...(init?.body ? { 'Content-Type': 'application/json' } : {}), ...init?.headers, }, }) if (!response.ok) { const details = await response.json().catch(() => undefined) throw new ApiError( typeof details === 'object' && details && 'error' in details ? String(details.error) : `Request failed (${response.status})`, response.status, details, ) } if (response.status === 204) return undefined as T return response.json() as Promise } const raw = { session: () => request('/auth/me'), players: () => request('/players'), events: () => request('/events'), event: (id: string) => request(`/events/${id}`), rsvps: (eventId: string) => request(`/events/${eventId}/rsvps`), teams: (eventId: string) => request(`/events/${eventId}/teams`), } async function adaptSeriesWithTeams(series: RawSeries) { const eventId = series.eventId || (series.tournamentId ? (await request(`/tournaments/${series.tournamentId}`)).eventId : '') const [teams, players] = await Promise.all([raw.teams(eventId), raw.players()]) const byId = new Map(players.map(adaptPlayer).map((player) => [player.id, player])) return adaptSeries(series, teams.map((team, index) => adaptTeam(team, byId, index))) } export const api = { session: async () => adaptSession(await raw.session()), logout: () => request('/auth/logout', { method: 'POST' }), profile: async () => adaptPlayer((await raw.session()).player), updateRatings: async ( displayName: string, ratings: Pick, preferredRoles: PlayerRole[], preferredPlayerIds: string[], avoidedPlayerIds: string[], ) => adaptPlayer(await request('/me/player', { method: 'PATCH', body: JSON.stringify({ displayName, ratings: { tank: ratings.tank, damage: ratings.damage, support: ratings.support }, preferredRoles: preferredRoles.map((role) => role === 'tank' ? 'Tank' : role === 'damage' ? 'Damage' : 'Support'), preferredPlayerIds, avoidedPlayerIds, }), })), players: async () => (await raw.players()).map(adaptPlayer), accounts: () => request('/accounts'), setModerator: (accountId: string, moderator: boolean) => request(`/accounts/${accountId}/moderator`, { method: 'PATCH', body: JSON.stringify({ moderator }) }), events: async () => { const [events, session] = await Promise.all([raw.events(), raw.session()]) return Promise.all(events.map(async (event) => { const rsvps = await raw.rsvps(event.id) return adaptEvent(event, rsvps, session.player.id) })) }, event: async (eventId: string) => { const [event, rsvps, session] = await Promise.all([raw.event(eventId), raw.rsvps(eventId), raw.session()]) return adaptEvent(event, rsvps, session.player.id) }, registrations: async (eventId: string) => { const [rsvps, players, session] = await Promise.all([raw.rsvps(eventId), raw.players(), raw.session()]) const byId = new Map(players.map(adaptPlayer).map((player) => [player.id, player])) return rsvps.map((rsvp) => adaptRegistration(rsvp, byId, adaptSession(session))) }, createParticipant: async ( eventId: string, displayName: string, ratings: Pick, status: RsvpStatus, ) => { const result = await request<{ player: RawPlayer; rsvp: RawRSVP }>(`/events/${eventId}/participants`, { method: 'POST', body: JSON.stringify({ displayName, ratings: { tank: ratings.tank, damage: ratings.damage, support: ratings.support }, status: statusToRaw(status), }), }) const player = adaptPlayer(result.player) return adaptRegistration(result.rsvp, new Map([[player.id, player]])) }, setRsvp: async (eventId: string, playerId: string, status: RsvpStatus) => { const [result, players, session] = await Promise.all([ request(`/events/${eventId}/rsvps/${playerId}`, { method: 'PUT', body: JSON.stringify({ status: statusToRaw(status) }) }), raw.players(), raw.session(), ]) return adaptRegistration(result, new Map(players.map(adaptPlayer).map((player) => [player.id, player])), adaptSession(session)) }, removeParticipant: (eventId: string, playerId: string) => request(`/events/${eventId}/rsvps/${playerId}`, { method: 'DELETE' }), createEvent: async (event: EventInput) => adaptEvent(await request('/events', { method: 'POST', body: JSON.stringify(event) })), updateEvent: async (eventId: string, event: EventInput) => adaptEvent(await request(`/events/${eventId}`, { method: 'PUT', body: JSON.stringify(event) })), cancelEvent: async (eventId: string, expectedVersion: number) => adaptEvent(await request(`/events/${eventId}/cancel`, { method: 'POST', body: JSON.stringify({ expectedVersion }) })), deleteEvent: (eventId: string) => request(`/events/${eventId}`, { method: 'DELETE' }), generateBalance: async (eventId: string) => { const [candidates, players] = await Promise.all([ request(`/events/${eventId}/balance`, { method: 'POST' }), raw.players(), ]) const adaptedPlayers = players.map(adaptPlayer) return candidates.map((candidate, index) => adaptBalance(candidate, adaptedPlayers, index)) }, closeRegistration: async (eventId: string, rulesetId: string, expectedVersion: number) => adaptEvent(await request(`/events/${eventId}/registration/close`, { method: 'POST', body: JSON.stringify({ rulesetId, expectedVersion }), })), revertWorkflowStage: async (eventId: string, expectedVersion: number) => adaptEvent(await request(`/events/${eventId}/workflow/back`, { method: 'POST', body: JSON.stringify({ expectedVersion }), })), generateWorkflowBalance: async (eventId: string, expectedVersion: number) => { const [result, players] = await Promise.all([ request<{ event: RawEvent; candidates: RawBalanceCandidate[] }>(`/events/${eventId}/balance/generate`, { method: 'POST', body: JSON.stringify({ expectedVersion }), }), raw.players(), ]) const adaptedPlayers = players.map(adaptPlayer) return { event: adaptEvent(result.event), candidates: result.candidates.map((candidate, index) => adaptBalance(candidate, adaptedPlayers, index)), } }, roster: (eventId: string) => request(`/events/${eventId}/roster`), selectWorkflowBalance: (eventId: string, candidate: BalanceCandidate, expectedVersion: number) => request(`/events/${eventId}/roster`, { method: 'PUT', body: JSON.stringify({ candidate: candidate.payload, expectedVersion }), }), swapRoster: (eventId: string, input: { teamAId: string; playerAId: string; teamBId: string; playerBId: string; expectedVersion: number }) => request(`/events/${eventId}/roster/swap`, { method: 'POST', body: JSON.stringify(input) }), moveRosterPlayer: (eventId: string, input: { fromTeamId: string; playerId: string; toTeamId: string; role: RawSlot['role']; expectedVersion: number }) => request(`/events/${eventId}/roster/move`, { method: 'POST', body: JSON.stringify(input) }), placeReservePlayer: (eventId: string, input: { teamId: string; reservePlayerId: string; role: RawSlot['role']; expectedVersion: number }) => request(`/events/${eventId}/roster/place-reserve`, { method: 'POST', body: JSON.stringify(input) }), removeRosterPlayer: (eventId: string, input: { teamId: string; playerId: string; expectedVersion: number }) => request(`/events/${eventId}/roster/remove`, { method: 'POST', body: JSON.stringify(input) }), substituteRoster: (eventId: string, input: { teamId: string; outgoingPlayerId: string; reservePlayerId: string; expectedVersion: number }, emergency = false) => request(`/events/${eventId}/roster/${emergency ? 'emergency-substitute' : 'substitute'}`, { method: 'POST', body: JSON.stringify(input) }), setRosterCaptain: (eventId: string, teamId: string, playerId: string, expectedVersion: number) => request(`/events/${eventId}/roster/captain`, { method: 'PUT', body: JSON.stringify({ teamId, playerId, expectedVersion }), }), confirmRosters: (eventId: string, expectedVersion: number, expectedRosterVersion: number) => request(`/events/${eventId}/roster/confirm`, { method: 'POST', body: JSON.stringify({ expectedVersion, expectedRosterVersion }), }), bracketDraft: (eventId: string) => request(`/events/${eventId}/bracket-draft`), initializeBracket: (eventId: string, expectedVersion: number) => request(`/events/${eventId}/bracket-draft/initialize`, { method: 'POST', body: JSON.stringify({ expectedVersion }) }), updateBracket: (eventId: string, matches: BracketDraftMatch[], expectedVersion: number) => request(`/events/${eventId}/bracket-draft`, { method: 'PUT', body: JSON.stringify({ matches, expectedVersion }) }), resetBracket: (eventId: string, expectedVersion: number) => request(`/events/${eventId}/bracket-draft/reset`, { method: 'POST', body: JSON.stringify({ expectedVersion }) }), confirmBracket: (eventId: string, expectedVersion: number) => request(`/events/${eventId}/bracket-draft/confirm`, { method: 'POST', body: JSON.stringify({ expectedVersion }) }), startScrim: async (eventId: string, expectedVersion: number) => { const result = await request<{ event: RawEvent; series?: RawSeries; tournament?: RawTournament }>(`/events/${eventId}/start`, { method: 'POST', body: JSON.stringify({ expectedVersion }), }) return { ...result, series: result.series ? await adaptSeriesWithTeams(result.series) : undefined } }, selectBalance: (eventId: string, candidate: BalanceCandidate) => request<{ status: string }>(`/events/${eventId}/teams`, { method: 'PUT', body: JSON.stringify(candidate.payload ?? { teams: candidate.teams.map((team) => ({ id: team.id, eventId, name: team.name, captainPlayerId: team.captainId ?? '', slots: team.members.map(({ player, assignedRole }) => ({ playerId: player.id, role: assignedRole === 'tank' ? 'Tank' : assignedRole === 'damage' ? 'Damage' : 'Support', rating: player.ratings[assignedRole], })), })), reserve: candidate.reserve.map((player) => player.id), score: candidate.score, } satisfies RawBalanceCandidate), }), teams: async (eventId: string) => { const [teams, players] = await Promise.all([raw.teams(eventId), raw.players()]) const byId = new Map(players.map(adaptPlayer).map((player) => [player.id, player])) return teams.map((team, index) => adaptTeam(team, byId, index)) }, assignCaptain: async (teamId: string, playerId: string) => adaptTeam(await request(`/teams/${teamId}/captain`, { method: 'PUT', body: JSON.stringify({ playerId }) }), new Map()), renameTeam: (teamId: string, name: string) => request(`/teams/${teamId}/name`, { method: 'PUT', body: JSON.stringify({ name }) }), series: async (seriesId: string) => { const series = await request(`/series/${seriesId}`) return adaptSeriesWithTeams(series) }, recordResult: async (seriesId: string, mapName: string, outcome: MapOutcome, expectedVersion: number) => adaptSeriesWithTeams(await request(`/series/${seriesId}/results`, { method: 'POST', body: JSON.stringify({ mapName, outcome, expectedVersion }), })), tossSeriesCoin: async (seriesId: string, seed: string, actingTeamId: string, expectedVersion: number) => adaptSeriesWithTeams(await request(`/series/${seriesId}/coin-toss`, { method: 'POST', body: JSON.stringify({ seed, actingTeamId, expectedVersion }), })), banSeriesMap: async (seriesId: string, teamId: string, map: string, expectedVersion: number) => adaptSeriesWithTeams(await request(`/series/${seriesId}/map-bans`, { method: 'POST', body: JSON.stringify({ teamId, map, expectedVersion }), })), pickSeriesMap: async (seriesId: string, teamId: string, map: string, expectedVersion: number) => adaptSeriesWithTeams(await request(`/series/${seriesId}/map-pick`, { method: 'POST', body: JSON.stringify({ teamId, map, expectedVersion }), })), banSeriesHero: async (seriesId: string, teamId: string, hero: string, expectedVersion: number) => adaptSeriesWithTeams(await request(`/series/${seriesId}/hero-bans`, { method: 'POST', body: JSON.stringify({ teamId, hero, expectedVersion }), })), recordSeriesMap: async (seriesId: string, outcome: MapOutcome, actingTeamId: string, expectedVersion: number) => adaptSeriesWithTeams(await request(`/series/${seriesId}/map-result`, { method: 'POST', body: JSON.stringify({ outcome, actingTeamId, expectedVersion }), })), correctResult: async (seriesId: string, index: number, mapName: string, outcome: MapOutcome, expectedVersion: number) => adaptSeriesWithTeams(await request(`/series/${seriesId}/results/${index}`, { method: 'PATCH', body: JSON.stringify({ mapName, outcome, expectedVersion }), })), coinToss: (input: { teamAId: string; teamBId: string; seed: string; eventId: string; actingTeamId: string }) => request<{ seed: string; winnerTeamId: string; performedAt: string }>('/coin-toss', { method: 'POST', body: JSON.stringify(input) }), mapBan: (draftId: string, input: { teamId: string; map: string; eventId: string; version: number }) => request<{ draft: unknown; version: number }>(`/drafts/${draftId}/map-bans`, { method: 'POST', body: JSON.stringify(input) }), heroBan: (draftId: string, input: { teamId: string; hero: string; eventId: string; version: number }) => request<{ draft: unknown; version: number }>(`/drafts/${draftId}/hero-bans`, { method: 'POST', body: JSON.stringify(input) }), bracket: async (eventId: string) => { const tournament = await request(`/events/${eventId}/tournament`) const [teams, players] = await Promise.all([raw.teams(tournament.eventId), raw.players()]) const byId = new Map(players.map(adaptPlayer).map((player) => [player.id, player])) return adaptTournament(tournament, teams.map((team, index) => adaptTeam(team, byId, index))) }, } export function subscribeToEvents( topic: string, onMessage: (event: RawRealtimeEvent) => void, onStatus?: (status: 'connected' | 'reconnecting') => void, ) { if (typeof EventSource === 'undefined') return () => undefined const source = new EventSource(`${API_BASE}/events/stream?topic=${encodeURIComponent(topic)}`, { withCredentials: true }) source.onopen = () => onStatus?.('connected') source.onerror = () => onStatus?.('reconnecting') source.addEventListener('update', (event) => { try { onMessage(JSON.parse(event.data) as RawRealtimeEvent) } catch { /* next query refresh reconciles state */ } }) return () => source.close() }