Files
mixmaker/frontend/src/api/client.ts
lemintare ae19c03542
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled
Add Discord event announcement functionality to backend
This commit introduces a new Discord event announcer to the backend, allowing for event announcements via Discord. It includes the addition of new environment variables for Discord configuration in `.env.example` and `compose.yaml`. The `main.go` file has been updated to initialize the announcer, and a new `discord` package has been created, containing the announcer logic and tests. Additionally, the service layer has been modified to support bracket draft management, enhancing the overall event workflow. Integration tests have been updated to ensure proper functionality of the new features.
2026-07-19 11:21:11 +03:00

765 lines
36 KiB
TypeScript

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<RsvpStatus, number>
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<TeamSide, string[]>
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<string, string[]> | 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<BracketDraftMatch & { series?: RawSeries }>; 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<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: [],
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<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: [],
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<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) {
const eventId = series.eventId || (series.tournamentId ? (await request<RawTournament>(`/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<void>('/auth/logout', { method: 'POST' }),
profile: async () => adaptPlayer((await raw.session()).player),
updateRatings: async (
displayName: string,
ratings: Pick<Ratings, PlayerRole>,
preferredRoles: PlayerRole[],
preferredPlayerIds: string[],
avoidedPlayerIds: 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,
avoidedPlayerIds,
}),
})),
players: async () => (await raw.players()).map(adaptPlayer),
accounts: () => request<RawAccount[]>('/accounts'),
setModerator: (accountId: string, moderator: boolean) =>
request<RawAccount>(`/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<Ratings, PlayerRole>,
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<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))
},
removeParticipant: (eventId: string, playerId: string) =>
request<void>(`/events/${eventId}/rsvps/${playerId}`, { method: 'DELETE' }),
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) })),
cancelEvent: async (eventId: string, expectedVersion: number) =>
adaptEvent(await request<RawEvent>(`/events/${eventId}/cancel`, { method: 'POST', body: JSON.stringify({ expectedVersion }) })),
deleteEvent: (eventId: string) => request<void>(`/events/${eventId}`, { method: 'DELETE' }),
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))
},
closeRegistration: async (eventId: string, rulesetId: string, expectedVersion: number) =>
adaptEvent(await request<RawEvent>(`/events/${eventId}/registration/close`, {
method: 'POST',
body: JSON.stringify({ rulesetId, expectedVersion }),
})),
revertWorkflowStage: async (eventId: string, expectedVersion: number) =>
adaptEvent(await request<RawEvent>(`/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<RawRosterDraft>(`/events/${eventId}/roster`),
selectWorkflowBalance: (eventId: string, candidate: BalanceCandidate, expectedVersion: number) =>
request<RawRosterDraft>(`/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<RawRosterDraft>(`/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<RawRosterDraft>(`/events/${eventId}/roster/move`, { method: 'POST', body: JSON.stringify(input) }),
placeReservePlayer: (eventId: string, input: { teamId: string; reservePlayerId: string; role: RawSlot['role']; expectedVersion: number }) =>
request<RawRosterDraft>(`/events/${eventId}/roster/place-reserve`, { method: 'POST', body: JSON.stringify(input) }),
removeRosterPlayer: (eventId: string, input: { teamId: string; playerId: string; expectedVersion: number }) =>
request<RawRosterDraft>(`/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<RawRosterDraft>(`/events/${eventId}/roster/${emergency ? 'emergency-substitute' : 'substitute'}`, { method: 'POST', body: JSON.stringify(input) }),
setRosterCaptain: (eventId: string, teamId: string, playerId: string, expectedVersion: number) =>
request<RawRosterDraft>(`/events/${eventId}/roster/captain`, {
method: 'PUT',
body: JSON.stringify({ teamId, playerId, expectedVersion }),
}),
confirmRosters: (eventId: string, expectedVersion: number, expectedRosterVersion: number) =>
request<RawEvent>(`/events/${eventId}/roster/confirm`, {
method: 'POST',
body: JSON.stringify({ expectedVersion, expectedRosterVersion }),
}),
bracketDraft: (eventId: string) => request<BracketDraft>(`/events/${eventId}/bracket-draft`),
initializeBracket: (eventId: string, expectedVersion: number) =>
request<BracketDraft>(`/events/${eventId}/bracket-draft/initialize`, { method: 'POST', body: JSON.stringify({ expectedVersion }) }),
updateBracket: (eventId: string, matches: BracketDraftMatch[], expectedVersion: number) =>
request<BracketDraft>(`/events/${eventId}/bracket-draft`, { method: 'PUT', body: JSON.stringify({ matches, expectedVersion }) }),
resetBracket: (eventId: string, expectedVersion: number) =>
request<BracketDraft>(`/events/${eventId}/bracket-draft/reset`, { method: 'POST', body: JSON.stringify({ expectedVersion }) }),
confirmBracket: (eventId: string, expectedVersion: number) =>
request<BracketDraft>(`/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<RawTeam>(`/teams/${teamId}/captain`, { method: 'PUT', body: JSON.stringify({ playerId }) }), new Map()),
renameTeam: (teamId: string, name: string) =>
request<RawTeam>(`/teams/${teamId}/name`, { method: 'PUT', body: JSON.stringify({ name }) }),
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 }),
})),
tossSeriesCoin: async (seriesId: string, seed: string, actingTeamId: string, expectedVersion: number) =>
adaptSeriesWithTeams(await request<RawSeries>(`/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<RawSeries>(`/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<RawSeries>(`/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<RawSeries>(`/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<RawSeries>(`/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<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,
) {
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()
}