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.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(<App />)
|
||||
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(<App />)
|
||||
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(<App />)
|
||||
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(<App />)
|
||||
|
||||
@@ -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<string, unknown>) => ({ 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 <section className={`featured-event ${match ? '' : 'without-score'}`}><div><Badge tone="live"><Radio />Live now</Badge><h2>{event.title}</h2><p>{match ? `${match.roundLabel} · ${match.currentStep.title}` : 'The scrim has started'}</p></div>{match && <div className="featured-score"><span>{match.teamAlpha.name}</span><strong>{match.score.alpha}<i>:</i>{match.score.beta}</strong><span>{match.teamBeta.name}</span></div>}{event.activeSeriesId ? <Link className="button primary" to="/watch/$seriesId" params={{ seriesId: event.activeSeriesId }}>Watch live <ChevronRight /></Link> : <Link className="button primary" to="/bracket/$eventId" params={{ eventId: event.id }}>Open bracket <ChevronRight /></Link>}</section>
|
||||
const liveMatches = tournament.data?.matches.filter((item) => item.status === 'live') ?? []
|
||||
const hasScore = Boolean(match) || liveMatches.length > 0
|
||||
return <section className={`featured-event ${hasScore ? '' : 'without-score'} ${liveMatches.length > 1 ? 'multiple-matches' : ''}`}><div><Badge tone="live"><Radio />Live now</Badge><h2>{event.title}</h2><p>{liveMatches.length > 0 ? <>{liveMatches.length} <span>active matches</span></> : match ? `${match.roundLabel} · ${match.currentStep.title}` : 'The scrim has started'}</p></div>{liveMatches.length > 0 ? <div className="featured-match-list">{liveMatches.map((item) => <div className="featured-match-score" key={item.id}><small>{item.label}</small><span>{item.teamAlpha ?? 'TBD'}</span><strong>{item.scoreAlpha ?? 0}<i>:</i>{item.scoreBeta ?? 0}</strong><span>{item.teamBeta ?? 'TBD'}</span></div>)}</div> : match && <div className="featured-score"><span>{match.teamAlpha.name}</span><strong>{match.score.alpha}<i>:</i>{match.score.beta}</strong><span>{match.teamBeta.name}</span></div>}<Link className="button primary" to="/events/$eventId/live" params={{ eventId: event.id }} search={{ seriesId: undefined }}>Open live <ChevronRight /></Link></section>
|
||||
}
|
||||
|
||||
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<RsvpStatus, number> = { 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 <div className={`page event-detail status-${event.status}`}><PageHeader eyebrow="Event details" title={event.title} description={event.description} actions={<>{event.status !== 'cancelled' && event.activeSeriesId && <Link className="button primary" to="/series/$seriesId" params={{ seriesId: event.activeSeriesId }}>Open live series</Link>}{event.status !== 'cancelled' && event.tournamentId && <Link className="button primary" 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>
|
||||
<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>
|
||||
}
|
||||
|
||||
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 <ErrorState message={error.message} retry={() => { void event.refetch(); void session.refetch(); void teams.refetch(); void bracket.refetch() }} />
|
||||
if (!loading && !targetSeriesId) return <ErrorState message="No active series is available for this event." />
|
||||
return <LoadingState label="Opening your live match…" />
|
||||
}
|
||||
|
||||
function FAQPage() {
|
||||
return <div className="page faq-page"><PageHeader eyebrow="Rules and help" title="Frequently asked questions" description="Everything players and captains need before the scrim starts." /><div className="faq-grid">
|
||||
<section className="card faq-card"><span>01</span><div><h2>How are teams balanced?</h2><p>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.</p></div></section>
|
||||
@@ -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 <div className="admin-grid">
|
||||
<form className="card settings-card" onSubmit={(e) => { e.preventDefault(); save.mutate() }}>
|
||||
<div className="section-title"><div><h2>{event ? 'Event settings' : 'New event'}</h2><p>Scheduling and registration</p></div>{event && <div className="event-admin-actions"><Badge tone={event.status === 'cancelled' ? 'danger' : event.status === 'live' ? 'live' : 'success'}>{event.status === 'cancelled' ? 'Cancelled' : event.status === 'completed' ? 'Completed' : event.status === 'live' ? 'LIVE' : 'Published'}</Badge><button className="button danger-outline" type="button" disabled={cancel.isPending || event.status === 'cancelled' || event.status === 'completed'} onClick={() => cancel.mutate()}>Cancel event</button><button className="icon-button delete-event" type="button" disabled={remove.isPending} aria-label="Delete event" title="Delete event" onClick={() => { if (window.confirm(language === 'ru' ? 'Удалить это событие навсегда?' : 'Delete this event permanently?')) remove.mutate() }}><X /></button></div>}</div>
|
||||
<div className="field-grid"><label>Event name<input required value={form.name} onChange={(e) => set('name', e.target.value)} /></label><label>Start time<input required type="datetime-local" value={form.startsAt} onChange={(e) => setStart(e.target.value)} /></label><label>End time<input required type="datetime-local" value={form.endsAt} onChange={(e) => set('endsAt', e.target.value)} /></label><label>Registration deadline<input required type="datetime-local" value={form.registrationDeadline} onChange={(e) => set('registrationDeadline', e.target.value)} /></label></div>
|
||||
<div className="field-grid"><label>Event name<input required value={form.name} onChange={(e) => set('name', e.target.value)} /></label><label>Start time<input required type="datetime-local" value={form.startsAt} onChange={(e) => setStart(e.target.value)} /></label><label>End time<input required type="datetime-local" value={form.endsAt} onChange={(e) => set('endsAt', e.target.value)} /></label><label>Registration deadline<input required readOnly type="datetime-local" value={form.registrationDeadline} /></label></div>
|
||||
<label>Description<textarea value={form.description} onChange={(e) => set('description', e.target.value)} /></label>
|
||||
<div className="form-footer"><p>{save.isError ? save.error.message : cancel.isError ? cancel.error.message : remove.isError ? remove.error.message : 'Times are sent to the server in UTC.'}</p><button className="button primary" disabled={save.isPending}>{save.isPending ? 'Saving…' : event ? 'Save changes' : 'Create event'}</button></div>
|
||||
</form>
|
||||
@@ -501,7 +551,7 @@ function WorkflowControl({ eventId }: { eventId: string }) {
|
||||
{state === 'RostersDraft' && roster.data && <WorkflowAction title="Confirm rosters" description="Every team must have a valid 1/2/2 lineup and a captain." button="Confirm rosters" pending={confirm.isPending} onClick={() => confirm.mutate()} />}
|
||||
{state === 'RostersConfirmed' && <WorkflowAction title="Start scrim" description="This atomically creates the Bo3 series or tournament bracket." button="Start scrim" pending={start.isPending} onClick={() => start.mutate()} />}
|
||||
{state === 'Live' && <section className="card workflow-action"><div><h2>Scrim is live</h2><p>Coin toss, bans, and map results are now available.</p></div>{event.data.activeSeriesId ? <Link className="button primary" to="/series/$seriesId" params={{ seriesId: event.data.activeSeriesId }}>Open series</Link> : <Link className="button primary" to="/bracket/$eventId" params={{ eventId }}>Open bracket</Link>}</section>}
|
||||
{state === 'Completed' && <section className="card workflow-action"><div><h2>Scrim completed</h2><p>The final result and full audit trail are preserved.</p></div></section>}
|
||||
{state === 'Completed' && <section className="card workflow-action"><div><h2>Scrim completed</h2><p>The final result and full audit trail are preserved.</p></div>{event.data.tournamentId ? <Link className="button primary" to="/bracket/$eventId" params={{ eventId }}>Open bracket</Link> : event.data.activeSeriesId ? <Link className="button primary" to="/watch/$seriesId" params={{ seriesId: event.data.activeSeriesId }}>Match history</Link> : null}</section>}
|
||||
{error && <p className="error-note">{error.message}</p>}
|
||||
</div>
|
||||
}
|
||||
@@ -617,6 +667,12 @@ function LiveSeriesPage() {
|
||||
const session = useQuery({ queryKey: ['session'], queryFn: api.session, initialData: demoMode ? demoSession : undefined })
|
||||
const [selected, setSelected] = useState<string>()
|
||||
const [outcome, setOutcome] = useState<MapOutcome>('TeamAWin')
|
||||
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)))
|
||||
useEffect(() => {
|
||||
if (series.data && session.data && !participant && !isStaff(session.data.account.role)) {
|
||||
void router.navigate({ to: '/watch/$seriesId', params: { seriesId }, replace: true })
|
||||
}
|
||||
}, [participant, series.data, seriesId, session.data])
|
||||
const result = useMutation({
|
||||
mutationFn: () => {
|
||||
const data = series.data!
|
||||
@@ -652,7 +708,23 @@ function LiveSeriesPage() {
|
||||
if ((series.isError && !series.data) || (session.isError && !session.data)) return <ErrorState message={series.error?.message ?? session.error?.message} retry={() => { void series.refetch(); void session.refetch() }} />
|
||||
if (!series.data || !session.data) return <LoadingState />
|
||||
const data = series.data
|
||||
return <div className="page live-page"><PageHeader eyebrow={`${data.roundLabel} · ${data.status}`} title="Series control" description="Actions are validated and recorded by the server." actions={<Link className="button secondary" to="/watch/$seriesId" params={{ seriesId: data.id }}><Radio />Spectator view</Link>} /><Scoreboard series={data} /><div className="live-layout"><section className={`turn-card team-${data.currentStep.activeTeam ?? 'alpha'}`}><div className="turn-header"><div><span className="eyebrow">Current step</span><h2>{data.currentStep.title}</h2><p>{data.currentStep.instruction}</p></div>{data.currentStep.activeTeam && <div className="turn-team"><span>Acting team</span><strong>{data.currentStep.activeTeam === 'alpha' ? data.teamAlpha.name : data.teamBeta.name}</strong></div>}</div>{data.currentStep.kind === 'result' ? <form className="result-form" onSubmit={(e) => { e.preventDefault(); result.mutate() }}><label>Outcome<select value={outcome} onChange={(e) => setOutcome(e.target.value as MapOutcome)}><option value="TeamAWin">{data.teamAlpha.name} wins</option><option value="TeamBWin">{data.teamBeta.name} wins</option><option value="Draw">Draw</option></select></label><button className="button team-action" disabled={result.isPending}>{result.isPending ? 'Recording…' : 'Record result'}</button>{result.isError && <p className="error-note">{result.error.message}</p>}</form> : data.currentStep.kind === 'complete' ? <div className="turn-footer"><p><Check />Series completed and bracket updated.</p></div> : <><div className="draft-options" role="radiogroup" aria-label="Available draft choices">{data.options.map((option) => <button role="radio" aria-checked={selected === option.id} key={option.id} disabled={option.disabled} className={selected === option.id ? 'selected' : ''} onClick={() => setSelected(option.id)}><span className={`role-chip ${option.role}`}>{option.role?.slice(0, 1).toUpperCase()}</span><strong>{option.name}</strong>{option.disabled ? <small>{option.disabledReason}</small> : <Check />}</button>)}</div><div className="turn-footer"><p><Shield />Draft commands use dedicated backend endpoints and server-side validation.</p><button className="button team-action" disabled={(data.currentStep.kind !== 'coin_toss' && !selected) || action.isPending} onClick={() => action.mutate()}>{action.isPending ? 'Confirming…' : data.currentStep.kind === 'coin_toss' ? 'Toss coin' : 'Confirm action'} <ChevronRight /></button></div>{action.isError && <p className="error-note">{action.error.message}</p>}</>}</section><aside className="match-sidebar"><MapTimeline series={data} /><AuditLog series={data} /></aside></div></div>
|
||||
const activeSide = data.currentStep.activeTeam ?? 'alpha'
|
||||
const activeTeam = activeSide === 'alpha' ? data.teamAlpha : data.teamBeta
|
||||
const canAct = isStaff(session.data.account.role) || activeTeam.captainId === session.data.player.id
|
||||
const canRecord = isStaff(session.data.account.role) || data.teamAlpha.captainId === session.data.player.id || data.teamBeta.captainId === session.data.player.id
|
||||
return <div className="page live-page">
|
||||
<PageHeader eyebrow={`${data.roundLabel} · ${data.status}`} title="Series control" description={canAct || canRecord ? 'Actions are validated and recorded by the server.' : 'You are playing in this match. Captain actions are read-only for team members.'} actions={<Link className="button secondary" to="/watch/$seriesId" params={{ seriesId: data.id }}><Radio />Spectator view</Link>} />
|
||||
<Scoreboard series={data} />
|
||||
<div className="live-layout">
|
||||
<section className={`turn-card team-${activeSide}`}>
|
||||
<div className="turn-header"><div><span className="eyebrow">Current step</span><h2>{data.currentStep.title}</h2><p>{data.currentStep.instruction}</p></div>{data.currentStep.activeTeam && <div className="turn-team"><span>Acting team</span><strong>{activeTeam.name}</strong></div>}</div>
|
||||
{data.currentStep.kind === 'result' ? <form className="result-form" onSubmit={(event) => { event.preventDefault(); if (canRecord) result.mutate() }}><label>Outcome<select disabled={!canRecord} value={outcome} onChange={(event) => setOutcome(event.target.value as MapOutcome)}><option value="TeamAWin">{data.teamAlpha.name} wins</option><option value="TeamBWin">{data.teamBeta.name} wins</option><option value="Draw">Draw</option></select></label><button className="button team-action" disabled={!canRecord || result.isPending}>{canRecord ? result.isPending ? 'Recording…' : 'Record result' : 'Captain action'}</button>{result.isError && <p className="error-note">{result.error.message}</p>}</form>
|
||||
: data.currentStep.kind === 'complete' ? <div className="turn-footer"><p><Check />Series completed and bracket updated.</p></div>
|
||||
: <><div className="draft-options" role="radiogroup" aria-label="Available draft choices">{data.options.map((option) => <button role="radio" aria-checked={selected === option.id} key={option.id} disabled={option.disabled || !canAct} className={selected === option.id ? 'selected' : ''} onClick={() => setSelected(option.id)}><span className={`role-chip ${option.role}`}>{option.role?.slice(0, 1).toUpperCase()}</span><strong>{option.name}</strong>{option.disabled ? <small>{option.disabledReason}</small> : <Check />}</button>)}</div><div className="turn-footer"><p><Shield />{canAct ? 'Draft commands use server-side validation.' : `Waiting for ${activeTeam.name} captain.`}</p><button className="button team-action" disabled={!canAct || (data.currentStep.kind !== 'coin_toss' && !selected) || action.isPending} onClick={() => action.mutate()}>{canAct ? action.isPending ? 'Confirming…' : data.currentStep.kind === 'coin_toss' ? 'Toss coin' : 'Confirm action' : 'Captain action'} <ChevronRight /></button></div>{action.isError && <p className="error-note">{action.error.message}</p>}</>}
|
||||
</section>
|
||||
<aside className="match-sidebar"><HeroBanDisplay series={data} /><SeriesBanHistory series={data} /><MapTimeline series={data} /><AuditLog series={data} /></aside>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
function Scoreboard({ series }: { series: Series }) {
|
||||
@@ -662,7 +734,35 @@ function Scoreboard({ series }: { series: Series }) {
|
||||
const betaCaptain = series.teamBeta.members.find(({ player }) => player.id === series.teamBeta.captainId)?.player.displayName
|
||||
const canRenameAlpha = controlPage && (isStaff(session.data?.account.role) || session.data?.player.id === series.teamAlpha.captainId)
|
||||
const canRenameBeta = controlPage && (isStaff(session.data?.account.role) || session.data?.player.id === series.teamBeta.captainId)
|
||||
return <><section className="scoreboard" aria-label="Current series score"><div className="score-team alpha"><span>Team Alpha</span><h2>{series.teamAlpha.name}</h2><small>{alphaCaptain ? `Captain · ${alphaCaptain}` : 'Captain not assigned'}</small></div><div className="series-score"><span>Best of 3</span><strong>{series.score.alpha}<i>:</i>{series.score.beta}</strong><Badge tone="live"><Radio />{series.status}</Badge></div><div className="score-team beta"><span>Team Beta</span><h2>{series.teamBeta.name}</h2><small>{betaCaptain ? `Captain · ${betaCaptain}` : 'Captain not assigned'}</small></div></section>{(canRenameAlpha || canRenameBeta) && <div className="team-name-controls">{canRenameAlpha && <TeamNameEditor team={series.teamAlpha} seriesId={series.id} />}{canRenameBeta && <TeamNameEditor team={series.teamBeta} seriesId={series.id} />}</div>}</>
|
||||
return <><TournamentStrip series={series} /><section className="scoreboard" aria-label="Current series score"><div className="score-team alpha"><span>Team Alpha</span><h2>{series.teamAlpha.name}</h2><small>{alphaCaptain ? `Captain · ${alphaCaptain}` : 'Captain not assigned'}</small></div><div className="series-score"><span>Best of 3</span><strong>{series.score.alpha}<i>:</i>{series.score.beta}</strong><Badge tone="live"><Radio />{series.status}</Badge></div><div className="score-team beta"><span>Team Beta</span><h2>{series.teamBeta.name}</h2><small>{betaCaptain ? `Captain · ${betaCaptain}` : 'Captain not assigned'}</small></div></section>{(canRenameAlpha || canRenameBeta) && <div className="team-name-controls">{canRenameAlpha && <TeamNameEditor team={series.teamAlpha} seriesId={series.id} />}{canRenameBeta && <TeamNameEditor team={series.teamBeta} seriesId={series.id} />}</div>}</>
|
||||
}
|
||||
|
||||
function TournamentStrip({ series }: { series: Series }) {
|
||||
const bracket = useQuery({
|
||||
queryKey: ['tournament', series.eventId],
|
||||
queryFn: () => api.bracket(series.eventId),
|
||||
enabled: Boolean(series.tournamentId),
|
||||
initialData: demoMode && series.tournamentId ? demoBracket : undefined,
|
||||
})
|
||||
if (!series.tournamentId || !bracket.data) return null
|
||||
const current = bracket.data.matches.find((match) => match.seriesId === series.id)
|
||||
const activeRound = bracket.data.matches.find((match) => match.status === 'live')?.round
|
||||
const activeMatches = bracket.data.matches.filter((match) => match.status === 'live' && match.round === activeRound && match.seriesId)
|
||||
const currentIndex = activeMatches.findIndex((match) => match.seriesId === series.id)
|
||||
const previous = currentIndex > 0 ? activeMatches[currentIndex - 1] : undefined
|
||||
const next = currentIndex >= 0 && currentIndex < activeMatches.length - 1 ? activeMatches[currentIndex + 1] : activeMatches.find((match) => match.seriesId !== series.id)
|
||||
const matchBody = (match: (typeof bracket.data.matches)[number]) => <><span>{match.teamAlpha ?? 'TBD'} <b>{match.scoreAlpha ?? '—'}</b></span><span>{match.teamBeta ?? 'TBD'} <b>{match.scoreBeta ?? '—'}</b></span></>
|
||||
return <section className="tournament-strip">
|
||||
<header><span><Trophy />Tournament bracket</span><Link to="/bracket/$eventId" params={{ eventId: series.eventId }}>Open full bracket</Link></header>
|
||||
<div className="tournament-strip-body">
|
||||
{previous?.seriesId ? <Link className="match-arrow" aria-label="Previous live match" to="/events/$eventId/live" params={{ eventId: series.eventId }} search={{ seriesId: previous.seriesId }}><ChevronLeft /></Link> : <span className="match-arrow disabled"><ChevronLeft /></span>}
|
||||
<div className="mini-bracket">{bracket.data.rounds.map((round, roundIndex) => <div key={round}><small>{round}</small>{bracket.data.matches.filter((match) => match.round === roundIndex).map((match) => match.status === 'pending' || match.status === 'ready'
|
||||
? <div className={`mini-match ${match.status}`} key={match.id}>{matchBody(match)}</div>
|
||||
: <Link className={`mini-match ${match.seriesId === series.id ? 'current' : ''} ${match.status}`} key={match.id} to="/events/$eventId/live" params={{ eventId: series.eventId }} search={{ seriesId: match.seriesId }}>{matchBody(match)}</Link>)}</div>)}</div>
|
||||
{next?.seriesId ? <Link className="match-arrow" aria-label="Next live match" to="/events/$eventId/live" params={{ eventId: series.eventId }} search={{ seriesId: next.seriesId }}><ChevronRight /></Link> : <span className="match-arrow disabled"><ChevronRight /></span>}
|
||||
</div>
|
||||
{current && <p>Current match · {current.label}</p>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function TeamNameEditor({ team, seriesId }: { team: Team; seriesId: string }) {
|
||||
@@ -682,25 +782,39 @@ function TeamNameEditor({ team, seriesId }: { team: Team; seriesId: string }) {
|
||||
})
|
||||
return <form className="team-name-editor" onSubmit={(event) => { event.preventDefault(); rename.mutate() }}><label><span>Team name</span><input minLength={2} maxLength={32} required value={name} onChange={(event) => setName(event.target.value)} /></label><button className="button secondary" disabled={rename.isPending || name.trim() === team.name}>{rename.isPending ? 'Saving…' : 'Rename'}</button>{rename.isError && <small className="error-note">{rename.error.message}</small>}</form>
|
||||
}
|
||||
function HeroBanDisplay({ series }: { series: Series }) {
|
||||
const activeMap = activeHeroBanMap(series)
|
||||
if (!activeMap) return null
|
||||
const bans = activeMap.heroBans
|
||||
return <section className="card hero-ban-panel"><div className="section-title"><div><h2>Hero bans</h2><p>{activeMap.name}</p></div><Badge tone="warning">{bans.length}/4</Badge></div><div className="ban-display">{Array.from({ length: 4 }, (_, index) => bans[index] ? <div key={`${bans[index].hero}-${index}`}><span>Ban {index + 1}</span><strong>{bans[index].hero}</strong><small>{bans[index].role}</small></div> : <div className="pending-ban" key={`pending-${index}`}><span>Ban {index + 1}</span><strong>Pending</strong></div>)}</div></section>
|
||||
}
|
||||
function SeriesBanHistory({ series }: { series: Series }) {
|
||||
const hasBans = series.heroBanHistory.alpha.length > 0 || series.heroBanHistory.beta.length > 0
|
||||
if (!hasBans) return null
|
||||
return <section className="card series-ban-history"><div className="section-title"><div><h2>Bo3 hero ban history</h2><p>A team cannot repeat its own hero ban.</p></div></div><div className="series-ban-teams">{([['alpha', series.teamAlpha], ['beta', series.teamBeta]] as const).map(([side, team]) => <div className={`team-${side}`} key={side}><strong>{team.name}</strong><div>{series.heroBanHistory[side].length > 0 ? series.heroBanHistory[side].map((hero) => <span key={hero}>{hero}</span>) : <small>No bans yet</small>}</div></div>)}</div></section>
|
||||
}
|
||||
function MapTimeline({ series }: { series: Series }) { return <section className="card map-timeline"><div className="section-title"><div><h2>Map timeline</h2><p>First to 2 wins</p></div></div>{series.maps.length === 0 ? <p className="empty-note">No results recorded.</p> : series.maps.map((map) => <div className={`map-row ${map.status}`} key={map.number}><span>0{map.number}</span><div><strong>{map.name}</strong><small>{map.mode} · {map.status}</small></div>{map.winner && <Badge tone={map.winner === 'alpha' ? 'warning' : 'blue'}>{map.winner === 'alpha' ? 'A' : map.winner === 'beta' ? 'B' : 'Draw'}</Badge>}</div>)}</section> }
|
||||
function AuditLog({ series }: { series: Series }) { return <section className="card audit-log"><div className="section-title"><div><h2>Match history</h2><p>Auditable server actions</p></div></div>{series.audit.length === 0 ? <p className="empty-note">No actions yet.</p> : series.audit.slice(-4).reverse().map((a) => <div key={a.id}><span className="history-dot" /><p><strong>{a.summary}</strong><small>{a.actorName} · {new Date(a.createdAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}</small></p></div>)}</section> }
|
||||
function AuditLog({ series }: { series: Series }) { return <section className="card audit-log"><div className="section-title"><div><h2>Match history</h2><p>All server actions · newest first</p></div><Badge tone="neutral">{series.audit.length}</Badge></div>{series.audit.length === 0 ? <p className="empty-note">No actions yet.</p> : <div className="audit-log-list" tabIndex={0}>{[...series.audit].reverse().map((a) => <div key={a.id}><span className="history-dot" /><p><strong>{a.summary}</strong><small>{a.actorName} · {new Date(a.createdAt).toLocaleString(undefined, { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })}</small></p></div>)}</div>}</section> }
|
||||
|
||||
function SpectatorPage() {
|
||||
const { seriesId } = spectatorRoute.useParams()
|
||||
const series = useQuery({ queryKey: ['series', seriesId], queryFn: () => api.series(seriesId), initialData: demoMode && seriesId === demoSeries.id ? demoSeries : undefined })
|
||||
if (series.isLoading) return <LoadingState />
|
||||
if (!series.data) return <ErrorState message={series.error?.message} retry={() => void series.refetch()} />
|
||||
const session = useQuery({ queryKey: ['session'], queryFn: api.session, initialData: demoMode ? demoSession : undefined })
|
||||
if (series.isLoading || session.isLoading) return <LoadingState />
|
||||
if (!series.data || !session.data) return <ErrorState message={series.error?.message ?? session.error?.message} retry={() => { void series.refetch(); void session.refetch() }} />
|
||||
const data = series.data
|
||||
return <div className="page spectator-page"><PageHeader eyebrow="Spectator mode" title={data.roundLabel} description="Read-only live view · Updates automatically" actions={<Badge tone="success"><span className="live-dot" />Connected</Badge>} /><Scoreboard series={data} /><section className="spectator-focus"><span className="eyebrow">{data.status}</span><h2>{data.currentStep.title}</h2><p>{data.currentStep.instruction}</p>{data.maps.at(-1)?.heroBans.length ? <div className="ban-display">{data.maps.at(-1)?.heroBans.map((ban, index) => <div key={`${ban.hero}-${index}`}><span>Ban {index + 1}</span><strong>{ban.hero}</strong><small>{ban.role}</small></div>)}</div> : null}</section><div className="spectator-grid"><MapTimeline series={data} /><AuditLog series={data} /></div></div>
|
||||
const participant = [data.teamAlpha, data.teamBeta].some((team) => team.members.some(({ player }) => player.id === session.data.player.id))
|
||||
const canOpenControl = participant || isStaff(session.data.account.role)
|
||||
return <div className="page spectator-page"><PageHeader eyebrow="Spectator mode" title={data.roundLabel} description="Read-only live view · Updates automatically" actions={<>{canOpenControl && <Link className="button secondary" to="/series/$seriesId" params={{ seriesId: data.id }}><Shield />Series control</Link>}<Badge tone="success"><span className="live-dot" />Connected</Badge></>} /><Scoreboard series={data} /><section className="spectator-focus"><span className="eyebrow">{data.status}</span><h2>{data.currentStep.title}</h2><p>{data.currentStep.instruction}</p></section><HeroBanDisplay series={data} /><SeriesBanHistory series={data} /><div className="spectator-grid"><MapTimeline series={data} /><AuditLog series={data} /></div></div>
|
||||
}
|
||||
|
||||
function BracketPage() {
|
||||
const { eventId: tournamentId } = bracketRoute.useParams()
|
||||
const bracket = useQuery({ queryKey: ['tournament', tournamentId], queryFn: () => api.bracket(tournamentId), initialData: demoMode && tournamentId === demoBracket.id ? demoBracket : undefined })
|
||||
const { eventId } = bracketRoute.useParams()
|
||||
const bracket = useQuery({ queryKey: ['tournament', eventId], queryFn: () => api.bracket(eventId), initialData: demoMode ? demoBracket : undefined })
|
||||
if (bracket.isLoading) return <LoadingState label="Loading bracket…" />
|
||||
if (bracket.isError || !bracket.data) return <ErrorState retry={() => void bracket.refetch()} />
|
||||
const data = bracket.data
|
||||
return <div className="page bracket-page"><PageHeader eyebrow="Single elimination" title={data.title} description="Winners advance automatically when the server completes a series." actions={<Badge tone="live"><Radio />{data.matches.filter((match) => match.status === 'live').length} live</Badge>} /><div className="bracket-scroll" tabIndex={0} aria-label="Tournament bracket">{data.rounds.map((round, ri) => <section className="bracket-round" key={round}><header><span>Round {ri + 1}</span><h2>{round}</h2></header><div className="bracket-matches">{data.matches.filter((m) => m.round === ri).map((m) => <article className={`bracket-match ${m.status}`} key={m.id}><div className="match-label"><span>{m.label}</span>{m.status === 'live' && <Badge tone="live">Live</Badge>}</div><div className={m.winner === 'alpha' ? 'winner' : ''}><span>{m.teamAlpha ?? 'TBD'}</span><strong>{m.scoreAlpha ?? '—'}</strong></div><div className={m.winner === 'beta' ? 'winner' : ''}><span>{m.teamBeta ?? 'TBD'}</span><strong>{m.scoreBeta ?? '—'}</strong></div>{m.seriesId && <Link to="/watch/$seriesId" params={{ seriesId: m.seriesId }}>Open series <ChevronRight /></Link>}</article>)}</div></section>)}</div><p className="bracket-help"><ChevronRight />Scroll horizontally to follow the bracket on smaller screens.</p></div>
|
||||
return <div className="page bracket-page"><PageHeader eyebrow="Single elimination" title={data.title} description="Winners advance automatically when the server completes a series." actions={<Badge tone="live"><Radio />{data.matches.filter((match) => match.status === 'live').length} live</Badge>} /><div className="bracket-scroll" tabIndex={0} aria-label="Tournament bracket">{data.rounds.map((round, ri) => <section className="bracket-round" key={round}><header><span>Round {ri + 1}</span><h2>{round}</h2></header><div className="bracket-matches">{data.matches.filter((m) => m.round === ri).map((m) => <article className={`bracket-match ${m.status}`} key={m.id}><div className="match-label"><span>{m.label}</span>{m.status === 'live' && <Badge tone="live">Live</Badge>}</div><div className={m.winner === 'alpha' ? 'winner' : ''}><span>{m.teamAlpha ?? 'TBD'}</span><strong>{m.scoreAlpha ?? '—'}</strong></div><div className={m.winner === 'beta' ? 'winner' : ''}><span>{m.teamBeta ?? 'TBD'}</span><strong>{m.scoreBeta ?? '—'}</strong></div>{m.seriesId && <Link to="/events/$eventId/live" params={{ eventId }} search={{ seriesId: m.seriesId }}>Open series <ChevronRight /></Link>}</article>)}</div></section>)}</div><p className="bracket-help"><ChevronRight />Scroll horizontally to follow the bracket on smaller screens.</p></div>
|
||||
}
|
||||
|
||||
function App() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { adaptEvent, adaptPlayer, adaptSeries, api, type RawEvent, type RawPlayer, type RawSeries } from './client'
|
||||
import { adaptEvent, adaptPlayer, adaptSeries, adaptTournament, api, type RawEvent, type RawPlayer, type RawSeries } from './client'
|
||||
|
||||
const player: RawPlayer = {
|
||||
id: 'player-1',
|
||||
@@ -79,6 +79,21 @@ describe('backend DTO adapters', () => {
|
||||
}
|
||||
expect(adaptSeries(series).score).toEqual({ alpha: 0, beta: 1 })
|
||||
expect(adaptSeries({ ...series, phase: 'CoinTossPending', results: null, maps: null, audit: null }).score).toEqual({ alpha: 0, beta: 0 })
|
||||
expect(adaptTournament({ id: 't-1', eventId: 'event-1', name: 'Cup', winnerTeamId: '', teamIds: ['a', 'b'], rounds: [[{ ...series, phase: 'MapBan' }]] }).matches[0].status).toBe('live')
|
||||
const heroDraft = adaptSeries({
|
||||
...series,
|
||||
phase: 'HeroBan',
|
||||
seriesBans: { a: ['Ana'], b: ['Tracer'] },
|
||||
heroDraft: {
|
||||
heroes: [{ name: 'Ana', role: 'Support' }, { name: 'Kiriko', role: 'Support' }],
|
||||
teamIds: ['a', 'b'],
|
||||
firstTeamId: 'a',
|
||||
bansPerTeam: 2,
|
||||
currentBans: [],
|
||||
},
|
||||
})
|
||||
expect(heroDraft.heroBanHistory).toEqual({ alpha: ['Ana'], beta: ['Tracer'] })
|
||||
expect(heroDraft.options.find((option) => option.name === 'Ana')).toMatchObject({ disabled: true })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ export interface DraftAction {
|
||||
}
|
||||
export interface Series {
|
||||
id: string
|
||||
eventId: string
|
||||
tournamentId?: string
|
||||
status: 'scheduled' | 'coin_toss' | 'map_draft' | 'hero_draft' | 'playing' | 'completed'
|
||||
roundLabel: string
|
||||
@@ -97,6 +98,7 @@ export interface Series {
|
||||
version: number
|
||||
}
|
||||
options: DraftOption[]
|
||||
heroBanHistory: Record<TeamSide, string[]>
|
||||
maps: Array<{
|
||||
number: number
|
||||
name: string
|
||||
@@ -109,7 +111,7 @@ export interface Series {
|
||||
}
|
||||
export interface BracketMatch {
|
||||
id: string; round: number; slot: number; label: string
|
||||
teamAlpha?: string; teamBeta?: string; scoreAlpha?: number; scoreBeta?: number
|
||||
teamAlpha?: string; teamBeta?: string; teamAlphaId?: string; teamBetaId?: string; scoreAlpha?: number; scoreBeta?: number
|
||||
winner?: TeamSide; seriesId?: string; status: 'pending' | 'ready' | 'live' | 'completed'
|
||||
}
|
||||
export interface Bracket { id: string; title: string; rounds: string[]; matches: BracketMatch[] }
|
||||
@@ -164,6 +166,7 @@ export interface RawSeries {
|
||||
mapDraft?: { pool: string[] | null; banned: string[] | null; firstTeamId: string; teamIds: [string, string]; actions: RawDraftAction[] | null }
|
||||
heroDraft?: { heroes: Array<{ name: string; role: 'Tank' | 'Damage' | 'Support' }> | null; teamIds: [string, string]; firstTeamId: string; bansPerTeam: number; currentBans: RawDraftAction[] | null }
|
||||
currentMap: string; playedMaps: string[] | null; nextMapPickerId: string; availableMaps: string[] | null
|
||||
seriesBans?: Record<string, string[]> | null
|
||||
maps: Array<{ number: number; name: string; heroBans: RawDraftAction[] | null; result?: RawMapResult }> | null
|
||||
audit: Array<{ kind: DraftAction['kind']; summary: string; teamId?: string; actorAccountId: string; at: string }> | null
|
||||
results: RawMapResult[] | null; version: number
|
||||
@@ -345,10 +348,14 @@ export function adaptSeries(raw: RawSeries, teams: Team[] = []): Series {
|
||||
: phase === 'MapPick'
|
||||
? (raw.availableMaps ?? []).map((name) => ({ id: name, name }))
|
||||
: phase === 'HeroBan' && raw.heroDraft
|
||||
? (raw.heroDraft.heroes ?? []).filter((hero) => !(raw.heroDraft?.currentBans ?? []).some((ban) => ban.value === hero.name)).map((hero) => ({ id: hero.name, name: hero.name, role: roleFromRaw(hero.role) }))
|
||||
? (raw.heroDraft.heroes ?? []).filter((hero) => !(raw.heroDraft?.currentBans ?? []).some((ban) => ban.value === hero.name)).map((hero) => {
|
||||
const repeated = (raw.seriesBans?.[nextHeroTeam ?? ''] ?? []).includes(hero.name)
|
||||
return { id: hero.name, name: hero.name, role: roleFromRaw(hero.role), disabled: repeated, disabledReason: repeated ? 'Already banned by this team in this Bo3' : undefined }
|
||||
})
|
||||
: []
|
||||
return {
|
||||
id: raw.id,
|
||||
eventId: raw.eventId,
|
||||
tournamentId: raw.tournamentId,
|
||||
status,
|
||||
roundLabel: 'Tournament series',
|
||||
@@ -364,13 +371,20 @@ export function adaptSeries(raw: RawSeries, teams: Team[] = []): Series {
|
||||
version: raw.version,
|
||||
},
|
||||
options,
|
||||
heroBanHistory: {
|
||||
alpha: raw.seriesBans?.[raw.teamAId] ?? [],
|
||||
beta: raw.seriesBans?.[raw.teamBId] ?? [],
|
||||
},
|
||||
maps: (raw.maps ?? activeResults.map((result, index) => ({ number: index + 1, name: result.mapName, heroBans: [], result }))).map((map) => ({
|
||||
number: map.number,
|
||||
name: map.name,
|
||||
mode: 'Map',
|
||||
status: map.result ? 'completed' : phase === 'Playing' ? 'playing' : phase === 'HeroBan' ? 'ready' : 'drafting',
|
||||
winner: map.result ? (map.result.outcome === 'TeamAWin' ? 'alpha' : map.result.outcome === 'TeamBWin' ? 'beta' : 'draw') : undefined,
|
||||
heroBans: (map.heroBans ?? []).map((ban) => ({ hero: ban.value, team: ban.teamId === raw.teamAId ? 'alpha' : 'beta', role: 'damage' as PlayerRole })),
|
||||
heroBans: (map.heroBans ?? []).map((ban) => {
|
||||
const heroRole = raw.heroDraft?.heroes?.find((hero) => hero.name === ban.value)?.role
|
||||
return { hero: ban.value, team: ban.teamId === raw.teamAId ? 'alpha' : 'beta', role: heroRole ? roleFromRaw(heroRole) : 'damage' as PlayerRole }
|
||||
}),
|
||||
})),
|
||||
audit: (raw.audit ?? []).map((action, index) => ({ id: `action-${index}`, kind: action.kind, summary: action.summary, actorName: action.actorAccountId, createdAt: action.at })),
|
||||
}
|
||||
@@ -390,11 +404,13 @@ export function adaptTournament(raw: RawTournament, teams: Team[] = []): Bracket
|
||||
label: `Match ${slot + 1}`,
|
||||
teamAlpha: names.get(series.teamAId) ?? (series.teamAId || undefined),
|
||||
teamBeta: names.get(series.teamBId) ?? (series.teamBId || undefined),
|
||||
teamAlphaId: series.teamAId || undefined,
|
||||
teamBetaId: series.teamBId || undefined,
|
||||
scoreAlpha: adapted.score.alpha,
|
||||
scoreBeta: adapted.score.beta,
|
||||
winner: series.winnerTeamId ? (series.winnerTeamId === series.teamAId ? 'alpha' : 'beta') : undefined,
|
||||
seriesId: series.id,
|
||||
status: series.winnerTeamId ? 'completed' : series.teamAId && series.teamBId ? 'ready' : 'pending',
|
||||
status: series.winnerTeamId ? 'completed' : series.teamAId && series.teamBId && series.phase ? 'live' : series.teamAId && series.teamBId ? 'ready' : 'pending',
|
||||
} satisfies BracketMatch
|
||||
})),
|
||||
}
|
||||
|
||||
@@ -102,6 +102,7 @@ export const demoEvents: MixEvent[] = [
|
||||
version: 6,
|
||||
rulesetId: 'standard-control-hybrid-control',
|
||||
activeSeriesId: 'series-1',
|
||||
tournamentId: 'bracket-1',
|
||||
},
|
||||
{
|
||||
id: 'event-4',
|
||||
@@ -224,6 +225,8 @@ export const demoBalanceCandidates: BalanceCandidate[] = [
|
||||
|
||||
export const demoSeries: Series = {
|
||||
id: 'series-1',
|
||||
eventId: 'event-3',
|
||||
tournamentId: 'bracket-1',
|
||||
status: 'hero_draft',
|
||||
roundLabel: 'Semifinal 1',
|
||||
teamAlpha: alpha,
|
||||
@@ -245,6 +248,10 @@ export const demoSeries: Series = {
|
||||
{ id: 'hero-5', name: 'Winston', role: 'tank', disabled: true, disabledReason: 'Already banned by this team' },
|
||||
{ id: 'hero-6', name: 'Sigma', role: 'tank' },
|
||||
],
|
||||
heroBanHistory: {
|
||||
alpha: ['Winston', 'D.Va'],
|
||||
beta: ['Ana', 'Tracer'],
|
||||
},
|
||||
maps: [
|
||||
{
|
||||
number: 1,
|
||||
@@ -282,8 +289,8 @@ export const demoBracket: Bracket = {
|
||||
title: 'Summer Clash #4',
|
||||
rounds: ['Semifinals', 'Grand Final'],
|
||||
matches: [
|
||||
{ id: 'm1', round: 0, slot: 0, label: 'Semifinal 1', teamAlpha: 'Ember Wolves', teamBeta: 'Azure Phantoms', scoreAlpha: 1, scoreBeta: 0, status: 'live', seriesId: 'series-1' },
|
||||
{ id: 'm2', round: 0, slot: 1, label: 'Semifinal 2', teamAlpha: 'Neon Foxes', teamBeta: 'Void Runners', scoreAlpha: 2, scoreBeta: 1, winner: 'alpha', status: 'completed', seriesId: 'series-2' },
|
||||
{ id: 'm1', round: 0, slot: 0, label: 'Semifinal 1', teamAlpha: 'Ember Wolves', teamBeta: 'Azure Phantoms', teamAlphaId: 'team-alpha', teamBetaId: 'team-beta', scoreAlpha: 1, scoreBeta: 0, status: 'live', seriesId: 'series-1' },
|
||||
{ id: 'm2', round: 0, slot: 1, label: 'Semifinal 2', teamAlpha: 'Neon Foxes', teamBeta: 'Void Runners', teamAlphaId: 'team-neon', teamBetaId: 'team-void', scoreAlpha: 0, scoreBeta: 1, status: 'live', seriesId: 'series-2' },
|
||||
{ id: 'm3', round: 1, slot: 0, label: 'Grand Final', teamBeta: 'Neon Foxes', status: 'pending' },
|
||||
],
|
||||
}
|
||||
|
||||
@@ -171,6 +171,21 @@ const translations: Record<string, string> = {
|
||||
'Select from roster': 'Выбрать из состава',
|
||||
'avg': 'среднее',
|
||||
'Series control': 'Управление серией',
|
||||
'Open live': 'Открыть эфир',
|
||||
'active matches': 'матча одновременно',
|
||||
'Opening your live match…': 'Открываем ваш матч…',
|
||||
'Tournament bracket': 'Турнирная сетка',
|
||||
'Open full bracket': 'Открыть полную сетку',
|
||||
'Previous live match': 'Предыдущий активный матч',
|
||||
'Next live match': 'Следующий активный матч',
|
||||
'Hero bans': 'Баны героев',
|
||||
'Bo3 hero ban history': 'История банов героев Bo3',
|
||||
'A team cannot repeat its own hero ban.': 'Команда не может повторять собственный бан героя.',
|
||||
'No bans yet': 'Банов пока нет',
|
||||
'All server actions · newest first': 'Все серверные действия · новые сверху',
|
||||
'Already banned by this team in this Bo3': 'Эта команда уже банила героя в данном Bo3',
|
||||
'Pending': 'Ожидание',
|
||||
'Captain action': 'Действие капитана',
|
||||
'Actions are validated and recorded by the server.': 'Действия проверяются и записываются сервером.',
|
||||
'Spectator view': 'Режим зрителя',
|
||||
'Current step': 'Текущий шаг',
|
||||
|
||||
@@ -82,6 +82,7 @@ main { min-height: calc(100vh - 72px); }
|
||||
.featured-score > span { font-size: 10px; letter-spacing: .1em; color: var(--muted); font-weight: 800; }
|
||||
.featured-score strong { font-size: 42px; letter-spacing: -.06em; }
|
||||
.featured-score i, .series-score i { color: #59616c; font-style: normal; padding: 0 8px; }
|
||||
.featured-event.multiple-matches { min-height: 210px; }.featured-match-list { display: grid; gap: 8px; min-width: 340px; }.featured-match-score { display: grid; grid-template-columns: 58px minmax(70px,1fr) auto minmax(70px,1fr); align-items: center; gap: 9px; padding: 9px 11px; border: 1px solid var(--border); border-radius: 7px; background: rgba(8,11,15,.72); }.featured-match-score small { color: var(--orange); font-size: 8px; text-transform: uppercase; }.featured-match-score span { color: var(--muted); font-size: 9px; font-weight: 800; text-align: center; }.featured-match-score strong { font-size: 21px; white-space: nowrap; }.featured-match-score i { color: #59616c; font-style: normal; padding: 0 5px; }
|
||||
.section-title { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 28px 0 15px; }
|
||||
.section-title h2 { font-size: 17px; }
|
||||
.section-title p { color: var(--muted); font-size: 12px; margin-top: 4px; }
|
||||
@@ -252,6 +253,7 @@ textarea { min-height: 85px; resize: vertical; }
|
||||
.moderator-panel { padding: 22px; }.moderator-panel > .section-title { margin-top: 0; }.moderator-list { display: grid; gap: 7px; }.moderator-list > div { min-height: 58px; display: grid; grid-template-columns: 34px minmax(0,1fr) auto; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; background: #101319; }.moderator-list > div > span:nth-child(2) { display: grid; }.moderator-list small { color: var(--muted); }.moderator-toggle { display: flex; align-items: center; gap: 8px; color: var(--muted); cursor: pointer; }.moderator-toggle input { accent-color: var(--orange); }
|
||||
|
||||
.scoreboard { min-height: 150px; display: grid; grid-template-columns: 1fr 210px 1fr; align-items: center; border: 1px solid var(--border); background: linear-gradient(90deg,rgba(74,37,13,.35),rgba(16,19,24,.9) 42% 58%,rgba(10,45,70,.35)); border-radius: 12px; overflow: hidden; }
|
||||
.tournament-strip { margin-bottom: 12px; padding: 14px; border: 1px solid var(--border); border-radius: 10px; background: #0d1014; }.tournament-strip > header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }.tournament-strip > header span,.tournament-strip > header a { display: flex; align-items: center; gap: 6px; font-size: 10px; }.tournament-strip > header svg { width: 15px; color: var(--orange); }.tournament-strip-body { display: grid; grid-template-columns: 34px minmax(0,1fr) 34px; align-items: center; gap: 8px; }.mini-bracket { display: flex; gap: 12px; overflow-x: auto; padding: 2px; }.mini-bracket > div { min-width: 175px; display: grid; gap: 5px; }.mini-bracket small { color: var(--muted); text-transform: uppercase; font-size: 8px; }.mini-match { display: grid; gap: 3px; padding: 7px 9px; border: 1px solid var(--border); border-radius: 6px; color: var(--muted); font-size: 9px; }.mini-match span { display: flex; justify-content: space-between; gap: 8px; }.mini-match.current { border-color: var(--orange); background: var(--orange-soft); color: var(--text); }.mini-match.live:not(.current) { border-color: #285c43; }.match-arrow { width: 34px; height: 44px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 7px; background: var(--panel); color: var(--text); }.match-arrow.disabled { opacity: .3; }.tournament-strip > p { margin: 8px 42px 0; color: var(--muted); font-size: 9px; }
|
||||
.score-team { padding: 25px 30px; }.score-team > span { color: var(--orange); font-size: 9px; text-transform: uppercase; letter-spacing: .18em; font-weight: 900; }.score-team h2 { margin: 5px 0; font-size: clamp(18px,2vw,28px); }.score-team small { color: var(--muted); }.score-team.beta { text-align: right; }.score-team.beta > span { color: var(--blue); }
|
||||
.series-score { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; border-left: 1px solid var(--border); border-right: 1px solid var(--border); }.series-score > span { color: var(--muted); text-transform: uppercase; letter-spacing: .16em; font-size: 9px; }.series-score strong { font-size: 48px; line-height: 1.25; }
|
||||
.team-name-controls { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 12px; margin-top: 12px; }.team-name-editor { display: flex; align-items: end; gap: 8px; padding: 12px; border: 1px solid var(--border); border-radius: 9px; background: var(--surface); }.team-name-editor label { flex: 1; display: grid; gap: 5px; }.team-name-editor label span { color: var(--muted); font-size: 9px; text-transform: uppercase; letter-spacing: .08em; }.team-name-editor input { width: 100%; }.team-name-editor .error-note { align-self: center; }
|
||||
@@ -270,10 +272,12 @@ textarea { min-height: 85px; resize: vertical; }
|
||||
.empty-note { color: var(--muted); font-size: 11px; padding: 12px 0; }
|
||||
.match-sidebar { display: grid; gap: 12px; }.map-timeline, .audit-log { padding: 18px; }.map-timeline .section-title, .audit-log .section-title { margin-top: 0; }
|
||||
.map-row { display: grid; grid-template-columns: 28px 1fr auto; align-items: center; gap: 9px; min-height: 54px; border-top: 1px solid var(--border); }.map-row > span { color: #515963; font-weight: 900; }.map-row > div { display: flex; flex-direction: column; gap: 4px; font-size: 11px; }.map-row small { color: var(--muted); font-size: 9px; text-transform: capitalize; }.map-row.drafting > span { color: var(--orange); }
|
||||
.audit-log > div:not(.section-title) { display: grid; grid-template-columns: 10px 1fr; gap: 8px; padding: 9px 0; }.history-dot { width: 6px; height: 6px; margin-top: 4px; border-radius: 50%; background: #4b525d; }.audit-log p { display: flex; flex-direction: column; gap: 4px; font-size: 10px; }.audit-log small { color: var(--muted); }
|
||||
.audit-log-list { max-height: 310px; overflow-y: auto; padding-right: 7px; scrollbar-color: #424a55 transparent; scrollbar-width: thin; }.audit-log-list > div { display: grid; grid-template-columns: 10px 1fr; gap: 8px; padding: 9px 0; border-bottom: 1px solid rgba(255,255,255,.04); }.audit-log-list > div:last-child { border-bottom: 0; }.history-dot { width: 6px; height: 6px; margin-top: 4px; border-radius: 50%; background: #4b525d; }.audit-log p { display: flex; flex-direction: column; gap: 4px; font-size: 10px; }.audit-log small { color: var(--muted); }
|
||||
|
||||
.spectator-page { max-width: 1100px; }.spectator-focus { margin: 16px 0; padding: 32px; text-align: center; border: 1px solid #244f70; border-radius: 12px; background: radial-gradient(circle at 50% 0,rgba(32,127,192,.17),transparent 60%),var(--surface); }.spectator-focus h2 { font-size: 27px; margin: 8px 0; }.spectator-focus > p { color: var(--blue); font-size: 11px; }
|
||||
.ban-display { display: grid; grid-template-columns: repeat(4,1fr); gap: 8px; margin-top: 28px; }.ban-display > div { min-height: 105px; padding: 14px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 7px; border: 1px solid var(--border); border-radius: 7px; background: #0d1014; }.ban-display span,.ban-display small { color: var(--muted); font-size: 9px; }.ban-display strong { font-size: 17px; }.ban-display .pending-ban { border-color: var(--blue); background: var(--blue-soft); color: var(--blue); }
|
||||
.hero-ban-panel { padding: 16px; }.hero-ban-panel .section-title { margin-top: 0; }.hero-ban-panel .ban-display { grid-template-columns: repeat(2,1fr); margin-top: 10px; }.hero-ban-panel .ban-display > div { min-height: 72px; padding: 8px; }.hero-ban-panel .ban-display strong { font-size: 11px; }
|
||||
.series-ban-history { padding: 16px; }.series-ban-history .section-title { margin-top: 0; }.series-ban-teams { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 9px; }.series-ban-teams > div { padding: 10px; border: 1px solid var(--border); border-radius: 7px; }.series-ban-teams > div.team-alpha { border-color: rgba(255,122,26,.35); }.series-ban-teams > div.team-beta { border-color: rgba(32,147,255,.35); }.series-ban-teams > div > strong { display: block; margin-bottom: 8px; font-size: 10px; }.series-ban-teams > div > div { display: flex; flex-wrap: wrap; gap: 5px; }.series-ban-teams span { padding: 4px 6px; border-radius: 4px; background: #171b22; color: var(--muted); font-size: 8px; }.series-ban-teams small { color: var(--muted); font-size: 8px; }
|
||||
.spectator-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.bracket-scroll { min-height: 520px; display: flex; gap: 110px; padding: 30px 60px; overflow-x: auto; border: 1px solid var(--border); border-radius: 12px; background: radial-gradient(circle at 50% 50%,#171b22,transparent 55%),#0d1014; }
|
||||
.bracket-round { min-width: 300px; }.bracket-round > header { margin-bottom: 20px; }.bracket-round > header span { color: var(--orange); font-size: 9px; text-transform: uppercase; letter-spacing: .15em; }.bracket-round > header h2 { margin-top: 4px; font-size: 18px; }
|
||||
@@ -302,7 +306,7 @@ textarea { min-height: 85px; resize: vertical; }
|
||||
.topbar { height: 62px; padding: 0 16px; }.page { padding: 28px 14px 92px; }.page-header { align-items: flex-start; flex-direction: column; margin-bottom: 22px; }.page-header h1 { font-size: 34px; }.page-actions { width: 100%; }
|
||||
.bottom-nav { display: grid !important; position: fixed; z-index: 20; bottom: 0; left: 0; right: 0; grid-template-columns: repeat(4,1fr); padding: 6px 8px max(6px,env(safe-area-inset-bottom)); border-top: 1px solid var(--border); background: rgba(13,16,20,.96); backdrop-filter: blur(16px); }
|
||||
.bottom-nav a { min-height: 52px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; color: #747c87; font-size: 9px; font-weight: 700; }.bottom-nav a svg { font-size: 18px; }.bottom-nav a.active { color: var(--orange); }
|
||||
.featured-event { padding: 23px; grid-template-columns: 1fr; }.featured-event > * { grid-column: 1; }.featured-score { margin: 10px 0; }.featured-event .button { width: 100%; }.featured-event h2 { font-size: 22px; }
|
||||
.featured-event { padding: 23px; grid-template-columns: 1fr; }.featured-event > * { grid-column: 1; }.featured-score { margin: 10px 0; }.featured-match-list { min-width: 0; width: 100%; }.featured-match-score { grid-template-columns: 45px minmax(55px,1fr) auto minmax(55px,1fr); padding: 8px 6px; gap: 5px; }.featured-event .button { width: 100%; }.featured-event h2 { font-size: 22px; }
|
||||
.event-card { grid-template-columns: 70px 1fr; }.date-block strong { font-size: 28px; }.event-card-body { padding: 17px; }
|
||||
.detail-layout { gap: 10px; }.event-hero,.roster-preview,.public-roster { padding: 20px; }.rsvp-control { grid-template-columns: 1fr; }.rsvp-control button { justify-content: flex-start; padding-left: 18px; }.public-roster-grid { grid-template-columns: 1fr; }
|
||||
.faq-grid { grid-template-columns: 1fr; }.faq-card { min-height: 0; }
|
||||
|
||||
6
frontend/src/series-utils.ts
Normal file
6
frontend/src/series-utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { Series } from './api/client'
|
||||
|
||||
export function activeHeroBanMap(series: Series) {
|
||||
if (series.currentStep.kind !== 'hero_ban' && series.currentStep.kind !== 'result') return undefined
|
||||
return [...series.maps].reverse().find((map) => map.status !== 'completed')
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Собран полный вертикальный production-пайплайн скрима: закрытие регистрации → versioned балансировка → ручная правка и подтверждение составов 1/2/2 → капитаны → атомарный старт Bo3 или турнира → жеребьёвка → баны карт → баны героев → результаты и завершение. Состояние события, roster draft и серии сохраняется в PostgreSQL с optimistic locking, аудитом и глобальной SSE-синхронизацией без ручного обновления страницы.
|
||||
|
||||
Admin UI содержит пошаговый workflow и roster editor с same-role swap, резервом и запуском. Live, spectator и bracket используют реальные API-команды и realtime refresh; demo fixtures остались только локальным showcase.
|
||||
Admin UI содержит пошаговый workflow и roster editor с same-role swap, резервом и запуском. Live, spectator и bracket используют реальные API-команды и realtime refresh; участник турнира автоматически открывает собственный матч, остальные попадают в spectator mode, а актуальная сетка и соседние параллельные матчи доступны прямо над серией. Demo fixtures остались только локальным showcase.
|
||||
Добавлена роль Moderator: она имеет все операционные права Admin, но управление списком модераторов доступно только Admin. До состояния Live workflow можно откатить на один этап назад.
|
||||
|
||||
## Подтверждённые требования
|
||||
@@ -26,7 +26,7 @@ Admin UI содержит пошаговый workflow и roster editor с same-r
|
||||
- последовательные баны карт и героев;
|
||||
- серии Bo3;
|
||||
- фиксация исхода каждой карты, счёта серии и итогового победителя;
|
||||
- турнирная сетка;
|
||||
- актуальная турнирная сетка с параллельными матчами, умным live-входом и переключением spectator/control;
|
||||
- production-развёртывание единым Docker Compose resource в Coolify;
|
||||
- PostgreSQL в том же Compose-стеке с persistent volume;
|
||||
- мобильный и десктопный интерфейс.
|
||||
@@ -48,7 +48,7 @@ Admin UI содержит пошаговый workflow и roster editor с same-r
|
||||
|
||||
## Предлагаемый стек
|
||||
|
||||
- backend: Go, PostgreSQL, REST, WebSocket/SSE;
|
||||
- backend: Go, PostgreSQL, REST, SSE;
|
||||
- frontend: React, TypeScript, Vite, TanStack Query/Router, Tailwind CSS, shadcn/ui;
|
||||
- контракт: OpenAPI;
|
||||
- локальная инфраструктура: Docker Compose;
|
||||
|
||||
@@ -88,7 +88,7 @@ Application-слой управляет единым versioned workflow собы
|
||||
|
||||
Ручное редактирование работает с серверным roster draft. Backend разрешает обмен только между одинаковыми ролевыми слотами и замену слота игроком из резерва, после чего пересчитывает средние рейтинги и метрики. Подтверждение требует полного состава 1/2/2, уникальных игроков и капитана внутри каждой команды.
|
||||
|
||||
`start-scrim` атомарно блокирует составы, создаёт одну или несколько Bo3-серий и первый draft state. Для двух команд создаётся одиночная серия; для четырёх и более подтверждённых команд создаётся single-elimination турнир. ID активной серии возвращается frontend, который переходит на live-экран.
|
||||
`start-scrim` атомарно блокирует составы, создаёт одну или несколько Bo3-серий и первый draft state. Для двух команд создаётся одиночная серия; для четырёх и более подтверждённых команд создаётся single-elimination турнир. Tournament read-model гидратируется свежими версиями всех сохранённых серий, поэтому параллельные матчи одного раунда независимо обновляют счёт и фазу в общей сетке. Умный event-level live-вход направляет участника в его матч, капитана/staff — к доступным командам, а остальных — в spectator mode; над серией остаются компактная сетка и навигация между активными матчами раунда.
|
||||
|
||||
### Драфт как конечный автомат
|
||||
|
||||
|
||||
@@ -236,6 +236,16 @@ paths:
|
||||
responses:
|
||||
"200": { description: Event cancelled, content: { application/json: { schema: { $ref: "#/components/schemas/Event" } } } }
|
||||
"409": { $ref: "#/components/responses/Conflict" }
|
||||
/api/events/{eventId}/tournament:
|
||||
get:
|
||||
tags: [Tournaments]
|
||||
operationId: getEventTournament
|
||||
description: Returns the tournament bracket hydrated with the latest state of every persisted series.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/EventId"
|
||||
responses:
|
||||
"200": { description: Live tournament bracket, content: { application/json: { schema: { $ref: "#/components/schemas/Tournament" } } } }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
/api/events/{eventId}/rsvps:
|
||||
get:
|
||||
tags: [Events]
|
||||
@@ -982,6 +992,7 @@ components:
|
||||
registrationDeadline:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Synchronized by the server to startsAt.
|
||||
Event:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/EventInput"
|
||||
@@ -1160,7 +1171,7 @@ components:
|
||||
type: integer
|
||||
Series:
|
||||
type: object
|
||||
required: [id, eventId, tournamentId, teamAId, teamBId, winnerTeamId, bestOf, rulesetId, phase, playedMaps, maps, audit, results, version]
|
||||
required: [id, eventId, tournamentId, teamAId, teamBId, winnerTeamId, bestOf, rulesetId, phase, playedMaps, seriesBans, maps, audit, results, version]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
@@ -1187,7 +1198,30 @@ components:
|
||||
playedMaps: { type: array, items: { type: string } }
|
||||
nextMapPickerId: { type: string }
|
||||
availableMaps: { type: array, items: { type: string } }
|
||||
maps: { type: array, items: { type: object } }
|
||||
seriesBans:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: array
|
||||
items: { type: string }
|
||||
maps:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [number, name, heroBans]
|
||||
properties:
|
||||
number: { type: integer }
|
||||
name: { type: string }
|
||||
heroBans:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [teamId, value, actorAccountId, at]
|
||||
properties:
|
||||
teamId: { type: string }
|
||||
value: { type: string }
|
||||
actorAccountId: { type: string }
|
||||
at: { type: string, format: date-time }
|
||||
result: { type: [object, "null"] }
|
||||
audit: { type: array, items: { type: object } }
|
||||
results:
|
||||
type: array
|
||||
|
||||
Reference in New Issue
Block a user