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 {team.side === 'alpha' ? 'A' : 'B'}

{team.name}

{rankLabel(team.averageRating, language)} avg
{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 {team.side === 'alpha' ? 'A' : 'B'}

{team.name}

{team.members.length}/5
{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 — уточнение продукта