Initialize project with basic structure, including Docker configuration, backend and frontend setup, environment configuration, and essential files for development.
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled

This commit is contained in:
2026-07-19 00:17:31 +03:00
parent d401e9009c
commit 1239fcee08
69 changed files with 12391 additions and 10 deletions

View File

@@ -0,0 +1,89 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { adaptEvent, adaptPlayer, adaptSeries, api, type RawEvent, type RawPlayer, type RawSeries } from './client'
const player: RawPlayer = {
id: 'player-1',
accountId: 'account-1',
displayName: 'Nova',
ratings: { tank: 36, damage: 28, support: 24 },
preferredRoles: ['Tank'],
preferredPlayerIds: ['player-2'],
createdAt: '2026-07-01T00:00:00Z',
updatedAt: '2026-07-18T00:00:00Z',
}
afterEach(() => vi.restoreAllMocks())
describe('backend DTO adapters', () => {
it('adapts backend role ratings without changing their values', () => {
expect(adaptPlayer(player)).toMatchObject({
displayName: 'Nova',
ratings: { tank: 36, damage: 28, support: 24 },
preferredRoles: ['tank'],
preferredPlayerIds: ['player-2'],
})
})
it('adapts backend RSVP values and counts', () => {
const event: RawEvent = {
id: 'event-1',
name: 'Scrim',
description: '',
startsAt: '2099-07-20T19:00:00Z',
endsAt: '2099-07-20T23:00:00Z',
registrationDeadline: '2099-07-20T18:00:00Z',
createdBy: 'account-1',
createdAt: '2026-07-01T00:00:00Z',
updatedAt: '2026-07-01T00:00:00Z',
}
const adapted = adaptEvent(event, [
{ eventId: event.id, playerId: player.id, actorAccountId: 'account-1', status: 'Going', source: 'player', updatedAt: event.updatedAt },
], player.id)
expect(adapted.myRsvp).toBe('going')
expect(adapted.counts.going).toBe(1)
})
it('keeps corrected results out of the authoritative score', () => {
const series: RawSeries = {
id: 'series-1',
tournamentId: 't-1',
teamAId: 'a',
teamBId: 'b',
winnerTeamId: '',
bestOf: 3,
version: 2,
results: [
{ mapName: 'Lijiang', outcome: 'TeamAWin', actorAccountId: 'admin', recordedAt: '2026-07-18T00:00:00Z' },
{ mapName: 'Lijiang', outcome: 'TeamBWin', actorAccountId: 'admin', recordedAt: '2026-07-18T00:01:00Z', correctionOf: 1 },
],
}
expect(adaptSeries(series).score).toEqual({ alpha: 0, beta: 1 })
})
})
describe('actual backend routes', () => {
it('updates the current player through PATCH /api/me/player', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify(player), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
await api.updateRatings('Nova', { tank: 37, damage: 29, support: 25 }, ['tank', 'damage'], ['player-2'])
expect(fetchMock).toHaveBeenCalledWith('/api/me/player', expect.objectContaining({ method: 'PATCH' }))
expect(fetchMock.mock.calls[0][1]?.body).toContain('"preferredRoles":["Tank","Damage"]')
})
it('sends backend RSVP enum values to the player-specific route', async () => {
const responses = [
{ eventId: 'event-1', playerId: 'player-1', actorAccountId: 'account-1', status: 'Going', source: 'player', updatedAt: '2026-07-18T00:00:00Z' },
[player],
{ account: { id: 'account-1', discordId: 'discord', username: 'Nova', avatarUrl: '', role: 'player', createdAt: '2026-07-01T00:00:00Z' }, player },
]
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => new Response(JSON.stringify(responses.shift()), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
await api.setRsvp('event-1', 'player-1', 'going')
const rsvpCall = fetchMock.mock.calls.find(([url]) => url === '/api/events/event-1/rsvps/player-1')
expect(rsvpCall?.[1]?.body).toBe(JSON.stringify({ status: 'Going' }))
})
})

529
frontend/src/api/client.ts Normal file
View File

@@ -0,0 +1,529 @@
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()
}

248
frontend/src/api/demo.ts Normal file
View File

@@ -0,0 +1,248 @@
import type {
BalanceCandidate,
Bracket,
MixEvent,
Player,
Registration,
Series,
Session,
Team,
} from './client'
import type { RankOrdinal } from '../ranks'
// Fixtures keep local development and visual review useful without a backend.
// Production builds always use the real API.
export const demoMode =
import.meta.env.DEV && import.meta.env.VITE_DEMO_MODE !== 'false'
const now = new Date()
const daysFromNow = (days: number, hour = 19) => {
const date = new Date(now)
date.setDate(date.getDate() + days)
date.setHours(hour, 0, 0, 0)
return date.toISOString()
}
const makePlayer = (
id: string,
displayName: string,
tank: RankOrdinal,
damage: RankOrdinal,
support: RankOrdinal,
): Player => ({
id,
displayName,
battleTag: `${displayName.replace(/\s/g, '')}#${1000 + Number(id.replace(/\D/g, '') || 0)}`,
ratings: { tank, damage, support, updatedAt: daysFromNow(-4) },
preferredRoles: tank >= damage && tank >= support ? ['tank'] : damage >= support ? ['damage'] : ['support'],
preferredPlayerIds: [],
})
export const demoProfile: Player = {
...makePlayer('p1', 'Lemintare', 30, 33, 27),
preferredRoles: ['damage', 'tank'],
preferredPlayerIds: ['p2', 'p5'],
}
export const demoSession: Session = {
account: {
id: 'account-1',
discordId: '125661705720381444',
displayName: 'Lemintare',
role: 'admin',
},
player: demoProfile,
}
export const demoEvents: MixEvent[] = [
{
id: 'event-1',
title: 'Saturday Night Scrim',
description: 'Community draft night · 4 teams · Bo3 single elimination',
startsAt: daysFromNow(2),
endsAt: daysFromNow(2, 23),
registrationDeadline: daysFromNow(1, 21),
status: 'registration',
rulesetName: 'Community Bo3 v2',
counts: { going: 18, maybe: 4, not_going: 3 },
myRsvp: 'going',
},
{
id: 'event-2',
title: 'Midweek Mix',
description: 'Fast queue with two balanced teams',
startsAt: daysFromNow(5),
endsAt: daysFromNow(5, 23),
registrationDeadline: daysFromNow(4),
status: 'registration',
rulesetName: 'Quick Mix v1',
counts: { going: 9, maybe: 6, not_going: 2 },
myRsvp: 'maybe',
},
{
id: 'event-3',
title: 'Summer Clash #4',
description: 'Live playoff bracket',
startsAt: daysFromNow(0, 20),
endsAt: daysFromNow(1, 1),
registrationDeadline: daysFromNow(-1),
status: 'live',
rulesetName: 'Tournament Bo3 v2',
counts: { going: 22, maybe: 0, not_going: 4 },
myRsvp: 'going',
},
]
const players = [
demoProfile,
makePlayer('p2', 'Nova', 36, 28, 29),
makePlayer('p3', 'Kairo', 24, 35, 26),
makePlayer('p4', 'MercyMain', 18, 27, 37),
makePlayer('p5', 'Pulse', 25, 34, 29),
makePlayer('p6', 'Astra', 31, 25, 36),
makePlayer('p7', 'Hex', 35, 29, 23),
makePlayer('p8', 'Orbit', 22, 34, 30),
makePlayer('p9', 'Miko', 27, 30, 36),
makePlayer('p10', 'Rook', 37, 26, 24),
makePlayer('p11', 'Echo', 26, 32, 31),
makePlayer('p12', 'Zenith', 28, 30, 35),
]
players[1].preferredPlayerIds = ['p1']
players[4].preferredPlayerIds = ['p1']
export const demoRegistrations: Registration[] = players.map((player, index) => ({
id: `reg-${index + 1}`,
player,
status: index < 9 ? 'going' : index < 11 ? 'maybe' : 'not_going',
updatedAt: daysFromNow(-(index % 4)),
changedBy:
index === 4
? { id: 'account-1', displayName: 'Lemintare', source: 'admin' }
: { id: player.id, displayName: player.displayName, source: 'self' },
}))
const alpha: Team = {
id: 'team-alpha',
name: 'Ember Wolves',
side: 'alpha',
captainId: 'p2',
averageRating: 35.6,
members: [
{ player: players[1], assignedRole: 'tank' },
{ player: players[2], assignedRole: 'damage' },
{ player: players[4], assignedRole: 'damage' },
{ player: players[3], assignedRole: 'support' },
{ player: players[5], assignedRole: 'support' },
],
}
const beta: Team = {
id: 'team-beta',
name: 'Azure Phantoms',
side: 'beta',
captainId: 'p7',
averageRating: 34.4,
members: [
{ player: players[6], assignedRole: 'tank' },
{ player: players[7], assignedRole: 'damage' },
{ player: players[10], assignedRole: 'damage' },
{ player: players[8], assignedRole: 'support' },
{ player: players[11], assignedRole: 'support' },
],
}
export const demoBalanceCandidates: BalanceCandidate[] = [
{
id: 'candidate-1',
score: 4,
teams: [alpha, beta],
reserve: [players[0], players[9]],
explanations: ['9 preferred role assignments', '2 preferred teammate pairs kept together'],
},
{
id: 'candidate-2',
score: 9,
teams: [
{ ...alpha, averageRating: 35.2 },
{ ...beta, averageRating: 34.6 },
],
reserve: [players[0], players[9]],
explanations: ['8 preferred role assignments', '2 preferred teammate pairs kept together'],
},
{
id: 'candidate-3',
score: 13,
teams: [
{ ...alpha, averageRating: 35.4 },
{ ...beta, averageRating: 34.5 },
],
reserve: [players[0], players[9]],
explanations: ['10 preferred role assignments', '1 preferred teammate pair kept together'],
},
]
export const demoSeries: Series = {
id: 'series-1',
status: 'hero_draft',
roundLabel: 'Semifinal 1',
teamAlpha: alpha,
teamBeta: beta,
score: { alpha: 1, beta: 0 },
version: 12,
currentStep: {
title: 'Hero ban 2 of 4',
instruction: 'Azure Phantoms choose a hero to remove for Map 2.',
activeTeam: 'beta',
kind: 'hero_ban',
version: 12,
},
options: [
{ id: 'hero-1', name: 'Ana', role: 'support' },
{ id: 'hero-2', name: 'Kiriko', role: 'support' },
{ id: 'hero-3', name: 'Tracer', role: 'damage' },
{ id: 'hero-4', name: 'Sojourn', role: 'damage' },
{ id: 'hero-5', name: 'Winston', role: 'tank', disabled: true, disabledReason: 'Already banned by this team' },
{ id: 'hero-6', name: 'Sigma', role: 'tank' },
],
maps: [
{
number: 1,
name: 'Lijiang Tower',
mode: 'Control',
status: 'completed',
winner: 'alpha',
heroBans: [
{ hero: 'Sombra', team: 'alpha', role: 'damage' },
{ hero: 'Winston', team: 'beta', role: 'tank' },
{ hero: 'Ana', team: 'alpha', role: 'support' },
{ hero: 'Tracer', team: 'beta', role: 'damage' },
],
},
{
number: 2,
name: 'Kings Row',
mode: 'Hybrid',
status: 'drafting',
heroBans: [{ hero: 'D.Va', team: 'alpha', role: 'tank' }],
},
{ number: 3, name: 'TBD', mode: 'Control', status: 'ready', heroBans: [] },
],
audit: [
{ id: 'a1', kind: 'coin_toss', summary: 'Ember Wolves won the toss', actorName: 'System', createdAt: daysFromNow(0, 20) },
{ id: 'a2', kind: 'map_selected', summary: 'Lijiang Tower selected', actorName: 'Nova', createdAt: daysFromNow(0, 20) },
{ id: 'a3', kind: 'result', summary: 'Map 1 won by Ember Wolves', actorName: 'Lemintare', createdAt: daysFromNow(0, 21) },
{ id: 'a4', kind: 'map_selected', summary: 'Kings Row selected', actorName: 'Hex', createdAt: daysFromNow(0, 21) },
{ id: 'a5', kind: 'hero_ban', summary: 'D.Va banned by Ember Wolves', actorName: 'Nova', createdAt: daysFromNow(0, 21) },
],
}
export const demoBracket: Bracket = {
id: 'bracket-1',
title: 'Summer Clash #4',
rounds: ['Semifinals', 'Grand Final'],
matches: [
{ id: 'm1', round: 0, slot: 0, label: 'Semifinal 1', teamAlpha: 'Ember Wolves', teamBeta: 'Azure Phantoms', scoreAlpha: 1, scoreBeta: 0, status: 'live', seriesId: 'series-1' },
{ id: 'm2', round: 0, slot: 1, label: 'Semifinal 2', teamAlpha: 'Neon Foxes', teamBeta: 'Void Runners', scoreAlpha: 2, scoreBeta: 1, winner: 'alpha', status: 'completed', seriesId: 'series-2' },
{ id: 'm3', round: 1, slot: 0, label: 'Grand Final', teamBeta: 'Neon Foxes', status: 'pending' },
],
}