From 5d3e66d2f1372132e55ef1d776f19c263bab47a2 Mon Sep 17 00:00:00 2001 From: lemintare Date: Sun, 19 Jul 2026 03:22:32 +0300 Subject: [PATCH] Refactor series handling in backend to include player context in Toss, Ban, Pick, and Record actions. Update service methods to accept player parameters, enhancing authorization checks and ensuring proper team actions. Modify integration tests to reflect new method signatures and improve tournament hydration logic in the store. Enhance frontend components to support new series features and improve user experience with live match navigation. --- .../adapter/httpapi/workflow_handlers.go | 15 +- backend/internal/adapter/postgres/store.go | 37 +++++ .../postgres/store_integration_test.go | 101 +++++++++++- backend/internal/application/service.go | 8 +- backend/internal/application/workflow.go | 43 +++-- frontend/src/App.test.tsx | 28 +++- frontend/src/App.tsx | 152 +++++++++++++++--- frontend/src/api/client.test.ts | 17 +- frontend/src/api/client.ts | 24 ++- frontend/src/api/demo.ts | 11 +- frontend/src/i18n.tsx | 15 ++ frontend/src/index.css | 8 +- frontend/src/series-utils.ts | 6 + memory_bank/active_context.md | 6 +- memory_bank/architecture.md | 2 +- openapi/openapi.yaml | 38 ++++- 16 files changed, 449 insertions(+), 62 deletions(-) create mode 100644 frontend/src/series-utils.ts 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'}
}
-
+