import { toRankOrdinal, type RankOrdinal } from '../ranks' export type AccountRole = 'admin' | '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[] } 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' rulesetName: string counts: Record myRsvp?: RsvpStatus } 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 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' | 'hero_ban' | 'result' | 'complete' version: number } options: DraftOption[] 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; 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 } // 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[] 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 } 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 RawMapResult { mapName: string; actorAccountId: string; outcome: MapOutcome recordedAt: string; correctionOf?: number } export interface RawSeries { id: string; tournamentId: string; teamAId: string; teamBId: string winnerTeamId: string; bestOf: number; results: RawMapResult[]; version: number } export interface RawTournament { id: string; eventId: string; name: string; winnerTeamId: string teamIds: string[]; rounds: RawSeries[][] } 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 ?? [], } } 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 starts = new Date(raw.startsAt).getTime() const ends = new Date(raw.endsAt).getTime() 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: now > ends ? 'completed' : now >= starts ? 'live' : now > deadline ? 'balancing' : 'registration', rulesetName: 'Server ruleset', counts, myRsvp: own ? statusFromRaw(own.status) : 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: [], } 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: [], }, 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 activeResults = raw.results.filter((_, index) => !raw.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 = Boolean(raw.winnerTeamId) return { id: raw.id, tournamentId: raw.tournamentId, status: complete ? 'completed' : raw.teamAId && raw.teamBId ? 'playing' : 'scheduled', roundLabel: 'Tournament series', teamAlpha: alpha, teamBeta: beta, score, version: raw.version, currentStep: { title: complete ? 'Series complete' : `Record map ${activeResults.length + 1}`, instruction: complete ? 'The tournament bracket has advanced.' : 'An admin can record the authoritative map result.', kind: complete ? 'complete' : 'result', version: raw.version, }, options: [], maps: activeResults.map((result, index) => ({ number: index + 1, name: result.mapName, mode: 'Map', status: 'completed', winner: result.outcome === 'TeamAWin' ? 'alpha' : result.outcome === 'TeamBWin' ? 'beta' : 'draw', heroBans: [], })), audit: raw.results.map((result, index) => ({ id: `result-${index}`, kind: 'result', summary: `${result.mapName}: ${result.outcome === 'TeamAWin' ? alpha.name : result.outcome === 'TeamBWin' ? beta.name : 'Draw'}`, actorName: result.actorAccountId, createdAt: result.recordedAt, })), } } export function adaptTournament(raw: RawTournament, teams: Team[] = []): Bracket { const names = new Map(teams.map((team) => [team.id, team.name])) 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), 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 ? '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) { if (!series.tournamentId) return adaptSeries(series) const tournament = await request(`/tournaments/${series.tournamentId}`) 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 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[], ) => 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, }), })), players: async () => (await raw.players()).map(adaptPlayer), 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))) }, 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)) }, 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) })), 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)) }, 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()), 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 }), })), 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, ) { 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() }