aboutsummaryrefslogtreecommitdiffhomepage
path: root/backend/api
diff options
context:
space:
mode:
Diffstat (limited to 'backend/api')
-rw-r--r--backend/api/generated.go137
-rw-r--r--backend/api/handler.go291
-rw-r--r--backend/api/handler_test.go337
3 files changed, 614 insertions, 151 deletions
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)")
+ }
+}