Add turn alerts and public team rosters
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-19 20:46:23 +03:00
parent a311b7761d
commit 5e0825f43f
8 changed files with 125 additions and 2 deletions

View File

@@ -31,6 +31,9 @@ describe('Mixmaker frontend', () => {
window.history.replaceState({}, '', '/events/event-3') window.history.replaceState({}, '', '/events/event-3')
render(<App />) render(<App />)
expect(await screen.findByRole('link', { name: /^open live$/i })).toHaveAttribute('href', '/events/event-3/live') expect(await screen.findByRole('link', { name: /^open live$/i })).toHaveAttribute('href', '/events/event-3/live')
expect(screen.getByRole('heading', { name: 'Balanced teams' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Ember Wolves' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Azure Phantoms' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Going' })).toBeDisabled() expect(screen.getByRole('button', { name: 'Going' })).toBeDisabled()
expect(screen.getByText('Registration has been closed by the organizer.')).toBeInTheDocument() expect(screen.getByText('Registration has been closed by the organizer.')).toBeInTheDocument()
}) })

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState, type DragEvent, type FormEvent, type ReactNode } from 'react' import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type FormEvent, type ReactNode } from 'react'
import { import {
CalendarDays, Check, ChevronLeft, ChevronRight, CircleHelp, Clock3, Gamepad2, ListFilter, LogOut, CalendarDays, Check, ChevronLeft, ChevronRight, CircleHelp, Clock3, Gamepad2, ListFilter, LogOut,
Menu, Radio, RefreshCw, Search, Shield, Sparkles, Swords, Trophy, UserRound, Menu, Radio, RefreshCw, Search, Shield, Sparkles, Swords, Trophy, UserRound,
@@ -21,6 +21,7 @@ import { LanguageSelector } from './i18n'
import { useLanguage } from './i18n-context' import { useLanguage } from './i18n-context'
import { rankLabel, rankOptions, toRankOrdinal, type RankOrdinal } from './ranks' import { rankLabel, rankOptions, toRankOrdinal, type RankOrdinal } from './ranks'
import { activeHeroBanMap } from './series-utils' import { activeHeroBanMap } from './series-utils'
import { playTurnSound, primeTurnSound, shouldNotifyTurn, type TurnSnapshot } from './turn-notification'
import { BracketEditor } from './components/bracket-editor/BracketEditor' import { BracketEditor } from './components/bracket-editor/BracketEditor'
type RouterContext = { session: Session | null } type RouterContext = { session: Session | null }
@@ -74,6 +75,14 @@ function AppShell() {
const [realtimeStatus, setRealtimeStatus] = useState<'connected' | 'reconnecting'>('reconnecting') const [realtimeStatus, setRealtimeStatus] = useState<'connected' | 'reconnecting'>('reconnecting')
const client = useQueryClient() const client = useQueryClient()
const session = useQuery({ queryKey: ['session'], queryFn: api.session, staleTime: Infinity }) const session = useQuery({ queryKey: ['session'], queryFn: api.session, staleTime: Infinity })
useEffect(() => {
window.addEventListener('pointerdown', primeTurnSound, { once: true })
window.addEventListener('keydown', primeTurnSound, { once: true })
return () => {
window.removeEventListener('pointerdown', primeTurnSound)
window.removeEventListener('keydown', primeTurnSound)
}
}, [])
useEffect(() => subscribeToEvents('*', (message) => { useEffect(() => subscribeToEvents('*', (message) => {
const [scope, id] = message.topic.split(':', 2) const [scope, id] = message.topic.split(':', 2)
if (scope === 'event' && id) { if (scope === 'event' && id) {
@@ -190,6 +199,7 @@ function EventPage() {
const demoEvent = demoEvents.find((event) => event.id === eventId) const demoEvent = demoEvents.find((event) => event.id === eventId)
const eventQuery = useQuery({ queryKey: ['event', eventId], queryFn: () => api.event(eventId), initialData: demoMode ? demoEvent : undefined }) const eventQuery = useQuery({ queryKey: ['event', eventId], queryFn: () => api.event(eventId), initialData: demoMode ? demoEvent : undefined })
const registrations = useQuery({ queryKey: ['registrations', eventId], queryFn: () => api.registrations(eventId), initialData: demoMode ? demoRegistrations : undefined }) const registrations = useQuery({ queryKey: ['registrations', eventId], queryFn: () => api.registrations(eventId), initialData: demoMode ? demoRegistrations : undefined })
const teams = useQuery({ queryKey: ['teams', eventId], queryFn: () => api.teams(eventId), initialData: demoMode && demoEvent && ['RostersDraft', 'RostersConfirmed', 'BracketDraft', 'Live', 'Completed'].includes(demoEvent.workflowState) ? demoBalanceCandidates[0].teams : undefined })
const session = useQuery({ queryKey: ['session'], queryFn: api.session, initialData: demoMode ? demoSession : undefined }) const session = useQuery({ queryKey: ['session'], queryFn: api.session, initialData: demoMode ? demoSession : undefined })
const rsvpMutation = useMutation({ const rsvpMutation = useMutation({
mutationFn: (status: RsvpStatus) => api.setRsvp(eventId, session.data!.player.id, status), mutationFn: (status: RsvpStatus) => api.setRsvp(eventId, session.data!.player.id, status),
@@ -211,7 +221,7 @@ function EventPage() {
return <div className={`page event-detail status-${event.status}`}><PageHeader eyebrow="Event details" title={event.title} description={event.description} actions={<>{event.status !== 'cancelled' && event.workflowState === 'Live' && <Link className="button primary" to="/events/$eventId/live" params={{ eventId }} search={{ seriesId: undefined }}>Open live</Link>}{event.workflowState === 'Completed' && event.activeSeriesId && !event.tournamentId && <Link className="button primary" to="/watch/$seriesId" params={{ seriesId: event.activeSeriesId }}>Match history</Link>}{event.status !== 'cancelled' && event.tournamentId && <Link className="button secondary" to="/bracket/$eventId" params={{ eventId }}>Open bracket</Link>}<Badge tone={event.workflowState === 'Cancelled' ? 'danger' : event.workflowState === 'Live' ? 'live' : event.workflowState === 'Completed' || event.workflowState === 'RegistrationOpen' ? 'success' : 'neutral'}>{event.workflowState.replace(/([a-z])([A-Z])/g, '$1 $2')}</Badge></>} /><div className="detail-layout"> return <div className={`page event-detail status-${event.status}`}><PageHeader eyebrow="Event details" title={event.title} description={event.description} actions={<>{event.status !== 'cancelled' && event.workflowState === 'Live' && <Link className="button primary" to="/events/$eventId/live" params={{ eventId }} search={{ seriesId: undefined }}>Open live</Link>}{event.workflowState === 'Completed' && event.activeSeriesId && !event.tournamentId && <Link className="button primary" to="/watch/$seriesId" params={{ seriesId: event.activeSeriesId }}>Match history</Link>}{event.status !== 'cancelled' && event.tournamentId && <Link className="button secondary" to="/bracket/$eventId" params={{ eventId }}>Open bracket</Link>}<Badge tone={event.workflowState === 'Cancelled' ? 'danger' : event.workflowState === 'Live' ? 'live' : event.workflowState === 'Completed' || event.workflowState === 'RegistrationOpen' ? 'success' : 'neutral'}>{event.workflowState.replace(/([a-z])([A-Z])/g, '$1 $2')}</Badge></>} /><div className="detail-layout">
<section className="card event-hero"><div className="event-time"><CalendarDays /><div><span>Starts</span><strong>{new Date(event.startsAt).toLocaleString(undefined, { weekday: 'long', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</strong></div></div><div className="event-time"><Clock3 /><div><span>Registration closes</span><strong>{new Date(event.registrationDeadline).toLocaleString(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit' })}</strong></div></div><div className="divider" /><h2>Are you playing?</h2><p>{event.workflowState === 'RegistrationOpen' ? 'Your latest response is sent to the organizer.' : 'Registration has been closed by the organizer.'}</p><div className="rsvp-control" role="group" aria-label="RSVP status">{(Object.keys(labels) as RsvpStatus[]).map((status) => <button key={status} disabled={rsvpMutation.isPending || event.workflowState !== 'RegistrationOpen'} className={rsvp === status ? `active ${status}` : ''} aria-pressed={rsvp === status} onClick={() => rsvpMutation.mutate(status)}>{status === 'going' ? <Check /> : status === 'maybe' ? <CircleHelp /> : <X />}{labels[status]}</button>)}</div>{rsvpMutation.isSuccess && <div className="save-note"><Check />Response saved · Server confirmed just now</div>}{rsvpMutation.isError && <p className="error-note">{rsvpMutation.error.message}</p>}</section> <section className="card event-hero"><div className="event-time"><CalendarDays /><div><span>Starts</span><strong>{new Date(event.startsAt).toLocaleString(undefined, { weekday: 'long', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</strong></div></div><div className="event-time"><Clock3 /><div><span>Registration closes</span><strong>{new Date(event.registrationDeadline).toLocaleString(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit' })}</strong></div></div><div className="divider" /><h2>Are you playing?</h2><p>{event.workflowState === 'RegistrationOpen' ? 'Your latest response is sent to the organizer.' : 'Registration has been closed by the organizer.'}</p><div className="rsvp-control" role="group" aria-label="RSVP status">{(Object.keys(labels) as RsvpStatus[]).map((status) => <button key={status} disabled={rsvpMutation.isPending || event.workflowState !== 'RegistrationOpen'} className={rsvp === status ? `active ${status}` : ''} aria-pressed={rsvp === status} onClick={() => rsvpMutation.mutate(status)}>{status === 'going' ? <Check /> : status === 'maybe' ? <CircleHelp /> : <X />}{labels[status]}</button>)}</div>{rsvpMutation.isSuccess && <div className="save-note"><Check />Response saved · Server confirmed just now</div>}{rsvpMutation.isError && <p className="error-note">{rsvpMutation.error.message}</p>}</section>
<aside className="card roster-preview"><div className="section-title"><div><h2>Attendance</h2><p>{attendanceTotal} responses</p></div><UsersRound /></div><div className="attendance-bar">{attendanceTotal > 0 && <>{counts.going > 0 && <span className="going" style={{ flexGrow: counts.going, width: 0 }} />}{counts.maybe > 0 && <span className="maybe" style={{ flexGrow: counts.maybe, width: 0 }} />}{counts.not_going > 0 && <span className="not-going" style={{ flexGrow: counts.not_going, width: 0 }} />}</>}</div><div className="attendance-stats"><span><i className="green" />Going <strong>{counts.going}</strong></span><span><i className="yellow" />Maybe <strong>{counts.maybe}</strong></span><span><i />No <strong>{counts.not_going}</strong></span></div>{isStaff(session.data.account.role) && <Link className="button secondary full" to="/admin">Manage participants <ChevronRight /></Link>}</aside> <aside className="card roster-preview"><div className="section-title"><div><h2>Attendance</h2><p>{attendanceTotal} responses</p></div><UsersRound /></div><div className="attendance-bar">{attendanceTotal > 0 && <>{counts.going > 0 && <span className="going" style={{ flexGrow: counts.going, width: 0 }} />}{counts.maybe > 0 && <span className="maybe" style={{ flexGrow: counts.maybe, width: 0 }} />}{counts.not_going > 0 && <span className="not-going" style={{ flexGrow: counts.not_going, width: 0 }} />}</>}</div><div className="attendance-stats"><span><i className="green" />Going <strong>{counts.going}</strong></span><span><i className="yellow" />Maybe <strong>{counts.maybe}</strong></span><span><i />No <strong>{counts.not_going}</strong></span></div>{isStaff(session.data.account.role) && <Link className="button secondary full" to="/admin">Manage participants <ChevronRight /></Link>}</aside>
</div><section className="card public-roster"><div className="section-title"><div><h2>Registered players</h2><p>Everyone can see who responded and their current status.</p></div><UsersRound /></div>{attendees.length === 0 ? <p className="empty-note">No responses yet.</p> : <div className="public-roster-grid">{attendees.map((registration) => <div className="public-roster-player" key={registration.id}><span className="mini-avatar">{registration.player.displayName.slice(0, 2).toUpperCase()}</span><strong>{registration.player.displayName}</strong><Badge tone={registration.status === 'going' ? 'success' : registration.status === 'maybe' ? 'warning' : 'neutral'}>{labels[registration.status]}</Badge></div>)}</div>}</section></div> </div>{teams.data && teams.data.length > 0 && <section className="event-teams"><div className="section-title"><div><h2>Balanced teams</h2><p>Confirmed match rosters are always available here.</p></div><Swords /></div><div className="teams-grid">{teams.data.map((team) => <PublicTeamCard key={team.id} team={team} />)}</div></section>}<section className="card public-roster"><div className="section-title"><div><h2>Registered players</h2><p>Everyone can see who responded and their current status.</p></div><UsersRound /></div>{attendees.length === 0 ? <p className="empty-note">No responses yet.</p> : <div className="public-roster-grid">{attendees.map((registration) => <div className="public-roster-player" key={registration.id}><span className="mini-avatar">{registration.player.displayName.slice(0, 2).toUpperCase()}</span><strong>{registration.player.displayName}</strong><Badge tone={registration.status === 'going' ? 'success' : registration.status === 'maybe' ? 'warning' : 'neutral'}>{labels[registration.status]}</Badge></div>)}</div>}</section></div>
} }
function LiveEntryPage() { function LiveEntryPage() {
@@ -705,6 +715,10 @@ function TeamCard({ team, eventId, allowCaptainAssignment = true }: { team: Team
return <article className={`card team-card team-${team.side}`}><header><div><span>Team {team.side === 'alpha' ? 'A' : 'B'}</span><h3>{team.name}</h3></div><strong className="team-rank">{rankLabel(team.averageRating, language)}<small> avg</small></strong></header><div className="team-members">{team.members.map(({ player, assignedRole }) => <div key={player.id}><span className={`role-chip ${assignedRole}`}>{assignedRole === 'tank' ? 'T' : assignedRole === 'damage' ? 'D' : 'S'}</span><strong>{player.displayName}</strong><span>{rankLabel(player.ratings[assignedRole], language)}</span></div>)}</div>{allowCaptainAssignment && <label className="captain-select"><span><Trophy />Team captain</span><select value={captain} disabled={assign.isPending} onChange={(e) => { setCaptain(e.target.value); assign.mutate(e.target.value) }}><option value="">Select from roster</option>{team.members.map(({ player }) => <option value={player.id} key={player.id}>{player.displayName}</option>)}</select>{assign.isError && <small className="error-note">{assign.error.message}</small>}</label>}</article> return <article className={`card team-card team-${team.side}`}><header><div><span>Team {team.side === 'alpha' ? 'A' : 'B'}</span><h3>{team.name}</h3></div><strong className="team-rank">{rankLabel(team.averageRating, language)}<small> avg</small></strong></header><div className="team-members">{team.members.map(({ player, assignedRole }) => <div key={player.id}><span className={`role-chip ${assignedRole}`}>{assignedRole === 'tank' ? 'T' : assignedRole === 'damage' ? 'D' : 'S'}</span><strong>{player.displayName}</strong><span>{rankLabel(player.ratings[assignedRole], language)}</span></div>)}</div>{allowCaptainAssignment && <label className="captain-select"><span><Trophy />Team captain</span><select value={captain} disabled={assign.isPending} onChange={(e) => { setCaptain(e.target.value); assign.mutate(e.target.value) }}><option value="">Select from roster</option>{team.members.map(({ player }) => <option value={player.id} key={player.id}>{player.displayName}</option>)}</select>{assign.isError && <small className="error-note">{assign.error.message}</small>}</label>}</article>
} }
function PublicTeamCard({ team }: { team: Team }) {
return <article className={`card team-card team-${team.side}`}><header><div><span>Team {team.side === 'alpha' ? 'A' : 'B'}</span><h3>{team.name}</h3></div><Badge tone={team.side === 'alpha' ? 'warning' : 'blue'}>{team.members.length}/5</Badge></header><div className="team-members">{team.members.map(({ player, assignedRole }) => <div key={player.id}><span className={`role-chip ${assignedRole}`}>{assignedRole === 'tank' ? 'T' : assignedRole === 'damage' ? 'D' : 'S'}</span><strong>{player.displayName}</strong>{player.id === team.captainId && <Badge tone="neutral">Captain</Badge>}</div>)}</div></article>
}
function LiveSeriesPage() { function LiveSeriesPage() {
const { seriesId } = liveRoute.useParams() const { seriesId } = liveRoute.useParams()
const client = useQueryClient() const client = useQueryClient()
@@ -714,7 +728,19 @@ function LiveSeriesPage() {
const [outcome, setOutcome] = useState<MapOutcome>('TeamAWin') const [outcome, setOutcome] = useState<MapOutcome>('TeamAWin')
const [heroRoleFilter, setHeroRoleFilter] = useState<'all' | PlayerRole>('all') const [heroRoleFilter, setHeroRoleFilter] = useState<'all' | PlayerRole>('all')
const [heroSearch, setHeroSearch] = useState('') const [heroSearch, setHeroSearch] = useState('')
const previousTurn = useRef<(TurnSnapshot & { seriesId: string }) | undefined>(undefined)
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))) 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)))
const playerSide = series.data && session.data
? series.data.teamAlpha.members.some(({ player }) => player.id === session.data?.player.id) ? 'alpha'
: series.data.teamBeta.members.some(({ player }) => player.id === session.data?.player.id) ? 'beta' : undefined
: undefined
useEffect(() => {
if (!series.data) return
const current = { seriesId, activeTeam: series.data.currentStep.activeTeam, version: series.data.version }
const previous = previousTurn.current?.seriesId === seriesId ? previousTurn.current : undefined
if (shouldNotifyTurn(previous, current, playerSide)) playTurnSound()
previousTurn.current = current
}, [playerSide, series.data, seriesId])
useEffect(() => { useEffect(() => {
if (series.data && session.data && !participant && !isStaff(session.data.account.role)) { if (series.data && session.data && !participant && !isStaff(session.data.account.role)) {
void router.navigate({ to: '/watch/$seriesId', params: { seriesId }, replace: true }) void router.navigate({ to: '/watch/$seriesId', params: { seriesId }, replace: true })

View File

@@ -74,6 +74,8 @@ const translations: Record<string, string> = {
'RSVP for': 'Статус участия для', 'RSVP for': 'Статус участия для',
'Response saved · Server confirmed just now': 'Ответ сохранён · Сервер подтвердил только что', 'Response saved · Server confirmed just now': 'Ответ сохранён · Сервер подтвердил только что',
'Attendance': 'Участие', 'Attendance': 'Участие',
'Balanced teams': 'Сбалансированные команды',
'Confirmed match rosters are always available here.': 'Здесь всегда можно посмотреть подтверждённые составы на матчи.',
'Registered players': 'Зарегистрированные игроки', 'Registered players': 'Зарегистрированные игроки',
'Everyone can see who responded and their current status.': 'Все могут видеть, кто ответил и какой статус выбрал.', 'Everyone can see who responded and their current status.': 'Все могут видеть, кто ответил и какой статус выбрал.',
'No responses yet.': 'Ответов пока нет.', 'No responses yet.': 'Ответов пока нет.',

View File

@@ -135,6 +135,8 @@ main { min-height: calc(100vh - 72px); }
.attendance-stats strong { margin-left: auto; color: #fff; } .attendance-stats strong { margin-left: auto; color: #fff; }
.attendance-stats i { width: 6px; height: 6px; border-radius: 50%; background: #59616b; } .attendance-stats i { width: 6px; height: 6px; border-radius: 50%; background: #59616b; }
.attendance-stats i.green { background: var(--green); }.attendance-stats i.yellow { background: var(--yellow); } .attendance-stats i.green { background: var(--green); }.attendance-stats i.yellow { background: var(--yellow); }
.event-teams { margin-top: 22px; }
.event-teams > .section-title { margin: 0 0 12px; }
.public-roster { margin-top: 18px; padding: 24px 28px; } .public-roster { margin-top: 18px; padding: 24px 28px; }
.public-roster .section-title { margin-top: 0; } .public-roster .section-title { margin-top: 0; }
.public-roster-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 8px; } .public-roster-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 8px; }

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { shouldNotifyTurn } from './turn-notification'
describe('turn notifications', () => {
it('notifies when the opponent action hands control to the player team', () => {
expect(shouldNotifyTurn(
{ activeTeam: 'beta', version: 7 },
{ activeTeam: 'alpha', version: 8 },
'alpha',
)).toBe(true)
})
it('does not notify on initial load or unrelated updates', () => {
expect(shouldNotifyTurn(undefined, { activeTeam: 'alpha', version: 8 }, 'alpha')).toBe(false)
expect(shouldNotifyTurn(
{ activeTeam: 'alpha', version: 7 },
{ activeTeam: 'alpha', version: 8 },
'alpha',
)).toBe(false)
expect(shouldNotifyTurn(
{ activeTeam: 'beta', version: 7 },
{ activeTeam: 'alpha', version: 8 },
'beta',
)).toBe(false)
})
})

View File

@@ -0,0 +1,61 @@
import type { TeamSide } from './api/client'
export interface TurnSnapshot {
activeTeam?: TeamSide
version: number
}
let audioContext: AudioContext | undefined
function getAudioContext() {
if (audioContext) return audioContext
const AudioContextConstructor = window.AudioContext
if (!AudioContextConstructor) return undefined
audioContext = new AudioContextConstructor()
return audioContext
}
export function primeTurnSound() {
const context = getAudioContext()
if (context?.state === 'suspended') void context.resume()
}
export function playTurnSound() {
const context = getAudioContext()
if (!context) return
const play = () => {
const start = context.currentTime
const gain = context.createGain()
gain.gain.setValueAtTime(0.0001, start)
gain.gain.exponentialRampToValueAtTime(0.16, start + 0.02)
gain.gain.exponentialRampToValueAtTime(0.0001, start + 0.42)
gain.connect(context.destination)
;[660, 880].forEach((frequency, index) => {
const oscillator = context.createOscillator()
const noteStart = start + index * 0.16
oscillator.type = 'sine'
oscillator.frequency.setValueAtTime(frequency, noteStart)
oscillator.connect(gain)
oscillator.start(noteStart)
oscillator.stop(noteStart + 0.2)
})
}
if (context.state === 'suspended') {
void context.resume().then(play).catch(() => undefined)
} else {
play()
}
}
export function shouldNotifyTurn(previous: TurnSnapshot | undefined, current: TurnSnapshot, playerSide?: TeamSide) {
return Boolean(
previous
&& playerSide
&& previous.version !== current.version
&& previous.activeTeam !== playerSide
&& current.activeTeam === playerSide,
)
}

View File

@@ -5,6 +5,7 @@
Собран полный вертикальный production-пайплайн скрима: закрытие регистрации → versioned балансировка → ручная правка и подтверждение составов 1/2/2 → капитаны → drag-and-drop редактор произвольного графа матчей → атомарный старт готовых Bo3 → жеребьёвка → баны карт → баны героев → результаты и динамическое разрешение Winner/Loser-переходов. Состояние события, roster draft, bracket draft и серии сохраняется в PostgreSQL с optimistic locking, аудитом и глобальной SSE-синхронизацией. Собран полный вертикальный production-пайплайн скрима: закрытие регистрации → versioned балансировка → ручная правка и подтверждение составов 1/2/2 → капитаны → drag-and-drop редактор произвольного графа матчей → атомарный старт готовых Bo3 → жеребьёвка → баны карт → баны героев → результаты и динамическое разрешение Winner/Loser-переходов. Состояние события, roster draft, bracket draft и серии сохраняется в PostgreSQL с optimistic locking, аудитом и глобальной SSE-синхронизацией.
Admin UI содержит пошаговый workflow и roster editor с same-role swap, резервом и запуском. Live, spectator и bracket используют реальные API-команды и realtime refresh; участник турнира автоматически открывает собственный матч, остальные попадают в spectator mode, а актуальная сетка и соседние параллельные матчи доступны прямо над серией. Demo fixtures остались только локальным showcase. Admin UI содержит пошаговый workflow и roster editor с same-role swap, резервом и запуском. Live, spectator и bracket используют реальные API-команды и realtime refresh; участник турнира автоматически открывает собственный матч, остальные попадают в spectator mode, а актуальная сетка и соседние параллельные матчи доступны прямо над серией. Demo fixtures остались только локальным showcase.
На странице события после выбора баланса публично показываются сохранённые составы команд. В live-control участник получает короткий звуковой сигнал, когда после действия соперника сервер передаёт ход его команде; первая загрузка страницы звук не запускает.
Добавлена роль Moderator: она имеет все операционные права Admin, но управление списком модераторов доступно только Admin. До состояния Live workflow можно откатить на один этап назад. Добавлена роль Moderator: она имеет все операционные права Admin, но управление списком модераторов доступно только Admin. До состояния Live workflow можно откатить на один этап назад.
Добавлен Discord-анонс нового микса: после успешного создания Event backend через Bot REST API публикует локализованный embed и кнопку регистрации. При создании staff может оставить включённой галочку уведомления `@everyone` или отправить тихий анонс без пинга. Ошибка Discord логируется, но не отменяет создание события; без bot token и channel ID интеграция отключена. Добавлен Discord-анонс нового микса: после успешного создания Event backend через Bot REST API публикует локализованный embed и кнопку регистрации. При создании staff может оставить включённой галочку уведомления `@everyone` или отправить тихий анонс без пинга. Ошибка Discord логируется, но не отменяет создание события; без bot token и channel ID интеграция отключена.
Добавлен PostgreSQL-backed Discord role worker. После подтверждения составов он создаёт и выдаёт роли по актуальным Team.Name, Captain и slot Tank/Damage/Support, учитывает live-переименование и аварийную замену, а при завершении/отмене/удалении/откате удаляет временные роли. Повтор jobs идемпотентен; guest и отсутствующие в guild игроки сохраняются как предупреждения и не блокируют микс. Добавлен PostgreSQL-backed Discord role worker. После подтверждения составов он создаёт и выдаёт роли по актуальным Team.Name, Captain и slot Tank/Damage/Support, учитывает live-переименование и аварийную замену, а при завершении/отмене/удалении/откате удаляет временные роли. Повтор jobs идемпотентен; guest и отсутствующие в guild игроки сохраняются как предупреждения и не блокируют микс.

View File

@@ -14,6 +14,8 @@
- [x] блокировать обычное изменение составов после старта; - [x] блокировать обычное изменение составов после старта;
- [x] разрешить аудируемую аварийную замену Admin; - [x] разрешить аудируемую аварийную замену Admin;
- [x] синхронизировать admin/live/spectator экраны через SSE; - [x] синхронизировать admin/live/spectator экраны через SSE;
- [x] показывать сохранённые составы на публичной странице события после балансировки;
- [x] уведомлять участника звуком, когда сервер передаёт ход его команде;
- [x] покрыть переходы и конфликты версий unit-, integration- и UI-тестами. - [x] покрыть переходы и конфликты версий unit-, integration- и UI-тестами.
## Этап 0 — уточнение продукта ## Этап 0 — уточнение продукта