Files
mixmaker/frontend/src/api/client.ts
lemintare 1239fcee08
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled
Initialize project with basic structure, including Docker configuration, backend and frontend setup, environment configuration, and essential files for development.
2026-07-19 00:17:31 +03:00

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()
}