diff options
Diffstat (limited to 'backend/api')
| -rw-r--r-- | backend/api/generated.go | 137 | ||||
| -rw-r--r-- | backend/api/handler.go | 291 | ||||
| -rw-r--r-- | backend/api/handler_test.go | 337 |
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(), ¶ms.Game1) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter game1: %s", err)) - } - - // ------------- Required query parameter "game2" ------------- - - err = runtime.BindQueryParameter("form", false, true, "game2", ctx.QueryParams(), ¶ms.Game2) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter game2: %s", err)) - } - - // ------------- Required query parameter "game3" ------------- - - err = runtime.BindQueryParameter("form", false, true, "game3", ctx.QueryParams(), ¶ms.Game3) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter game3: %s", err)) - } - - // ------------- Required query parameter "game4" ------------- - - err = runtime.BindQueryParameter("form", false, true, "game4", ctx.QueryParams(), ¶ms.Game4) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter game4: %s", err)) - } - - // ------------- Required query parameter "game5" ------------- - - err = runtime.BindQueryParameter("form", false, true, "game5", ctx.QueryParams(), ¶ms.Game5) + err = runtime.BindStyledParameterWithOptions("simple", "tournament_id", ctx.Param("tournament_id"), &tournamentID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter game5: %s", err)) + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter tournament_id: %s", err)) } // Invoke the callback with all the unmarshaled arguments - err = w.Handler.GetTournament(ctx, params) + err = w.Handler.GetTournament(ctx, tournamentID) return err } @@ -414,7 +390,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.POST(baseURL+"/login", wrapper.PostLogin) router.POST(baseURL+"/logout", wrapper.PostLogout) router.GET(baseURL+"/me", wrapper.GetMe) - router.GET(baseURL+"/tournament", wrapper.GetTournament) + router.GET(baseURL+"/tournaments/:tournament_id", wrapper.GetTournament) } @@ -806,7 +782,7 @@ func (response GetMe401JSONResponse) VisitGetMeResponse(w http.ResponseWriter) e } type GetTournamentRequestObject struct { - Params GetTournamentParams + TournamentID int `json:"tournament_id"` } type GetTournamentResponseObject interface { @@ -884,7 +860,7 @@ type StrictServerInterface interface { // (GET /me) GetMe(ctx context.Context, request GetMeRequestObject) (GetMeResponseObject, error) - // (GET /tournament) + // (GET /tournaments/{tournament_id}) GetTournament(ctx context.Context, request GetTournamentRequestObject) (GetTournamentResponseObject, error) } @@ -1161,10 +1137,10 @@ func (sh *strictHandler) GetMe(ctx echo.Context) error { } // GetTournament operation middleware -func (sh *strictHandler) GetTournament(ctx echo.Context, params GetTournamentParams) error { +func (sh *strictHandler) GetTournament(ctx echo.Context, tournamentID int) error { var request GetTournamentRequestObject - request.Params = params + request.TournamentID = tournamentID handler := func(ctx echo.Context, request interface{}) (interface{}, error) { return sh.ssi.GetTournament(ctx.Request().Context(), request.(GetTournamentRequestObject)) @@ -1188,28 +1164,29 @@ func (sh *strictHandler) GetTournament(ctx echo.Context, params GetTournamentPar // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/+xaW2/bNhT+KwK3R9V2LtuD37IhKAqkgLFm2ENRCJR0bLOjSJWXOF7g/z6QlKwbJctN", - "ssGp3xyJPNfvO+eQyhNKeJZzBkxJNH9CMllDhu3PWyG4MD9ywXMQioB9nIGUeAXmp9rmgOZIKkHYCu12", - "IRLwTRMBKZp/3i/8EpYLefwVEoV2Ibp9hEQrwtknhZW2coHpzGxjnAEKkdCMGakhkjpJQEoUoo3gbBVh", - "JjcgUIgUyYBrhULrA6EQgTXZbjYv938TpkAwTIsHlUWl6SF6jzPoOpsSmVO8jVjxtrMt1QIbPyIJCWep", - "rC0ySlcgzKoVziAi6cBL9/gJ/Sxgiebop2mVlmmRk6kx8d6s24WIyCjXMSVJTWbMOQXMzOsMExYZy0FY", - "k4iCTB6S/6d0BhXisBB4a/7OBY8pZIe2L4pluxBJhYWCNMLK43KIHt+t+Lvq6a/XHeyUAatHp+502MyM", - "Jw+V2a1o+OC4j2wNhxcPF2arpoq4rV7Y3GEFUpn9BskeBMUgVSQTLiCSOs6IquLCNKU4poDmSmgIR8XJ", - "YD31Q9EqGSHX5adg3VBC2yRtJ8laUuoNez3dq/NFflFhq8U8kIkguVHv9ZZittJFHRqByrtyeYXnXkJK", - "nOUUot5AK6LoiPpXU1PuCRtu1ZxoKh0I1F3N7RKp+To3AjZkqbwY/QOzvwlb3TIltt1Al272oKYSU7Bg", - "ZBHZo9ET3xYPvqM+FLZU4GshrjeM91wLUzKY8nQ2rJI1jK+XlayPZme3dLYbYiF/2C4nq2PcYAtx4bgY", - "m5tieTSQI7fk8jiJl0MSN4QxB5/2u57a7wuT1Xd8lyYJZ1GO1dr/VkY4zQjzt1KKY6CjCKIliN4UmZc9", - "9rX8L8XU9nTa3d7k0r5usIxYwpbcKnQVC93QGCvBpQzKiSjYQBzcLD6gED2AkLbaotnkajIzRvMcGM4J", - "mqOryWwyM00Vq7WN+dSkySETLJdMQmwL/pCiOXoPtimaPixA5pxJt/hyNnNFh6mCgzjPKUnszulX6aq9", - "g5efBOP5aUe6Q6R0Iv3ha/QgdL+GwOwEqYI1loGdSyGFdGKUXM8ujnJssPHaKdVjwo2dhAMiA82wVmsu", - "yD97/Vf/pf4lFzFJU2ATs24XFniYPhXs3R1ChsWSwBkoO6J+fkKGgBZfKESOKbUxsEqZI1/lSKeafHlx", - "yI0DmgdYZ1w9B1dG+fXrKzfxlyAeQAQJZoyrYElYGqgqLZAGAiTXIoE+uE9NdZ6Ws1TOpQf5C+5OCguK", - "t7+72fk1KWBN/42n22egv2cG9h0E/FBvGr3zU/PMhzfKB2pPx5Esj8ZDDcGwwp2m3Un6RPrD3rehWLdv", - "CdoEckLOzeKHIoc7so5rF5/c2nPDODeMt8qJDVbJutExDp4u/zJbaj1DnlTTsL9wmhKzBdNFY8VR3aRL", - "JE97OR9yfzQuCXfvO4pFxR3xqRCo5tqoq6DGFfihK6FS+Jkvb5UvlK/cdW//5HVnl7zUWJRjKTdcpN77", - "5+Ouhll5eVZIfMYY9Z3OaDn2M5DH/JNn1R5BXKuDEHL/nnDaI6xzOBs8wH8EdIbYC0dcNT5V9kW+9kGz", - "07vhMaf2PLbEVELoevk3DWLbbOYXx7XycLzky1eTfPVqkq9fTfIv/+fI1ITTuO/aHXrVhJynozc5He12", - "/wYAAP//Hl9qBRMoAAA=", + "H4sIAAAAAAAC/+xaTW/bOBP+KwLf96jaThPswbfsIigKpICxTbGHohAoaWKzlUgth4rrGv7vC5L6FiXL", + "SbqLpL454nA4H88zw4/sSSTSTHDgCslyTzDaQErNzxsphdQ/MikykIqB+ZwCIl2D/ql2GZAlQSUZX5PD", + "wScS/s6ZhJgsP1eCX/xSUIRfIVLk4JOb7xDlign+UVGVG73A81RP44ID8YnMOddafYJ5FAEi8clWCr4O", + "KMctSOITxVIQuSK+8YElEIAx2UzWg9XfjCuQnCbFh9qi0nSfvKMp9J2NGWYJ3QW8GO1Ni3NJtR8BQiR4", + "jA0hvegapJZa0xQCFo8M2s978n8J92RJ/jev0zIvcjLXJt5puYNPGAZZHiYsaugMhUiAcj2cUsYDbTlI", + "YxJTkOIx/Z/QGlSoo1LSnf47kyJMID02fVWIHXyCikoFcUCVw2WffH+zFm/qr79d9bBTBqwZnabTfjsz", + "jjzUZnei4YJjFdkGDi8eLvTUPFHMTnXC5pYqQKXnayQ7EBQCqgAjISHAPEyZquPC8yShYQJkqWQO/qQ4", + "aazHbiiaRSbotfkpWDeW0C5Ju0kylpTr+oOeVsu5Ir+qsdVhHmAkWaaXd3qbUL7Oizo0AZW3pXiN50FC", + "Ik2zBILBQCumkgn1r7FMOcdvudVwor3oSKBuG26XSM02mVawZffKidE/Kf/G+PqGK7nrB7p0cwA1tZqC", + "BROLSIVGR3w7PHhEfShsqcHXQdxgGO9ELnXJ4MpBVUmjb6ACZD8GLD/aDoArWWibVHJrc2x2HNU3pSra", + "PErlBz3TpZLnaSBFPtiuVKVigCOdbLTle7W5FdfW6nXAaj/HszYAYgQYIHOOUzHbccpM9K3mcZtsnHs2", + "jTZ9hkG4A3fztuC+mMq0QjwYYZwVeXuaxrejGgWyTm1ujJr0HsWWyfhghLaMc5CBTsPJKKw0l6Y0DK5i", + "70rqpwItJ+4CWSR4kFG1cY9iQOOUcXe2ExpCMqkAj4TCDg7Y5wC2jU01p0fZyuTSvn6wtFrG74VZ0HZE", + "cp2EVEmB6JU7bm8LoXe9ek988gASDWLIYnY5W2ijRQacZowsyeVsMVvoNFG1MTGfa/pYHoGp1TohZov3", + "PiZL8g7MpksXDgmYCY5W+O1iYZsaV0WNp1mWsMjMnH9Fi1gLeDdlp1dac2TolVfHThYHwtfa45C7DXh6", + "JqDyNhQ9c+6BGOKZXuRqcXGSY6MbO3MKcphwbU5aHkMv5zRXGyHZj2r9y39z/XshQxbHwGda7uAXeJjv", + "i6p6OIYMgyVJU1DmCPR5TzQBDb50DzJMaRwz6pRZ8tWO9KrOl2eH3DSgOYB1xtVTcKUXv/r5i+v4I8gH", + "kF5EORfKu2c89lSdFog9CShyGcEQ3Oe6Os/LvXom0IH8lbAn0VVCd3/Ys9nPpIAx/XcR756A/oEzluug", + "6YZ62+iDm5pnPrxSPiTm9iXA8uplrCFoVtjbGntT80L6Q+XbWKy7t1BdAlkl52bxS5HDXolMaxcfrey5", + "YZwbxmvlxJaqaNPqGEdPl3/pKY2egS+qaZhfNI7NtQdNVi2Jk7pJn0iO9nI+5P5qXJL2XWESi4o3iJdC", + "oIZrk66CWk8sx66ESuVnvrxWviRiba97h3det0bkubZFGUXcChk7759Puxrm5eVZofEJ26hHOvO0J5uX", + "zqoKQSJXRyFk//3lZW9hrcPp6AH+A5AzxJ454vV7Gc73rSfc0cv1xgv6lGbefRv+z1q6ar38T3tBH3lj", + "PHfv19m9D4d/AgAA///ukgFcEykAAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/backend/api/handler.go b/backend/api/handler.go index 7efacf3..74ffcf8 100644 --- a/backend/api/handler.go +++ b/backend/api/handler.go @@ -454,99 +454,264 @@ func (h *Handler) PostGamePlaySubmit(ctx context.Context, request PostGamePlaySu } func (h *Handler) GetTournament(ctx context.Context, request GetTournamentRequestObject, _ *db.User) (GetTournamentResponseObject, error) { - gameIDs := []int32{ - int32(request.Params.Game1), - int32(request.Params.Game2), - int32(request.Params.Game3), - int32(request.Params.Game4), - int32(request.Params.Game5), + tournamentID := int32(request.TournamentID) + + tournament, err := h.q.GetTournamentByID(ctx, tournamentID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return GetTournament404JSONResponse{Message: "Tournament not found"}, nil + } + return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - matches := make([]TournamentMatch, 0, 5) + entryRows, err := h.q.ListTournamentEntries(ctx, tournamentID) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } - for _, gameID := range gameIDs { - gameRow, err := h.q.GetGameByID(ctx, gameID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - continue - } - return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + seedToUser := make(map[int]User) + entries := make([]TournamentEntry, len(entryRows)) + for i, e := range entryRows { + u := User{ + UserID: int(e.UserID), + Username: e.Username, + DisplayName: e.DisplayName, + IconPath: e.IconPath, + IsAdmin: e.IsAdmin, + Label: toNullable(e.Label), + } + seedToUser[int(e.Seed)] = u + entries[i] = TournamentEntry{ + User: u, + Seed: int(e.Seed), } + } - playerRows, err := h.q.ListMainPlayers(ctx, []int32{gameID}) - if err != nil { - return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + matchRows, err := h.q.ListTournamentMatches(ctx, tournamentID) + if err != nil { + return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + bracketSize := int(tournament.BracketSize) + numRounds := int(tournament.NumRounds) + bracketSeeds := standardBracketSeeds(bracketSize) + + // Index matches by (round, position) + type matchKey struct{ round, position int } + matchByKey := make(map[matchKey]db.TournamentMatch) + for _, m := range matchRows { + matchByKey[matchKey{int(m.Round), int(m.Position)}] = m + } + + // Collect game IDs for batch fetching + gameIDs := make(map[int32]bool) + for _, m := range matchRows { + if m.GameID != nil { + gameIDs[*m.GameID] = true } + } - var player1, player2 *User - if len(playerRows) > 0 { - p1 := User{ - UserID: int(playerRows[0].UserID), - Username: playerRows[0].Username, - DisplayName: playerRows[0].DisplayName, - IconPath: playerRows[0].IconPath, - IsAdmin: playerRows[0].IsAdmin, - Label: toNullable(playerRows[0].Label), - } - player1 = &p1 + // Fetch rankings for all games that have started + type rankingResult struct { + scores map[int]int // userID -> score + winnerID int + } + gameRankings := make(map[int32]*rankingResult) + for gid := range gameIDs { + gameRow, err := h.q.GetGameByID(ctx, gid) + if err != nil { + continue + } + if !gameRow.StartedAt.Valid { + continue } - if len(playerRows) > 1 { - p2 := User{ - UserID: int(playerRows[1].UserID), - Username: playerRows[1].Username, - DisplayName: playerRows[1].DisplayName, - IconPath: playerRows[1].IconPath, - IsAdmin: playerRows[1].IsAdmin, - Label: toNullable(playerRows[1].Label), + rankingRows, err := h.q.GetRanking(ctx, gid) + if err != nil || len(rankingRows) == 0 { + continue + } + rr := &rankingResult{scores: make(map[int]int)} + for i, r := range rankingRows { + rr.scores[int(r.User.UserID)] = int(r.Submission.CodeSize) + if i == 0 { + rr.winnerID = int(r.User.UserID) } - player2 = &p2 } + gameRankings[gid] = rr + } + + // Build match results bottom-up + type matchResult struct { + player1 *User + player2 *User + p1Score *int + p2Score *int + winnerUID *int + isBye bool + } + resultByKey := make(map[matchKey]*matchResult) + + for round := 0; round < numRounds; round++ { + numPositions := bracketSize / (1 << (round + 1)) + for pos := 0; pos < numPositions; pos++ { + m, exists := matchByKey[matchKey{round, pos}] + mr := &matchResult{} - 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)") + } +} |
