Add event management features to API, including event cancellation, deletion, registration closure, and workflow balance generation. Introduce new roster management endpoints and enhance participant handling with avoided player preferences. Update database schema and service logic to support these changes.
This commit is contained in:
@@ -16,6 +16,7 @@ export interface Player {
|
||||
ratings: Ratings
|
||||
preferredRoles: PlayerRole[]
|
||||
preferredPlayerIds: string[]
|
||||
avoidedPlayerIds: string[]
|
||||
}
|
||||
export interface Session {
|
||||
account: { id: string; discordId: string; displayName: string; avatarUrl?: string; role: AccountRole }
|
||||
@@ -35,11 +36,17 @@ export interface MixEvent {
|
||||
startsAt: string
|
||||
endsAt: string
|
||||
registrationDeadline: string
|
||||
status: 'registration' | 'balancing' | 'live' | 'completed'
|
||||
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' | 'Live' | 'Completed' | 'Cancelled'
|
||||
export interface TeamPlayer { player: Player; assignedRole: PlayerRole }
|
||||
export interface Team {
|
||||
id: string
|
||||
@@ -86,7 +93,7 @@ export interface Series {
|
||||
title: string
|
||||
instruction: string
|
||||
activeTeam?: TeamSide
|
||||
kind: 'coin_toss' | 'map_ban' | 'hero_ban' | 'result' | 'complete'
|
||||
kind: 'coin_toss' | 'map_ban' | 'map_pick' | 'hero_ban' | 'result' | 'complete'
|
||||
version: number
|
||||
}
|
||||
options: DraftOption[]
|
||||
@@ -124,12 +131,14 @@ 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
|
||||
@@ -140,14 +149,26 @@ 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; tournamentId: string; teamAId: string; teamBId: string
|
||||
winnerTeamId: string; bestOf: number; results: RawMapResult[]; version: number
|
||||
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[]; banned: string[]; firstTeamId: string; teamIds: [string, string]; actions: RawDraftAction[] }
|
||||
heroDraft?: { heroes: Array<{ name: string; role: 'Tank' | 'Damage' | 'Support' }>; teamIds: [string, string]; firstTeamId: string; bansPerTeam: number; currentBans: RawDraftAction[] }
|
||||
currentMap: string; playedMaps: string[]; nextMapPickerId: string; availableMaps: string[]
|
||||
maps: Array<{ number: number; name: string; heroBans: RawDraftAction[]; result?: RawMapResult }>
|
||||
audit: Array<{ kind: DraftAction['kind']; summary: string; teamId?: string; actorAccountId: string; at: string }>
|
||||
results: RawMapResult[]; 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[][]
|
||||
@@ -178,6 +199,7 @@ export function adaptPlayer(raw: RawPlayer): Player {
|
||||
ratings,
|
||||
preferredRoles,
|
||||
preferredPlayerIds: raw.preferredPlayerIds ?? [],
|
||||
avoidedPlayerIds: raw.avoidedPlayerIds ?? [],
|
||||
}
|
||||
}
|
||||
export function adaptSession(raw: RawSession): Session {
|
||||
@@ -194,7 +216,6 @@ export function adaptSession(raw: RawSession): Session {
|
||||
}
|
||||
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 }
|
||||
@@ -207,10 +228,18 @@ export function adaptEvent(raw: RawEvent, rsvps: RawRSVP[] = [], currentPlayerId
|
||||
startsAt: raw.startsAt,
|
||||
endsAt: raw.endsAt,
|
||||
registrationDeadline: raw.registrationDeadline,
|
||||
status: now > ends ? 'completed' : now >= starts ? 'live' : now > deadline ? 'balancing' : 'registration',
|
||||
status: raw.state === 'Cancelled' ? 'cancelled'
|
||||
: raw.state === 'Completed' || now > ends ? '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 {
|
||||
@@ -220,6 +249,7 @@ export function adaptRegistration(raw: RawRSVP, players: Map<string, Player>, se
|
||||
ratings: { tank: 1, damage: 1, support: 1, updatedAt: raw.updatedAt },
|
||||
preferredRoles: [],
|
||||
preferredPlayerIds: [],
|
||||
avoidedPlayerIds: [],
|
||||
}
|
||||
return {
|
||||
id: `${raw.eventId}:${raw.playerId}`,
|
||||
@@ -246,6 +276,7 @@ export function adaptTeam(raw: RawTeam, players: Map<string, Player>, index = 0)
|
||||
},
|
||||
preferredRoles: [roleFromRaw(slot.role)],
|
||||
preferredPlayerIds: [],
|
||||
avoidedPlayerIds: [],
|
||||
},
|
||||
assignedRole: roleFromRaw(slot.role),
|
||||
}))
|
||||
@@ -294,38 +325,54 @@ export function adaptSeries(raw: RawSeries, teams: Team[] = []): Series {
|
||||
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)
|
||||
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) => ({ id: hero.name, name: hero.name, role: roleFromRaw(hero.role) }))
|
||||
: []
|
||||
return {
|
||||
id: raw.id,
|
||||
tournamentId: raw.tournamentId,
|
||||
status: complete ? 'completed' : raw.teamAId && raw.teamBId ? 'playing' : 'scheduled',
|
||||
status,
|
||||
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',
|
||||
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: [],
|
||||
maps: activeResults.map((result, index) => ({
|
||||
number: index + 1,
|
||||
name: result.mapName,
|
||||
options,
|
||||
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: '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,
|
||||
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) => ({ hero: ban.value, team: ban.teamId === raw.teamAId ? 'alpha' : 'beta', role: '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 {
|
||||
@@ -399,9 +446,8 @@ const raw = {
|
||||
}
|
||||
|
||||
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 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)))
|
||||
}
|
||||
@@ -415,6 +461,7 @@ export const api = {
|
||||
ratings: Pick<Ratings, PlayerRole>,
|
||||
preferredRoles: PlayerRole[],
|
||||
preferredPlayerIds: string[],
|
||||
avoidedPlayerIds: string[],
|
||||
) =>
|
||||
adaptPlayer(await request<RawPlayer>('/me/player', {
|
||||
method: 'PATCH',
|
||||
@@ -423,6 +470,7 @@ export const api = {
|
||||
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),
|
||||
@@ -467,8 +515,13 @@ export const api = {
|
||||
])
|
||||
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' }),
|
||||
@@ -477,6 +530,50 @@ export const api = {
|
||||
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 }),
|
||||
})),
|
||||
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) }),
|
||||
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 }),
|
||||
}),
|
||||
startScrim: (eventId: string, expectedVersion: number) =>
|
||||
request<{ event: RawEvent; series?: RawSeries; tournament?: RawTournament }>(`/events/${eventId}/start`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ expectedVersion }),
|
||||
}),
|
||||
selectBalance: (eventId: string, candidate: BalanceCandidate) =>
|
||||
request<{ status: string }>(`/events/${eventId}/teams`, {
|
||||
method: 'PUT',
|
||||
@@ -512,6 +609,31 @@ export const api = {
|
||||
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',
|
||||
@@ -536,6 +658,7 @@ export function subscribeToEvents(
|
||||
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')
|
||||
|
||||
Reference in New Issue
Block a user