01 How are teams balanced? The server builds role-locked 1 Tank / 2 Damage / 2 Support teams. Rank balance is the main goal. Preferred roles, preferred teammates, and up to three avoided teammates are soft preferences and never override a fair valid roster.
@@ -286,13 +336,12 @@ function toLocalInput(value: string) {
function eventInput(event?: MixEvent): EventInput {
const start = new Date(Date.now() + 86_400_000)
const end = new Date(start.getTime() + 2 * 3_600_000)
- const deadline = new Date(start.getTime() - 3_600_000)
return {
name: event?.title ?? '',
description: event?.description ?? '',
startsAt: toLocalInput(event?.startsAt ?? start.toISOString()),
endsAt: toLocalInput(event?.endsAt ?? end.toISOString()),
- registrationDeadline: toLocalInput(event?.registrationDeadline ?? deadline.toISOString()),
+ registrationDeadline: toLocalInput(event?.startsAt ?? start.toISOString()),
}
}
@@ -338,18 +387,19 @@ function EventSetup({ event, onCreated, onDeleted }: { event?: MixEvent; onCreat
})
const set = (key: keyof EventInput, value: string) => setForm((current) => ({ ...current, [key]: value }))
const setStart = (value: string) => setForm((current) => {
- if (event) return { ...current, startsAt: value }
+ if (event) return { ...current, startsAt: value, registrationDeadline: value }
const start = new Date(value)
return {
...current,
startsAt: value,
+ registrationDeadline: value,
endsAt: Number.isNaN(start.getTime()) ? current.endsAt : toLocalInput(new Date(start.getTime() + 2 * 3_600_000).toISOString()),
}
})
return
@@ -501,7 +551,7 @@ function WorkflowControl({ eventId }: { eventId: string }) {
{state === 'RostersDraft' && roster.data &&
confirm.mutate()} />}
{state === 'RostersConfirmed' && start.mutate()} />}
{state === 'Live' && Scrim is live Coin toss, bans, and map results are now available.
{event.data.activeSeriesId ? Open series : Open bracket} }
- {state === 'Completed' && Scrim completed The final result and full audit trail are preserved.
}
+ {state === 'Completed' && Scrim completed The final result and full audit trail are preserved.
{event.data.tournamentId ? Open bracket : event.data.activeSeriesId ? Match history : null} }
{error && {error.message}
}
}
@@ -617,6 +667,12 @@ function LiveSeriesPage() {
const session = useQuery({ queryKey: ['session'], queryFn: api.session, initialData: demoMode ? demoSession : undefined })
const [selected, setSelected] = useState
()
const [outcome, setOutcome] = useState('TeamAWin')
+ const participant = Boolean(series.data && session.data && [series.data.teamAlpha, series.data.teamBeta].some((team) => team.members.some(({ player }) => player.id === session.data?.player.id)))
+ useEffect(() => {
+ if (series.data && session.data && !participant && !isStaff(session.data.account.role)) {
+ void router.navigate({ to: '/watch/$seriesId', params: { seriesId }, replace: true })
+ }
+ }, [participant, series.data, seriesId, session.data])
const result = useMutation({
mutationFn: () => {
const data = series.data!
@@ -652,7 +708,23 @@ function LiveSeriesPage() {
if ((series.isError && !series.data) || (session.isError && !session.data)) return { void series.refetch(); void session.refetch() }} />
if (!series.data || !session.data) return
const data = series.data
- return Spectator view} />Current step {data.currentStep.title} {data.currentStep.instruction}
{data.currentStep.activeTeam &&
Acting team {data.currentStep.activeTeam === 'alpha' ? data.teamAlpha.name : data.teamBeta.name}
}
{data.currentStep.kind === 'result' ? { e.preventDefault(); result.mutate() }}>Outcome setOutcome(e.target.value as MapOutcome)}>{data.teamAlpha.name} wins {data.teamBeta.name} wins Draw {result.isPending ? 'Recording…' : 'Record result'} {result.isError && {result.error.message}
} : data.currentStep.kind === 'complete' ? Series completed and bracket updated.
: <>{data.options.map((option) => setSelected(option.id)}>{option.role?.slice(0, 1).toUpperCase()} {option.name} {option.disabled ? {option.disabledReason} : } )}
Draft commands use dedicated backend endpoints and server-side validation.
action.mutate()}>{action.isPending ? 'Confirming…' : data.currentStep.kind === 'coin_toss' ? 'Toss coin' : 'Confirm action'} {action.isError && {action.error.message}
}>}
+ const activeSide = data.currentStep.activeTeam ?? 'alpha'
+ const activeTeam = activeSide === 'alpha' ? data.teamAlpha : data.teamBeta
+ const canAct = isStaff(session.data.account.role) || activeTeam.captainId === session.data.player.id
+ const canRecord = isStaff(session.data.account.role) || data.teamAlpha.captainId === session.data.player.id || data.teamBeta.captainId === session.data.player.id
+ return
+
Spectator view} />
+
+
+
+ Current step {data.currentStep.title} {data.currentStep.instruction}
{data.currentStep.activeTeam &&
Acting team {activeTeam.name}
}
+ {data.currentStep.kind === 'result' ? { event.preventDefault(); if (canRecord) result.mutate() }}>Outcome setOutcome(event.target.value as MapOutcome)}>{data.teamAlpha.name} wins {data.teamBeta.name} wins Draw {canRecord ? result.isPending ? 'Recording…' : 'Record result' : 'Captain action'} {result.isError && {result.error.message}
}
+ : data.currentStep.kind === 'complete' ? Series completed and bracket updated.
+ : <>{data.options.map((option) => setSelected(option.id)}>{option.role?.slice(0, 1).toUpperCase()} {option.name} {option.disabled ? {option.disabledReason} : } )}
{canAct ? 'Draft commands use server-side validation.' : `Waiting for ${activeTeam.name} captain.`}
action.mutate()}>{canAct ? action.isPending ? 'Confirming…' : data.currentStep.kind === 'coin_toss' ? 'Toss coin' : 'Confirm action' : 'Captain action'} {action.isError && {action.error.message}
}>}
+
+
+
+
}
function Scoreboard({ series }: { series: Series }) {
@@ -662,7 +734,35 @@ function Scoreboard({ series }: { series: Series }) {
const betaCaptain = series.teamBeta.members.find(({ player }) => player.id === series.teamBeta.captainId)?.player.displayName
const canRenameAlpha = controlPage && (isStaff(session.data?.account.role) || session.data?.player.id === series.teamAlpha.captainId)
const canRenameBeta = controlPage && (isStaff(session.data?.account.role) || session.data?.player.id === series.teamBeta.captainId)
- return <>Team Alpha
{series.teamAlpha.name} {alphaCaptain ? `Captain · ${alphaCaptain}` : 'Captain not assigned'} Best of 3 {series.score.alpha}: {series.score.beta} {series.status}
Team Beta
{series.teamBeta.name} {betaCaptain ? `Captain · ${betaCaptain}` : 'Captain not assigned'} {(canRenameAlpha || canRenameBeta) && {canRenameAlpha && }{canRenameBeta && }
}>
+ return <>Team Alpha
{series.teamAlpha.name} {alphaCaptain ? `Captain · ${alphaCaptain}` : 'Captain not assigned'} Best of 3 {series.score.alpha}: {series.score.beta} {series.status}
Team Beta
{series.teamBeta.name} {betaCaptain ? `Captain · ${betaCaptain}` : 'Captain not assigned'} {(canRenameAlpha || canRenameBeta) && {canRenameAlpha && }{canRenameBeta && }
}>
+}
+
+function TournamentStrip({ series }: { series: Series }) {
+ const bracket = useQuery({
+ queryKey: ['tournament', series.eventId],
+ queryFn: () => api.bracket(series.eventId),
+ enabled: Boolean(series.tournamentId),
+ initialData: demoMode && series.tournamentId ? demoBracket : undefined,
+ })
+ if (!series.tournamentId || !bracket.data) return null
+ const current = bracket.data.matches.find((match) => match.seriesId === series.id)
+ const activeRound = bracket.data.matches.find((match) => match.status === 'live')?.round
+ const activeMatches = bracket.data.matches.filter((match) => match.status === 'live' && match.round === activeRound && match.seriesId)
+ const currentIndex = activeMatches.findIndex((match) => match.seriesId === series.id)
+ const previous = currentIndex > 0 ? activeMatches[currentIndex - 1] : undefined
+ const next = currentIndex >= 0 && currentIndex < activeMatches.length - 1 ? activeMatches[currentIndex + 1] : activeMatches.find((match) => match.seriesId !== series.id)
+ const matchBody = (match: (typeof bracket.data.matches)[number]) => <>{match.teamAlpha ?? 'TBD'} {match.scoreAlpha ?? '—'} {match.teamBeta ?? 'TBD'} {match.scoreBeta ?? '—'} >
+ return
+ Tournament bracket Open full bracket
+
+ {previous?.seriesId ?
:
}
+
{bracket.data.rounds.map((round, roundIndex) =>
{round} {bracket.data.matches.filter((match) => match.round === roundIndex).map((match) => match.status === 'pending' || match.status === 'ready'
+ ?
{matchBody(match)}
+ :
{matchBody(match)})}
)}
+ {next?.seriesId ?
:
}
+
+ {current && Current match · {current.label}
}
+
}
function TeamNameEditor({ team, seriesId }: { team: Team; seriesId: string }) {
@@ -682,25 +782,39 @@ function TeamNameEditor({ team, seriesId }: { team: Team; seriesId: string }) {
})
return { event.preventDefault(); rename.mutate() }}>Team name setName(event.target.value)} />{rename.isPending ? 'Saving…' : 'Rename'} {rename.isError && {rename.error.message} }
}
+function HeroBanDisplay({ series }: { series: Series }) {
+ const activeMap = activeHeroBanMap(series)
+ if (!activeMap) return null
+ const bans = activeMap.heroBans
+ return Hero bans {activeMap.name}
{bans.length}/4 {Array.from({ length: 4 }, (_, index) => bans[index] ?
Ban {index + 1} {bans[index].hero} {bans[index].role}
:
Ban {index + 1} Pending
)}
+}
+function SeriesBanHistory({ series }: { series: Series }) {
+ const hasBans = series.heroBanHistory.alpha.length > 0 || series.heroBanHistory.beta.length > 0
+ if (!hasBans) return null
+ return Bo3 hero ban history A team cannot repeat its own hero ban.
{([['alpha', series.teamAlpha], ['beta', series.teamBeta]] as const).map(([side, team]) =>
{team.name} {series.heroBanHistory[side].length > 0 ? series.heroBanHistory[side].map((hero) => {hero} ) : No bans yet }
)}
+}
function MapTimeline({ series }: { series: Series }) { return Map timeline First to 2 wins
{series.maps.length === 0 ? No results recorded.
: series.maps.map((map) => 0{map.number} {map.name} {map.mode} · {map.status}
{map.winner &&
{map.winner === 'alpha' ? 'A' : map.winner === 'beta' ? 'B' : 'Draw'} }
)} }
-function AuditLog({ series }: { series: Series }) { return Match history Auditable server actions
{series.audit.length === 0 ? No actions yet.
: series.audit.slice(-4).reverse().map((a) => {a.summary} {a.actorName} · {new Date(a.createdAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
)} }
+function AuditLog({ series }: { series: Series }) { return Match history All server actions · newest first
{series.audit.length} {series.audit.length === 0 ? No actions yet.
: {[...series.audit].reverse().map((a) =>
{a.summary} {a.actorName} · {new Date(a.createdAt).toLocaleString(undefined, { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })}
)}
} }
function SpectatorPage() {
const { seriesId } = spectatorRoute.useParams()
const series = useQuery({ queryKey: ['series', seriesId], queryFn: () => api.series(seriesId), initialData: demoMode && seriesId === demoSeries.id ? demoSeries : undefined })
- if (series.isLoading) return
- if (!series.data) return void series.refetch()} />
+ const session = useQuery({ queryKey: ['session'], queryFn: api.session, initialData: demoMode ? demoSession : undefined })
+ if (series.isLoading || session.isLoading) return
+ if (!series.data || !session.data) return { void series.refetch(); void session.refetch() }} />
const data = series.data
- return Connected} />{data.status} {data.currentStep.title} {data.currentStep.instruction}
{data.maps.at(-1)?.heroBans.length ? {data.maps.at(-1)?.heroBans.map((ban, index) =>
Ban {index + 1} {ban.hero} {ban.role}
)}
: null}
+ const participant = [data.teamAlpha, data.teamBeta].some((team) => team.members.some(({ player }) => player.id === session.data.player.id))
+ const canOpenControl = participant || isStaff(session.data.account.role)
+ return {canOpenControl && Series control} Connected >} />{data.status} {data.currentStep.title} {data.currentStep.instruction}
}
function BracketPage() {
- const { eventId: tournamentId } = bracketRoute.useParams()
- const bracket = useQuery({ queryKey: ['tournament', tournamentId], queryFn: () => api.bracket(tournamentId), initialData: demoMode && tournamentId === demoBracket.id ? demoBracket : undefined })
+ const { eventId } = bracketRoute.useParams()
+ const bracket = useQuery({ queryKey: ['tournament', eventId], queryFn: () => api.bracket(eventId), initialData: demoMode ? demoBracket : undefined })
if (bracket.isLoading) return
if (bracket.isError || !bracket.data) return void bracket.refetch()} />
const data = bracket.data
- return {data.matches.filter((match) => match.status === 'live').length} live} />{data.rounds.map((round, ri) =>
{data.matches.filter((m) => m.round === ri).map((m) =>
{m.label} {m.status === 'live' && Live }
{m.teamAlpha ?? 'TBD'} {m.scoreAlpha ?? '—'}
{m.teamBeta ?? 'TBD'} {m.scoreBeta ?? '—'}
{m.seriesId && Open series })}
)}
Scroll horizontally to follow the bracket on smaller screens.
+ return {data.matches.filter((match) => match.status === 'live').length} live} />{data.rounds.map((round, ri) =>
{data.matches.filter((m) => m.round === ri).map((m) =>
{m.label} {m.status === 'live' && Live }
{m.teamAlpha ?? 'TBD'} {m.scoreAlpha ?? '—'}
{m.teamBeta ?? 'TBD'} {m.scoreBeta ?? '—'}
{m.seriesId && Open series })}
)}
Scroll horizontally to follow the bracket on smaller screens.
}
function App() {
diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts
index 63542e2..d1adad6 100644
--- a/frontend/src/api/client.test.ts
+++ b/frontend/src/api/client.test.ts
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
-import { adaptEvent, adaptPlayer, adaptSeries, api, type RawEvent, type RawPlayer, type RawSeries } from './client'
+import { adaptEvent, adaptPlayer, adaptSeries, adaptTournament, api, type RawEvent, type RawPlayer, type RawSeries } from './client'
const player: RawPlayer = {
id: 'player-1',
@@ -79,6 +79,21 @@ describe('backend DTO adapters', () => {
}
expect(adaptSeries(series).score).toEqual({ alpha: 0, beta: 1 })
expect(adaptSeries({ ...series, phase: 'CoinTossPending', results: null, maps: null, audit: null }).score).toEqual({ alpha: 0, beta: 0 })
+ expect(adaptTournament({ id: 't-1', eventId: 'event-1', name: 'Cup', winnerTeamId: '', teamIds: ['a', 'b'], rounds: [[{ ...series, phase: 'MapBan' }]] }).matches[0].status).toBe('live')
+ const heroDraft = adaptSeries({
+ ...series,
+ phase: 'HeroBan',
+ seriesBans: { a: ['Ana'], b: ['Tracer'] },
+ heroDraft: {
+ heroes: [{ name: 'Ana', role: 'Support' }, { name: 'Kiriko', role: 'Support' }],
+ teamIds: ['a', 'b'],
+ firstTeamId: 'a',
+ bansPerTeam: 2,
+ currentBans: [],
+ },
+ })
+ expect(heroDraft.heroBanHistory).toEqual({ alpha: ['Ana'], beta: ['Tracer'] })
+ expect(heroDraft.options.find((option) => option.name === 'Ana')).toMatchObject({ disabled: true })
})
})
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 0bfb496..5e0ac56 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -82,6 +82,7 @@ export interface DraftAction {
}
export interface Series {
id: string
+ eventId: string
tournamentId?: string
status: 'scheduled' | 'coin_toss' | 'map_draft' | 'hero_draft' | 'playing' | 'completed'
roundLabel: string
@@ -97,6 +98,7 @@ export interface Series {
version: number
}
options: DraftOption[]
+ heroBanHistory: Record
maps: Array<{
number: number
name: string
@@ -109,7 +111,7 @@ export interface Series {
}
export interface BracketMatch {
id: string; round: number; slot: number; label: string
- teamAlpha?: string; teamBeta?: string; scoreAlpha?: number; scoreBeta?: number
+ 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[] }
@@ -164,6 +166,7 @@ export interface RawSeries {
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 | 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
@@ -345,10 +348,14 @@ export function adaptSeries(raw: RawSeries, teams: Team[] = []): Series {
: 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) }))
+ ? (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',
@@ -364,13 +371,20 @@ export function adaptSeries(raw: RawSeries, teams: Team[] = []): Series {
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) => ({ hero: ban.value, team: ban.teamId === raw.teamAId ? 'alpha' : 'beta', role: 'damage' as PlayerRole })),
+ 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 })),
}
@@ -390,11 +404,13 @@ export function adaptTournament(raw: RawTournament, teams: Team[] = []): Bracket
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 ? 'ready' : 'pending',
+ status: series.winnerTeamId ? 'completed' : series.teamAId && series.teamBId && series.phase ? 'live' : series.teamAId && series.teamBId ? 'ready' : 'pending',
} satisfies BracketMatch
})),
}
diff --git a/frontend/src/api/demo.ts b/frontend/src/api/demo.ts
index b42cc08..563c816 100644
--- a/frontend/src/api/demo.ts
+++ b/frontend/src/api/demo.ts
@@ -102,6 +102,7 @@ export const demoEvents: MixEvent[] = [
version: 6,
rulesetId: 'standard-control-hybrid-control',
activeSeriesId: 'series-1',
+ tournamentId: 'bracket-1',
},
{
id: 'event-4',
@@ -224,6 +225,8 @@ export const demoBalanceCandidates: BalanceCandidate[] = [
export const demoSeries: Series = {
id: 'series-1',
+ eventId: 'event-3',
+ tournamentId: 'bracket-1',
status: 'hero_draft',
roundLabel: 'Semifinal 1',
teamAlpha: alpha,
@@ -245,6 +248,10 @@ export const demoSeries: Series = {
{ id: 'hero-5', name: 'Winston', role: 'tank', disabled: true, disabledReason: 'Already banned by this team' },
{ id: 'hero-6', name: 'Sigma', role: 'tank' },
],
+ heroBanHistory: {
+ alpha: ['Winston', 'D.Va'],
+ beta: ['Ana', 'Tracer'],
+ },
maps: [
{
number: 1,
@@ -282,8 +289,8 @@ export const demoBracket: Bracket = {
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: 'm1', round: 0, slot: 0, label: 'Semifinal 1', teamAlpha: 'Ember Wolves', teamBeta: 'Azure Phantoms', teamAlphaId: 'team-alpha', teamBetaId: 'team-beta', scoreAlpha: 1, scoreBeta: 0, status: 'live', seriesId: 'series-1' },
+ { id: 'm2', round: 0, slot: 1, label: 'Semifinal 2', teamAlpha: 'Neon Foxes', teamBeta: 'Void Runners', teamAlphaId: 'team-neon', teamBetaId: 'team-void', scoreAlpha: 0, scoreBeta: 1, status: 'live', seriesId: 'series-2' },
{ id: 'm3', round: 1, slot: 0, label: 'Grand Final', teamBeta: 'Neon Foxes', status: 'pending' },
],
}
diff --git a/frontend/src/i18n.tsx b/frontend/src/i18n.tsx
index 362f24a..9f41a59 100644
--- a/frontend/src/i18n.tsx
+++ b/frontend/src/i18n.tsx
@@ -171,6 +171,21 @@ const translations: Record = {
'Select from roster': 'Выбрать из состава',
'avg': 'среднее',
'Series control': 'Управление серией',
+ 'Open live': 'Открыть эфир',
+ 'active matches': 'матча одновременно',
+ 'Opening your live match…': 'Открываем ваш матч…',
+ 'Tournament bracket': 'Турнирная сетка',
+ 'Open full bracket': 'Открыть полную сетку',
+ 'Previous live match': 'Предыдущий активный матч',
+ 'Next live match': 'Следующий активный матч',
+ 'Hero bans': 'Баны героев',
+ 'Bo3 hero ban history': 'История банов героев Bo3',
+ 'A team cannot repeat its own hero ban.': 'Команда не может повторять собственный бан героя.',
+ 'No bans yet': 'Банов пока нет',
+ 'All server actions · newest first': 'Все серверные действия · новые сверху',
+ 'Already banned by this team in this Bo3': 'Эта команда уже банила героя в данном Bo3',
+ 'Pending': 'Ожидание',
+ 'Captain action': 'Действие капитана',
'Actions are validated and recorded by the server.': 'Действия проверяются и записываются сервером.',
'Spectator view': 'Режим зрителя',
'Current step': 'Текущий шаг',
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 57967c5..076eb5b 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -82,6 +82,7 @@ main { min-height: calc(100vh - 72px); }
.featured-score > span { font-size: 10px; letter-spacing: .1em; color: var(--muted); font-weight: 800; }
.featured-score strong { font-size: 42px; letter-spacing: -.06em; }
.featured-score i, .series-score i { color: #59616c; font-style: normal; padding: 0 8px; }
+.featured-event.multiple-matches { min-height: 210px; }.featured-match-list { display: grid; gap: 8px; min-width: 340px; }.featured-match-score { display: grid; grid-template-columns: 58px minmax(70px,1fr) auto minmax(70px,1fr); align-items: center; gap: 9px; padding: 9px 11px; border: 1px solid var(--border); border-radius: 7px; background: rgba(8,11,15,.72); }.featured-match-score small { color: var(--orange); font-size: 8px; text-transform: uppercase; }.featured-match-score span { color: var(--muted); font-size: 9px; font-weight: 800; text-align: center; }.featured-match-score strong { font-size: 21px; white-space: nowrap; }.featured-match-score i { color: #59616c; font-style: normal; padding: 0 5px; }
.section-title { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 28px 0 15px; }
.section-title h2 { font-size: 17px; }
.section-title p { color: var(--muted); font-size: 12px; margin-top: 4px; }
@@ -252,6 +253,7 @@ textarea { min-height: 85px; resize: vertical; }
.moderator-panel { padding: 22px; }.moderator-panel > .section-title { margin-top: 0; }.moderator-list { display: grid; gap: 7px; }.moderator-list > div { min-height: 58px; display: grid; grid-template-columns: 34px minmax(0,1fr) auto; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; background: #101319; }.moderator-list > div > span:nth-child(2) { display: grid; }.moderator-list small { color: var(--muted); }.moderator-toggle { display: flex; align-items: center; gap: 8px; color: var(--muted); cursor: pointer; }.moderator-toggle input { accent-color: var(--orange); }
.scoreboard { min-height: 150px; display: grid; grid-template-columns: 1fr 210px 1fr; align-items: center; border: 1px solid var(--border); background: linear-gradient(90deg,rgba(74,37,13,.35),rgba(16,19,24,.9) 42% 58%,rgba(10,45,70,.35)); border-radius: 12px; overflow: hidden; }
+.tournament-strip { margin-bottom: 12px; padding: 14px; border: 1px solid var(--border); border-radius: 10px; background: #0d1014; }.tournament-strip > header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }.tournament-strip > header span,.tournament-strip > header a { display: flex; align-items: center; gap: 6px; font-size: 10px; }.tournament-strip > header svg { width: 15px; color: var(--orange); }.tournament-strip-body { display: grid; grid-template-columns: 34px minmax(0,1fr) 34px; align-items: center; gap: 8px; }.mini-bracket { display: flex; gap: 12px; overflow-x: auto; padding: 2px; }.mini-bracket > div { min-width: 175px; display: grid; gap: 5px; }.mini-bracket small { color: var(--muted); text-transform: uppercase; font-size: 8px; }.mini-match { display: grid; gap: 3px; padding: 7px 9px; border: 1px solid var(--border); border-radius: 6px; color: var(--muted); font-size: 9px; }.mini-match span { display: flex; justify-content: space-between; gap: 8px; }.mini-match.current { border-color: var(--orange); background: var(--orange-soft); color: var(--text); }.mini-match.live:not(.current) { border-color: #285c43; }.match-arrow { width: 34px; height: 44px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 7px; background: var(--panel); color: var(--text); }.match-arrow.disabled { opacity: .3; }.tournament-strip > p { margin: 8px 42px 0; color: var(--muted); font-size: 9px; }
.score-team { padding: 25px 30px; }.score-team > span { color: var(--orange); font-size: 9px; text-transform: uppercase; letter-spacing: .18em; font-weight: 900; }.score-team h2 { margin: 5px 0; font-size: clamp(18px,2vw,28px); }.score-team small { color: var(--muted); }.score-team.beta { text-align: right; }.score-team.beta > span { color: var(--blue); }
.series-score { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; border-left: 1px solid var(--border); border-right: 1px solid var(--border); }.series-score > span { color: var(--muted); text-transform: uppercase; letter-spacing: .16em; font-size: 9px; }.series-score strong { font-size: 48px; line-height: 1.25; }
.team-name-controls { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 12px; margin-top: 12px; }.team-name-editor { display: flex; align-items: end; gap: 8px; padding: 12px; border: 1px solid var(--border); border-radius: 9px; background: var(--surface); }.team-name-editor label { flex: 1; display: grid; gap: 5px; }.team-name-editor label span { color: var(--muted); font-size: 9px; text-transform: uppercase; letter-spacing: .08em; }.team-name-editor input { width: 100%; }.team-name-editor .error-note { align-self: center; }
@@ -270,10 +272,12 @@ textarea { min-height: 85px; resize: vertical; }
.empty-note { color: var(--muted); font-size: 11px; padding: 12px 0; }
.match-sidebar { display: grid; gap: 12px; }.map-timeline, .audit-log { padding: 18px; }.map-timeline .section-title, .audit-log .section-title { margin-top: 0; }
.map-row { display: grid; grid-template-columns: 28px 1fr auto; align-items: center; gap: 9px; min-height: 54px; border-top: 1px solid var(--border); }.map-row > span { color: #515963; font-weight: 900; }.map-row > div { display: flex; flex-direction: column; gap: 4px; font-size: 11px; }.map-row small { color: var(--muted); font-size: 9px; text-transform: capitalize; }.map-row.drafting > span { color: var(--orange); }
-.audit-log > div:not(.section-title) { display: grid; grid-template-columns: 10px 1fr; gap: 8px; padding: 9px 0; }.history-dot { width: 6px; height: 6px; margin-top: 4px; border-radius: 50%; background: #4b525d; }.audit-log p { display: flex; flex-direction: column; gap: 4px; font-size: 10px; }.audit-log small { color: var(--muted); }
+.audit-log-list { max-height: 310px; overflow-y: auto; padding-right: 7px; scrollbar-color: #424a55 transparent; scrollbar-width: thin; }.audit-log-list > div { display: grid; grid-template-columns: 10px 1fr; gap: 8px; padding: 9px 0; border-bottom: 1px solid rgba(255,255,255,.04); }.audit-log-list > div:last-child { border-bottom: 0; }.history-dot { width: 6px; height: 6px; margin-top: 4px; border-radius: 50%; background: #4b525d; }.audit-log p { display: flex; flex-direction: column; gap: 4px; font-size: 10px; }.audit-log small { color: var(--muted); }
.spectator-page { max-width: 1100px; }.spectator-focus { margin: 16px 0; padding: 32px; text-align: center; border: 1px solid #244f70; border-radius: 12px; background: radial-gradient(circle at 50% 0,rgba(32,127,192,.17),transparent 60%),var(--surface); }.spectator-focus h2 { font-size: 27px; margin: 8px 0; }.spectator-focus > p { color: var(--blue); font-size: 11px; }
.ban-display { display: grid; grid-template-columns: repeat(4,1fr); gap: 8px; margin-top: 28px; }.ban-display > div { min-height: 105px; padding: 14px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 7px; border: 1px solid var(--border); border-radius: 7px; background: #0d1014; }.ban-display span,.ban-display small { color: var(--muted); font-size: 9px; }.ban-display strong { font-size: 17px; }.ban-display .pending-ban { border-color: var(--blue); background: var(--blue-soft); color: var(--blue); }
+.hero-ban-panel { padding: 16px; }.hero-ban-panel .section-title { margin-top: 0; }.hero-ban-panel .ban-display { grid-template-columns: repeat(2,1fr); margin-top: 10px; }.hero-ban-panel .ban-display > div { min-height: 72px; padding: 8px; }.hero-ban-panel .ban-display strong { font-size: 11px; }
+.series-ban-history { padding: 16px; }.series-ban-history .section-title { margin-top: 0; }.series-ban-teams { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 9px; }.series-ban-teams > div { padding: 10px; border: 1px solid var(--border); border-radius: 7px; }.series-ban-teams > div.team-alpha { border-color: rgba(255,122,26,.35); }.series-ban-teams > div.team-beta { border-color: rgba(32,147,255,.35); }.series-ban-teams > div > strong { display: block; margin-bottom: 8px; font-size: 10px; }.series-ban-teams > div > div { display: flex; flex-wrap: wrap; gap: 5px; }.series-ban-teams span { padding: 4px 6px; border-radius: 4px; background: #171b22; color: var(--muted); font-size: 8px; }.series-ban-teams small { color: var(--muted); font-size: 8px; }
.spectator-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.bracket-scroll { min-height: 520px; display: flex; gap: 110px; padding: 30px 60px; overflow-x: auto; border: 1px solid var(--border); border-radius: 12px; background: radial-gradient(circle at 50% 50%,#171b22,transparent 55%),#0d1014; }
.bracket-round { min-width: 300px; }.bracket-round > header { margin-bottom: 20px; }.bracket-round > header span { color: var(--orange); font-size: 9px; text-transform: uppercase; letter-spacing: .15em; }.bracket-round > header h2 { margin-top: 4px; font-size: 18px; }
@@ -302,7 +306,7 @@ textarea { min-height: 85px; resize: vertical; }
.topbar { height: 62px; padding: 0 16px; }.page { padding: 28px 14px 92px; }.page-header { align-items: flex-start; flex-direction: column; margin-bottom: 22px; }.page-header h1 { font-size: 34px; }.page-actions { width: 100%; }
.bottom-nav { display: grid !important; position: fixed; z-index: 20; bottom: 0; left: 0; right: 0; grid-template-columns: repeat(4,1fr); padding: 6px 8px max(6px,env(safe-area-inset-bottom)); border-top: 1px solid var(--border); background: rgba(13,16,20,.96); backdrop-filter: blur(16px); }
.bottom-nav a { min-height: 52px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; color: #747c87; font-size: 9px; font-weight: 700; }.bottom-nav a svg { font-size: 18px; }.bottom-nav a.active { color: var(--orange); }
- .featured-event { padding: 23px; grid-template-columns: 1fr; }.featured-event > * { grid-column: 1; }.featured-score { margin: 10px 0; }.featured-event .button { width: 100%; }.featured-event h2 { font-size: 22px; }
+ .featured-event { padding: 23px; grid-template-columns: 1fr; }.featured-event > * { grid-column: 1; }.featured-score { margin: 10px 0; }.featured-match-list { min-width: 0; width: 100%; }.featured-match-score { grid-template-columns: 45px minmax(55px,1fr) auto minmax(55px,1fr); padding: 8px 6px; gap: 5px; }.featured-event .button { width: 100%; }.featured-event h2 { font-size: 22px; }
.event-card { grid-template-columns: 70px 1fr; }.date-block strong { font-size: 28px; }.event-card-body { padding: 17px; }
.detail-layout { gap: 10px; }.event-hero,.roster-preview,.public-roster { padding: 20px; }.rsvp-control { grid-template-columns: 1fr; }.rsvp-control button { justify-content: flex-start; padding-left: 18px; }.public-roster-grid { grid-template-columns: 1fr; }
.faq-grid { grid-template-columns: 1fr; }.faq-card { min-height: 0; }
diff --git a/frontend/src/series-utils.ts b/frontend/src/series-utils.ts
new file mode 100644
index 0000000..cc44ebc
--- /dev/null
+++ b/frontend/src/series-utils.ts
@@ -0,0 +1,6 @@
+import type { Series } from './api/client'
+
+export function activeHeroBanMap(series: Series) {
+ if (series.currentStep.kind !== 'hero_ban' && series.currentStep.kind !== 'result') return undefined
+ return [...series.maps].reverse().find((map) => map.status !== 'completed')
+}
diff --git a/memory_bank/active_context.md b/memory_bank/active_context.md
index ac9ba59..9c9ecac 100644
--- a/memory_bank/active_context.md
+++ b/memory_bank/active_context.md
@@ -4,7 +4,7 @@
Собран полный вертикальный production-пайплайн скрима: закрытие регистрации → versioned балансировка → ручная правка и подтверждение составов 1/2/2 → капитаны → атомарный старт Bo3 или турнира → жеребьёвка → баны карт → баны героев → результаты и завершение. Состояние события, roster draft и серии сохраняется в PostgreSQL с optimistic locking, аудитом и глобальной SSE-синхронизацией без ручного обновления страницы.
-Admin UI содержит пошаговый workflow и roster editor с same-role swap, резервом и запуском. Live, spectator и bracket используют реальные API-команды и realtime refresh; demo fixtures остались только локальным showcase.
+Admin UI содержит пошаговый workflow и roster editor с same-role swap, резервом и запуском. Live, spectator и bracket используют реальные API-команды и realtime refresh; участник турнира автоматически открывает собственный матч, остальные попадают в spectator mode, а актуальная сетка и соседние параллельные матчи доступны прямо над серией. Demo fixtures остались только локальным showcase.
Добавлена роль Moderator: она имеет все операционные права Admin, но управление списком модераторов доступно только Admin. До состояния Live workflow можно откатить на один этап назад.
## Подтверждённые требования
@@ -26,7 +26,7 @@ Admin UI содержит пошаговый workflow и roster editor с same-r
- последовательные баны карт и героев;
- серии Bo3;
- фиксация исхода каждой карты, счёта серии и итогового победителя;
-- турнирная сетка;
+- актуальная турнирная сетка с параллельными матчами, умным live-входом и переключением spectator/control;
- production-развёртывание единым Docker Compose resource в Coolify;
- PostgreSQL в том же Compose-стеке с persistent volume;
- мобильный и десктопный интерфейс.
@@ -48,7 +48,7 @@ Admin UI содержит пошаговый workflow и roster editor с same-r
## Предлагаемый стек
-- backend: Go, PostgreSQL, REST, WebSocket/SSE;
+- backend: Go, PostgreSQL, REST, SSE;
- frontend: React, TypeScript, Vite, TanStack Query/Router, Tailwind CSS, shadcn/ui;
- контракт: OpenAPI;
- локальная инфраструктура: Docker Compose;
diff --git a/memory_bank/architecture.md b/memory_bank/architecture.md
index 651827a..8dc60e2 100644
--- a/memory_bank/architecture.md
+++ b/memory_bank/architecture.md
@@ -88,7 +88,7 @@ Application-слой управляет единым versioned workflow собы
Ручное редактирование работает с серверным roster draft. Backend разрешает обмен только между одинаковыми ролевыми слотами и замену слота игроком из резерва, после чего пересчитывает средние рейтинги и метрики. Подтверждение требует полного состава 1/2/2, уникальных игроков и капитана внутри каждой команды.
-`start-scrim` атомарно блокирует составы, создаёт одну или несколько Bo3-серий и первый draft state. Для двух команд создаётся одиночная серия; для четырёх и более подтверждённых команд создаётся single-elimination турнир. ID активной серии возвращается frontend, который переходит на live-экран.
+`start-scrim` атомарно блокирует составы, создаёт одну или несколько Bo3-серий и первый draft state. Для двух команд создаётся одиночная серия; для четырёх и более подтверждённых команд создаётся single-elimination турнир. Tournament read-model гидратируется свежими версиями всех сохранённых серий, поэтому параллельные матчи одного раунда независимо обновляют счёт и фазу в общей сетке. Умный event-level live-вход направляет участника в его матч, капитана/staff — к доступным командам, а остальных — в spectator mode; над серией остаются компактная сетка и навигация между активными матчами раунда.
### Драфт как конечный автомат
diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml
index 27deec5..5d65ed2 100644
--- a/openapi/openapi.yaml
+++ b/openapi/openapi.yaml
@@ -236,6 +236,16 @@ paths:
responses:
"200": { description: Event cancelled, content: { application/json: { schema: { $ref: "#/components/schemas/Event" } } } }
"409": { $ref: "#/components/responses/Conflict" }
+ /api/events/{eventId}/tournament:
+ get:
+ tags: [Tournaments]
+ operationId: getEventTournament
+ description: Returns the tournament bracket hydrated with the latest state of every persisted series.
+ parameters:
+ - $ref: "#/components/parameters/EventId"
+ responses:
+ "200": { description: Live tournament bracket, content: { application/json: { schema: { $ref: "#/components/schemas/Tournament" } } } }
+ "404": { $ref: "#/components/responses/NotFound" }
/api/events/{eventId}/rsvps:
get:
tags: [Events]
@@ -982,6 +992,7 @@ components:
registrationDeadline:
type: string
format: date-time
+ description: Synchronized by the server to startsAt.
Event:
allOf:
- $ref: "#/components/schemas/EventInput"
@@ -1160,7 +1171,7 @@ components:
type: integer
Series:
type: object
- required: [id, eventId, tournamentId, teamAId, teamBId, winnerTeamId, bestOf, rulesetId, phase, playedMaps, maps, audit, results, version]
+ required: [id, eventId, tournamentId, teamAId, teamBId, winnerTeamId, bestOf, rulesetId, phase, playedMaps, seriesBans, maps, audit, results, version]
properties:
id:
type: string
@@ -1187,7 +1198,30 @@ components:
playedMaps: { type: array, items: { type: string } }
nextMapPickerId: { type: string }
availableMaps: { type: array, items: { type: string } }
- maps: { type: array, items: { type: object } }
+ seriesBans:
+ type: object
+ additionalProperties:
+ type: array
+ items: { type: string }
+ maps:
+ type: array
+ items:
+ type: object
+ required: [number, name, heroBans]
+ properties:
+ number: { type: integer }
+ name: { type: string }
+ heroBans:
+ type: array
+ items:
+ type: object
+ required: [teamId, value, actorAccountId, at]
+ properties:
+ teamId: { type: string }
+ value: { type: string }
+ actorAccountId: { type: string }
+ at: { type: string, format: date-time }
+ result: { type: [object, "null"] }
audit: { type: array, items: { type: object } }
results:
type: array