diff --git a/backend/internal/adapter/httpapi/workflow_handlers.go b/backend/internal/adapter/httpapi/workflow_handlers.go index ddf3bf2..647d57d 100644 --- a/backend/internal/adapter/httpapi/workflow_handlers.go +++ b/backend/internal/adapter/httpapi/workflow_handlers.go @@ -207,7 +207,8 @@ func (s *Server) tossSeriesCoin(w http.ResponseWriter, r *http.Request) { writeError(w, err) return } - out, err := s.service.TossSeriesCoin(r.Context(), who(r).account, series.ID, in.Seed, in.ExpectedVersion) + id := who(r) + out, err := s.service.TossSeriesCoin(r.Context(), id.account, id.player, series.ID, in.Seed, in.ActingTeamID, in.ExpectedVersion) respond(w, out, err, http.StatusOK) } @@ -228,7 +229,8 @@ func (s *Server) banSeriesMap(w http.ResponseWriter, r *http.Request) { writeError(w, err) return } - out, err := s.service.BanSeriesMap(r.Context(), who(r).account, series.ID, in.TeamID, in.Map, in.ExpectedVersion) + id := who(r) + out, err := s.service.BanSeriesMap(r.Context(), id.account, id.player, series.ID, in.TeamID, in.Map, in.ExpectedVersion) respond(w, out, err, http.StatusOK) } @@ -249,7 +251,8 @@ func (s *Server) pickSeriesMap(w http.ResponseWriter, r *http.Request) { writeError(w, err) return } - out, err := s.service.PickSeriesMap(r.Context(), who(r).account, series.ID, in.TeamID, in.Map, in.ExpectedVersion) + id := who(r) + out, err := s.service.PickSeriesMap(r.Context(), id.account, id.player, series.ID, in.TeamID, in.Map, in.ExpectedVersion) respond(w, out, err, http.StatusOK) } @@ -270,7 +273,8 @@ func (s *Server) banSeriesHero(w http.ResponseWriter, r *http.Request) { writeError(w, err) return } - out, err := s.service.BanSeriesHero(r.Context(), who(r).account, series.ID, in.TeamID, in.Hero, in.ExpectedVersion) + id := who(r) + out, err := s.service.BanSeriesHero(r.Context(), id.account, id.player, series.ID, in.TeamID, in.Hero, in.ExpectedVersion) respond(w, out, err, http.StatusOK) } @@ -291,6 +295,7 @@ func (s *Server) recordSeriesResult(w http.ResponseWriter, r *http.Request) { writeError(w, err) return } - out, err := s.service.RecordSeriesResult(r.Context(), who(r).account, series.ID, in.Outcome, in.ExpectedVersion) + id := who(r) + out, err := s.service.RecordSeriesResult(r.Context(), id.account, id.player, series.ID, in.ActingTeamID, in.Outcome, in.ExpectedVersion) respond(w, out, err, http.StatusOK) } diff --git a/backend/internal/adapter/postgres/store.go b/backend/internal/adapter/postgres/store.go index e95ea74..b2b9dbd 100644 --- a/backend/internal/adapter/postgres/store.go +++ b/backend/internal/adapter/postgres/store.go @@ -612,6 +612,9 @@ func (s *Store) GetTournament(ctx context.Context, id string) (domain.Tournament if err == nil { err = json.Unmarshal(body, &out) } + if err == nil { + err = s.hydrateTournament(ctx, &out) + } return out, mapError(err) } @@ -622,9 +625,43 @@ func (s *Store) GetTournamentByEvent(ctx context.Context, eventID string) (domai if err == nil { err = json.Unmarshal(body, &out) } + if err == nil { + err = s.hydrateTournament(ctx, &out) + } return out, mapError(err) } +func (s *Store) hydrateTournament(ctx context.Context, tournament *domain.Tournament) error { + rows, err := s.pool.Query(ctx, `SELECT body FROM series WHERE tournament_id=$1`, tournament.ID) + if err != nil { + return err + } + defer rows.Close() + live := make(map[string]domain.Series) + for rows.Next() { + var body []byte + var series domain.Series + if err := rows.Scan(&body); err != nil { + return err + } + if err := json.Unmarshal(body, &series); err != nil { + return err + } + live[series.ID] = series + } + if err := rows.Err(); err != nil { + return err + } + for round := range tournament.Rounds { + for match := range tournament.Rounds[round] { + if series, ok := live[tournament.Rounds[round][match].ID]; ok { + tournament.Rounds[round][match] = series + } + } + } + return nil +} + func (s *Store) SaveDraft(ctx context.Context, id, kind string, value any, expectedVersion int) error { body, _ := json.Marshal(value) if expectedVersion < 0 { diff --git a/backend/internal/adapter/postgres/store_integration_test.go b/backend/internal/adapter/postgres/store_integration_test.go index d0917f1..c337ec9 100644 --- a/backend/internal/adapter/postgres/store_integration_test.go +++ b/backend/internal/adapter/postgres/store_integration_test.go @@ -135,20 +135,20 @@ func TestFullScrimPipeline(t *testing.T) { t.Fatalf("start failed: %#v, %v", started, err) } seriesIDs = append(seriesIDs, started.Series.ID) - series, err := service.TossSeriesCoin(ctx, admin, started.Series.ID, "integration-seed", started.Series.Version) + series, err := service.TossSeriesCoin(ctx, admin, domain.Player{}, started.Series.ID, "integration-seed", started.Series.TeamAID, started.Series.Version) if err != nil { t.Fatal(err) } for mapNumber := 0; mapNumber < 2; mapNumber++ { if series.Phase == domain.MapPickPhase { - series, err = service.PickSeriesMap(ctx, admin, series.ID, series.NextMapPickerID, series.AvailableMaps[0], series.Version) + series, err = service.PickSeriesMap(ctx, admin, domain.Player{}, series.ID, series.NextMapPickerID, series.AvailableMaps[0], series.Version) if err != nil { t.Fatal(err) } } for series.Phase != domain.HeroBanPhase { name := firstUnbanned(series.MapDraft.Pool, series.MapDraft.Banned) - series, err = service.BanSeriesMap(ctx, admin, series.ID, series.MapDraft.NextTeam(), name, series.Version) + series, err = service.BanSeriesMap(ctx, admin, domain.Player{}, series.ID, series.MapDraft.NextTeam(), name, series.Version) if err != nil { t.Fatal(err) } @@ -156,12 +156,12 @@ func TestFullScrimPipeline(t *testing.T) { for series.Phase != domain.PlayingPhase { teamID := series.HeroDraft.NextTeam() hero := firstAvailableHero(series.HeroDraft, teamID) - series, err = service.BanSeriesHero(ctx, admin, series.ID, teamID, hero, series.Version) + series, err = service.BanSeriesHero(ctx, admin, domain.Player{}, series.ID, teamID, hero, series.Version) if err != nil { t.Fatal(err) } } - series, err = service.RecordSeriesResult(ctx, admin, series.ID, domain.TeamAWin, series.Version) + series, err = service.RecordSeriesResult(ctx, admin, domain.Player{}, series.ID, series.TeamAID, domain.TeamAWin, series.Version) if err != nil { t.Fatal(err) } @@ -175,6 +175,97 @@ func TestFullScrimPipeline(t *testing.T) { } } +func TestParallelTournamentHydratesLiveSeriesAndFinal(t *testing.T) { + databaseURL := os.Getenv("TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("TEST_DATABASE_URL is not configured") + } + ctx := context.Background() + if err := Migrate(ctx, databaseURL, "../../../migrations"); err != nil { + t.Fatal(err) + } + store, err := Open(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + defer store.Close() + suffix := fmt.Sprintf("%d", time.Now().UnixNano()) + accountID, eventID, tournamentID := "parallel-admin-"+suffix, "parallel-event-"+suffix, "parallel-tournament-"+suffix + _, err = store.pool.Exec(ctx, `INSERT INTO accounts(id,discord_id,username,avatar_url,role,created_at) VALUES($1,$1,'Parallel Admin','','admin',now())`, accountID) + if err != nil { + t.Fatal(err) + } + defer func() { + _, _ = store.pool.Exec(ctx, `DELETE FROM series WHERE tournament_id=$1`, tournamentID) + _, _ = store.pool.Exec(ctx, `DELETE FROM events WHERE id=$1`, eventID) + _, _ = store.pool.Exec(ctx, `DELETE FROM accounts WHERE id=$1`, accountID) + }() + service := application.New(store, discardPublisher{}) + event, err := service.CreateEvent(ctx, domain.Account{ID: accountID, Role: domain.RoleAdmin}, domain.Event{ + ID: eventID, Name: "Parallel tournament", StartsAt: time.Now().Add(time.Hour), EndsAt: time.Now().Add(3 * time.Hour), + RegistrationDeadline: time.Now(), RulesetID: "standard-control-hybrid-control", + }) + if err != nil { + t.Fatal(err) + } + eventID = event.ID + rules, err := store.GetRuleset(ctx, event.RulesetID) + if err != nil { + t.Fatal(err) + } + tournament, err := domain.NewTournament(tournamentID, eventID, event.Name, []string{"team-a", "team-b", "team-c", "team-d"}) + if err != nil { + t.Fatal(err) + } + if _, err = store.SaveTournament(ctx, *tournament); err != nil { + t.Fatal(err) + } + for match := range tournament.Rounds[0] { + slot := tournament.Rounds[0][match] + series, createErr := domain.NewSeries(slot.ID, eventID, tournamentID, [2]string{slot.TeamAID, slot.TeamBID}, rules) + if createErr != nil { + t.Fatal(createErr) + } + if match == 0 { + if createErr = series.Toss("parallel-seed", accountID, time.Now().UTC(), rules); createErr != nil { + t.Fatal(createErr) + } + } + if _, createErr = store.SaveSeries(ctx, *series); createErr != nil { + t.Fatal(createErr) + } + } + hydrated, err := store.GetTournamentByEvent(ctx, eventID) + if err != nil { + t.Fatal(err) + } + if hydrated.Rounds[0][0].Phase != domain.MapBanPhase || hydrated.Rounds[0][1].Phase != domain.CoinTossPending { + t.Fatalf("parallel series were not hydrated independently: %s / %s", hydrated.Rounds[0][0].Phase, hydrated.Rounds[0][1].Phase) + } + for match := range hydrated.Rounds[0] { + hydrated.Rounds[0][match].WinnerTeamID = hydrated.Rounds[0][match].TeamAID + hydrated.Rounds[0][match].Phase = domain.SeriesComplete + if err = hydrated.Advance(0, match); err != nil { + t.Fatal(err) + } + } + if _, err = store.SaveTournament(ctx, hydrated); err != nil { + t.Fatal(err) + } + finalSlot := hydrated.Rounds[1][0] + final, err := domain.NewSeries(finalSlot.ID, eventID, tournamentID, [2]string{finalSlot.TeamAID, finalSlot.TeamBID}, rules) + if err != nil { + t.Fatal(err) + } + if _, err = store.SaveSeries(ctx, *final); err != nil { + t.Fatal(err) + } + hydrated, err = store.GetTournamentByEvent(ctx, eventID) + if err != nil || hydrated.Rounds[1][0].Phase != domain.CoinTossPending { + t.Fatalf("final was not persisted and hydrated: phase=%s err=%v", hydrated.Rounds[1][0].Phase, err) + } +} + func firstUnbanned(pool, banned []string) string { for _, name := range pool { found := false diff --git a/backend/internal/application/service.go b/backend/internal/application/service.go index f1fa504..9f41d2b 100644 --- a/backend/internal/application/service.go +++ b/backend/internal/application/service.go @@ -148,9 +148,7 @@ func (s *Service) CreateEvent(ctx context.Context, actor domain.Account, event d if !actor.IsStaff() { return domain.Event{}, domain.ErrForbidden } - if event.RegistrationDeadline.IsZero() { - event.RegistrationDeadline = event.StartsAt - } + event.RegistrationDeadline = event.StartsAt event.ID, event.CreatedBy, event.CreatedAt, event.UpdatedAt = NewID(), actor.ID, s.Now(), s.Now() event.State, event.Version = domain.RegistrationOpen, 0 if event.RulesetID == "" { @@ -174,9 +172,7 @@ func (s *Service) UpdateEvent(ctx context.Context, actor domain.Account, eventID if err != nil { return domain.Event{}, err } - if changes.RegistrationDeadline.IsZero() { - changes.RegistrationDeadline = changes.StartsAt - } + changes.RegistrationDeadline = changes.StartsAt current.Name = changes.Name current.Description = changes.Description current.StartsAt = changes.StartsAt diff --git a/backend/internal/application/workflow.go b/backend/internal/application/workflow.go index c9ed708..fe459a7 100644 --- a/backend/internal/application/workflow.go +++ b/backend/internal/application/workflow.go @@ -518,32 +518,32 @@ func (s *Service) StartScrim(ctx context.Context, actor domain.Account, eventID return result, err } -func (s *Service) TossSeriesCoin(ctx context.Context, actor domain.Account, seriesID, seed string, expectedVersion int) (domain.Series, error) { - return s.mutateSeries(ctx, actor, seriesID, expectedVersion, "series.coin_tossed", func(series *domain.Series, rules domain.Ruleset) error { +func (s *Service) TossSeriesCoin(ctx context.Context, actor domain.Account, player domain.Player, seriesID, seed, actingTeamID string, expectedVersion int) (domain.Series, error) { + return s.mutateSeries(ctx, actor, player, seriesID, actingTeamID, expectedVersion, "series.coin_tossed", func(series *domain.Series, rules domain.Ruleset) error { return series.Toss(seed, actor.ID, s.Now(), rules) }) } -func (s *Service) BanSeriesMap(ctx context.Context, actor domain.Account, seriesID, teamID, name string, expectedVersion int) (domain.Series, error) { - return s.mutateSeries(ctx, actor, seriesID, expectedVersion, "series.map_banned", func(series *domain.Series, rules domain.Ruleset) error { +func (s *Service) BanSeriesMap(ctx context.Context, actor domain.Account, player domain.Player, seriesID, teamID, name string, expectedVersion int) (domain.Series, error) { + return s.mutateSeries(ctx, actor, player, seriesID, teamID, expectedVersion, "series.map_banned", func(series *domain.Series, rules domain.Ruleset) error { return series.BanMap(teamID, name, actor.ID, s.Now(), rules) }) } -func (s *Service) PickSeriesMap(ctx context.Context, actor domain.Account, seriesID, teamID, name string, expectedVersion int) (domain.Series, error) { - return s.mutateSeries(ctx, actor, seriesID, expectedVersion, "series.map_picked", func(series *domain.Series, rules domain.Ruleset) error { +func (s *Service) PickSeriesMap(ctx context.Context, actor domain.Account, player domain.Player, seriesID, teamID, name string, expectedVersion int) (domain.Series, error) { + return s.mutateSeries(ctx, actor, player, seriesID, teamID, expectedVersion, "series.map_picked", func(series *domain.Series, rules domain.Ruleset) error { return series.PickMap(teamID, name, actor.ID, s.Now(), rules) }) } -func (s *Service) BanSeriesHero(ctx context.Context, actor domain.Account, seriesID, teamID, hero string, expectedVersion int) (domain.Series, error) { - return s.mutateSeries(ctx, actor, seriesID, expectedVersion, "series.hero_banned", func(series *domain.Series, _ domain.Ruleset) error { +func (s *Service) BanSeriesHero(ctx context.Context, actor domain.Account, player domain.Player, seriesID, teamID, hero string, expectedVersion int) (domain.Series, error) { + return s.mutateSeries(ctx, actor, player, seriesID, teamID, expectedVersion, "series.hero_banned", func(series *domain.Series, _ domain.Ruleset) error { return series.BanHero(teamID, hero, actor.ID, s.Now()) }) } -func (s *Service) RecordSeriesResult(ctx context.Context, actor domain.Account, seriesID string, outcome domain.MapOutcome, expectedVersion int) (domain.Series, error) { - out, err := s.mutateSeries(ctx, actor, seriesID, expectedVersion, "series.map_recorded", func(series *domain.Series, rules domain.Ruleset) error { +func (s *Service) RecordSeriesResult(ctx context.Context, actor domain.Account, player domain.Player, seriesID, actingTeamID string, outcome domain.MapOutcome, expectedVersion int) (domain.Series, error) { + out, err := s.mutateSeries(ctx, actor, player, seriesID, actingTeamID, expectedVersion, "series.map_recorded", func(series *domain.Series, rules domain.Ruleset) error { return series.RecordCurrentMap(outcome, actor.ID, s.Now(), rules) }) if err != nil || out.WinnerTeamID == "" { @@ -610,7 +610,7 @@ func (s *Service) RecordSeriesResult(ctx context.Context, actor domain.Account, return out, err } -func (s *Service) mutateSeries(ctx context.Context, actor domain.Account, seriesID string, expectedVersion int, action string, mutation func(*domain.Series, domain.Ruleset) error) (domain.Series, error) { +func (s *Service) mutateSeries(ctx context.Context, actor domain.Account, player domain.Player, seriesID, actingTeamID string, expectedVersion int, action string, mutation func(*domain.Series, domain.Ruleset) error) (domain.Series, error) { series, err := s.Store.GetSeries(ctx, seriesID) if err != nil { return series, err @@ -622,6 +622,23 @@ func (s *Service) mutateSeries(ctx context.Context, actor domain.Account, series if event.State != domain.Live { return series, fmt.Errorf("%w: event is not live", domain.ErrConflict) } + if actingTeamID != series.TeamAID && actingTeamID != series.TeamBID { + return series, domain.ErrForbidden + } + teams, err := s.Store.ListTeams(ctx, series.EventID) + if err != nil { + return series, err + } + authorized := actor.IsStaff() + for _, team := range teams { + if team.ID == actingTeamID && CanActForTeam(actor, player, team) { + authorized = true + break + } + } + if !authorized { + return series, domain.ErrForbidden + } if series.Version != expectedVersion { return series, fmt.Errorf("%w: stale series version", domain.ErrConflict) } @@ -636,6 +653,10 @@ func (s *Service) mutateSeries(ctx context.Context, actor domain.Account, series if err == nil { _ = s.Store.AppendAudit(ctx, actor.ID, action, seriesID, series) s.Bus.Publish("series:"+seriesID, series) + if series.TournamentID != "" { + s.Bus.Publish("tournament:"+series.TournamentID, series) + } + s.Bus.Publish("event:"+series.EventID, series) } return series, err } diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index fdb5808..e783944 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1,6 +1,8 @@ import { cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' import App from './App' +import { demoSeries } from './api/demo' +import { activeHeroBanMap } from './series-utils' describe('Mixmaker frontend', () => { afterEach(() => { @@ -15,6 +17,8 @@ describe('Mixmaker frontend', () => { expect(screen.getByText('Saturday Night Scrim')).toBeInTheDocument() expect(screen.getByRole('link', { name: 'Open Summer Clash #4' })).toHaveClass('status-live') expect(screen.getByRole('link', { name: 'Open Cancelled Community Mix' })).toHaveClass('status-cancelled') + expect(screen.getByText('Ember Wolves')).toBeInTheDocument() + expect(screen.getByText('Neon Foxes')).toBeInTheDocument() }) it('renders the Discord login screen', async () => { @@ -26,11 +30,33 @@ describe('Mixmaker frontend', () => { it('locks RSVP and exposes live navigation after the scrim starts', async () => { window.history.replaceState({}, '', '/events/event-3') render() - expect(await screen.findByRole('link', { name: /open live series/i })).toHaveAttribute('href', '/series/series-1') + expect(await screen.findByRole('link', { name: /^open live$/i })).toHaveAttribute('href', '/events/event-3/live') expect(screen.getByRole('button', { name: 'Going' })).toBeDisabled() expect(screen.getByText('Registration has been closed by the organizer.')).toBeInTheDocument() }) + it('keeps the full scrollable match and Bo3 ban history visible', async () => { + window.history.replaceState({}, '', '/watch/series-1') + render() + expect(await screen.findByRole('heading', { name: 'Bo3 hero ban history' })).toBeInTheDocument() + expect(screen.getByText('Ember Wolves won the toss')).toBeInTheDocument() + expect(screen.getByText('Winston')).toBeInTheDocument() + }) + + it('opens a roster participant in series mode', async () => { + window.history.replaceState({}, '', '/events/event-3/live') + render() + expect(await screen.findByRole('heading', { name: 'Series control' })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /spectator view/i })).toHaveAttribute('href', '/watch/series-1') + }) + + it('does not reuse completed-map hero bans during map pick', () => { + expect(activeHeroBanMap({ + ...demoSeries, + currentStep: { ...demoSeries.currentStep, kind: 'map_pick' }, + })).toBeUndefined() + }) + it('explains map selection and captain rules in the FAQ', async () => { window.history.replaceState({}, '', '/faq') render() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 87e16cc..392a49a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState, type DragEvent, type FormEvent, type ReactNode } from 'react' import { - CalendarDays, Check, ChevronRight, CircleHelp, Clock3, Gamepad2, ListFilter, LogOut, + CalendarDays, Check, ChevronLeft, ChevronRight, CircleHelp, Clock3, Gamepad2, ListFilter, LogOut, Menu, Radio, RefreshCw, Shield, Sparkles, Swords, Trophy, UserRound, UsersRound, X, Zap, } from 'lucide-react' @@ -20,6 +20,7 @@ import { import { LanguageSelector } from './i18n' import { useLanguage } from './i18n-context' import { rankLabel, rankOptions, toRankOrdinal, type RankOrdinal } from './ranks' +import { activeHeroBanMap } from './series-utils' type RouterContext = { session: Session | null } const isStaff = (role?: Session['account']['role']) => role === 'admin' || role === 'moderator' @@ -35,6 +36,12 @@ const profileRoute = createRoute({ getParentRoute: () => protectedRoute, path: ' const faqRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/faq', component: FAQPage }) const eventsRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/events', component: EventsPage }) const eventRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/events/$eventId', component: EventPage }) +const liveEntryRoute = createRoute({ + getParentRoute: () => protectedRoute, + path: '/events/$eventId/live', + validateSearch: (search: Record) => ({ seriesId: typeof search.seriesId === 'string' ? search.seriesId : undefined }), + component: LiveEntryPage, +}) const liveRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/series/$seriesId', component: LiveSeriesPage }) const spectatorRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/watch/$seriesId', component: SpectatorPage }) const bracketRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/bracket/$eventId', component: BracketPage }) @@ -43,7 +50,7 @@ const adminRoute = createRoute({ beforeLoad: ({ context }) => { if (!isStaff(context.session?.account.role)) throw redirect({ to: '/events' }) }, }) const routeTree = rootRoute.addChildren([loginRoute, protectedRoute.addChildren([ - indexRoute, profileRoute, faqRoute, eventsRoute, eventRoute, liveRoute, spectatorRoute, bracketRoute, adminRoute, + indexRoute, profileRoute, faqRoute, eventsRoute, eventRoute, liveEntryRoute, liveRoute, spectatorRoute, bracketRoute, adminRoute, ])]) const router = createRouter({ routeTree, context: { session: null } }) declare module '@tanstack/react-router' { interface Register { router: typeof router } } @@ -150,11 +157,19 @@ function FeaturedLiveEvent({ event }: { event: MixEvent }) { const series = useQuery({ queryKey: ['series', event.activeSeriesId], queryFn: () => api.series(event.activeSeriesId!), - enabled: Boolean(event.activeSeriesId), + enabled: Boolean(event.activeSeriesId) && !event.tournamentId, initialData: demoMode && event.activeSeriesId === demoSeries.id ? demoSeries : undefined, }) + const tournament = useQuery({ + queryKey: ['tournament', event.id], + queryFn: () => api.bracket(event.id), + enabled: Boolean(event.tournamentId), + initialData: demoMode && event.tournamentId ? demoBracket : undefined, + }) const match = series.data - return
Live now

{event.title}

{match ? `${match.roundLabel} · ${match.currentStep.title}` : 'The scrim has started'}

{match &&
{match.teamAlpha.name}{match.score.alpha}:{match.score.beta}{match.teamBeta.name}
}{event.activeSeriesId ? Watch live : Open bracket }
+ const liveMatches = tournament.data?.matches.filter((item) => item.status === 'live') ?? [] + const hasScore = Boolean(match) || liveMatches.length > 0 + return
1 ? 'multiple-matches' : ''}`}>
Live now

{event.title}

{liveMatches.length > 0 ? <>{liveMatches.length} active matches : match ? `${match.roundLabel} · ${match.currentStep.title}` : 'The scrim has started'}

{liveMatches.length > 0 ?
{liveMatches.map((item) =>
{item.label}{item.teamAlpha ?? 'TBD'}{item.scoreAlpha ?? 0}:{item.scoreBeta ?? 0}{item.teamBeta ?? 'TBD'}
)}
: match &&
{match.teamAlpha.name}{match.score.alpha}:{match.score.beta}{match.teamBeta.name}
}Open live
} function EventCard({ event }: { event: MixEvent }) { @@ -190,12 +205,47 @@ function EventPage() { const attendanceTotal = Object.values(counts).reduce((total, count) => total + count, 0) const statusOrder: Record = { going: 0, maybe: 1, not_going: 2 } const attendees = [...(registrations.data ?? [])].sort((a, b) => statusOrder[a.status] - statusOrder[b.status] || a.player.displayName.localeCompare(b.player.displayName)) - return
{event.status !== 'cancelled' && event.activeSeriesId && Open live series}{event.status !== 'cancelled' && event.tournamentId && Open bracket}{event.workflowState.replace(/([a-z])([A-Z])/g, '$1 $2')}} />
+ 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]}
)}
}
} +function LiveEntryPage() { + const { eventId } = liveEntryRoute.useParams() + const { seriesId: requestedSeriesId } = liveEntryRoute.useSearch() + const event = useQuery({ queryKey: ['event', eventId], queryFn: () => api.event(eventId), initialData: demoMode ? demoEvents.find((item) => item.id === eventId) : undefined }) + const session = useQuery({ queryKey: ['session'], queryFn: api.session, initialData: demoMode ? demoSession : undefined }) + const teams = useQuery({ queryKey: ['teams', eventId], queryFn: () => api.teams(eventId), initialData: demoMode ? demoBalanceCandidates[0].teams : undefined }) + const bracket = useQuery({ + queryKey: ['tournament', eventId], + queryFn: () => api.bracket(eventId), + enabled: Boolean(event.data?.tournamentId), + initialData: demoMode && event.data?.tournamentId ? demoBracket : undefined, + }) + const loading = event.isLoading || session.isLoading || teams.isLoading || (Boolean(event.data?.tournamentId) && bracket.isLoading) + const error = event.error ?? session.error ?? teams.error ?? bracket.error + const teamIds = useMemo(() => new Set((teams.data ?? []).filter((team) => team.members.some(({ player }) => player.id === session.data?.player.id)).map((team) => team.id)), [session.data?.player.id, teams.data]) + const matches = bracket.data?.matches ?? [] + const activeMatches = matches.filter((match) => match.status === 'live' && match.seriesId) + const requested = requestedSeriesId ? matches.find((match) => match.seriesId === requestedSeriesId) : undefined + const ownMatch = activeMatches.find((match) => teamIds.has(match.teamAlphaId ?? '') || teamIds.has(match.teamBetaId ?? '')) + const target = requested ?? ownMatch ?? activeMatches[0] ?? matches.find((match) => match.seriesId) + const targetSeriesId = event.data?.tournamentId ? target?.seriesId : event.data?.activeSeriesId + const targetIsOwn = event.data?.tournamentId + ? Boolean(target && (teamIds.has(target.teamAlphaId ?? '') || teamIds.has(target.teamBetaId ?? ''))) + : teamIds.size > 0 + const control = isStaff(session.data?.account.role) || targetIsOwn + useEffect(() => { + if (loading || error || !targetSeriesId) return + if (control) void router.navigate({ to: '/series/$seriesId', params: { seriesId: targetSeriesId }, replace: true }) + else void router.navigate({ to: '/watch/$seriesId', params: { seriesId: targetSeriesId }, replace: true }) + }, [control, error, loading, targetSeriesId]) + if (error) return { void event.refetch(); void session.refetch(); void teams.refetch(); void bracket.refetch() }} /> + if (!loading && !targetSeriesId) return + return +} + function FAQPage() { return
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
{ e.preventDefault(); save.mutate() }}>

{event ? 'Event settings' : 'New event'}

Scheduling and registration

{event &&
{event.status === 'cancelled' ? 'Cancelled' : event.status === 'completed' ? 'Completed' : event.status === 'live' ? 'LIVE' : 'Published'}
}
-
+