diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index 5798897..645538f 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -31,6 +31,9 @@ describe('Mixmaker frontend', () => {
window.history.replaceState({}, '', '/events/event-3')
render()
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.getByText('Registration has been closed by the organizer.')).toBeInTheDocument()
})
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 22d7026..6a4f811 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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 {
CalendarDays, Check, ChevronLeft, ChevronRight, CircleHelp, Clock3, Gamepad2, ListFilter, LogOut,
Menu, Radio, RefreshCw, Search, Shield, Sparkles, Swords, Trophy, UserRound,
@@ -21,6 +21,7 @@ import { LanguageSelector } from './i18n'
import { useLanguage } from './i18n-context'
import { rankLabel, rankOptions, toRankOrdinal, type RankOrdinal } from './ranks'
import { activeHeroBanMap } from './series-utils'
+import { playTurnSound, primeTurnSound, shouldNotifyTurn, type TurnSnapshot } from './turn-notification'
import { BracketEditor } from './components/bracket-editor/BracketEditor'
type RouterContext = { session: Session | null }
@@ -74,6 +75,14 @@ function AppShell() {
const [realtimeStatus, setRealtimeStatus] = useState<'connected' | 'reconnecting'>('reconnecting')
const client = useQueryClient()
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) => {
const [scope, id] = message.topic.split(':', 2)
if (scope === 'event' && id) {
@@ -190,6 +199,7 @@ function EventPage() {
const demoEvent = demoEvents.find((event) => event.id === eventId)
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 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 rsvpMutation = useMutation({
mutationFn: (status: RsvpStatus) => api.setRsvp(eventId, session.data!.player.id, status),
@@ -211,7 +221,7 @@ function EventPage() {
return
{event.status !== 'cancelled' && event.workflowState === 'Live' && Open live}{event.workflowState === 'Completed' && event.activeSeriesId && !event.tournamentId && Match history}{event.status !== 'cancelled' && event.tournamentId && Open bracket}{event.workflowState.replace(/([a-z])([A-Z])/g, '$1 $2')}>} />
Starts{new Date(event.startsAt).toLocaleString(undefined, { weekday: 'long', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
Registration closes{new Date(event.registrationDeadline).toLocaleString(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit' })}
Are you playing?
{event.workflowState === 'RegistrationOpen' ? 'Your latest response is sent to the organizer.' : 'Registration has been closed by the organizer.'}
{(Object.keys(labels) as RsvpStatus[]).map((status) => )}
{rsvpMutation.isSuccess && Response saved · Server confirmed just now
}{rsvpMutation.isError && {rsvpMutation.error.message}
}
-
Registered players
Everyone can see who responded and their current status.
{attendees.length === 0 ? No responses yet.
: {attendees.map((registration) =>
{registration.player.displayName.slice(0, 2).toUpperCase()}{registration.player.displayName}{labels[registration.status]}
)}
}
+ {teams.data && teams.data.length > 0 && Balanced teams
Confirmed match rosters are always available here.
{teams.data.map((team) =>
)}
}Registered players
Everyone can see who responded and their current status.
{attendees.length === 0 ? No responses yet.
: {attendees.map((registration) =>
{registration.player.displayName.slice(0, 2).toUpperCase()}{registration.player.displayName}{labels[registration.status]}
)}
}
}
function LiveEntryPage() {
@@ -705,6 +715,10 @@ function TeamCard({ team, eventId, allowCaptainAssignment = true }: { team: Team
return {team.members.map(({ player, assignedRole }) =>
{assignedRole === 'tank' ? 'T' : assignedRole === 'damage' ? 'D' : 'S'}{player.displayName}{rankLabel(player.ratings[assignedRole], language)}
)}
{allowCaptainAssignment && }
}
+function PublicTeamCard({ team }: { team: Team }) {
+ return {team.members.map(({ player, assignedRole }) =>
{assignedRole === 'tank' ? 'T' : assignedRole === 'damage' ? 'D' : 'S'}{player.displayName}{player.id === team.captainId && Captain}
)}
+}
+
function LiveSeriesPage() {
const { seriesId } = liveRoute.useParams()
const client = useQueryClient()
@@ -714,7 +728,19 @@ function LiveSeriesPage() {
const [outcome, setOutcome] = useState('TeamAWin')
const [heroRoleFilter, setHeroRoleFilter] = useState<'all' | PlayerRole>('all')
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 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(() => {
if (series.data && session.data && !participant && !isStaff(session.data.account.role)) {
void router.navigate({ to: '/watch/$seriesId', params: { seriesId }, replace: true })
diff --git a/frontend/src/i18n.tsx b/frontend/src/i18n.tsx
index b20e66c..f15e768 100644
--- a/frontend/src/i18n.tsx
+++ b/frontend/src/i18n.tsx
@@ -74,6 +74,8 @@ const translations: Record = {
'RSVP for': 'Статус участия для',
'Response saved · Server confirmed just now': 'Ответ сохранён · Сервер подтвердил только что',
'Attendance': 'Участие',
+ 'Balanced teams': 'Сбалансированные команды',
+ 'Confirmed match rosters are always available here.': 'Здесь всегда можно посмотреть подтверждённые составы на матчи.',
'Registered players': 'Зарегистрированные игроки',
'Everyone can see who responded and their current status.': 'Все могут видеть, кто ответил и какой статус выбрал.',
'No responses yet.': 'Ответов пока нет.',
diff --git a/frontend/src/index.css b/frontend/src/index.css
index ab671f7..bf4d2dd 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -135,6 +135,8 @@ main { min-height: calc(100vh - 72px); }
.attendance-stats strong { margin-left: auto; color: #fff; }
.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); }
+.event-teams { margin-top: 22px; }
+.event-teams > .section-title { margin: 0 0 12px; }
.public-roster { margin-top: 18px; padding: 24px 28px; }
.public-roster .section-title { margin-top: 0; }
.public-roster-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 8px; }
diff --git a/frontend/src/turn-notification.test.ts b/frontend/src/turn-notification.test.ts
new file mode 100644
index 0000000..8a7d128
--- /dev/null
+++ b/frontend/src/turn-notification.test.ts
@@ -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)
+ })
+})
diff --git a/frontend/src/turn-notification.ts b/frontend/src/turn-notification.ts
new file mode 100644
index 0000000..20d7538
--- /dev/null
+++ b/frontend/src/turn-notification.ts
@@ -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,
+ )
+}
diff --git a/memory_bank/active_context.md b/memory_bank/active_context.md
index 038a725..0490683 100644
--- a/memory_bank/active_context.md
+++ b/memory_bank/active_context.md
@@ -5,6 +5,7 @@
Собран полный вертикальный 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.
+На странице события после выбора баланса публично показываются сохранённые составы команд. В live-control участник получает короткий звуковой сигнал, когда после действия соперника сервер передаёт ход его команде; первая загрузка страницы звук не запускает.
Добавлена роль Moderator: она имеет все операционные права Admin, но управление списком модераторов доступно только Admin. До состояния Live workflow можно откатить на один этап назад.
Добавлен 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 игроки сохраняются как предупреждения и не блокируют микс.
diff --git a/memory_bank/roadmap.md b/memory_bank/roadmap.md
index 96f866b..4bc90fb 100644
--- a/memory_bank/roadmap.md
+++ b/memory_bank/roadmap.md
@@ -14,6 +14,8 @@
- [x] блокировать обычное изменение составов после старта;
- [x] разрешить аудируемую аварийную замену Admin;
- [x] синхронизировать admin/live/spectator экраны через SSE;
+- [x] показывать сохранённые составы на публичной странице события после балансировки;
+- [x] уведомлять участника звуком, когда сервер передаёт ход его команде;
- [x] покрыть переходы и конфликты версий unit-, integration- и UI-тестами.
## Этап 0 — уточнение продукта