aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-18 22:38:15 +0900
committernsfisis <nsfisis@gmail.com>2026-02-18 22:38:15 +0900
commit9f9efc2bc07810d2e06b37bad94e5922681eadef (patch)
tree79bcce2bf065a7ea282aa7855822c3bdee92ee7c
parentc095200dc79f24c0cd17a2e3ba15c85a2971ea9a (diff)
downloadphperkaigi-2026-albatross-9f9efc2bc07810d2e06b37bad94e5922681eadef.tar.gz
phperkaigi-2026-albatross-9f9efc2bc07810d2e06b37bad94e5922681eadef.tar.zst
phperkaigi-2026-albatross-9f9efc2bc07810d2e06b37bad94e5922681eadef.zip
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 <noreply@anthropic.com>
-rw-r--r--backend/admin/handler.go287
-rw-r--r--backend/admin/handler_test.go298
-rw-r--r--backend/admin/templates/dashboard.html3
-rw-r--r--backend/admin/templates/tournament_edit.html48
-rw-r--r--backend/admin/templates/tournament_new.html22
-rw-r--r--backend/admin/templates/tournaments.html20
-rw-r--r--backend/api/generated.go137
-rw-r--r--backend/api/handler.go291
-rw-r--r--backend/api/handler_test.go337
-rw-r--r--backend/db/models.go23
-rw-r--r--backend/db/querier.go11
-rw-r--r--backend/db/query.sql.go246
-rw-r--r--backend/fixtures/dev.sql22
-rw-r--r--backend/query.sql52
-rw-r--r--backend/schema.sql30
-rw-r--r--frontend/app/App.tsx10
-rw-r--r--frontend/app/api/client.ts12
-rw-r--r--frontend/app/api/schema.d.ts31
-rw-r--r--frontend/app/pages/TournamentPage.test.tsx60
-rw-r--r--frontend/app/pages/TournamentPage.tsx575
-rw-r--r--openapi/api-server.yaml73
-rw-r--r--typespec/api-server/models.tsp18
-rw-r--r--typespec/api-server/routes.tsp10
23 files changed, 2048 insertions, 568 deletions
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
@@ -11,6 +11,9 @@
<a href="{{ .BasePath }}admin/problems">Problems</a>
</p>
<p>
+ <a href="{{ .BasePath }}admin/tournaments">Tournaments</a>
+</p>
+<p>
<a href="{{ .BasePath }}admin/online-qualifying-ranking">Online Qualifying Ranking</a>
</p>
<form method="post" action="{{ .BasePath }}admin/fix">
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" }}
+<a href="{{ .BasePath }}admin/dashboard">Dashboard</a> | <a href="{{ .BasePath }}admin/tournaments">Tournaments</a>
+{{ end }}
+
+{{ define "content" }}
+<form method="post">
+ <div>
+ <label>Display Name</label>
+ <input type="text" name="display_name" value="{{ .Tournament.DisplayName }}" required>
+ </div>
+
+ <h3>Bracket (size={{ .Tournament.BracketSize }}, rounds={{ .Tournament.NumRounds }})</h3>
+
+ <h3>Entries (seed → user)</h3>
+ {{ range .Seeds }}
+ {{ $seed := . }}
+ <div>
+ <label>Seed {{ $seed }}</label>
+ <select name="seed_{{ $seed }}">
+ <option value="0">-- none (bye) --</option>
+ {{ range $.Users }}
+ <option value="{{ .UserID }}" {{ if eq .UserID (index $.SeedUserMap $seed) }}selected{{ end }}>{{ .Username }} (id={{ .UserID }})</option>
+ {{ end }}
+ </select>
+ </div>
+ {{ end }}
+
+ <h3>Matches (game assignment)</h3>
+ {{ range .Matches }}
+ <div>
+ <label>Round {{ .Round }} Pos {{ .Position }} ({{ .Description }})</label>
+ {{ $matchID := .MatchID }}
+ <select name="match_{{ $matchID }}">
+ <option value="0">-- no game --</option>
+ {{ range $.Games }}
+ <option value="{{ .GameID }}" {{ if eq .GameID (index $.MatchGameMap $matchID) }}selected{{ end }}>{{ .DisplayName }} (id={{ .GameID }})</option>
+ {{ end }}
+ </select>
+ </div>
+ {{ end }}
+
+ <div>
+ <button type="submit">Save</button>
+ </div>
+</form>
+{{ 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" }}
+<a href="{{ .BasePath }}admin/dashboard">Dashboard</a> | <a href="{{ .BasePath }}admin/tournaments">Tournaments</a>
+{{ end }}
+
+{{ define "content" }}
+<form method="post">
+ <div>
+ <label>Display Name</label>
+ <input type="text" name="display_name" required>
+ </div>
+ <div>
+ <label>Number of Participants</label>
+ <input type="number" name="num_participants" min="2" max="64" value="4" required>
+ <small>Bracket size will be rounded up to next power of 2.</small>
+ </div>
+ <div>
+ <button type="submit">Create</button>
+ </div>
+</form>
+{{ 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" }}
+<a href="{{ .BasePath }}admin/dashboard">Dashboard</a>
+{{ end }}
+
+{{ define "content" }}
+<div>
+ <a href="{{ .BasePath }}admin/tournaments/new">Create New Tournament</a>
+</div>
+<ul>
+ {{ range .Tournaments }}
+ <li>
+ <a href="{{ $.BasePath }}admin/tournaments/{{ .TournamentID }}">
+ {{ .DisplayName }} (id={{ .TournamentID }} bracket_size={{ .BracketSize }})
+ </a>
+ </li>
+ {{ end }}
+</ul>
+{{ 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(), &params.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(), &params.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(), &params.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(), &params.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(), &params.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{}
- var winnerID *int
- var player1Score, player2Score *int
+ 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
+ }
+ }
+ }
- 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)
+ // 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
+ }
- 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 mr.player2 != nil {
+ if s, ok := rr.scores[mr.player2.UserID]; ok {
+ score := s
+ mr.p2Score = &score
}
}
- if player2 != nil && player2.UserID == userID {
- player2Score = &score
- if winnerID == nil {
- winnerID = &userID
+ // 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
}
+ }
- match := TournamentMatch{
- GameID: int(gameID),
- Player1: player1,
- Player2: player2,
- Player1Score: player1Score,
- Player2Score: player2Score,
- Winner: winnerID,
+ // 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
+ }
+ }
+
+ 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() {
</ProtectedRoute>
)}
</Route>
- <Route path="/tournament">
- <ProtectedRoute>
- <TournamentPage />
- </ProtectedRoute>
+ <Route path="/tournament/:tournamentId">
+ {(params) => (
+ <ProtectedRoute>
+ <TournamentPage tournamentId={params.tournamentId} />
+ </ProtectedRoute>
+ )}
</Route>
<Route>
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
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(<TournamentPage tournamentId="1" />);
+ expect(screen.getByText("Loading...")).toBeDefined();
+ });
+
+ test("shows error for invalid tournament ID", () => {
+ render(<TournamentPage tournamentId="abc" />);
+ expect(screen.getByText("Invalid tournament ID")).toBeDefined();
+ });
+
+ test("shows error for zero tournament ID", () => {
+ render(<TournamentPage tournamentId="0" />);
+ 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 (
+ <div className="flex flex-col items-center gap-1 p-2 opacity-30">
+ <span className="text-gray-400 text-sm">BYE</span>
+ </div>
+ );
+ }
return (
<BorderedContainer>
- <div className="flex flex-col items-center gap-2">
- <span className="text-gray-800 text-md">予選 {rank} 位</span>
- <span className="font-medium text-lg">{player?.display_name}</span>
- {player?.icon_path && (
+ <div className="flex flex-col items-center gap-1">
+ <span className="text-gray-600 text-xs">Seed {entry.seed}</span>
+ <span className="font-medium text-sm truncate max-w-full">
+ {entry.user.display_name}
+ </span>
+ {entry.user.icon_path && (
<UserIcon
- iconPath={player.icon_path}
- displayName={player.display_name}
- className="w-16 h-16 my-auto"
+ iconPath={entry.user.icon_path}
+ displayName={entry.user.display_name}
+ className="w-12 h-12"
/>
)}
</div>
@@ -27,245 +47,243 @@ function Player({ player, rank }: { player: User | null; rank: number }) {
);
}
-function BranchVL({ className = "" }: { className?: string }) {
- return (
- <div className="grid grid-cols-2">
- <div></div>
- <div className={`border-l-4 ${className}`}></div>
- </div>
- );
-}
-
-function BranchVR({ className = "" }: { className?: string }) {
- return (
- <div className="grid grid-cols-2">
- <div className={`border-r-4 ${className}`}></div>
- <div></div>
- </div>
- );
-}
-
-function BranchVL2({
- score,
- className = "",
-}: {
- score: number | null;
- className?: string;
-}) {
- return (
- <div className="grid grid-cols-3">
- <div className={`border-r-4 ${className}`}></div>
- <div className={`border-t-4 p-2 font-bold text-xl ${className}`}>
- {score}
- </div>
- <div className={`border-t-4 ${className}`}></div>
- </div>
- );
-}
-
-function BranchVR2({
- score,
- className = "",
-}: {
- score: number | null;
- className?: string;
-}) {
- return (
- <div className="grid grid-cols-3">
- <div className={`border-t-4 ${className}`}></div>
- <div className={`border-t-4 p-2 font-bold text-xl ${className}`}>
- {score}
+function MatchCell({ match }: { match: TournamentMatch }) {
+ if (match.is_bye) {
+ return (
+ <div className="flex items-center justify-center h-full opacity-30">
+ <span className="text-gray-400 text-xs">BYE</span>
</div>
- <div className={`border-l-4 ${className}`}></div>
- </div>
- );
-}
+ );
+ }
-function BranchV3({ className = "" }: { className?: string }) {
- return <div className={`border-r-4 ${className}`}></div>;
-}
+ 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 (
- <div className="grid grid-cols-3">
- <div className={`border-t-4 ${className1}`}></div>
- <div className={`border-t-4 ${className2}`}></div>
- <div className={`border-t-4 p-2 font-bold text-xl ${className3}`}>
- {score}
+ <div className="flex flex-col gap-1 p-1">
+ <div
+ className={`border-2 ${p1Color} rounded px-2 py-1 text-xs flex justify-between`}
+ >
+ <span className="truncate">{match.player1?.display_name ?? "?"}</span>
+ {match.player1_score !== undefined && (
+ <span className="font-bold ml-1">{match.player1_score}</span>
+ )}
</div>
- </div>
- );
-}
-
-function BranchH2({
- score,
- className1,
- className2,
- className3,
-}: {
- score?: number | null;
- className1: string;
- className2: string;
- className3: string;
-}) {
- return (
- <div className="grid grid-cols-3">
<div
- className={`border-t-4 p-2 font-bold text-xl text-right ${className1}`}
+ className={`border-2 ${p2Color} rounded px-2 py-1 text-xs flex justify-between`}
>
- {score}
+ <span className="truncate">{match.player2?.display_name ?? "?"}</span>
+ {match.player2_score !== undefined && (
+ <span className="font-bold ml-1">{match.player2_score}</span>
+ )}
</div>
- <div className={`border-t-4 ${className2}`}></div>
- <div className={`border-t-4 ${className3}`}></div>
</div>
);
}
-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 (
- <div className="grid grid-cols-2">
- <div></div>
+ <div
+ className="grid h-8"
+ style={{
+ gridColumn: `${position * colSpan + 1} / span ${colSpan}`,
+ }}
+ >
<div
- className={`border-l-4 border-t-4 p-2 font-bold text-xl ${className}`}
+ className="grid"
+ style={{
+ gridTemplateColumns: `repeat(${colSpan}, 1fr)`,
+ }}
>
- {score}
+ <div
+ className={`border-t-4 border-r-2 ${leftColor}`}
+ style={{ gridColumn: `1 / span ${leftHalf}` }}
+ />
+ <div
+ className={`border-t-4 border-l-2 ${rightColor}`}
+ style={{ gridColumn: `${leftHalf + 1} / span ${rightHalf}` }}
+ />
</div>
</div>
);
}
-function BranchR({
- score,
- className = "",
-}: {
- score: number | null;
- className?: string;
-}) {
- return (
- <div className="grid grid-cols-2">
+function TournamentBracket({ tournament }: { tournament: Tournament }) {
+ const { bracket_size, num_rounds, entries, matches } = tournament;
+
+ const matchByKey = new Map<string, TournamentMatch>();
+ for (const m of matches) {
+ matchByKey.set(`${m.round}-${m.position}`, m);
+ }
+
+ const entryBySeed = new Map<number, TournamentEntry>();
+ 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(
+ <div
+ key={`match-${round}-${pos}`}
+ style={{
+ gridColumn: `${pos * colSpan + 1} / span ${colSpan}`,
+ }}
+ >
+ {match ? <MatchCell match={match} /> : null}
+ </div>,
+ );
+ }
+ rows.push(
<div
- className={`border-r-4 border-t-4 p-2 font-bold text-xl text-right ${className}`}
+ key={`round-${round}`}
+ className="grid"
+ style={{
+ gridTemplateColumns: `repeat(${bracket_size}, 1fr)`,
+ }}
>
- {score}
- </div>
- <div></div>
- </div>
- );
-}
+ {matchCells}
+ </div>,
+ );
-function BranchL2({ className = "" }: { className?: string }) {
- return (
- <div className="grid grid-cols-2">
- <div className={`border-l-4 ${className}`}></div>
- <div></div>
- </div>
- );
-}
+ // 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(
+ <Connector
+ key={`conn-${round}-${pos}`}
+ position={pos}
+ colSpan={colSpan}
+ match={match}
+ />,
+ );
+ }
+ rows.push(
+ <div
+ key={`conn-row-${round}`}
+ className="grid"
+ style={{
+ gridTemplateColumns: `repeat(${bracket_size}, 1fr)`,
+ }}
+ >
+ {connectors}
+ </div>,
+ );
+ }
-function BranchR2({ className = "" }: { className?: string }) {
- return (
- <div className="grid grid-cols-2">
- <div></div>
- <div className={`border-r-4 ${className}`}></div>
- </div>
+ // 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(
+ <div
+ key={`player-${slot}`}
+ style={{ gridColumn: `${slot + 1} / span 1` }}
+ >
+ <PlayerCard entry={entry} />
+ </div>,
+ );
+ }
+ rows.push(
+ <div
+ key="players"
+ className="grid gap-1"
+ style={{ gridTemplateColumns: `repeat(${bracket_size}, 1fr)` }}
+ >
+ {playerCards}
+ </div>,
);
-}
-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 <div className="flex flex-col gap-0">{rows}</div>;
}
-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<number>(bracketSize).fill(0);
+ seeds[0] = 1;
+ for (let size = 2; size <= bracketSize; size *= 2) {
+ const temp = new Array<number>(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<string | null>(null);
+ const [tournament, setTournament] = useState<Tournament | null>(null);
+ const [loading, setLoading] = useState(isValidId);
+ const [error, setError] = useState<string | null>(
+ 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 (
- <div className="min-h-screen bg-gray-100 flex items-center justify-center">
- <p className="text-red-500">{paramsError}</p>
- </div>
- );
- }
+ }, [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 (
<div className="p-6 bg-gray-100 min-h-screen">
- <div className="max-w-5xl mx-auto">
+ <div className="max-w-6xl mx-auto">
<h1 className="text-3xl font-bold text-transparent bg-clip-text bg-phperkaigi text-center mb-8">
- PHPerKaigi 2026 PHP Code Battle
+ {tournament.display_name}
</h1>
-
- <div className="grid grid-rows-5">
- <div className="grid grid-cols-6">
- <div></div>
- <div></div>
- <BranchV3
- className={getBorderColor(match5, [
- playerID1,
- playerID5,
- playerID4,
- playerID3,
- playerID6,
- playerID2,
- ])}
- />
- <div></div>
- <div></div>
- <div></div>
- </div>
- <div className="grid grid-cols-6">
- <div></div>
- <BranchVL2
- score={getScore(match5, [playerID1, playerID5, playerID4])}
- className={getBorderColor(match5, [
- playerID1,
- playerID5,
- playerID4,
- ])}
- />
- <BranchH
- className1={getBorderColor(match5, [
- playerID1,
- playerID5,
- playerID4,
- ])}
- className2={getBorderColor(match5, [
- playerID1,
- playerID5,
- playerID4,
- ])}
- className3={getBorderColor(match5, [
- playerID1,
- playerID5,
- playerID4,
- ])}
- />
- <BranchH
- className1={getBorderColor(match5, [
- playerID3,
- playerID6,
- playerID2,
- ])}
- className2={getBorderColor(match5, [
- playerID3,
- playerID6,
- playerID2,
- ])}
- className3={getBorderColor(match5, [
- playerID3,
- playerID6,
- playerID2,
- ])}
- />
- <BranchVR2
- score={getScore(match5, [playerID3, playerID6, playerID2])}
- className={getBorderColor(match5, [
- playerID3,
- playerID6,
- playerID2,
- ])}
- />
- <div></div>
- </div>
- <div className="grid grid-cols-6">
- <BranchL
- score={getScore(match3, [playerID1])}
- className={getBorderColor(match3, [playerID1])}
- />
- <BranchH
- score={getScore(match3, [playerID5, playerID4])}
- className1={getBorderColor(match3, [playerID1])}
- className2={getBorderColor(match3, [playerID5, playerID4])}
- className3={getBorderColor(match3, [playerID5, playerID4])}
- />
- <BranchL2
- className={getBorderColor(match3, [playerID5, playerID4])}
- />
- <BranchR2
- className={getBorderColor(match4, [playerID3, playerID6])}
- />
- <BranchH2
- score={getScore(match4, [playerID3, playerID6])}
- className1={getBorderColor(match4, [playerID3, playerID6])}
- className2={getBorderColor(match4, [playerID3, playerID6])}
- className3={getBorderColor(match4, [playerID2])}
- />
- <BranchR
- score={getScore(match4, [playerID2])}
- className={getBorderColor(match4, [playerID2])}
- />
- </div>
- <div className="grid grid-cols-6">
- <BranchVL className={getBorderColor(match3, [playerID1])} />
- <BranchL
- score={getScore(match1, [playerID5])}
- className={getBorderColor(match1, [playerID5])}
- />
- <BranchR
- score={getScore(match1, [playerID4])}
- className={getBorderColor(match1, [playerID4])}
- />
- <BranchL
- score={getScore(match2, [playerID3])}
- className={getBorderColor(match2, [playerID3])}
- />
- <BranchR
- score={getScore(match2, [playerID6])}
- className={getBorderColor(match2, [playerID6])}
- />
- <BranchVR className={getBorderColor(match4, [playerID2])} />
- </div>
- <div className="grid grid-cols-6 gap-6">
- <Player player={player1} rank={1} />
- <Player player={player5} rank={5} />
- <Player player={player4} rank={4} />
- <Player player={player3} rank={3} />
- <Player player={player6} rank={6} />
- <Player player={player2} rank={2} />
- </div>
- </div>
+ <TournamentBracket tournament={tournament} />
</div>
</div>
);
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;
};