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.
765 lines
36 KiB
TypeScript
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()
|
|
}
|