From 9f9efc2bc07810d2e06b37bad94e5922681eadef Mon Sep 17 00:00:00 2001 From: nsfisis Date: Wed, 18 Feb 2026 22:38:15 +0900 Subject: feat: refactor tournament to generic DB-backed N-person bracket Replace hardcoded 6-person tournament with a generic single-elimination bracket system backed by new DB tables (tournaments, tournament_entries, tournament_matches). Includes admin CRUD, standard seeding algorithm, bye handling, and a CSS Grid bracket renderer on the frontend. Add comprehensive tests for backend API/admin handlers, seeding logic, and frontend bracket component. Co-Authored-By: Claude Opus 4.6 --- backend/admin/handler.go | 287 +++++++++++++ backend/admin/handler_test.go | 298 ++++++++++++++ backend/admin/templates/dashboard.html | 3 + backend/admin/templates/tournament_edit.html | 48 +++ backend/admin/templates/tournament_new.html | 22 + backend/admin/templates/tournaments.html | 20 + backend/api/generated.go | 137 +++---- backend/api/handler.go | 295 +++++++++++--- backend/api/handler_test.go | 337 +++++++++++++++- backend/db/models.go | 23 ++ backend/db/querier.go | 11 + backend/db/query.sql.go | 246 ++++++++++++ backend/fixtures/dev.sql | 22 + backend/query.sql | 52 +++ backend/schema.sql | 30 ++ frontend/app/App.tsx | 10 +- frontend/app/api/client.ts | 12 +- frontend/app/api/schema.d.ts | 31 +- frontend/app/pages/TournamentPage.test.tsx | 60 +++ frontend/app/pages/TournamentPage.tsx | 575 +++++++++++---------------- openapi/api-server.yaml | 73 ++-- typespec/api-server/models.tsp | 18 +- typespec/api-server/routes.tsp | 10 +- 23 files changed, 2050 insertions(+), 570 deletions(-) create mode 100644 backend/admin/templates/tournament_edit.html create mode 100644 backend/admin/templates/tournament_new.html create mode 100644 backend/admin/templates/tournaments.html create mode 100644 frontend/app/pages/TournamentPage.test.tsx diff --git a/backend/admin/handler.go b/backend/admin/handler.go index 6ca0a3f..6e981bc 100644 --- a/backend/admin/handler.go +++ b/backend/admin/handler.go @@ -79,6 +79,12 @@ func (h *Handler) RegisterHandlers(g *echo.Group) { g.GET("/problems/:problemID/testcases/:testcaseID", h.getTestcaseEdit) g.POST("/problems/:problemID/testcases/:testcaseID", h.postTestcaseEdit) g.POST("/problems/:problemID/testcases/:testcaseID/delete", h.postTestcaseDelete) + + g.GET("/tournaments", h.getTournaments) + g.GET("/tournaments/new", h.getTournamentNew) + g.POST("/tournaments/new", h.postTournamentNew) + g.GET("/tournaments/:tournamentID", h.getTournamentEdit) + g.POST("/tournaments/:tournamentID", h.postTournamentEdit) } func (h *Handler) getDashboard(c echo.Context) error { @@ -951,3 +957,284 @@ func (h *Handler) postTestcaseDelete(c echo.Context) error { return c.Redirect(http.StatusSeeOther, h.conf.BasePath+"admin/problems/"+strconv.Itoa(problemID)+"/testcases") } + +func (h *Handler) getTournaments(c echo.Context) error { + rows, err := h.q.ListTournaments(c.Request().Context()) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + tournaments := make([]echo.Map, len(rows)) + for i, t := range rows { + tournaments[i] = echo.Map{ + "TournamentID": t.TournamentID, + "DisplayName": t.DisplayName, + "BracketSize": t.BracketSize, + } + } + + return c.Render(http.StatusOK, "tournaments", echo.Map{ + "BasePath": h.conf.BasePath, + "Title": "Tournaments", + "Tournaments": tournaments, + }) +} + +func (h *Handler) getTournamentNew(c echo.Context) error { + return c.Render(http.StatusOK, "tournament_new", echo.Map{ + "BasePath": h.conf.BasePath, + "Title": "New Tournament", + }) +} + +func nextPowerOf2(n int) int { + p := 1 + for p < n { + p *= 2 + } + return p +} + +func log2Int(n int) int { + r := 0 + for n > 1 { + n /= 2 + r++ + } + return r +} + +func (h *Handler) postTournamentNew(c echo.Context) error { + displayName := c.FormValue("display_name") + numParticipants, err := strconv.Atoi(c.FormValue("num_participants")) + if err != nil || numParticipants < 2 { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid num_participants") + } + + bracketSize := nextPowerOf2(numParticipants) + numRounds := log2Int(bracketSize) + + ctx := c.Request().Context() + err = h.txm.RunInTx(ctx, func(qtx db.Querier) error { + tournamentID, err := qtx.CreateTournament(ctx, db.CreateTournamentParams{ + DisplayName: displayName, + BracketSize: int32(bracketSize), + NumRounds: int32(numRounds), + }) + if err != nil { + return err + } + // Create match slots for all rounds + for round := 0; round < numRounds; round++ { + numPositions := bracketSize / (1 << (round + 1)) + for pos := 0; pos < numPositions; pos++ { + if err := qtx.CreateTournamentMatch(ctx, db.CreateTournamentMatchParams{ + TournamentID: tournamentID, + Round: int32(round), + Position: int32(pos), + }); err != nil { + return err + } + } + } + return nil + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + return c.Redirect(http.StatusSeeOther, h.conf.BasePath+"admin/tournaments") +} + +func (h *Handler) getTournamentEdit(c echo.Context) error { + tournamentID, err := strconv.Atoi(c.Param("tournamentID")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid tournament id") + } + ctx := c.Request().Context() + + tournament, err := h.q.GetTournamentByID(ctx, int32(tournamentID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return echo.NewHTTPError(http.StatusNotFound) + } + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + entryRows, err := h.q.ListTournamentEntries(ctx, int32(tournamentID)) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + seedUserMap := make(map[int]int) + for _, e := range entryRows { + seedUserMap[int(e.Seed)] = int(e.UserID) + } + + matchRows, err := h.q.ListTournamentMatches(ctx, int32(tournamentID)) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + matchGameMap := make(map[int]int) + var matches []echo.Map + for _, m := range matchRows { + gameID := 0 + if m.GameID != nil { + gameID = int(*m.GameID) + } + matchGameMap[int(m.TournamentMatchID)] = gameID + matches = append(matches, echo.Map{ + "MatchID": int(m.TournamentMatchID), + "Round": int(m.Round), + "Position": int(m.Position), + "Description": "R" + strconv.Itoa(int(m.Round)) + "P" + strconv.Itoa(int(m.Position)), + "IsBye": false, + }) + } + + userRows, err := h.q.ListUsers(ctx) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + var users []echo.Map + for _, r := range userRows { + users = append(users, echo.Map{ + "UserID": int(r.UserID), + "Username": r.Username, + }) + } + + gameRows, err := h.q.ListAllGames(ctx) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + var games []echo.Map + for _, g := range gameRows { + games = append(games, echo.Map{ + "GameID": int(g.GameID), + "DisplayName": g.DisplayName, + }) + } + + seeds := make([]int, tournament.BracketSize) + for i := range seeds { + seeds[i] = i + 1 + } + + return c.Render(http.StatusOK, "tournament_edit", echo.Map{ + "BasePath": h.conf.BasePath, + "Title": "Tournament Edit", + "Tournament": echo.Map{ + "TournamentID": tournament.TournamentID, + "DisplayName": tournament.DisplayName, + "BracketSize": tournament.BracketSize, + "NumRounds": tournament.NumRounds, + }, + "Seeds": seeds, + "SeedUserMap": seedUserMap, + "Matches": matches, + "MatchGameMap": matchGameMap, + "Users": users, + "Games": games, + }) +} + +func (h *Handler) postTournamentEdit(c echo.Context) error { + tournamentID, err := strconv.Atoi(c.Param("tournamentID")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid tournament id") + } + ctx := c.Request().Context() + + tournament, err := h.q.GetTournamentByID(ctx, int32(tournamentID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return echo.NewHTTPError(http.StatusNotFound) + } + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + displayName := c.FormValue("display_name") + + // Parse seed → user assignments + type seedEntry struct { + seed int + userID int + } + var seedEntries []seedEntry + for seed := 1; seed <= int(tournament.BracketSize); seed++ { + raw := c.FormValue("seed_" + strconv.Itoa(seed)) + if raw == "" || raw == "0" { + continue + } + userID, err := strconv.Atoi(raw) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid seed_"+strconv.Itoa(seed)) + } + seedEntries = append(seedEntries, seedEntry{seed: seed, userID: userID}) + } + + // Parse match → game assignments + matchRows, err := h.q.ListTournamentMatches(ctx, int32(tournamentID)) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + type matchGame struct { + matchID int + gameID *int32 + } + var matchGames []matchGame + for _, m := range matchRows { + raw := c.FormValue("match_" + strconv.Itoa(int(m.TournamentMatchID))) + var gameID *int32 + if raw != "" && raw != "0" { + gid, err := strconv.Atoi(raw) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid match game") + } + gid32 := int32(gid) + gameID = &gid32 + } + matchGames = append(matchGames, matchGame{matchID: int(m.TournamentMatchID), gameID: gameID}) + } + + err = h.txm.RunInTx(ctx, func(qtx db.Querier) error { + if err := qtx.UpdateTournament(ctx, db.UpdateTournamentParams{ + TournamentID: int32(tournamentID), + DisplayName: displayName, + BracketSize: tournament.BracketSize, + NumRounds: tournament.NumRounds, + }); err != nil { + return err + } + + // Replace entries + if err := qtx.DeleteTournamentEntries(ctx, int32(tournamentID)); err != nil { + return err + } + for _, se := range seedEntries { + if err := qtx.CreateTournamentEntry(ctx, db.CreateTournamentEntryParams{ + TournamentID: int32(tournamentID), + UserID: int32(se.userID), + Seed: int32(se.seed), + }); err != nil { + return err + } + } + + // Update match game assignments + for _, mg := range matchGames { + if err := qtx.UpdateTournamentMatchGame(ctx, db.UpdateTournamentMatchGameParams{ + TournamentMatchID: int32(mg.matchID), + GameID: mg.gameID, + }); err != nil { + return err + } + } + + return nil + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + return c.Redirect(http.StatusSeeOther, h.conf.BasePath+"admin/tournaments") +} diff --git a/backend/admin/handler_test.go b/backend/admin/handler_test.go index 20c45ef..0e13c60 100644 --- a/backend/admin/handler_test.go +++ b/backend/admin/handler_test.go @@ -44,6 +44,16 @@ type mockQuerier struct { getTestcaseResultsBySubmIDFunc func(ctx context.Context, submissionID int32) ([]db.TestcaseResult, error) listTestcasesByGameIDFunc func(ctx context.Context, gameID int32) ([]db.Testcase, error) updateGameStartedAtFunc func(ctx context.Context, arg db.UpdateGameStartedAtParams) error + listTournamentsFunc func(ctx context.Context) ([]db.Tournament, error) + getTournamentByIDFunc func(ctx context.Context, tournamentID int32) (db.Tournament, error) + createTournamentFunc func(ctx context.Context, arg db.CreateTournamentParams) (int32, error) + updateTournamentFunc func(ctx context.Context, arg db.UpdateTournamentParams) error + listTournamentEntriesFunc func(ctx context.Context, tournamentID int32) ([]db.ListTournamentEntriesRow, error) + deleteTournamentEntriesFunc func(ctx context.Context, tournamentID int32) error + createTournamentEntryFunc func(ctx context.Context, arg db.CreateTournamentEntryParams) error + listTournamentMatchesFunc func(ctx context.Context, tournamentID int32) ([]db.TournamentMatch, error) + createTournamentMatchFunc func(ctx context.Context, arg db.CreateTournamentMatchParams) error + updateTournamentMatchGameFunc func(ctx context.Context, arg db.UpdateTournamentMatchGameParams) error } func (m *mockQuerier) GetUserByID(ctx context.Context, userID int32) (db.User, error) { @@ -204,6 +214,76 @@ func (m *mockQuerier) ListTestcasesByProblemIDForUpdate(ctx context.Context, pro return m.ListTestcasesByProblemID(ctx, problemID) } +func (m *mockQuerier) ListTournaments(ctx context.Context) ([]db.Tournament, error) { + if m.listTournamentsFunc != nil { + return m.listTournamentsFunc(ctx) + } + return nil, nil +} + +func (m *mockQuerier) GetTournamentByID(ctx context.Context, tournamentID int32) (db.Tournament, error) { + if m.getTournamentByIDFunc != nil { + return m.getTournamentByIDFunc(ctx, tournamentID) + } + return db.Tournament{}, pgx.ErrNoRows +} + +func (m *mockQuerier) CreateTournament(ctx context.Context, arg db.CreateTournamentParams) (int32, error) { + if m.createTournamentFunc != nil { + return m.createTournamentFunc(ctx, arg) + } + return 1, nil +} + +func (m *mockQuerier) UpdateTournament(ctx context.Context, arg db.UpdateTournamentParams) error { + if m.updateTournamentFunc != nil { + return m.updateTournamentFunc(ctx, arg) + } + return nil +} + +func (m *mockQuerier) ListTournamentEntries(ctx context.Context, tournamentID int32) ([]db.ListTournamentEntriesRow, error) { + if m.listTournamentEntriesFunc != nil { + return m.listTournamentEntriesFunc(ctx, tournamentID) + } + return nil, nil +} + +func (m *mockQuerier) DeleteTournamentEntries(ctx context.Context, tournamentID int32) error { + if m.deleteTournamentEntriesFunc != nil { + return m.deleteTournamentEntriesFunc(ctx, tournamentID) + } + return nil +} + +func (m *mockQuerier) CreateTournamentEntry(ctx context.Context, arg db.CreateTournamentEntryParams) error { + if m.createTournamentEntryFunc != nil { + return m.createTournamentEntryFunc(ctx, arg) + } + return nil +} + +func (m *mockQuerier) ListTournamentMatches(ctx context.Context, tournamentID int32) ([]db.TournamentMatch, error) { + if m.listTournamentMatchesFunc != nil { + return m.listTournamentMatchesFunc(ctx, tournamentID) + } + return nil, nil +} + +func (m *mockQuerier) CreateTournamentMatch(ctx context.Context, arg db.CreateTournamentMatchParams) error { + if m.createTournamentMatchFunc != nil { + return m.createTournamentMatchFunc(ctx, arg) + } + return nil +} + +func (m *mockQuerier) UpdateTournamentMatchGame(ctx context.Context, arg db.UpdateTournamentMatchGameParams) error { + if m.updateTournamentMatchGameFunc != nil { + return m.updateTournamentMatchGameFunc(ctx, arg) + } + return nil +} + // mockTxManager implements db.TxManager for testing. type mockTxManager struct { runInTxFunc func(ctx context.Context, fn func(q db.Querier) error) error @@ -1062,3 +1142,221 @@ func TestGetSubmissionDetail_NotFound(t *testing.T) { t.Errorf("status = %d, want %d", httpErr.Code, http.StatusNotFound) } } + +// --- Tournament admin tests --- + +func TestNextPowerOf2(t *testing.T) { + tests := []struct { + input int + expected int + }{ + {2, 2}, + {3, 4}, + {4, 4}, + {5, 8}, + {6, 8}, + {7, 8}, + {8, 8}, + {9, 16}, + } + for _, tt := range tests { + got := nextPowerOf2(tt.input) + if got != tt.expected { + t.Errorf("nextPowerOf2(%d) = %d, want %d", tt.input, got, tt.expected) + } + } +} + +func TestLog2Int(t *testing.T) { + tests := []struct { + input int + expected int + }{ + {1, 0}, + {2, 1}, + {4, 2}, + {8, 3}, + {16, 4}, + } + for _, tt := range tests { + got := log2Int(tt.input) + if got != tt.expected { + t.Errorf("log2Int(%d) = %d, want %d", tt.input, got, tt.expected) + } + } +} + +func TestGetTournaments_Empty(t *testing.T) { + h := newTestHandler(&mockQuerier{}) + c, rec := newEchoContext(http.MethodGet, "/admin/tournaments", nil) + err := h.getTournaments(c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } +} + +func TestGetTournaments_WithData(t *testing.T) { + h := newTestHandler(&mockQuerier{ + listTournamentsFunc: func(_ context.Context) ([]db.Tournament, error) { + return []db.Tournament{ + {TournamentID: 1, DisplayName: "Tournament A", BracketSize: 4}, + {TournamentID: 2, DisplayName: "Tournament B", BracketSize: 8}, + }, nil + }, + }) + c, _ := newEchoContext(http.MethodGet, "/admin/tournaments", nil) + err := h.getTournaments(c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestGetTournamentNew(t *testing.T) { + h := newTestHandler(&mockQuerier{}) + c, rec := newEchoContext(http.MethodGet, "/admin/tournaments/new", nil) + err := h.getTournamentNew(c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } +} + +func TestPostTournamentNew_Success(t *testing.T) { + var createdParams db.CreateTournamentParams + var matchCount int + h := &Handler{ + q: &mockQuerier{}, + txm: &mockTxManager{ + runInTxFunc: func(_ context.Context, fn func(q db.Querier) error) error { + return fn(&mockQuerier{ + createTournamentFunc: func(_ context.Context, arg db.CreateTournamentParams) (int32, error) { + createdParams = arg + return 1, nil + }, + createTournamentMatchFunc: func(_ context.Context, _ db.CreateTournamentMatchParams) error { + matchCount++ + return nil + }, + }) + }, + }, + conf: &config.Config{BasePath: "/test/"}, + } + form := url.Values{ + "display_name": {"Test Tournament"}, + "num_participants": {"3"}, + } + c, rec := newEchoContextWithForm("/admin/tournaments/new", nil, form) + err := h.postTournamentNew(c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusSeeOther { + t.Errorf("status = %d, want %d", rec.Code, http.StatusSeeOther) + } + if createdParams.DisplayName != "Test Tournament" { + t.Errorf("display_name = %q, want %q", createdParams.DisplayName, "Test Tournament") + } + if createdParams.BracketSize != 4 { + t.Errorf("bracket_size = %d, want 4", createdParams.BracketSize) + } + if createdParams.NumRounds != 2 { + t.Errorf("num_rounds = %d, want 2", createdParams.NumRounds) + } + // bracket_size=4, num_rounds=2 → round 0: 2 matches + round 1: 1 match = 3 matches + if matchCount != 3 { + t.Errorf("match count = %d, want 3", matchCount) + } +} + +func TestPostTournamentNew_InvalidParticipants(t *testing.T) { + h := newTestHandler(&mockQuerier{}) + form := url.Values{ + "display_name": {"Test"}, + "num_participants": {"abc"}, + } + c, _ := newEchoContextWithForm("/admin/tournaments/new", nil, form) + err := h.postTournamentNew(c) + if err == nil { + t.Fatal("expected error for invalid num_participants") + } + httpErr, ok := err.(*echo.HTTPError) + if !ok { + t.Fatalf("expected echo.HTTPError, got %T", err) + } + if httpErr.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", httpErr.Code, http.StatusBadRequest) + } +} + +func TestGetTournamentEdit_NotFound(t *testing.T) { + h := newTestHandler(&mockQuerier{}) + c, _ := newEchoContext(http.MethodGet, "/admin/tournaments/999", map[string]string{ + "tournamentID": "999", + }) + err := h.getTournamentEdit(c) + if err == nil { + t.Fatal("expected error for non-existent tournament") + } + httpErr, ok := err.(*echo.HTTPError) + if !ok { + t.Fatalf("expected echo.HTTPError, got %T", err) + } + if httpErr.Code != http.StatusNotFound { + t.Errorf("status = %d, want %d", httpErr.Code, http.StatusNotFound) + } +} + +func TestGetTournamentEdit_Success(t *testing.T) { + h := newTestHandler(&mockQuerier{ + getTournamentByIDFunc: func(_ context.Context, _ int32) (db.Tournament, error) { + return db.Tournament{ + TournamentID: 1, + DisplayName: "Test", + BracketSize: 4, + NumRounds: 2, + }, nil + }, + listTournamentMatchesFunc: func(_ context.Context, _ int32) ([]db.TournamentMatch, error) { + return []db.TournamentMatch{ + {TournamentMatchID: 1, Round: 0, Position: 0}, + }, nil + }, + }) + c, rec := newEchoContext(http.MethodGet, "/admin/tournaments/1", map[string]string{ + "tournamentID": "1", + }) + err := h.getTournamentEdit(c) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } +} + +func TestPostTournamentEdit_NotFound(t *testing.T) { + h := newTestHandler(&mockQuerier{}) + form := url.Values{ + "display_name": {"Updated"}, + } + c, _ := newEchoContextWithForm("/admin/tournaments/999", map[string]string{ + "tournamentID": "999", + }, form) + err := h.postTournamentEdit(c) + if err == nil { + t.Fatal("expected error for non-existent tournament") + } + httpErr, ok := err.(*echo.HTTPError) + if !ok { + t.Fatalf("expected echo.HTTPError, got %T", err) + } + if httpErr.Code != http.StatusNotFound { + t.Errorf("status = %d, want %d", httpErr.Code, http.StatusNotFound) + } +} diff --git a/backend/admin/templates/dashboard.html b/backend/admin/templates/dashboard.html index 5a7b484..2e9336a 100644 --- a/backend/admin/templates/dashboard.html +++ b/backend/admin/templates/dashboard.html @@ -10,6 +10,9 @@

Problems

+

+ Tournaments +

Online Qualifying Ranking

diff --git a/backend/admin/templates/tournament_edit.html b/backend/admin/templates/tournament_edit.html new file mode 100644 index 0000000..6c1ad6c --- /dev/null +++ b/backend/admin/templates/tournament_edit.html @@ -0,0 +1,48 @@ +{{ template "base.html" . }} + +{{ define "breadcrumb" }} +Dashboard | Tournaments +{{ end }} + +{{ define "content" }} +
+
+ + +
+ +

Bracket (size={{ .Tournament.BracketSize }}, rounds={{ .Tournament.NumRounds }})

+ +

Entries (seed → user)

+ {{ range .Seeds }} + {{ $seed := . }} +
+ + +
+ {{ end }} + +

Matches (game assignment)

+ {{ range .Matches }} +
+ + {{ $matchID := .MatchID }} + +
+ {{ end }} + +
+ +
+
+{{ end }} diff --git a/backend/admin/templates/tournament_new.html b/backend/admin/templates/tournament_new.html new file mode 100644 index 0000000..14b0c4c --- /dev/null +++ b/backend/admin/templates/tournament_new.html @@ -0,0 +1,22 @@ +{{ template "base.html" . }} + +{{ define "breadcrumb" }} +Dashboard | Tournaments +{{ end }} + +{{ define "content" }} +
+
+ + +
+
+ + + Bracket size will be rounded up to next power of 2. +
+
+ +
+
+{{ end }} diff --git a/backend/admin/templates/tournaments.html b/backend/admin/templates/tournaments.html new file mode 100644 index 0000000..09989a2 --- /dev/null +++ b/backend/admin/templates/tournaments.html @@ -0,0 +1,20 @@ +{{ template "base.html" . }} + +{{ define "breadcrumb" }} +Dashboard +{{ end }} + +{{ define "content" }} +
+ Create New Tournament +
+ +{{ end }} diff --git a/backend/api/generated.go b/backend/api/generated.go index 1cc692c..a419123 100644 --- a/backend/api/generated.go +++ b/backend/api/generated.go @@ -99,17 +99,32 @@ type RankingEntry struct { // Tournament defines model for Tournament. type Tournament struct { - Matches []TournamentMatch `json:"matches"` + BracketSize int `json:"bracket_size"` + DisplayName string `json:"display_name"` + Entries []TournamentEntry `json:"entries"` + Matches []TournamentMatch `json:"matches"` + NumRounds int `json:"num_rounds"` + TournamentID int `json:"tournament_id"` +} + +// TournamentEntry defines model for TournamentEntry. +type TournamentEntry struct { + Seed int `json:"seed"` + User User `json:"user"` } // TournamentMatch defines model for TournamentMatch. type TournamentMatch struct { - GameID int `json:"game_id"` - Player1 *User `json:"player1,omitempty"` - Player1Score *int `json:"player1_score,omitempty"` - Player2 *User `json:"player2,omitempty"` - Player2Score *int `json:"player2_score,omitempty"` - Winner *int `json:"winner,omitempty"` + GameID *int `json:"game_id,omitempty"` + IsBye bool `json:"is_bye"` + Player1 *User `json:"player1,omitempty"` + Player1Score *int `json:"player1_score,omitempty"` + Player2 *User `json:"player2,omitempty"` + Player2Score *int `json:"player2_score,omitempty"` + Position int `json:"position"` + Round int `json:"round"` + TournamentMatchID int `json:"tournament_match_id"` + WinnerUserID *int `json:"winner_user_id,omitempty"` } // User defines model for User. @@ -138,15 +153,6 @@ type PostLoginJSONBody struct { Username string `json:"username"` } -// GetTournamentParams defines parameters for GetTournament. -type GetTournamentParams struct { - Game1 int `form:"game1" json:"game1"` - Game2 int `form:"game2" json:"game2"` - Game3 int `form:"game3" json:"game3"` - Game4 int `form:"game4" json:"game4"` - Game5 int `form:"game5" json:"game5"` -} - // PostGamePlayCodeJSONRequestBody defines body for PostGamePlayCode for application/json ContentType. type PostGamePlayCodeJSONRequestBody PostGamePlayCodeJSONBody @@ -189,8 +195,8 @@ type ServerInterface interface { // (GET /me) GetMe(ctx echo.Context) error - // (GET /tournament) - GetTournament(ctx echo.Context, params GetTournamentParams) error + // (GET /tournaments/{tournament_id}) + GetTournament(ctx echo.Context, tournamentID int) error } // ServerInterfaceWrapper converts echo contexts to parameters. @@ -333,46 +339,16 @@ func (w *ServerInterfaceWrapper) GetMe(ctx echo.Context) error { // GetTournament converts echo context to params. func (w *ServerInterfaceWrapper) GetTournament(ctx echo.Context) error { var err error + // ------------- Path parameter "tournament_id" ------------- + var tournamentID int - // Parameter object where we will unmarshal all parameters from the context - var params GetTournamentParams - // ------------- Required query parameter "game1" ------------- - - err = runtime.BindQueryParameter("form", false, true, "game1", ctx.QueryParams(), ¶ms.Game1) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter game1: %s", err)) - } - - // ------------- Required query parameter "game2" ------------- - - err = runtime.BindQueryParameter("form", false, true, "game2", ctx.QueryParams(), ¶ms.Game2) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter game2: %s", err)) - } - - // ------------- Required query parameter "game3" ------------- - - err = runtime.BindQueryParameter("form", false, true, "game3", ctx.QueryParams(), ¶ms.Game3) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter game3: %s", err)) - } - - // ------------- Required query parameter "game4" ------------- - - err = runtime.BindQueryParameter("form", false, true, "game4", ctx.QueryParams(), ¶ms.Game4) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter game4: %s", err)) - } - - // ------------- Required query parameter "game5" ------------- - - err = runtime.BindQueryParameter("form", false, true, "game5", ctx.QueryParams(), ¶ms.Game5) + err = runtime.BindStyledParameterWithOptions("simple", "tournament_id", ctx.Param("tournament_id"), &tournamentID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter game5: %s", err)) + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter tournament_id: %s", err)) } // Invoke the callback with all the unmarshaled arguments - err = w.Handler.GetTournament(ctx, params) + err = w.Handler.GetTournament(ctx, tournamentID) return err } @@ -414,7 +390,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.POST(baseURL+"/login", wrapper.PostLogin) router.POST(baseURL+"/logout", wrapper.PostLogout) router.GET(baseURL+"/me", wrapper.GetMe) - router.GET(baseURL+"/tournament", wrapper.GetTournament) + router.GET(baseURL+"/tournaments/:tournament_id", wrapper.GetTournament) } @@ -806,7 +782,7 @@ func (response GetMe401JSONResponse) VisitGetMeResponse(w http.ResponseWriter) e } type GetTournamentRequestObject struct { - Params GetTournamentParams + TournamentID int `json:"tournament_id"` } type GetTournamentResponseObject interface { @@ -884,7 +860,7 @@ type StrictServerInterface interface { // (GET /me) GetMe(ctx context.Context, request GetMeRequestObject) (GetMeResponseObject, error) - // (GET /tournament) + // (GET /tournaments/{tournament_id}) GetTournament(ctx context.Context, request GetTournamentRequestObject) (GetTournamentResponseObject, error) } @@ -1161,10 +1137,10 @@ func (sh *strictHandler) GetMe(ctx echo.Context) error { } // GetTournament operation middleware -func (sh *strictHandler) GetTournament(ctx echo.Context, params GetTournamentParams) error { +func (sh *strictHandler) GetTournament(ctx echo.Context, tournamentID int) error { var request GetTournamentRequestObject - request.Params = params + request.TournamentID = tournamentID handler := func(ctx echo.Context, request interface{}) (interface{}, error) { return sh.ssi.GetTournament(ctx.Request().Context(), request.(GetTournamentRequestObject)) @@ -1188,28 +1164,29 @@ func (sh *strictHandler) GetTournament(ctx echo.Context, params GetTournamentPar // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/+xaW2/bNhT+KwK3R9V2LtuD37IhKAqkgLFm2ENRCJR0bLOjSJWXOF7g/z6QlKwbJctN", - "ssGp3xyJPNfvO+eQyhNKeJZzBkxJNH9CMllDhu3PWyG4MD9ywXMQioB9nIGUeAXmp9rmgOZIKkHYCu12", - "IRLwTRMBKZp/3i/8EpYLefwVEoV2Ibp9hEQrwtknhZW2coHpzGxjnAEKkdCMGakhkjpJQEoUoo3gbBVh", - "JjcgUIgUyYBrhULrA6EQgTXZbjYv938TpkAwTIsHlUWl6SF6jzPoOpsSmVO8jVjxtrMt1QIbPyIJCWep", - "rC0ySlcgzKoVziAi6cBL9/gJ/Sxgiebop2mVlmmRk6kx8d6s24WIyCjXMSVJTWbMOQXMzOsMExYZy0FY", - "k4iCTB6S/6d0BhXisBB4a/7OBY8pZIe2L4pluxBJhYWCNMLK43KIHt+t+Lvq6a/XHeyUAatHp+502MyM", - "Jw+V2a1o+OC4j2wNhxcPF2arpoq4rV7Y3GEFUpn9BskeBMUgVSQTLiCSOs6IquLCNKU4poDmSmgIR8XJ", - "YD31Q9EqGSHX5adg3VBC2yRtJ8laUuoNez3dq/NFflFhq8U8kIkguVHv9ZZittJFHRqByrtyeYXnXkJK", - "nOUUot5AK6LoiPpXU1PuCRtu1ZxoKh0I1F3N7RKp+To3AjZkqbwY/QOzvwlb3TIltt1Al272oKYSU7Bg", - "ZBHZo9ET3xYPvqM+FLZU4GshrjeM91wLUzKY8nQ2rJI1jK+XlayPZme3dLYbYiF/2C4nq2PcYAtx4bgY", - "m5tieTSQI7fk8jiJl0MSN4QxB5/2u57a7wuT1Xd8lyYJZ1GO1dr/VkY4zQjzt1KKY6CjCKIliN4UmZc9", - "9rX8L8XU9nTa3d7k0r5usIxYwpbcKnQVC93QGCvBpQzKiSjYQBzcLD6gED2AkLbaotnkajIzRvMcGM4J", - "mqOryWwyM00Vq7WN+dSkySETLJdMQmwL/pCiOXoPtimaPixA5pxJt/hyNnNFh6mCgzjPKUnszulX6aq9", - "g5efBOP5aUe6Q6R0Iv3ha/QgdL+GwOwEqYI1loGdSyGFdGKUXM8ujnJssPHaKdVjwo2dhAMiA82wVmsu", - "yD97/Vf/pf4lFzFJU2ATs24XFniYPhXs3R1ChsWSwBkoO6J+fkKGgBZfKESOKbUxsEqZI1/lSKeafHlx", - "yI0DmgdYZ1w9B1dG+fXrKzfxlyAeQAQJZoyrYElYGqgqLZAGAiTXIoE+uE9NdZ6Ws1TOpQf5C+5OCguK", - "t7+72fk1KWBN/42n22egv2cG9h0E/FBvGr3zU/PMhzfKB2pPx5Esj8ZDDcGwwp2m3Un6RPrD3rehWLdv", - "CdoEckLOzeKHIoc7so5rF5/c2nPDODeMt8qJDVbJutExDp4u/zJbaj1DnlTTsL9wmhKzBdNFY8VR3aRL", - "JE97OR9yfzQuCXfvO4pFxR3xqRCo5tqoq6DGFfihK6FS+Jkvb5UvlK/cdW//5HVnl7zUWJRjKTdcpN77", - "5+Ouhll5eVZIfMYY9Z3OaDn2M5DH/JNn1R5BXKuDEHL/nnDaI6xzOBs8wH8EdIbYC0dcNT5V9kW+9kGz", - "07vhMaf2PLbEVELoevk3DWLbbOYXx7XycLzky1eTfPVqkq9fTfIv/+fI1ITTuO/aHXrVhJynozc5He12", - "/wYAAP//Hl9qBRMoAAA=", + "H4sIAAAAAAAC/+xaTW/bOBP+KwLf96jaThPswbfsIigKpICxTbGHohAoaWKzlUgth4rrGv7vC5L6FiXL", + "SbqLpL454nA4H88zw4/sSSTSTHDgCslyTzDaQErNzxsphdQ/MikykIqB+ZwCIl2D/ql2GZAlQSUZX5PD", + "wScS/s6ZhJgsP1eCX/xSUIRfIVLk4JOb7xDlign+UVGVG73A81RP44ID8YnMOddafYJ5FAEi8clWCr4O", + "KMctSOITxVIQuSK+8YElEIAx2UzWg9XfjCuQnCbFh9qi0nSfvKMp9J2NGWYJ3QW8GO1Ni3NJtR8BQiR4", + "jA0hvegapJZa0xQCFo8M2s978n8J92RJ/jev0zIvcjLXJt5puYNPGAZZHiYsaugMhUiAcj2cUsYDbTlI", + "YxJTkOIx/Z/QGlSoo1LSnf47kyJMID02fVWIHXyCikoFcUCVw2WffH+zFm/qr79d9bBTBqwZnabTfjsz", + "jjzUZnei4YJjFdkGDi8eLvTUPFHMTnXC5pYqQKXnayQ7EBQCqgAjISHAPEyZquPC8yShYQJkqWQO/qQ4", + "aazHbiiaRSbotfkpWDeW0C5Ju0kylpTr+oOeVsu5Ir+qsdVhHmAkWaaXd3qbUL7Oizo0AZW3pXiN50FC", + "Ik2zBILBQCumkgn1r7FMOcdvudVwor3oSKBuG26XSM02mVawZffKidE/Kf/G+PqGK7nrB7p0cwA1tZqC", + "BROLSIVGR3w7PHhEfShsqcHXQdxgGO9ELnXJ4MpBVUmjb6ACZD8GLD/aDoArWWibVHJrc2x2HNU3pSra", + "PErlBz3TpZLnaSBFPtiuVKVigCOdbLTle7W5FdfW6nXAaj/HszYAYgQYIHOOUzHbccpM9K3mcZtsnHs2", + "jTZ9hkG4A3fztuC+mMq0QjwYYZwVeXuaxrejGgWyTm1ujJr0HsWWyfhghLaMc5CBTsPJKKw0l6Y0DK5i", + "70rqpwItJ+4CWSR4kFG1cY9iQOOUcXe2ExpCMqkAj4TCDg7Y5wC2jU01p0fZyuTSvn6wtFrG74VZ0HZE", + "cp2EVEmB6JU7bm8LoXe9ek988gASDWLIYnY5W2ijRQacZowsyeVsMVvoNFG1MTGfa/pYHoGp1TohZov3", + "PiZL8g7MpksXDgmYCY5W+O1iYZsaV0WNp1mWsMjMnH9Fi1gLeDdlp1dac2TolVfHThYHwtfa45C7DXh6", + "JqDyNhQ9c+6BGOKZXuRqcXGSY6MbO3MKcphwbU5aHkMv5zRXGyHZj2r9y39z/XshQxbHwGda7uAXeJjv", + "i6p6OIYMgyVJU1DmCPR5TzQBDb50DzJMaRwz6pRZ8tWO9KrOl2eH3DSgOYB1xtVTcKUXv/r5i+v4I8gH", + "kF5EORfKu2c89lSdFog9CShyGcEQ3Oe6Os/LvXom0IH8lbAn0VVCd3/Ys9nPpIAx/XcR756A/oEzluug", + "6YZ62+iDm5pnPrxSPiTm9iXA8uplrCFoVtjbGntT80L6Q+XbWKy7t1BdAlkl52bxS5HDXolMaxcfrey5", + "YZwbxmvlxJaqaNPqGEdPl3/pKY2egS+qaZhfNI7NtQdNVi2Jk7pJn0iO9nI+5P5qXJL2XWESi4o3iJdC", + "oIZrk66CWk8sx66ESuVnvrxWviRiba97h3det0bkubZFGUXcChk7759Puxrm5eVZofEJ26hHOvO0J5uX", + "zqoKQSJXRyFk//3lZW9hrcPp6AH+A5AzxJ454vV7Gc73rSfc0cv1xgv6lGbefRv+z1q6ar38T3tBH3lj", + "PHfv19m9D4d/AgAA///ukgFcEykAAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/backend/api/handler.go b/backend/api/handler.go index 7efacf3..74ffcf8 100644 --- a/backend/api/handler.go +++ b/backend/api/handler.go @@ -454,99 +454,264 @@ func (h *Handler) PostGamePlaySubmit(ctx context.Context, request PostGamePlaySu } func (h *Handler) GetTournament(ctx context.Context, request GetTournamentRequestObject, _ *db.User) (GetTournamentResponseObject, error) { - gameIDs := []int32{ - int32(request.Params.Game1), - int32(request.Params.Game2), - int32(request.Params.Game3), - int32(request.Params.Game4), - int32(request.Params.Game5), + tournamentID := int32(request.TournamentID) + + tournament, err := h.q.GetTournamentByID(ctx, tournamentID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return GetTournament404JSONResponse{Message: "Tournament not found"}, nil + } + return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - matches := make([]TournamentMatch, 0, 5) + entryRows, err := h.q.ListTournamentEntries(ctx, tournamentID) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } - for _, gameID := range gameIDs { - gameRow, err := h.q.GetGameByID(ctx, gameID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - continue - } - return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + seedToUser := make(map[int]User) + entries := make([]TournamentEntry, len(entryRows)) + for i, e := range entryRows { + u := User{ + UserID: int(e.UserID), + Username: e.Username, + DisplayName: e.DisplayName, + IconPath: e.IconPath, + IsAdmin: e.IsAdmin, + Label: toNullable(e.Label), } + seedToUser[int(e.Seed)] = u + entries[i] = TournamentEntry{ + User: u, + Seed: int(e.Seed), + } + } - playerRows, err := h.q.ListMainPlayers(ctx, []int32{gameID}) - if err != nil { - return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + matchRows, err := h.q.ListTournamentMatches(ctx, tournamentID) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + bracketSize := int(tournament.BracketSize) + numRounds := int(tournament.NumRounds) + bracketSeeds := standardBracketSeeds(bracketSize) + + // Index matches by (round, position) + type matchKey struct{ round, position int } + matchByKey := make(map[matchKey]db.TournamentMatch) + for _, m := range matchRows { + matchByKey[matchKey{int(m.Round), int(m.Position)}] = m + } + + // Collect game IDs for batch fetching + gameIDs := make(map[int32]bool) + for _, m := range matchRows { + if m.GameID != nil { + gameIDs[*m.GameID] = true } + } - var player1, player2 *User - if len(playerRows) > 0 { - p1 := User{ - UserID: int(playerRows[0].UserID), - Username: playerRows[0].Username, - DisplayName: playerRows[0].DisplayName, - IconPath: playerRows[0].IconPath, - IsAdmin: playerRows[0].IsAdmin, - Label: toNullable(playerRows[0].Label), - } - player1 = &p1 + // Fetch rankings for all games that have started + type rankingResult struct { + scores map[int]int // userID -> score + winnerID int + } + gameRankings := make(map[int32]*rankingResult) + for gid := range gameIDs { + gameRow, err := h.q.GetGameByID(ctx, gid) + if err != nil { + continue + } + if !gameRow.StartedAt.Valid { + continue } - if len(playerRows) > 1 { - p2 := User{ - UserID: int(playerRows[1].UserID), - Username: playerRows[1].Username, - DisplayName: playerRows[1].DisplayName, - IconPath: playerRows[1].IconPath, - IsAdmin: playerRows[1].IsAdmin, - Label: toNullable(playerRows[1].Label), + rankingRows, err := h.q.GetRanking(ctx, gid) + if err != nil || len(rankingRows) == 0 { + continue + } + rr := &rankingResult{scores: make(map[int]int)} + for i, r := range rankingRows { + rr.scores[int(r.User.UserID)] = int(r.Submission.CodeSize) + if i == 0 { + rr.winnerID = int(r.User.UserID) } - player2 = &p2 } + gameRankings[gid] = rr + } + + // Build match results bottom-up + type matchResult struct { + player1 *User + player2 *User + p1Score *int + p2Score *int + winnerUID *int + isBye bool + } + resultByKey := make(map[matchKey]*matchResult) + + for round := 0; round < numRounds; round++ { + numPositions := bracketSize / (1 << (round + 1)) + for pos := 0; pos < numPositions; pos++ { + m, exists := matchByKey[matchKey{round, pos}] + mr := &matchResult{} + + if round == 0 { + // First round: resolve players from bracket seeds + slot1 := pos * 2 + slot2 := pos*2 + 1 + seed1 := bracketSeeds[slot1] + seed2 := bracketSeeds[slot2] + + if u, ok := seedToUser[seed1]; ok { + mr.player1 = &u + } + if u, ok := seedToUser[seed2]; ok { + mr.player2 = &u + } + } else { + // Later rounds: resolve from child match winners + child1 := resultByKey[matchKey{round - 1, pos * 2}] + child2 := resultByKey[matchKey{round - 1, pos*2 + 1}] + + if child1 != nil && child1.winnerUID != nil { + if u, ok := seedToUser[findSeedByUserID(entries, *child1.winnerUID)]; ok { + mr.player1 = &u + } + } + if child2 != nil && child2.winnerUID != nil { + if u, ok := seedToUser[findSeedByUserID(entries, *child2.winnerUID)]; ok { + mr.player2 = &u + } + } + } + + // Check for bye + if mr.player1 == nil && mr.player2 != nil { + mr.isBye = true + uid := mr.player2.UserID + mr.winnerUID = &uid + } else if mr.player1 != nil && mr.player2 == nil { + mr.isBye = true + uid := mr.player1.UserID + mr.winnerUID = &uid + } - var winnerID *int - var player1Score, player2Score *int - - if gameRow.StartedAt.Valid { - rankingRows, err := h.q.GetRanking(ctx, gameID) - if err == nil && len(rankingRows) > 0 { - // Find scores for each player - for _, ranking := range rankingRows { - userID := int(ranking.User.UserID) - score := int(ranking.Submission.CodeSize) - - if player1 != nil && player1.UserID == userID { - player1Score = &score - if winnerID == nil { - winnerID = &userID + // Resolve scores from game + if exists && m.GameID != nil && !mr.isBye { + if rr, ok := gameRankings[*m.GameID]; ok { + if mr.player1 != nil { + if s, ok := rr.scores[mr.player1.UserID]; ok { + score := s + mr.p1Score = &score } } - if player2 != nil && player2.UserID == userID { - player2Score = &score - if winnerID == nil { - winnerID = &userID + if mr.player2 != nil { + if s, ok := rr.scores[mr.player2.UserID]; ok { + score := s + mr.p2Score = &score + } + } + // Winner is the one with the best (lowest) score in the ranking + if mr.player1 != nil && mr.player2 != nil { + if rr.winnerID == mr.player1.UserID || rr.winnerID == mr.player2.UserID { + w := rr.winnerID + mr.winnerUID = &w + } else { + // Both players have scores; pick the one with lower score + if mr.p1Score != nil && mr.p2Score != nil { + if *mr.p1Score <= *mr.p2Score { + w := mr.player1.UserID + mr.winnerUID = &w + } else { + w := mr.player2.UserID + mr.winnerUID = &w + } + } } } } } + + resultByKey[matchKey{round, pos}] = mr } + } + + // Build API response matches + apiMatches := make([]TournamentMatch, 0, len(matchRows)) + for round := 0; round < numRounds; round++ { + numPositions := bracketSize / (1 << (round + 1)) + for pos := 0; pos < numPositions; pos++ { + m, exists := matchByKey[matchKey{round, pos}] + mr := resultByKey[matchKey{round, pos}] + + matchID := 0 + var gameID *int + if exists { + matchID = int(m.TournamentMatchID) + if m.GameID != nil { + gid := int(*m.GameID) + gameID = &gid + } + } - match := TournamentMatch{ - GameID: int(gameID), - Player1: player1, - Player2: player2, - Player1Score: player1Score, - Player2Score: player2Score, - Winner: winnerID, + apiMatches = append(apiMatches, TournamentMatch{ + TournamentMatchID: matchID, + Round: round, + Position: pos, + GameID: gameID, + Player1: mr.player1, + Player2: mr.player2, + Player1Score: mr.p1Score, + Player2Score: mr.p2Score, + WinnerUserID: mr.winnerUID, + IsBye: mr.isBye, + }) } - matches = append(matches, match) } return GetTournament200JSONResponse{ Tournament: Tournament{ - Matches: matches, + TournamentID: int(tournament.TournamentID), + DisplayName: tournament.DisplayName, + BracketSize: bracketSize, + NumRounds: numRounds, + Entries: entries, + Matches: apiMatches, }, }, nil } +func findSeedByUserID(entries []TournamentEntry, userID int) int { + for _, e := range entries { + if e.User.UserID == userID { + return e.Seed + } + } + return 0 +} + +// standardBracketSeeds returns the seed assignments for each slot in a standard +// single-elimination bracket. For bracket_size=8: +// Position: [0]=1, [1]=8, [2]=5, [3]=4, [4]=3, [5]=6, [6]=7, [7]=2 +// This ensures Seed 1 vs Seed 2 are on opposite sides, and higher seeds face lower seeds. +func standardBracketSeeds(bracketSize int) []int { + seeds := make([]int, bracketSize) + seeds[0] = 1 + // Build the bracket by repeatedly splitting + for size := 2; size <= bracketSize; size *= 2 { + // For each pair in the current level, the new opponent for seed[i] + // is (size + 1 - seed[i]) + temp := make([]int, size) + for i := 0; i < size/2; i++ { + temp[i*2] = seeds[i] + temp[i*2+1] = size + 1 - seeds[i] + } + copy(seeds, temp) + } + return seeds +} + func isGameRunning(game db.GetGameByIDRow) bool { if !game.StartedAt.Valid { return false diff --git a/backend/api/handler_test.go b/backend/api/handler_test.go index 2340d33..a3ca3fb 100644 --- a/backend/api/handler_test.go +++ b/backend/api/handler_test.go @@ -16,14 +16,17 @@ import ( // mockQuerier implements db.Querier for testing. type mockQuerier struct { db.Querier - getGameByIDFunc func(ctx context.Context, gameID int32) (db.GetGameByIDRow, error) - listMainPlayersFunc func(ctx context.Context, gameIDs []int32) ([]db.ListMainPlayersRow, error) - listPublicGamesFunc func(ctx context.Context) ([]db.ListPublicGamesRow, error) - deleteSessionFunc func(ctx context.Context, sessionID string) error - getLatestStateFunc func(ctx context.Context, arg db.GetLatestStateParams) (db.GetLatestStateRow, error) - updateCodeFunc func(ctx context.Context, arg db.UpdateCodeParams) error - getRankingFunc func(ctx context.Context, gameID int32) ([]db.GetRankingRow, error) - getLatestStatesFunc func(ctx context.Context, gameID int32) ([]db.GetLatestStatesOfMainPlayersRow, error) + getGameByIDFunc func(ctx context.Context, gameID int32) (db.GetGameByIDRow, error) + listMainPlayersFunc func(ctx context.Context, gameIDs []int32) ([]db.ListMainPlayersRow, error) + listPublicGamesFunc func(ctx context.Context) ([]db.ListPublicGamesRow, error) + deleteSessionFunc func(ctx context.Context, sessionID string) error + getLatestStateFunc func(ctx context.Context, arg db.GetLatestStateParams) (db.GetLatestStateRow, error) + updateCodeFunc func(ctx context.Context, arg db.UpdateCodeParams) error + getRankingFunc func(ctx context.Context, gameID int32) ([]db.GetRankingRow, error) + getLatestStatesFunc func(ctx context.Context, gameID int32) ([]db.GetLatestStatesOfMainPlayersRow, error) + getTournamentByIDFunc func(ctx context.Context, tournamentID int32) (db.Tournament, error) + listTournamentEntriesFunc func(ctx context.Context, tournamentID int32) ([]db.ListTournamentEntriesRow, error) + listTournamentMatchesFunc func(ctx context.Context, tournamentID int32) ([]db.TournamentMatch, error) } func (m *mockQuerier) GetGameByID(ctx context.Context, gameID int32) (db.GetGameByIDRow, error) { @@ -82,6 +85,27 @@ func (m *mockQuerier) GetLatestStatesOfMainPlayers(ctx context.Context, gameID i return nil, nil } +func (m *mockQuerier) GetTournamentByID(ctx context.Context, tournamentID int32) (db.Tournament, error) { + if m.getTournamentByIDFunc != nil { + return m.getTournamentByIDFunc(ctx, tournamentID) + } + return db.Tournament{}, pgx.ErrNoRows +} + +func (m *mockQuerier) ListTournamentEntries(ctx context.Context, tournamentID int32) ([]db.ListTournamentEntriesRow, error) { + if m.listTournamentEntriesFunc != nil { + return m.listTournamentEntriesFunc(ctx, tournamentID) + } + return nil, nil +} + +func (m *mockQuerier) ListTournamentMatches(ctx context.Context, tournamentID int32) ([]db.TournamentMatch, error) { + if m.listTournamentMatchesFunc != nil { + return m.listTournamentMatchesFunc(ctx, tournamentID) + } + return nil, nil +} + // mockTxManager implements db.TxManager for testing. type mockTxManager struct{} @@ -725,3 +749,300 @@ func TestToNullableWith(t *testing.T) { } }) } + +// --- Tournament tests --- + +func TestStandardBracketSeeds(t *testing.T) { + tests := []struct { + name string + bracketSize int + expected []int + }{ + { + name: "bracket_size=2", + bracketSize: 2, + expected: []int{1, 2}, + }, + { + name: "bracket_size=4", + bracketSize: 4, + expected: []int{1, 4, 2, 3}, + }, + { + name: "bracket_size=8", + bracketSize: 8, + expected: []int{1, 8, 4, 5, 2, 7, 3, 6}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := standardBracketSeeds(tt.bracketSize) + if len(got) != len(tt.expected) { + t.Fatalf("expected length %d, got %d", len(tt.expected), len(got)) + } + for i, v := range tt.expected { + if got[i] != v { + t.Errorf("position %d: expected seed %d, got %d", i, v, got[i]) + } + } + }) + } +} + +func TestStandardBracketSeeds_Seed1And2OppositeSides(t *testing.T) { + seeds := standardBracketSeeds(8) + // Seed 1 should be in the first half, Seed 2 in the second half + seed1Pos := -1 + seed2Pos := -1 + for i, s := range seeds { + if s == 1 { + seed1Pos = i + } + if s == 2 { + seed2Pos = i + } + } + if seed1Pos >= 4 { + t.Errorf("Seed 1 should be in first half, but at position %d", seed1Pos) + } + if seed2Pos < 4 { + t.Errorf("Seed 2 should be in second half, but at position %d", seed2Pos) + } +} + +func TestStandardBracketSeeds_AllSeedsPresent(t *testing.T) { + for _, size := range []int{2, 4, 8, 16} { + seeds := standardBracketSeeds(size) + seen := make(map[int]bool) + for _, s := range seeds { + if s < 1 || s > size { + t.Errorf("bracket_size=%d: seed %d out of range", size, s) + } + if seen[s] { + t.Errorf("bracket_size=%d: duplicate seed %d", size, s) + } + seen[s] = true + } + if len(seen) != size { + t.Errorf("bracket_size=%d: expected %d unique seeds, got %d", size, size, len(seen)) + } + } +} + +func TestFindSeedByUserID(t *testing.T) { + entries := []TournamentEntry{ + {User: User{UserID: 10}, Seed: 1}, + {User: User{UserID: 20}, Seed: 2}, + {User: User{UserID: 30}, Seed: 3}, + } + + if got := findSeedByUserID(entries, 10); got != 1 { + t.Errorf("expected seed 1 for user 10, got %d", got) + } + if got := findSeedByUserID(entries, 20); got != 2 { + t.Errorf("expected seed 2 for user 20, got %d", got) + } + if got := findSeedByUserID(entries, 999); got != 0 { + t.Errorf("expected seed 0 for unknown user, got %d", got) + } +} + +func TestGetTournament_NotFound(t *testing.T) { + h := Handler{ + q: &mockQuerier{}, + txm: &mockTxManager{}, + hub: &mockGameHub{}, + auth: &mockAuthenticator{}, + conf: &config.Config{}, + } + user := &db.User{UserID: 1} + resp, err := h.GetTournament(context.Background(), GetTournamentRequestObject{TournamentID: 999}, user) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := resp.(GetTournament404JSONResponse); !ok { + t.Errorf("expected 404 response, got %T", resp) + } +} + +func TestGetTournament_Success_NoEntries(t *testing.T) { + h := Handler{ + q: &mockQuerier{ + getTournamentByIDFunc: func(_ context.Context, _ int32) (db.Tournament, error) { + return db.Tournament{ + TournamentID: 1, + DisplayName: "Test Tournament", + BracketSize: 4, + NumRounds: 2, + }, nil + }, + }, + txm: &mockTxManager{}, + hub: &mockGameHub{}, + auth: &mockAuthenticator{}, + conf: &config.Config{}, + } + user := &db.User{UserID: 1} + resp, err := h.GetTournament(context.Background(), GetTournamentRequestObject{TournamentID: 1}, user) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + okResp, ok := resp.(GetTournament200JSONResponse) + if !ok { + t.Fatalf("expected 200 response, got %T", resp) + } + if okResp.Tournament.TournamentID != 1 { + t.Errorf("expected tournament ID 1, got %d", okResp.Tournament.TournamentID) + } + if okResp.Tournament.DisplayName != "Test Tournament" { + t.Errorf("expected display name 'Test Tournament', got %q", okResp.Tournament.DisplayName) + } + if okResp.Tournament.BracketSize != 4 { + t.Errorf("expected bracket size 4, got %d", okResp.Tournament.BracketSize) + } + if len(okResp.Tournament.Entries) != 0 { + t.Errorf("expected 0 entries, got %d", len(okResp.Tournament.Entries)) + } +} + +func TestGetTournament_WithEntriesAndMatches(t *testing.T) { + gameID := int32(10) + h := Handler{ + q: &mockQuerier{ + getTournamentByIDFunc: func(_ context.Context, _ int32) (db.Tournament, error) { + return db.Tournament{ + TournamentID: 1, + DisplayName: "Test", + BracketSize: 4, + NumRounds: 2, + }, nil + }, + listTournamentEntriesFunc: func(_ context.Context, _ int32) ([]db.ListTournamentEntriesRow, error) { + return []db.ListTournamentEntriesRow{ + {Seed: 1, UserID: 100, Username: "alice", DisplayName: "Alice", IsAdmin: false}, + {Seed: 2, UserID: 200, Username: "bob", DisplayName: "Bob", IsAdmin: false}, + {Seed: 3, UserID: 300, Username: "carol", DisplayName: "Carol", IsAdmin: false}, + }, nil + }, + listTournamentMatchesFunc: func(_ context.Context, _ int32) ([]db.TournamentMatch, error) { + return []db.TournamentMatch{ + {TournamentMatchID: 1, TournamentID: 1, Round: 0, Position: 0, GameID: &gameID}, + {TournamentMatchID: 2, TournamentID: 1, Round: 0, Position: 1, GameID: nil}, + {TournamentMatchID: 3, TournamentID: 1, Round: 1, Position: 0, GameID: nil}, + }, nil + }, + getGameByIDFunc: func(_ context.Context, _ int32) (db.GetGameByIDRow, error) { + return db.GetGameByIDRow{ + GameID: 10, + StartedAt: pgtype.Timestamp{Valid: false}, + }, nil + }, + }, + txm: &mockTxManager{}, + hub: &mockGameHub{}, + auth: &mockAuthenticator{}, + conf: &config.Config{}, + } + user := &db.User{UserID: 1} + resp, err := h.GetTournament(context.Background(), GetTournamentRequestObject{TournamentID: 1}, user) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + okResp, ok := resp.(GetTournament200JSONResponse) + if !ok { + t.Fatalf("expected 200 response, got %T", resp) + } + + // Check entries + if len(okResp.Tournament.Entries) != 3 { + t.Fatalf("expected 3 entries, got %d", len(okResp.Tournament.Entries)) + } + + // Check matches: bracket_size=4, num_rounds=2 → round 0: 2 matches, round 1: 1 match + if len(okResp.Tournament.Matches) != 3 { + t.Fatalf("expected 3 matches, got %d", len(okResp.Tournament.Matches)) + } + + // Round 0, Position 0: Seed 1 (Alice) vs Seed 4 (bye) + m0 := okResp.Tournament.Matches[0] + if m0.Round != 0 || m0.Position != 0 { + t.Errorf("match 0: expected round=0, pos=0, got round=%d, pos=%d", m0.Round, m0.Position) + } + if m0.Player1 == nil || m0.Player1.Username != "alice" { + t.Errorf("match 0 player1: expected alice") + } + if m0.Player2 != nil { + t.Errorf("match 0 player2: expected nil (bye), got %v", m0.Player2) + } + if !m0.IsBye { + t.Error("match 0: expected is_bye=true") + } + if m0.WinnerUserID == nil || *m0.WinnerUserID != 100 { + t.Error("match 0: expected winner to be Alice (user_id=100)") + } + + // Round 0, Position 1: Seed 2 (Bob) vs Seed 3 (Carol) + m1 := okResp.Tournament.Matches[1] + if m1.Round != 0 || m1.Position != 1 { + t.Errorf("match 1: expected round=0, pos=1, got round=%d, pos=%d", m1.Round, m1.Position) + } + if m1.Player1 == nil || m1.Player1.Username != "bob" { + t.Errorf("match 1 player1: expected bob, got %v", m1.Player1) + } + if m1.Player2 == nil || m1.Player2.Username != "carol" { + t.Errorf("match 1 player2: expected carol, got %v", m1.Player2) + } + if m1.IsBye { + t.Error("match 1: expected is_bye=false") + } +} + +func TestGetTournament_ByeAutoWinner(t *testing.T) { + // 3 players in bracket_size=4: seed 4 is empty → round 0, pos 0 is a bye + // The bye winner should propagate to round 1 + h := Handler{ + q: &mockQuerier{ + getTournamentByIDFunc: func(_ context.Context, _ int32) (db.Tournament, error) { + return db.Tournament{ + TournamentID: 1, + DisplayName: "Bye Test", + BracketSize: 4, + NumRounds: 2, + }, nil + }, + listTournamentEntriesFunc: func(_ context.Context, _ int32) ([]db.ListTournamentEntriesRow, error) { + return []db.ListTournamentEntriesRow{ + {Seed: 1, UserID: 100, Username: "alice", DisplayName: "Alice"}, + {Seed: 2, UserID: 200, Username: "bob", DisplayName: "Bob"}, + {Seed: 3, UserID: 300, Username: "carol", DisplayName: "Carol"}, + }, nil + }, + listTournamentMatchesFunc: func(_ context.Context, _ int32) ([]db.TournamentMatch, error) { + return []db.TournamentMatch{ + {TournamentMatchID: 1, Round: 0, Position: 0}, + {TournamentMatchID: 2, Round: 0, Position: 1}, + {TournamentMatchID: 3, Round: 1, Position: 0}, + }, nil + }, + }, + txm: &mockTxManager{}, + hub: &mockGameHub{}, + auth: &mockAuthenticator{}, + conf: &config.Config{}, + } + user := &db.User{UserID: 1} + resp, err := h.GetTournament(context.Background(), GetTournamentRequestObject{TournamentID: 1}, user) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + okResp := resp.(GetTournament200JSONResponse) + + // Round 1, Position 0 (final): player1 should be Alice (bye winner from round 0 pos 0) + final := okResp.Tournament.Matches[2] + if final.Round != 1 || final.Position != 0 { + t.Fatalf("expected final at round=1, pos=0") + } + if final.Player1 == nil || final.Player1.UserID != 100 { + t.Error("final player1: expected Alice (bye winner)") + } +} diff --git a/backend/db/models.go b/backend/db/models.go index c4a713d..3086c3b 100644 --- a/backend/db/models.go +++ b/backend/db/models.go @@ -74,6 +74,29 @@ type TestcaseResult struct { CreatedAt pgtype.Timestamp } +type Tournament struct { + TournamentID int32 + DisplayName string + BracketSize int32 + NumRounds int32 + CreatedAt pgtype.Timestamp +} + +type TournamentEntry struct { + TournamentEntryID int32 + TournamentID int32 + UserID int32 + Seed int32 +} + +type TournamentMatch struct { + TournamentMatchID int32 + TournamentID int32 + Round int32 + Position int32 + GameID *int32 +} + type User struct { UserID int32 Username string diff --git a/backend/db/querier.go b/backend/db/querier.go index 89d4b55..3b9545a 100644 --- a/backend/db/querier.go +++ b/backend/db/querier.go @@ -17,11 +17,16 @@ type Querier interface { CreateSubmission(ctx context.Context, arg CreateSubmissionParams) (int32, error) CreateTestcase(ctx context.Context, arg CreateTestcaseParams) (int32, error) CreateTestcaseResult(ctx context.Context, arg CreateTestcaseResultParams) error + CreateTournament(ctx context.Context, arg CreateTournamentParams) (int32, error) + CreateTournamentEntry(ctx context.Context, arg CreateTournamentEntryParams) error + CreateTournamentMatch(ctx context.Context, arg CreateTournamentMatchParams) error CreateUser(ctx context.Context, username string) (int32, error) CreateUserAuth(ctx context.Context, arg CreateUserAuthParams) error DeleteExpiredSessions(ctx context.Context) error DeleteSession(ctx context.Context, sessionID string) error DeleteTestcase(ctx context.Context, testcaseID int32) error + DeleteTournamentEntries(ctx context.Context, tournamentID int32) error + DeleteTournamentMatches(ctx context.Context, tournamentID int32) error GetGameByID(ctx context.Context, gameID int32) (GetGameByIDRow, error) GetLatestState(ctx context.Context, arg GetLatestStateParams) (GetLatestStateRow, error) GetLatestStatesOfMainPlayers(ctx context.Context, gameID int32) ([]GetLatestStatesOfMainPlayersRow, error) @@ -32,6 +37,7 @@ type Querier interface { GetSubmissionsByGameID(ctx context.Context, gameID int32) ([]Submission, error) GetTestcaseByID(ctx context.Context, testcaseID int32) (Testcase, error) GetTestcaseResultsBySubmissionID(ctx context.Context, submissionID int32) ([]TestcaseResult, error) + GetTournamentByID(ctx context.Context, tournamentID int32) (Tournament, error) GetUserAuthByUsername(ctx context.Context, username string) (GetUserAuthByUsernameRow, error) GetUserByID(ctx context.Context, userID int32) (User, error) GetUserBySession(ctx context.Context, sessionID string) (User, error) @@ -45,6 +51,9 @@ type Querier interface { ListTestcases(ctx context.Context) ([]Testcase, error) ListTestcasesByGameID(ctx context.Context, gameID int32) ([]Testcase, error) ListTestcasesByProblemID(ctx context.Context, problemID int32) ([]Testcase, error) + ListTournamentEntries(ctx context.Context, tournamentID int32) ([]ListTournamentEntriesRow, error) + ListTournamentMatches(ctx context.Context, tournamentID int32) ([]TournamentMatch, error) + ListTournaments(ctx context.Context) ([]Tournament, error) ListUsers(ctx context.Context) ([]User, error) RemoveAllMainPlayers(ctx context.Context, gameID int32) error SyncGameStateBestScoreSubmission(ctx context.Context, arg SyncGameStateBestScoreSubmissionParams) error @@ -56,6 +65,8 @@ type Querier interface { UpdateProblem(ctx context.Context, arg UpdateProblemParams) error UpdateSubmissionStatus(ctx context.Context, arg UpdateSubmissionStatusParams) error UpdateTestcase(ctx context.Context, arg UpdateTestcaseParams) error + UpdateTournament(ctx context.Context, arg UpdateTournamentParams) error + UpdateTournamentMatchGame(ctx context.Context, arg UpdateTournamentMatchGameParams) error UpdateUser(ctx context.Context, arg UpdateUserParams) error UpdateUserIconPath(ctx context.Context, arg UpdateUserIconPathParams) error } diff --git a/backend/db/query.sql.go b/backend/db/query.sql.go index 1d6d11c..50aa02e 100644 --- a/backend/db/query.sql.go +++ b/backend/db/query.sql.go @@ -186,6 +186,57 @@ func (q *Queries) CreateTestcaseResult(ctx context.Context, arg CreateTestcaseRe return err } +const createTournament = `-- name: CreateTournament :one +INSERT INTO tournaments (display_name, bracket_size, num_rounds) +VALUES ($1, $2, $3) +RETURNING tournament_id +` + +type CreateTournamentParams struct { + DisplayName string + BracketSize int32 + NumRounds int32 +} + +func (q *Queries) CreateTournament(ctx context.Context, arg CreateTournamentParams) (int32, error) { + row := q.db.QueryRow(ctx, createTournament, arg.DisplayName, arg.BracketSize, arg.NumRounds) + var tournament_id int32 + err := row.Scan(&tournament_id) + return tournament_id, err +} + +const createTournamentEntry = `-- name: CreateTournamentEntry :exec +INSERT INTO tournament_entries (tournament_id, user_id, seed) +VALUES ($1, $2, $3) +` + +type CreateTournamentEntryParams struct { + TournamentID int32 + UserID int32 + Seed int32 +} + +func (q *Queries) CreateTournamentEntry(ctx context.Context, arg CreateTournamentEntryParams) error { + _, err := q.db.Exec(ctx, createTournamentEntry, arg.TournamentID, arg.UserID, arg.Seed) + return err +} + +const createTournamentMatch = `-- name: CreateTournamentMatch :exec +INSERT INTO tournament_matches (tournament_id, round, position) +VALUES ($1, $2, $3) +` + +type CreateTournamentMatchParams struct { + TournamentID int32 + Round int32 + Position int32 +} + +func (q *Queries) CreateTournamentMatch(ctx context.Context, arg CreateTournamentMatchParams) error { + _, err := q.db.Exec(ctx, createTournamentMatch, arg.TournamentID, arg.Round, arg.Position) + return err +} + const createUser = `-- name: CreateUser :one INSERT INTO users (username, display_name, is_admin) VALUES ($1, $1, false) @@ -242,6 +293,26 @@ func (q *Queries) DeleteTestcase(ctx context.Context, testcaseID int32) error { return err } +const deleteTournamentEntries = `-- name: DeleteTournamentEntries :exec +DELETE FROM tournament_entries +WHERE tournament_id = $1 +` + +func (q *Queries) DeleteTournamentEntries(ctx context.Context, tournamentID int32) error { + _, err := q.db.Exec(ctx, deleteTournamentEntries, tournamentID) + return err +} + +const deleteTournamentMatches = `-- name: DeleteTournamentMatches :exec +DELETE FROM tournament_matches +WHERE tournament_id = $1 +` + +func (q *Queries) DeleteTournamentMatches(ctx context.Context, tournamentID int32) error { + _, err := q.db.Exec(ctx, deleteTournamentMatches, tournamentID) + return err +} + const getGameByID = `-- name: GetGameByID :one SELECT game_id, game_type, is_public, display_name, duration_seconds, created_at, started_at, games.problem_id, problems.problem_id, title, description, language, sample_code FROM games JOIN problems ON games.problem_id = problems.problem_id @@ -643,6 +714,25 @@ func (q *Queries) GetTestcaseResultsBySubmissionID(ctx context.Context, submissi return items, nil } +const getTournamentByID = `-- name: GetTournamentByID :one +SELECT tournament_id, display_name, bracket_size, num_rounds, created_at FROM tournaments +WHERE tournament_id = $1 +LIMIT 1 +` + +func (q *Queries) GetTournamentByID(ctx context.Context, tournamentID int32) (Tournament, error) { + row := q.db.QueryRow(ctx, getTournamentByID, tournamentID) + var i Tournament + err := row.Scan( + &i.TournamentID, + &i.DisplayName, + &i.BracketSize, + &i.NumRounds, + &i.CreatedAt, + ) + return i, err +} + const getUserAuthByUsername = `-- name: GetUserAuthByUsername :one SELECT users.user_id, username, display_name, icon_path, is_admin, label, created_at, user_auth_id, user_auths.user_id, auth_type, password_hash FROM users JOIN user_auths ON users.user_id = user_auths.user_id @@ -1054,6 +1144,123 @@ func (q *Queries) ListTestcasesByProblemID(ctx context.Context, problemID int32) return items, nil } +const listTournamentEntries = `-- name: ListTournamentEntries :many +SELECT tournament_entries.tournament_entry_id, tournament_entries.tournament_id, tournament_entries.user_id, tournament_entries.seed, users.user_id, users.username, users.display_name, users.icon_path, users.is_admin, users.label, users.created_at +FROM tournament_entries +JOIN users ON tournament_entries.user_id = users.user_id +WHERE tournament_entries.tournament_id = $1 +ORDER BY tournament_entries.seed +` + +type ListTournamentEntriesRow struct { + TournamentEntryID int32 + TournamentID int32 + UserID int32 + Seed int32 + UserID_2 int32 + Username string + DisplayName string + IconPath *string + IsAdmin bool + Label *string + CreatedAt pgtype.Timestamp +} + +func (q *Queries) ListTournamentEntries(ctx context.Context, tournamentID int32) ([]ListTournamentEntriesRow, error) { + rows, err := q.db.Query(ctx, listTournamentEntries, tournamentID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListTournamentEntriesRow + for rows.Next() { + var i ListTournamentEntriesRow + if err := rows.Scan( + &i.TournamentEntryID, + &i.TournamentID, + &i.UserID, + &i.Seed, + &i.UserID_2, + &i.Username, + &i.DisplayName, + &i.IconPath, + &i.IsAdmin, + &i.Label, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listTournamentMatches = `-- name: ListTournamentMatches :many +SELECT tournament_match_id, tournament_id, round, position, game_id FROM tournament_matches +WHERE tournament_id = $1 +ORDER BY round, position +` + +func (q *Queries) ListTournamentMatches(ctx context.Context, tournamentID int32) ([]TournamentMatch, error) { + rows, err := q.db.Query(ctx, listTournamentMatches, tournamentID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []TournamentMatch + for rows.Next() { + var i TournamentMatch + if err := rows.Scan( + &i.TournamentMatchID, + &i.TournamentID, + &i.Round, + &i.Position, + &i.GameID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listTournaments = `-- name: ListTournaments :many +SELECT tournament_id, display_name, bracket_size, num_rounds, created_at FROM tournaments +ORDER BY tournament_id +` + +func (q *Queries) ListTournaments(ctx context.Context) ([]Tournament, error) { + rows, err := q.db.Query(ctx, listTournaments) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Tournament + for rows.Next() { + var i Tournament + if err := rows.Scan( + &i.TournamentID, + &i.DisplayName, + &i.BracketSize, + &i.NumRounds, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listUsers = `-- name: ListUsers :many SELECT user_id, username, display_name, icon_path, is_admin, label, created_at FROM users ORDER BY users.user_id @@ -1305,6 +1512,45 @@ func (q *Queries) UpdateTestcase(ctx context.Context, arg UpdateTestcaseParams) return err } +const updateTournament = `-- name: UpdateTournament :exec +UPDATE tournaments +SET display_name = $2, bracket_size = $3, num_rounds = $4 +WHERE tournament_id = $1 +` + +type UpdateTournamentParams struct { + TournamentID int32 + DisplayName string + BracketSize int32 + NumRounds int32 +} + +func (q *Queries) UpdateTournament(ctx context.Context, arg UpdateTournamentParams) error { + _, err := q.db.Exec(ctx, updateTournament, + arg.TournamentID, + arg.DisplayName, + arg.BracketSize, + arg.NumRounds, + ) + return err +} + +const updateTournamentMatchGame = `-- name: UpdateTournamentMatchGame :exec +UPDATE tournament_matches +SET game_id = $2 +WHERE tournament_match_id = $1 +` + +type UpdateTournamentMatchGameParams struct { + TournamentMatchID int32 + GameID *int32 +} + +func (q *Queries) UpdateTournamentMatchGame(ctx context.Context, arg UpdateTournamentMatchGameParams) error { + _, err := q.db.Exec(ctx, updateTournamentMatchGame, arg.TournamentMatchID, arg.GameID) + return err +} + const updateUser = `-- name: UpdateUser :exec UPDATE users SET diff --git a/backend/fixtures/dev.sql b/backend/fixtures/dev.sql index bc25011..2876456 100644 --- a/backend/fixtures/dev.sql +++ b/backend/fixtures/dev.sql @@ -44,3 +44,25 @@ VALUES (5, '', '42'), (6, '', '42'), (7, '', '42'); + +-- Tournament: 3 participants, bracket_size=4, num_rounds=2 +INSERT INTO tournaments +(display_name, bracket_size, num_rounds) +VALUES + ('TEST Tournament', 4, 2); + +-- Entries: 3 players with seeds 1-3 (seed 4 = bye) +INSERT INTO tournament_entries +(tournament_id, user_id, seed) +VALUES + (1, 1, 1), + (1, 2, 2), + (1, 3, 3); + +-- Matches: Round 0 has 2 matches, Round 1 has 1 match (final) +INSERT INTO tournament_matches +(tournament_id, round, position, game_id) +VALUES + (1, 0, 0, 1), + (1, 0, 1, 2), + (1, 1, 0, 3); diff --git a/backend/query.sql b/backend/query.sql index 4297e42..b4b589c 100644 --- a/backend/query.sql +++ b/backend/query.sql @@ -290,3 +290,55 @@ DELETE FROM sessions WHERE session_id = $1; -- name: DeleteExpiredSessions :exec DELETE FROM sessions WHERE expires_at < NOW(); + +-- name: GetTournamentByID :one +SELECT * FROM tournaments +WHERE tournament_id = $1 +LIMIT 1; + +-- name: ListTournaments :many +SELECT * FROM tournaments +ORDER BY tournament_id; + +-- name: CreateTournament :one +INSERT INTO tournaments (display_name, bracket_size, num_rounds) +VALUES ($1, $2, $3) +RETURNING tournament_id; + +-- name: UpdateTournament :exec +UPDATE tournaments +SET display_name = $2, bracket_size = $3, num_rounds = $4 +WHERE tournament_id = $1; + +-- name: ListTournamentEntries :many +SELECT tournament_entries.*, users.* +FROM tournament_entries +JOIN users ON tournament_entries.user_id = users.user_id +WHERE tournament_entries.tournament_id = $1 +ORDER BY tournament_entries.seed; + +-- name: CreateTournamentEntry :exec +INSERT INTO tournament_entries (tournament_id, user_id, seed) +VALUES ($1, $2, $3); + +-- name: DeleteTournamentEntries :exec +DELETE FROM tournament_entries +WHERE tournament_id = $1; + +-- name: ListTournamentMatches :many +SELECT * FROM tournament_matches +WHERE tournament_id = $1 +ORDER BY round, position; + +-- name: CreateTournamentMatch :exec +INSERT INTO tournament_matches (tournament_id, round, position) +VALUES ($1, $2, $3); + +-- name: DeleteTournamentMatches :exec +DELETE FROM tournament_matches +WHERE tournament_id = $1; + +-- name: UpdateTournamentMatchGame :exec +UPDATE tournament_matches +SET game_id = $2 +WHERE tournament_match_id = $1; diff --git a/backend/schema.sql b/backend/schema.sql index 4a4b1ac..1e7f823 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -104,3 +104,33 @@ CREATE TABLE sessions ( ); CREATE INDEX idx_sessions_user_id ON sessions(user_id); CREATE INDEX idx_sessions_expires_at ON sessions(expires_at); + +CREATE TABLE tournaments ( + tournament_id SERIAL PRIMARY KEY, + display_name VARCHAR(255) NOT NULL, + bracket_size INT NOT NULL, + num_rounds INT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE tournament_entries ( + tournament_entry_id SERIAL PRIMARY KEY, + tournament_id INT NOT NULL, + user_id INT NOT NULL, + seed INT NOT NULL, + CONSTRAINT fk_tournament_entries_tournament_id FOREIGN KEY(tournament_id) REFERENCES tournaments(tournament_id), + CONSTRAINT fk_tournament_entries_user_id FOREIGN KEY(user_id) REFERENCES users(user_id), + CONSTRAINT uq_tournament_entries_tournament_user UNIQUE(tournament_id, user_id), + CONSTRAINT uq_tournament_entries_tournament_seed UNIQUE(tournament_id, seed) +); + +CREATE TABLE tournament_matches ( + tournament_match_id SERIAL PRIMARY KEY, + tournament_id INT NOT NULL, + round INT NOT NULL, + position INT NOT NULL, + game_id INT, + CONSTRAINT fk_tournament_matches_tournament_id FOREIGN KEY(tournament_id) REFERENCES tournaments(tournament_id), + CONSTRAINT fk_tournament_matches_game_id FOREIGN KEY(game_id) REFERENCES games(game_id), + CONSTRAINT uq_tournament_matches_tournament_round_position UNIQUE(tournament_id, round, position) +); diff --git a/frontend/app/App.tsx b/frontend/app/App.tsx index fcf6977..651de32 100644 --- a/frontend/app/App.tsx +++ b/frontend/app/App.tsx @@ -42,10 +42,12 @@ export default function App() { )} - - - - + + {(params) => ( + + + + )}
diff --git a/frontend/app/api/client.ts b/frontend/app/api/client.ts index 26c20d1..c9647ba 100644 --- a/frontend/app/api/client.ts +++ b/frontend/app/api/client.ts @@ -108,16 +108,10 @@ class AuthenticatedApiClient { return data; } - async getTournament( - game1: number, - game2: number, - game3: number, - game4: number, - game5: number, - ) { - const { data, error } = await client.GET("/tournament", { + async getTournament(tournamentId: number) { + const { data, error } = await client.GET("/tournaments/{tournament_id}", { params: { - query: { game1, game2, game3, game4, game5 }, + path: { tournament_id: tournamentId }, }, }); if (error) throw new Error(error.message); diff --git a/frontend/app/api/schema.d.ts b/frontend/app/api/schema.d.ts index 6d27df0..b891bfa 100644 --- a/frontend/app/api/schema.d.ts +++ b/frontend/app/api/schema.d.ts @@ -164,7 +164,7 @@ export interface paths { patch?: never; trace?: never; }; - "/tournament": { + "/tournaments/{tournament_id}": { parameters: { query?: never; header?: never; @@ -223,15 +223,28 @@ export interface components { code: string | null; }; Tournament: { + tournament_id: number; + display_name: string; + bracket_size: number; + num_rounds: number; + entries: components["schemas"]["TournamentEntry"][]; matches: components["schemas"]["TournamentMatch"][]; }; + TournamentEntry: { + user: components["schemas"]["User"]; + seed: number; + }; TournamentMatch: { - game_id: number; + tournament_match_id: number; + round: number; + position: number; + game_id?: number; player1?: components["schemas"]["User"]; player2?: components["schemas"]["User"]; player1_score?: number; player2_score?: number; - winner?: number; + winner_user_id?: number; + is_bye: boolean; }; User: { user_id: number; @@ -700,15 +713,11 @@ export interface operations { }; getTournament: { parameters: { - query: { - game1: number; - game2: number; - game3: number; - game4: number; - game5: number; - }; + query?: never; header?: never; - path?: never; + path: { + tournament_id: number; + }; cookie?: never; }; requestBody?: never; diff --git a/frontend/app/pages/TournamentPage.test.tsx b/frontend/app/pages/TournamentPage.test.tsx new file mode 100644 index 0000000..3c6f116 --- /dev/null +++ b/frontend/app/pages/TournamentPage.test.tsx @@ -0,0 +1,60 @@ +/** + * @vitest-environment jsdom + */ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import TournamentPage, { standardBracketSeedsForTest } from "./TournamentPage"; + +afterEach(() => { + cleanup(); +}); + +describe("standardBracketSeeds", () => { + test("bracket_size=2 returns [1, 2]", () => { + const seeds = standardBracketSeedsForTest(2); + expect(seeds).toEqual([1, 2]); + }); + + test("bracket_size=4 returns [1, 4, 2, 3]", () => { + const seeds = standardBracketSeedsForTest(4); + expect(seeds).toEqual([1, 4, 2, 3]); + }); + + test("bracket_size=8 returns [1, 8, 4, 5, 2, 7, 3, 6]", () => { + const seeds = standardBracketSeedsForTest(8); + expect(seeds).toEqual([1, 8, 4, 5, 2, 7, 3, 6]); + }); + + test("all seeds present for size 16", () => { + const seeds = standardBracketSeedsForTest(16); + expect(seeds).toHaveLength(16); + const sorted = [...seeds].sort((a, b) => a - b); + expect(sorted).toEqual(Array.from({ length: 16 }, (_, i) => i + 1)); + }); + + test("seed 1 and seed 2 on opposite sides for size 8", () => { + const seeds = standardBracketSeedsForTest(8); + const pos1 = seeds.indexOf(1); + const pos2 = seeds.indexOf(2); + // Seed 1 in first half (0-3), Seed 2 in second half (4-7) + expect(pos1).toBeLessThan(4); + expect(pos2).toBeGreaterThanOrEqual(4); + }); +}); + +describe("TournamentPage", () => { + test("shows loading state initially", () => { + render(); + expect(screen.getByText("Loading...")).toBeDefined(); + }); + + test("shows error for invalid tournament ID", () => { + render(); + expect(screen.getByText("Invalid tournament ID")).toBeDefined(); + }); + + test("shows error for zero tournament ID", () => { + render(); + expect(screen.getByText("Invalid tournament ID")).toBeDefined(); + }); +}); diff --git a/frontend/app/pages/TournamentPage.tsx b/frontend/app/pages/TournamentPage.tsx index 8debd0a..e3ec912 100644 --- a/frontend/app/pages/TournamentPage.tsx +++ b/frontend/app/pages/TournamentPage.tsx @@ -6,20 +6,40 @@ import UserIcon from "../components/UserIcon"; import { APP_NAME } from "../config"; import { usePageTitle } from "../hooks/usePageTitle"; +type Tournament = components["schemas"]["Tournament"]; type TournamentMatch = components["schemas"]["TournamentMatch"]; -type User = components["schemas"]["User"]; +type TournamentEntry = components["schemas"]["TournamentEntry"]; -function Player({ player, rank }: { player: User | null; rank: number }) { +function getBorderColor(match: TournamentMatch, userID?: number): string { + if (!match.winner_user_id) { + return "border-black"; + } + if (userID !== undefined && match.winner_user_id === userID) { + return "border-pink-700"; + } + return "border-gray-400"; +} + +function PlayerCard({ entry }: { entry: TournamentEntry | undefined }) { + if (!entry) { + return ( +
+ BYE +
+ ); + } return ( -
- 予選 {rank} 位 - {player?.display_name} - {player?.icon_path && ( +
+ Seed {entry.seed} + + {entry.user.display_name} + + {entry.user.icon_path && ( )}
@@ -27,245 +47,243 @@ function Player({ player, rank }: { player: User | null; rank: number }) { ); } -function BranchVL({ className = "" }: { className?: string }) { - return ( -
-
-
-
- ); -} - -function BranchVR({ className = "" }: { className?: string }) { - return ( -
-
-
-
- ); -} - -function BranchVL2({ - score, - className = "", -}: { - score: number | null; - className?: string; -}) { - return ( -
-
-
- {score} -
-
-
- ); -} - -function BranchVR2({ - score, - className = "", -}: { - score: number | null; - className?: string; -}) { - return ( -
-
-
- {score} +function MatchCell({ match }: { match: TournamentMatch }) { + if (match.is_bye) { + return ( +
+ BYE
-
-
- ); -} + ); + } -function BranchV3({ className = "" }: { className?: string }) { - return
; -} + const p1Color = match.winner_user_id + ? match.winner_user_id === match.player1?.user_id + ? "border-pink-700" + : "border-gray-400" + : "border-black"; + const p2Color = match.winner_user_id + ? match.winner_user_id === match.player2?.user_id + ? "border-pink-700" + : "border-gray-400" + : "border-black"; -function BranchH({ - score, - className1, - className2, - className3, -}: { - score?: number | null; - className1: string; - className2: string; - className3: string; -}) { return ( -
-
-
-
- {score} +
+
+ {match.player1?.display_name ?? "?"} + {match.player1_score !== undefined && ( + {match.player1_score} + )}
-
- ); -} - -function BranchH2({ - score, - className1, - className2, - className3, -}: { - score?: number | null; - className1: string; - className2: string; - className3: string; -}) { - return ( -
- {score} + {match.player2?.display_name ?? "?"} + {match.player2_score !== undefined && ( + {match.player2_score} + )}
-
-
); } -function BranchL({ - score, - className = "", +function Connector({ + position, + colSpan, + match, }: { - score: number | null; - className?: string; + position: number; + colSpan: number; + match: TournamentMatch | undefined; }) { + const leftHalf = colSpan / 2; + const rightHalf = colSpan - leftHalf; + + const leftColor = match + ? getBorderColor(match, match.player1?.user_id) + : "border-black"; + const rightColor = match + ? getBorderColor(match, match.player2?.user_id) + : "border-black"; + return ( -
-
+
- {score} +
+
); } -function BranchR({ - score, - className = "", -}: { - score: number | null; - className?: string; -}) { - return ( -
+function TournamentBracket({ tournament }: { tournament: Tournament }) { + const { bracket_size, num_rounds, entries, matches } = tournament; + + const matchByKey = new Map(); + for (const m of matches) { + matchByKey.set(`${m.round}-${m.position}`, m); + } + + const entryBySeed = new Map(); + for (const e of entries) { + entryBySeed.set(e.seed, e); + } + + const bracketSeeds = standardBracketSeeds(bracket_size); + + // Build rows top-to-bottom: final → ... → round 0 → players + const rows: React.ReactNode[] = []; + + // Rounds from top (final) to bottom (round 0) + for (let round = num_rounds - 1; round >= 0; round--) { + const numPositions = bracket_size / (1 << (round + 1)); + const colSpan = bracket_size / numPositions; + + // Match cells for this round + const matchCells: React.ReactNode[] = []; + for (let pos = 0; pos < numPositions; pos++) { + const match = matchByKey.get(`${round}-${pos}`); + matchCells.push( +
+ {match ? : null} +
, + ); + } + rows.push(
- {score} -
-
-
- ); -} + {matchCells} +
, + ); -function BranchL2({ className = "" }: { className?: string }) { - return ( -
-
-
-
- ); -} + // Connectors below this round's matches + const connectors: React.ReactNode[] = []; + for (let pos = 0; pos < numPositions; pos++) { + const match = matchByKey.get(`${round}-${pos}`); + connectors.push( + , + ); + } + rows.push( +
+ {connectors} +
, + ); + } -function BranchR2({ className = "" }: { className?: string }) { - return ( -
-
-
-
+ // Player cards row (bottom) + const playerCards: React.ReactNode[] = []; + for (let slot = 0; slot < bracket_size; slot++) { + const seed = bracketSeeds[slot]!; + const entry = entryBySeed.get(seed); + playerCards.push( +
+ +
, + ); + } + rows.push( +
+ {playerCards} +
, ); -} -function getPlayer(match: TournamentMatch, playerID: number): User | null { - if (match.player1?.user_id === playerID) return match.player1; - if (match.player2?.user_id === playerID) return match.player2; - return null; + return
{rows}
; } -function getScore(match: TournamentMatch, playerIDs: number[]): number | null { - if (match.player1 && playerIDs.includes(match.player1.user_id)) - return match.player1_score ?? null; - if (match.player2 && playerIDs.includes(match.player2.user_id)) - return match.player2_score ?? null; - return null; -} +// Exported for testing as standardBracketSeedsForTest +export { standardBracketSeeds as standardBracketSeedsForTest }; -function getBorderColor(match: TournamentMatch, playerIDs: number[]): string { - if (!match.winner) { - return "border-black"; - } - if (playerIDs.includes(match.winner)) { - return "border-pink-700"; +function standardBracketSeeds(bracketSize: number): number[] { + const seeds = new Array(bracketSize).fill(0); + seeds[0] = 1; + for (let size = 2; size <= bracketSize; size *= 2) { + const temp = new Array(size).fill(0); + for (let i = 0; i < size / 2; i++) { + temp[i * 2] = seeds[i]!; + temp[i * 2 + 1] = size + 1 - seeds[i]!; + } + for (let i = 0; i < size; i++) { + seeds[i] = temp[i]!; + } } - return "border-gray-400"; + return seeds; } -export default function TournamentPage() { +export default function TournamentPage({ + tournamentId, +}: { + tournamentId: string; +}) { usePageTitle(`Tournament | ${APP_NAME}`); - const params = new URLSearchParams(window.location.search); - const game1 = Number(params.get("game1")); - const game2 = Number(params.get("game2")); - const game3 = Number(params.get("game3")); - const game4 = Number(params.get("game4")); - const game5 = Number(params.get("game5")); - const gamesValid = game1 && game2 && game3 && game4 && game5; + const id = Number(tournamentId); + const isValidId = id > 0; - const pIDs = [ - Number(params.get("player1")), - Number(params.get("player2")), - Number(params.get("player3")), - Number(params.get("player4")), - Number(params.get("player5")), - Number(params.get("player6")), - ]; - const playersValid = pIDs.every((id) => id); - - const paramsValid = gamesValid && playersValid; - const paramsError = !gamesValid - ? "Missing or invalid game parameters" - : !playersValid - ? "Missing or invalid player parameters" - : null; - - const [tournament, setTournament] = useState<{ - matches: TournamentMatch[]; - } | null>(null); - const playerIDs = paramsValid ? pIDs : []; - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const [tournament, setTournament] = useState(null); + const [loading, setLoading] = useState(isValidId); + const [error, setError] = useState( + isValidId ? null : "Invalid tournament ID", + ); useEffect(() => { - if (!paramsValid) { + if (!isValidId) { return; } const apiClient = createApiClient(); apiClient - .getTournament(game1, game2, game3, game4, game5) + .getTournament(id) .then(({ tournament }) => setTournament(tournament)) .catch(() => setError("Failed to load tournament")) .finally(() => setLoading(false)); - }, [paramsValid, game1, game2, game3, game4, game5]); - - if (paramsError) { - return ( -
-

{paramsError}

-
- ); - } + }, [id, isValidId]); if (loading) { return ( @@ -283,162 +301,13 @@ export default function TournamentPage() { ); } - const match1 = tournament.matches[0]!; - const match2 = tournament.matches[1]!; - const match3 = tournament.matches[2]!; - const match4 = tournament.matches[3]!; - const match5 = tournament.matches[4]!; - - const playerID1 = playerIDs[0]!; - const playerID2 = playerIDs[1]!; - const playerID3 = playerIDs[2]!; - const playerID4 = playerIDs[3]!; - const playerID5 = playerIDs[4]!; - const playerID6 = playerIDs[5]!; - - const player5 = getPlayer(match1, playerID5); - const player4 = getPlayer(match1, playerID4); - const player3 = getPlayer(match2, playerID3); - const player6 = getPlayer(match2, playerID6); - const player1 = getPlayer(match3, playerID1); - const player2 = getPlayer(match4, playerID2); - return (
-
+

- PHPerKaigi 2026 PHP Code Battle + {tournament.display_name}

- -
-
-
-
- -
-
-
-
-
-
- - - - -
-
-
- - - - - - -
-
- - - - - - -
-
- - - - - - -
-
+
); diff --git a/openapi/api-server.yaml b/openapi/api-server.yaml index 21fb989..ed535d3 100644 --- a/openapi/api-server.yaml +++ b/openapi/api-server.yaml @@ -347,40 +347,15 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' - /tournament: + /tournaments/{tournament_id}: get: operationId: getTournament parameters: - - name: game1 - in: query - required: true - schema: - type: integer - explode: false - - name: game2 - in: query - required: true - schema: - type: integer - explode: false - - name: game3 - in: query - required: true - schema: - type: integer - explode: false - - name: game4 - in: query - required: true - schema: - type: integer - explode: false - - name: game5 - in: query + - name: tournament_id + in: path required: true schema: type: integer - explode: false responses: '200': description: The request has succeeded. @@ -530,17 +505,53 @@ components: Tournament: type: object required: + - tournament_id + - display_name + - bracket_size + - num_rounds + - entries - matches properties: + tournament_id: + type: integer + display_name: + type: string + bracket_size: + type: integer + num_rounds: + type: integer + entries: + type: array + items: + $ref: '#/components/schemas/TournamentEntry' matches: type: array items: $ref: '#/components/schemas/TournamentMatch' + TournamentEntry: + type: object + required: + - user + - seed + properties: + user: + $ref: '#/components/schemas/User' + seed: + type: integer TournamentMatch: type: object required: - - game_id + - tournament_match_id + - round + - position + - is_bye properties: + tournament_match_id: + type: integer + round: + type: integer + position: + type: integer game_id: type: integer player1: @@ -551,8 +562,10 @@ components: type: integer player2_score: type: integer - winner: + winner_user_id: type: integer + is_bye: + type: boolean User: type: object required: diff --git a/typespec/api-server/models.tsp b/typespec/api-server/models.tsp index 47519be..6605767 100644 --- a/typespec/api-server/models.tsp +++ b/typespec/api-server/models.tsp @@ -106,14 +106,28 @@ model RankingEntry { } model Tournament { + tournament_id: integer; + display_name: string; + bracket_size: integer; + num_rounds: integer; + entries: TournamentEntry[]; matches: TournamentMatch[]; } +model TournamentEntry { + user: User; + seed: integer; +} + model TournamentMatch { - game_id: integer; + tournament_match_id: integer; + round: integer; + position: integer; + game_id?: integer; player1?: User; player2?: User; player1_score?: integer; player2_score?: integer; - winner?: integer; + winner_user_id?: integer; + is_bye: boolean; } diff --git a/typespec/api-server/routes.tsp b/typespec/api-server/routes.tsp index 3409cea..a67ab8f 100644 --- a/typespec/api-server/routes.tsp +++ b/typespec/api-server/routes.tsp @@ -110,16 +110,10 @@ op getGameWatchLatestStates(@path game_id: integer): { // ---------- Tournament ---------- -@route("/tournament") +@route("/tournaments/{tournament_id}") @get @operationId("getTournament") -op getTournament( - @query game1: integer, - @query game2: integer, - @query game3: integer, - @query game4: integer, - @query game5: integer, -): { +op getTournament(@path tournament_id: integer): { @body body: { tournament: Tournament; }; -- cgit v1.3.1