530 lines
21 KiB
TypeScript
530 lines
21 KiB
TypeScript
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<RsvpStatus, number>
|
|
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<string, Player>, 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<string, Player>, 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<T>(path: string, init?: RequestInit): Promise<T> {
|
|
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<T>
|
|
}
|
|
const raw = {
|
|
session: () => request<RawSession>('/auth/me'),
|
|
players: () => request<RawPlayer[]>('/players'),
|
|
events: () => request<RawEvent[]>('/events'),
|
|
event: (id: string) => request<RawEvent>(`/events/${id}`),
|
|
rsvps: (eventId: string) => request<RawRSVP[]>(`/events/${eventId}/rsvps`),
|
|
teams: (eventId: string) => request<RawTeam[]>(`/events/${eventId}/teams`),
|
|
}
|
|
|
|
async function adaptSeriesWithTeams(series: RawSeries) {
|
|
if (!series.tournamentId) return adaptSeries(series)
|
|
const tournament = await request<RawTournament>(`/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<void>('/auth/logout', { method: 'POST' }),
|
|
profile: async () => adaptPlayer((await raw.session()).player),
|
|
updateRatings: async (
|
|
displayName: string,
|
|
ratings: Pick<Ratings, PlayerRole>,
|
|
preferredRoles: PlayerRole[],
|
|
preferredPlayerIds: string[],
|
|
) =>
|
|
adaptPlayer(await request<RawPlayer>('/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<RawRSVP>(`/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<RawEvent>('/events', { method: 'POST', body: JSON.stringify(event) })),
|
|
updateEvent: async (eventId: string, event: EventInput) => adaptEvent(await request<RawEvent>(`/events/${eventId}`, { method: 'PUT', body: JSON.stringify(event) })),
|
|
generateBalance: async (eventId: string) => {
|
|
const [candidates, players] = await Promise.all([
|
|
request<RawBalanceCandidate[]>(`/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<RawTeam>(`/teams/${teamId}/captain`, { method: 'PUT', body: JSON.stringify({ playerId }) }), new Map()),
|
|
series: async (seriesId: string) => {
|
|
const series = await request<RawSeries>(`/series/${seriesId}`)
|
|
return adaptSeriesWithTeams(series)
|
|
},
|
|
recordResult: async (seriesId: string, mapName: string, outcome: MapOutcome, expectedVersion: number) =>
|
|
adaptSeriesWithTeams(await request<RawSeries>(`/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<RawSeries>(`/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<RawTournament>(`/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()
|
|
}
|