aboutsummaryrefslogtreecommitdiffhomepage
path: root/backend
diff options
context:
space:
mode:
Diffstat (limited to 'backend')
-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
15 files changed, 1676 insertions, 151 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)
+);