From e8db174d3e464a5764a9f4bfd82172261bd50519 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sat, 21 Feb 2026 10:29:21 +0900 Subject: refactor(api): separate business logic into game, tournament, session packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract business logic from api/handler.go into dedicated service packages: - session: context helpers (resolves admin → api import dependency) - game: game state, code submission, ranking, watch logic - tournament: bracket construction and seed ordering - api/convert.go: domain → API type conversion functions api/handler.go is now a thin adapter that delegates to services and maps domain errors to HTTP status codes. Co-Authored-By: Claude Opus 4.6 --- backend/admin/handler.go | 4 +- backend/admin/handler_test.go | 4 +- backend/api/auth_middleware.go | 34 +- backend/api/auth_middleware_test.go | 63 +-- backend/api/convert.go | 160 +++++++ backend/api/handler.go | 636 +++---------------------- backend/api/handler_test.go | 829 +++++++++++---------------------- backend/api/handler_wrapper.go | 37 +- backend/game/errors.go | 9 + backend/game/service.go | 406 ++++++++++++++++ backend/game/service_test.go | 82 ++++ backend/gen/api/handler_wrapper_gen.go | 19 +- backend/main.go | 5 +- backend/session/context.go | 39 ++ backend/tournament/service.go | 303 ++++++++++++ backend/tournament/service_test.go | 97 ++++ 16 files changed, 1478 insertions(+), 1249 deletions(-) create mode 100644 backend/api/convert.go create mode 100644 backend/game/errors.go create mode 100644 backend/game/service.go create mode 100644 backend/game/service_test.go create mode 100644 backend/session/context.go create mode 100644 backend/tournament/service.go create mode 100644 backend/tournament/service_test.go diff --git a/backend/admin/handler.go b/backend/admin/handler.go index 8eac425..09303ac 100644 --- a/backend/admin/handler.go +++ b/backend/admin/handler.go @@ -14,9 +14,9 @@ import ( "github.com/labstack/echo/v4" "albatross-2026-backend/account" - "albatross-2026-backend/api" "albatross-2026-backend/config" "albatross-2026-backend/db" + "albatross-2026-backend/session" ) var jst = time.FixedZone("Asia/Tokyo", 9*60*60) @@ -39,7 +39,7 @@ func NewHandler(q db.Querier, txm db.TxManager, hub GameHub, conf *config.Config func (h *Handler) newAdminMiddleware() echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { - user, ok := api.GetUserFromContext(c.Request().Context()) + user, ok := session.GetUserFromContext(c.Request().Context()) if !ok { return c.Redirect(http.StatusSeeOther, h.conf.BasePath+"login") } diff --git a/backend/admin/handler_test.go b/backend/admin/handler_test.go index 9de9a0b..20f5775 100644 --- a/backend/admin/handler_test.go +++ b/backend/admin/handler_test.go @@ -14,9 +14,9 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v4" - "albatross-2026-backend/api" "albatross-2026-backend/config" "albatross-2026-backend/db" + "albatross-2026-backend/session" ) // mockQuerier implements db.Querier for admin handler testing. @@ -395,7 +395,7 @@ func newEchoContextWithForm(path string, params map[string]string, form url.Valu // --- Admin middleware tests --- func setUserInContext(c echo.Context, user *db.User) { - ctx := api.SetUserInContext(c.Request().Context(), user) + ctx := session.SetUserInContext(c.Request().Context(), user) c.SetRequest(c.Request().WithContext(ctx)) } diff --git a/backend/api/auth_middleware.go b/backend/api/auth_middleware.go index f2a3987..a588185 100644 --- a/backend/api/auth_middleware.go +++ b/backend/api/auth_middleware.go @@ -1,17 +1,13 @@ package api import ( - "context" - "github.com/labstack/echo/v4" "albatross-2026-backend/auth" "albatross-2026-backend/db" + "albatross-2026-backend/session" ) -type sessionIDContextKey struct{} -type userContextKey struct{} - func SessionCookieMiddleware(q db.Querier) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { @@ -25,31 +21,14 @@ func SessionCookieMiddleware(q db.Querier) echo.MiddlewareFunc { return next(c) } ctx := c.Request().Context() - ctx = context.WithValue(ctx, sessionIDContextKey{}, hashedID) - ctx = context.WithValue(ctx, userContextKey{}, &user) + ctx = session.SetSessionIDInContext(ctx, hashedID) + ctx = session.SetUserInContext(ctx, &user) c.SetRequest(c.Request().WithContext(ctx)) return next(c) } } } -func GetSessionIDFromContext(ctx context.Context) (string, bool) { - sessionID, ok := ctx.Value(sessionIDContextKey{}).(string) - return sessionID, ok -} - -func GetUserFromContext(ctx context.Context) (*db.User, bool) { - user, ok := ctx.Value(userContextKey{}).(*db.User) - return user, ok -} - -// SetUserInContext sets a user in the context. Intended for testing. -func SetUserInContext(ctx context.Context, user *db.User) context.Context { - return context.WithValue(ctx, userContextKey{}, user) -} - -type clientIPContextKey struct{} - // ClientIPMiddleware extracts the client IP from echo.Context.RealIP() // and stores it in the request's context.Context so that handlers // receiving only context.Context (via generated code) can access it. @@ -57,14 +36,9 @@ func ClientIPMiddleware() echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { ip := c.RealIP() - ctx := context.WithValue(c.Request().Context(), clientIPContextKey{}, ip) + ctx := session.SetClientIPInContext(c.Request().Context(), ip) c.SetRequest(c.Request().WithContext(ctx)) return next(c) } } } - -func GetClientIPFromContext(ctx context.Context) string { - ip, _ := ctx.Value(clientIPContextKey{}).(string) - return ip -} diff --git a/backend/api/auth_middleware_test.go b/backend/api/auth_middleware_test.go index d84eef7..9fc20af 100644 --- a/backend/api/auth_middleware_test.go +++ b/backend/api/auth_middleware_test.go @@ -9,62 +9,9 @@ import ( "github.com/labstack/echo/v4" "albatross-2026-backend/db" + "albatross-2026-backend/session" ) -func TestGetSessionIDFromContext_NotSet(t *testing.T) { - ctx := context.Background() - _, ok := GetSessionIDFromContext(ctx) - if ok { - t.Error("expected ok=false when session ID is not set") - } -} - -func TestGetSessionIDFromContext_Set(t *testing.T) { - ctx := context.WithValue(context.Background(), sessionIDContextKey{}, "abc123") - id, ok := GetSessionIDFromContext(ctx) - if !ok { - t.Fatal("expected ok=true when session ID is set") - } - if id != "abc123" { - t.Errorf("expected session ID 'abc123', got %q", id) - } -} - -func TestGetUserFromContext_NotSet(t *testing.T) { - ctx := context.Background() - _, ok := GetUserFromContext(ctx) - if ok { - t.Error("expected ok=false when user is not set") - } -} - -func TestGetUserFromContext_Set(t *testing.T) { - user := &db.User{UserID: 42, Username: "testuser"} - ctx := context.WithValue(context.Background(), userContextKey{}, user) - u, ok := GetUserFromContext(ctx) - if !ok { - t.Fatal("expected ok=true when user is set") - } - if u.UserID != 42 { - t.Errorf("expected user ID 42, got %d", u.UserID) - } - if u.Username != "testuser" { - t.Errorf("expected username 'testuser', got %q", u.Username) - } -} - -func TestSetUserInContext(t *testing.T) { - user := &db.User{UserID: 7, Username: "admin"} - ctx := SetUserInContext(context.Background(), user) - u, ok := GetUserFromContext(ctx) - if !ok { - t.Fatal("expected ok=true after SetUserInContext") - } - if u.UserID != 7 { - t.Errorf("expected user ID 7, got %d", u.UserID) - } -} - // mockSessionQuerier implements the subset of db.Querier needed by SessionCookieMiddleware. type mockSessionQuerier struct { db.Querier @@ -89,7 +36,7 @@ func TestSessionCookieMiddleware_NoCookie(t *testing.T) { handler := mw(func(c echo.Context) error { called = true // User should not be set - _, ok := GetUserFromContext(c.Request().Context()) + _, ok := session.GetUserFromContext(c.Request().Context()) if ok { t.Error("expected no user in context when no cookie is present") } @@ -122,14 +69,14 @@ func TestSessionCookieMiddleware_ValidSession(t *testing.T) { var called bool handler := mw(func(c echo.Context) error { called = true - user, ok := GetUserFromContext(c.Request().Context()) + user, ok := session.GetUserFromContext(c.Request().Context()) if !ok { t.Fatal("expected user in context") } if user.UserID != 10 { t.Errorf("expected user ID 10, got %d", user.UserID) } - sid, ok := GetSessionIDFromContext(c.Request().Context()) + sid, ok := session.GetSessionIDFromContext(c.Request().Context()) if !ok { t.Fatal("expected session ID in context") } @@ -164,7 +111,7 @@ func TestSessionCookieMiddleware_InvalidSession(t *testing.T) { var called bool handler := mw(func(c echo.Context) error { called = true - _, ok := GetUserFromContext(c.Request().Context()) + _, ok := session.GetUserFromContext(c.Request().Context()) if ok { t.Error("expected no user in context for invalid session") } diff --git a/backend/api/convert.go b/backend/api/convert.go new file mode 100644 index 0000000..f05f3bf --- /dev/null +++ b/backend/api/convert.go @@ -0,0 +1,160 @@ +package api + +import ( + "github.com/oapi-codegen/nullable" + + "albatross-2026-backend/game" + "albatross-2026-backend/tournament" +) + +func toAPIUser(p game.Player) User { + return User{ + UserID: p.UserID, + Username: p.Username, + DisplayName: p.DisplayName, + IconPath: p.IconPath, + IsAdmin: p.IsAdmin, + Label: toNullable(p.Label), + } +} + +func toAPIGame(g game.GameDetail) Game { + var startedAt *int64 + if g.StartedAt != nil { + ts := g.StartedAt.Unix() + startedAt = &ts + } + mainPlayers := make([]User, len(g.MainPlayers)) + for i, p := range g.MainPlayers { + mainPlayers[i] = toAPIUser(p) + } + return Game{ + GameID: g.GameID, + GameType: GameType(g.GameType), + IsPublic: g.IsPublic, + DisplayName: g.DisplayName, + DurationSeconds: g.DurationSeconds, + StartedAt: startedAt, + Problem: Problem{ + ProblemID: g.Problem.ProblemID, + Title: g.Problem.Title, + Description: g.Problem.Description, + Language: ProblemLanguage(g.Problem.Language), + SampleCode: g.Problem.SampleCode, + }, + MainPlayers: mainPlayers, + } +} + +func toAPILatestState(s game.LatestState) LatestGameState { + var score nullable.Nullable[int] + if s.Score != nil { + score = nullable.NewNullableWithValue(*s.Score) + } else { + score = nullable.NewNullNullable[int]() + } + var submittedAt nullable.Nullable[int64] + if s.BestScoreSubmittedAt != nil { + submittedAt = nullable.NewNullableWithValue(*s.BestScoreSubmittedAt) + } else { + submittedAt = nullable.NewNullNullable[int64]() + } + return LatestGameState{ + Code: s.Code, + Score: score, + BestScoreSubmittedAt: submittedAt, + Status: ExecutionStatus(s.Status), + } +} + +func toAPIRankingEntry(r game.RankingEntry) RankingEntry { + var code nullable.Nullable[string] + if r.Code != nil { + code = nullable.NewNullableWithValue(*r.Code) + } else { + code = nullable.NewNullNullable[string]() + } + return RankingEntry{ + Player: toAPIUser(r.Player), + Score: r.Score, + SubmittedAt: r.SubmittedAt, + Code: code, + } +} + +func toAPISubmission(s game.SubmissionDetail) Submission { + return Submission{ + SubmissionID: s.SubmissionID, + GameID: s.GameID, + Code: s.Code, + CodeSize: s.CodeSize, + Status: ExecutionStatus(s.Status), + CreatedAt: s.CreatedAt, + } +} + +func toAPITournamentUser(p tournament.Player) User { + return User{ + UserID: p.UserID, + Username: p.Username, + DisplayName: p.DisplayName, + IconPath: p.IconPath, + IsAdmin: p.IsAdmin, + Label: toNullable(p.Label), + } +} + +func toAPITournamentPlayerPtr(p *tournament.Player) *User { + if p == nil { + return nil + } + u := toAPITournamentUser(*p) + return &u +} + +func toAPITournament(t tournament.TournamentBracket) Tournament { + entries := make([]TournamentEntry, len(t.Entries)) + for i, e := range t.Entries { + entries[i] = TournamentEntry{ + User: toAPITournamentUser(e.User), + Seed: e.Seed, + } + } + matches := make([]TournamentMatch, len(t.Matches)) + for i, m := range t.Matches { + matches[i] = TournamentMatch{ + TournamentMatchID: m.TournamentMatchID, + Round: m.Round, + Position: m.Position, + GameID: m.GameID, + Player1: toAPITournamentPlayerPtr(m.Player1), + Player2: toAPITournamentPlayerPtr(m.Player2), + Player1Score: m.Player1Score, + Player2Score: m.Player2Score, + WinnerUserID: m.WinnerUserID, + IsBye: m.IsBye, + } + } + return Tournament{ + TournamentID: t.TournamentID, + DisplayName: t.DisplayName, + BracketSize: t.BracketSize, + NumRounds: t.NumRounds, + Entries: entries, + Matches: matches, + } +} + +func toNullable[T any](p *T) nullable.Nullable[T] { + if p == nil { + return nullable.NewNullNullable[T]() + } + return nullable.NewNullableWithValue(*p) +} + +func toNullableWith[T, U any](p *T, f func(T) U) nullable.Nullable[U] { + if p == nil { + return nullable.NewNullNullable[U]() + } + return nullable.NewNullableWithValue(f(*p)) +} diff --git a/backend/api/handler.go b/backend/api/handler.go index dcebfa1..4d729f1 100644 --- a/backend/api/handler.go +++ b/backend/api/handler.go @@ -9,14 +9,15 @@ import ( "strconv" "time" - "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v4" - "github.com/oapi-codegen/nullable" "albatross-2026-backend/auth" "albatross-2026-backend/config" "albatross-2026-backend/db" + "albatross-2026-backend/game" + "albatross-2026-backend/session" + "albatross-2026-backend/tournament" ) type AuthenticatorInterface interface { @@ -24,16 +25,11 @@ type AuthenticatorInterface interface { } type Handler struct { - q db.Querier - txm db.TxManager - hub GameHubInterface - auth AuthenticatorInterface - conf *config.Config -} - -type GameHubInterface interface { - CalcCodeSize(code string, language string) int - EnqueueTestTasks(ctx context.Context, submissionID, gameID, userID int, language, code string) error + gameSvc *game.Service + tournamentSvc *tournament.Service + auth AuthenticatorInterface + conf *config.Config + q db.Querier // for session management (login/logout) } type postLoginCookieResponse struct { @@ -51,7 +47,7 @@ func (r postLoginCookieResponse) VisitPostLoginResponse(w http.ResponseWriter) e func (h *Handler) PostLogin(ctx context.Context, request PostLoginRequestObject) (PostLoginResponseObject, error) { username := request.Body.Username password := request.Body.Password - ip := GetClientIPFromContext(ctx) + ip := session.GetClientIPFromContext(ctx) userID, err := h.auth.Login(ctx, username, password) if err != nil { @@ -138,7 +134,7 @@ func (r postLogoutCookieResponse) VisitPostLogoutResponse(w http.ResponseWriter) } func (h *Handler) PostLogout(ctx context.Context, _ PostLogoutRequestObject, _ *db.User) (PostLogoutResponseObject, error) { - if sessionID, ok := GetSessionIDFromContext(ctx); ok { + if sessionID, ok := session.GetSessionIDFromContext(ctx); ok { _ = h.q.DeleteSession(ctx, sessionID) } return postLogoutCookieResponse{ @@ -155,631 +151,125 @@ func (h *Handler) PostLogout(ctx context.Context, _ PostLogoutRequestObject, _ * } func (h *Handler) GetGames(ctx context.Context, _ GetGamesRequestObject, _ *db.User) (GetGamesResponseObject, error) { - gameRows, err := h.q.ListPublicGames(ctx) - if err != nil { - return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } - games := make([]Game, len(gameRows)) - gameIDs := make([]int32, len(gameRows)) - gameID2Index := make(map[int32]int, len(gameRows)) - for i, row := range gameRows { - var startedAt *int64 - if row.StartedAt.Valid { - startedAtTimestamp := row.StartedAt.Time.Unix() - startedAt = &startedAtTimestamp - } - games[i] = Game{ - GameID: int(row.GameID), - GameType: GameType(row.GameType), - IsPublic: row.IsPublic, - DisplayName: row.DisplayName, - DurationSeconds: int(row.DurationSeconds), - StartedAt: startedAt, - Problem: Problem{ - ProblemID: int(row.ProblemID), - Title: row.Title, - Description: row.Description, - Language: ProblemLanguage(row.Language), - SampleCode: row.SampleCode, - }, - } - gameIDs[i] = row.GameID - gameID2Index[row.GameID] = i - } - mainPlayerRows, err := h.q.ListMainPlayers(ctx, gameIDs) + games, err := h.gameSvc.ListPublicGames(ctx) if err != nil { return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - for _, row := range mainPlayerRows { - idx := gameID2Index[row.GameID] - game := &games[idx] - game.MainPlayers = append(game.MainPlayers, User{ - UserID: int(row.UserID), - Username: row.Username, - DisplayName: row.DisplayName, - IconPath: row.IconPath, - IsAdmin: row.IsAdmin, - Label: toNullable(row.Label), - }) + apiGames := make([]Game, len(games)) + for i, g := range games { + apiGames[i] = toAPIGame(g) } - return GetGames200JSONResponse{ - Games: games, - }, nil + return GetGames200JSONResponse{Games: apiGames}, nil } func (h *Handler) GetGame(ctx context.Context, request GetGameRequestObject, user *db.User) (GetGameResponseObject, error) { - gameID := request.GameID - row, err := h.q.GetGameByID(ctx, int32(gameID)) + isAdmin := user != nil && user.IsAdmin + g, err := h.gameSvc.GetGameByID(ctx, request.GameID, isAdmin) if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return GetGame404JSONResponse{ - Message: "Game not found", - }, nil + if errors.Is(err, game.ErrNotFound) { + return GetGame404JSONResponse{Message: "Game not found"}, nil } return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - if !row.IsPublic && (user == nil || !user.IsAdmin) { - return GetGame404JSONResponse{ - Message: "Game not found", - }, nil - } - var startedAt *int64 - if row.StartedAt.Valid { - startedAtTimestamp := row.StartedAt.Time.Unix() - startedAt = &startedAtTimestamp - } - mainPlayerRows, err := h.q.ListMainPlayers(ctx, []int32{int32(gameID)}) - if err != nil { - return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } - mainPlayers := make([]User, len(mainPlayerRows)) - for i, playerRow := range mainPlayerRows { - mainPlayers[i] = User{ - UserID: int(playerRow.UserID), - Username: playerRow.Username, - DisplayName: playerRow.DisplayName, - IconPath: playerRow.IconPath, - IsAdmin: playerRow.IsAdmin, - Label: toNullable(playerRow.Label), - } - } - game := Game{ - GameID: int(row.GameID), - GameType: GameType(row.GameType), - IsPublic: row.IsPublic, - DisplayName: row.DisplayName, - DurationSeconds: int(row.DurationSeconds), - StartedAt: startedAt, - Problem: Problem{ - ProblemID: int(row.ProblemID), - Title: row.Title, - Description: row.Description, - Language: ProblemLanguage(row.Language), - SampleCode: row.SampleCode, - }, - MainPlayers: mainPlayers, - } - return GetGame200JSONResponse{ - Game: game, - }, nil + return GetGame200JSONResponse{Game: toAPIGame(g)}, nil } func (h *Handler) GetGamePlayLatestState(ctx context.Context, request GetGamePlayLatestStateRequestObject, user *db.User) (GetGamePlayLatestStateResponseObject, error) { - gameID := request.GameID - row, err := h.q.GetLatestState(ctx, db.GetLatestStateParams{ - GameID: int32(gameID), - UserID: user.UserID, - }) + state, err := h.gameSvc.GetLatestState(ctx, request.GameID, user.UserID) if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return GetGamePlayLatestState200JSONResponse{ - State: LatestGameState{ - Code: "", - Score: nullable.NewNullNullable[int](), - BestScoreSubmittedAt: nullable.NewNullNullable[int64](), - Status: None, - }, - }, nil - } return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - return GetGamePlayLatestState200JSONResponse{ - State: LatestGameState{ - Code: row.Code, - Score: toNullableWith(row.CodeSize, func(x int32) int { return int(x) }), - BestScoreSubmittedAt: nullable.NewNullableWithValue(row.CreatedAt.Time.Unix()), - Status: ExecutionStatus(row.Status), - }, - }, nil + return GetGamePlayLatestState200JSONResponse{State: toAPILatestState(state)}, nil } func (h *Handler) GetGameWatchLatestStates(ctx context.Context, request GetGameWatchLatestStatesRequestObject, user *db.User) (GetGameWatchLatestStatesResponseObject, error) { - gameID := request.GameID - rows, err := h.q.GetLatestStatesOfMainPlayers(ctx, int32(gameID)) - if err != nil { - return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + var userID *int32 + var isAdmin bool + if user != nil { + userID = &user.UserID + isAdmin = user.IsAdmin } - states := make(map[string]LatestGameState, len(rows)) - for _, row := range rows { - var code string - if row.Code != nil { - code = *row.Code - } - var status ExecutionStatus - if row.Status != nil { - status = ExecutionStatus(*row.Status) - } else { - status = None - } - states[strconv.Itoa(int(row.UserID))] = LatestGameState{ - Code: code, - Score: toNullableWith(row.CodeSize, func(x int32) int { return int(x) }), - BestScoreSubmittedAt: nullable.NewNullableWithValue(row.CreatedAt.Time.Unix()), - Status: status, - } - - if user != nil && row.UserID == user.UserID && !user.IsAdmin { + stateMap, err := h.gameSvc.GetWatchLatestStates(ctx, request.GameID, userID, isAdmin) + if err != nil { + if errors.Is(err, game.ErrForbidden) { return GetGameWatchLatestStates403JSONResponse{ Message: "You are one of the main players of this game", }, nil } + return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - return GetGameWatchLatestStates200JSONResponse{ - States: states, - }, nil + states := make(map[string]LatestGameState, len(stateMap)) + for uid, s := range stateMap { + states[strconv.Itoa(uid)] = toAPILatestState(s) + } + return GetGameWatchLatestStates200JSONResponse{States: states}, nil } func (h *Handler) GetGameWatchRanking(ctx context.Context, request GetGameWatchRankingRequestObject, _ *db.User) (GetGameWatchRankingResponseObject, error) { - gameID := request.GameID - - gameRow, err := h.q.GetGameByID(ctx, int32(gameID)) + ranking, _, err := h.gameSvc.GetRanking(ctx, request.GameID) if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return GetGameWatchRanking404JSONResponse{ - Message: "Game not found", - }, nil + if errors.Is(err, game.ErrNotFound) { + return GetGameWatchRanking404JSONResponse{Message: "Game not found"}, nil } return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - gameFinished := isGameFinished(gameRow) - - rows, err := h.q.GetRanking(ctx, int32(gameID)) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return GetGameWatchRanking200JSONResponse{}, nil - } - } - ranking := make([]RankingEntry, len(rows)) - for i, row := range rows { - var code nullable.Nullable[string] - if gameFinished { - code = nullable.NewNullableWithValue(row.Submission.Code) - } else { - code = nullable.NewNullNullable[string]() - } - ranking[i] = RankingEntry{ - Player: User{ - UserID: int(row.User.UserID), - Username: row.User.Username, - DisplayName: row.User.DisplayName, - IconPath: row.User.IconPath, - IsAdmin: row.User.IsAdmin, - Label: toNullable(row.User.Label), - }, - Score: int(row.Submission.CodeSize), - SubmittedAt: row.Submission.CreatedAt.Time.Unix(), - Code: code, - } + apiRanking := make([]RankingEntry, len(ranking)) + for i, r := range ranking { + apiRanking[i] = toAPIRankingEntry(r) } - return GetGameWatchRanking200JSONResponse{ - Ranking: ranking, - }, nil + return GetGameWatchRanking200JSONResponse{Ranking: apiRanking}, nil } func (h *Handler) GetGamePlaySubmissions(ctx context.Context, request GetGamePlaySubmissionsRequestObject, user *db.User) (GetGamePlaySubmissionsResponseObject, error) { - gameID := request.GameID - - _, err := h.q.GetGameByID(ctx, int32(gameID)) + submissions, err := h.gameSvc.GetSubmissions(ctx, request.GameID, user.UserID) if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return GetGamePlaySubmissions404JSONResponse{ - Message: "Game not found", - }, nil + if errors.Is(err, game.ErrNotFound) { + return GetGamePlaySubmissions404JSONResponse{Message: "Game not found"}, nil } return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - - rows, err := h.q.GetSubmissionsByGameIDAndUserID(ctx, db.GetSubmissionsByGameIDAndUserIDParams{ - GameID: int32(gameID), - UserID: user.UserID, - }) - if err != nil { - return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } - - submissions := make([]Submission, len(rows)) - for i, row := range rows { - submissions[i] = Submission{ - SubmissionID: int(row.SubmissionID), - GameID: int(row.GameID), - Code: row.Code, - CodeSize: int(row.CodeSize), - Status: ExecutionStatus(row.Status), - CreatedAt: row.CreatedAt.Time.Unix(), - } + apiSubmissions := make([]Submission, len(submissions)) + for i, s := range submissions { + apiSubmissions[i] = toAPISubmission(s) } - - return GetGamePlaySubmissions200JSONResponse{ - Submissions: submissions, - }, nil + return GetGamePlaySubmissions200JSONResponse{Submissions: apiSubmissions}, nil } func (h *Handler) PostGamePlayCode(ctx context.Context, request PostGamePlayCodeRequestObject, user *db.User) (PostGamePlayCodeResponseObject, error) { - gameID := request.GameID - - gameRow, err := h.q.GetGameByID(ctx, int32(gameID)) + err := h.gameSvc.SaveCode(ctx, request.GameID, user.UserID, request.Body.Code) if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return PostGamePlayCode404JSONResponse{ - Message: "Game not found", - }, nil + if errors.Is(err, game.ErrNotFound) { + return PostGamePlayCode404JSONResponse{Message: "Game not found"}, nil + } + if errors.Is(err, game.ErrGameNotRunning) { + return PostGamePlayCode403JSONResponse{Message: "Game is not running"}, nil } - return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } - if !isGameRunning(gameRow) { - return PostGamePlayCode403JSONResponse{ - Message: "Game is not running", - }, nil - } - - err = h.q.UpdateCode(ctx, db.UpdateCodeParams{ - GameID: int32(gameID), - UserID: user.UserID, - Code: request.Body.Code, - Status: "none", - }) - if err != nil { return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } return PostGamePlayCode200Response{}, nil } func (h *Handler) PostGamePlaySubmit(ctx context.Context, request PostGamePlaySubmitRequestObject, user *db.User) (PostGamePlaySubmitResponseObject, error) { - gameID := request.GameID - code := request.Body.Code - - gameRow, err := h.q.GetGameByID(ctx, int32(gameID)) + err := h.gameSvc.SubmitCode(ctx, request.GameID, user.UserID, request.Body.Code) if err != nil { - if errors.Is(err, pgx.ErrNoRows) { + if errors.Is(err, game.ErrNotFound) { return PostGamePlaySubmit404JSONResponse{}, nil } - return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } - - language := gameRow.Language - codeSize := h.hub.CalcCodeSize(code, language) - - if !isGameRunning(gameRow) { - return PostGamePlaySubmit403JSONResponse{ - Message: "Game is not running", - }, nil - } - - var submissionID int32 - err = h.txm.RunInTx(ctx, func(qtx db.Querier) error { - if err := qtx.UpdateCodeAndStatus(ctx, db.UpdateCodeAndStatusParams{ - GameID: int32(gameID), - UserID: user.UserID, - Code: code, - Status: "running", - }); err != nil { - return err + if errors.Is(err, game.ErrGameNotRunning) { + return PostGamePlaySubmit403JSONResponse{Message: "Game is not running"}, nil } - var err error - submissionID, err = qtx.CreateSubmission(ctx, db.CreateSubmissionParams{ - GameID: int32(gameID), - UserID: user.UserID, - Code: code, - CodeSize: int32(codeSize), - }) - return err - }) - if err != nil { - return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } - - err = h.hub.EnqueueTestTasks(ctx, int(submissionID), gameID, int(user.UserID), language, code) - if err != nil { return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } return PostGamePlaySubmit200Response{}, nil } func (h *Handler) GetTournament(ctx context.Context, request GetTournamentRequestObject, _ *db.User) (GetTournamentResponseObject, error) { - tournamentID := int32(request.TournamentID) - - tournament, err := h.q.GetTournamentByID(ctx, tournamentID) + t, err := h.tournamentSvc.GetTournament(ctx, request.TournamentID) if err != nil { - if errors.Is(err, pgx.ErrNoRows) { + if errors.Is(err, game.ErrNotFound) { return GetTournament404JSONResponse{Message: "Tournament not found"}, nil } return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - - entryRows, err := h.q.ListTournamentEntries(ctx, tournamentID) - if err != nil { - 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), - } - } - - 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 - } - } - - // 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 - } - 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) - } - } - 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 := range numRounds { - numPositions := bracketSize / (1 << (round + 1)) - for pos := range numPositions { - m, exists := matchByKey[matchKey{round, pos}] - mr := &matchResult{} - - if round == 0 { - // First round: resolve players from bracket seeds - slot1 := pos * 2 - slot2 := pos*2 + 1 - seed1 := bracketSeeds[slot1] - seed2 := bracketSeeds[slot2] - - if u, ok := seedToUser[seed1]; ok { - mr.player1 = &u - } - if u, ok := seedToUser[seed2]; ok { - mr.player2 = &u - } - } else { - // Later rounds: resolve from child match winners - child1 := resultByKey[matchKey{round - 1, pos * 2}] - child2 := resultByKey[matchKey{round - 1, pos*2 + 1}] - - if child1 != nil && child1.winnerUID != nil { - if u, ok := seedToUser[findSeedByUserID(entries, *child1.winnerUID)]; ok { - mr.player1 = &u - } - } - if child2 != nil && child2.winnerUID != nil { - if u, ok := seedToUser[findSeedByUserID(entries, *child2.winnerUID)]; ok { - mr.player2 = &u - } - } - } - - // Check for bye - if mr.player1 == nil && mr.player2 != nil { - mr.isBye = true - uid := mr.player2.UserID - mr.winnerUID = &uid - } else if mr.player1 != nil && mr.player2 == nil { - mr.isBye = true - uid := mr.player1.UserID - mr.winnerUID = &uid - } - - // 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 - } - } - // Winner is the one with the best (lowest) score in the ranking - if mr.player1 != nil && mr.player2 != nil { - if rr.winnerID == mr.player1.UserID || rr.winnerID == mr.player2.UserID { - w := rr.winnerID - mr.winnerUID = &w - } else { - // Both players have scores; pick the one with lower score - if mr.p1Score != nil && mr.p2Score != nil { - if *mr.p1Score <= *mr.p2Score { - w := mr.player1.UserID - mr.winnerUID = &w - } else { - w := mr.player2.UserID - mr.winnerUID = &w - } - } - } - } - } - } - - resultByKey[matchKey{round, pos}] = mr - } - } - - // Build API response matches - apiMatches := make([]TournamentMatch, 0, len(matchRows)) - for round := 0; round < numRounds; round++ { - numPositions := bracketSize / (1 << (round + 1)) - for pos := 0; pos < numPositions; pos++ { - m, exists := matchByKey[matchKey{round, pos}] - mr := resultByKey[matchKey{round, pos}] - - matchID := 0 - var gameID *int - if exists { - matchID = int(m.TournamentMatchID) - if m.GameID != nil { - gid := int(*m.GameID) - gameID = &gid - } - } - - 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, - }) - } - } - - return GetTournament200JSONResponse{ - Tournament: Tournament{ - 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 - } - endTime := game.StartedAt.Time.Add(time.Duration(game.DurationSeconds) * time.Second) - return time.Now().Before(endTime) -} - -func isGameFinished(game db.GetGameByIDRow) bool { - if !game.StartedAt.Valid { - return false - } - endTime := game.StartedAt.Time.Add(time.Duration(game.DurationSeconds) * time.Second) - return !time.Now().Before(endTime) -} - -func toNullable[T any](p *T) nullable.Nullable[T] { - if p == nil { - return nullable.NewNullNullable[T]() - } - return nullable.NewNullableWithValue(*p) -} - -func toNullableWith[T, U any](p *T, f func(T) U) nullable.Nullable[U] { - if p == nil { - return nullable.NewNullNullable[U]() - } - return nullable.NewNullableWithValue(f(*p)) + return GetTournament200JSONResponse{Tournament: toAPITournament(t)}, nil } diff --git a/backend/api/handler_test.go b/backend/api/handler_test.go index a28d605..2b8c06a 100644 --- a/backend/api/handler_test.go +++ b/backend/api/handler_test.go @@ -11,6 +11,9 @@ import ( "albatross-2026-backend/config" "albatross-2026-backend/db" + "albatross-2026-backend/game" + "albatross-2026-backend/session" + "albatross-2026-backend/tournament" ) // mockQuerier implements db.Querier for testing. @@ -28,6 +31,7 @@ type mockQuerier struct { listTournamentEntriesFunc func(ctx context.Context, tournamentID int32) ([]db.ListTournamentEntriesRow, error) listTournamentMatchesFunc func(ctx context.Context, tournamentID int32) ([]db.TournamentMatch, error) getSubmissionsByGameIDAndUserIDFunc func(ctx context.Context, arg db.GetSubmissionsByGameIDAndUserIDParams) ([]db.Submission, error) + getUserByIDFunc func(ctx context.Context, userID int32) (db.User, error) } func (m *mockQuerier) GetGameByID(ctx context.Context, gameID int32) (db.GetGameByIDRow, error) { @@ -114,6 +118,13 @@ func (m *mockQuerier) ListTournamentMatches(ctx context.Context, tournamentID in return nil, nil } +func (m *mockQuerier) GetUserByID(ctx context.Context, userID int32) (db.User, error) { + if m.getUserByIDFunc != nil { + return m.getUserByIDFunc(ctx, userID) + } + return db.User{}, pgx.ErrNoRows +} + // mockTxManager implements db.TxManager for testing. type mockTxManager struct{} @@ -121,7 +132,7 @@ func (m *mockTxManager) RunInTx(_ context.Context, fn func(q db.Querier) error) return fn(&mockQuerier{}) } -// mockGameHub implements GameHubInterface for testing. +// mockGameHub implements game.GameHubInterface for testing. type mockGameHub struct { calcCodeSizeResult int enqueueErr error @@ -145,14 +156,29 @@ func (m *mockAuthenticator) Login(_ context.Context, _, _ string) (int, error) { return m.loginResult, m.loginErr } -func TestGetGamePlaySubmissions_GameNotFound(t *testing.T) { - h := Handler{ - q: &mockQuerier{}, - txm: &mockTxManager{}, - hub: &mockGameHub{}, - auth: &mockAuthenticator{}, - conf: &config.Config{}, +func newTestHandler(q *mockQuerier) Handler { + hub := &mockGameHub{} + return Handler{ + gameSvc: game.NewService(q, &mockTxManager{}, hub), + tournamentSvc: tournament.NewService(q), + auth: &mockAuthenticator{}, + conf: &config.Config{}, + q: q, } +} + +func newTestHandlerWithHub(q *mockQuerier, hub *mockGameHub) Handler { + return Handler{ + gameSvc: game.NewService(q, &mockTxManager{}, hub), + tournamentSvc: tournament.NewService(q), + auth: &mockAuthenticator{}, + conf: &config.Config{}, + q: q, + } +} + +func TestGetGamePlaySubmissions_GameNotFound(t *testing.T) { + h := newTestHandler(&mockQuerier{}) user := &db.User{UserID: 1} resp, err := h.GetGamePlaySubmissions(context.Background(), GetGamePlaySubmissionsRequestObject{ GameID: 999, @@ -166,20 +192,14 @@ func TestGetGamePlaySubmissions_GameNotFound(t *testing.T) { } func TestGetGamePlaySubmissions_Empty(t *testing.T) { - h := Handler{ - q: &mockQuerier{ - getGameByIDFunc: func(_ context.Context, _ int32) (db.GetGameByIDRow, error) { - return db.GetGameByIDRow{ - GameID: 1, - Language: "php", - }, nil - }, + h := newTestHandler(&mockQuerier{ + getGameByIDFunc: func(_ context.Context, _ int32) (db.GetGameByIDRow, error) { + return db.GetGameByIDRow{ + GameID: 1, + Language: "php", + }, nil }, - txm: &mockTxManager{}, - hub: &mockGameHub{}, - auth: &mockAuthenticator{}, - conf: &config.Config{}, - } + }) user := &db.User{UserID: 1} resp, err := h.GetGamePlaySubmissions(context.Background(), GetGamePlaySubmissionsRequestObject{ GameID: 1, @@ -198,45 +218,39 @@ func TestGetGamePlaySubmissions_Empty(t *testing.T) { func TestGetGamePlaySubmissions_WithSubmissions(t *testing.T) { now := time.Now() - h := Handler{ - q: &mockQuerier{ - getGameByIDFunc: func(_ context.Context, _ int32) (db.GetGameByIDRow, error) { - return db.GetGameByIDRow{ - GameID: 1, - Language: "php", - }, nil - }, - getSubmissionsByGameIDAndUserIDFunc: func(_ context.Context, arg db.GetSubmissionsByGameIDAndUserIDParams) ([]db.Submission, error) { - if arg.GameID != 1 || arg.UserID != 42 { - t.Errorf("unexpected query params: game_id=%d, user_id=%d", arg.GameID, arg.UserID) - } - return []db.Submission{ - { - SubmissionID: 10, - GameID: 1, - UserID: 42, - Code: "= 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 + v, err := result.Get() + if err != nil { + t.Fatalf("unexpected error: %v", err) } - if len(seen) != size { - t.Errorf("bracket_size=%d: expected %d unique seeds, got %d", size, size, len(seen)) + if v != "hello" { + t.Errorf("expected 'hello', got %q", v) } - } + }) } -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) - } -} +// --- Tournament tests --- func TestGetTournament_NotFound(t *testing.T) { - h := Handler{ - q: &mockQuerier{}, - txm: &mockTxManager{}, - hub: &mockGameHub{}, - auth: &mockAuthenticator{}, - conf: &config.Config{}, - } + h := newTestHandler(&mockQuerier{}) user := &db.User{UserID: 1} resp, err := h.GetTournament(context.Background(), GetTournamentRequestObject{TournamentID: 999}, user) if err != nil { @@ -1010,22 +748,16 @@ func TestGetTournament_NotFound(t *testing.T) { } 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 - }, + h := newTestHandler(&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 { @@ -1051,42 +783,36 @@ func TestGetTournament_Success_NoEntries(t *testing.T) { 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 - }, + h := newTestHandler(&mockQuerier{ + getTournamentByIDFunc: func(_ context.Context, _ int32) (db.Tournament, error) { + return db.Tournament{ + TournamentID: 1, + DisplayName: "Test", + BracketSize: 4, + NumRounds: 2, + }, nil }, - txm: &mockTxManager{}, - hub: &mockGameHub{}, - auth: &mockAuthenticator{}, - conf: &config.Config{}, - } + 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 + }, + }) user := &db.User{UserID: 1} resp, err := h.GetTournament(context.Background(), GetTournamentRequestObject{TournamentID: 1}, user) if err != nil { @@ -1097,17 +823,14 @@ func TestGetTournament_WithEntriesAndMatches(t *testing.T) { 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) @@ -1125,7 +848,6 @@ func TestGetTournament_WithEntriesAndMatches(t *testing.T) { 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) @@ -1142,38 +864,30 @@ func TestGetTournament_WithEntriesAndMatches(t *testing.T) { } 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 - }, + h := newTestHandler(&mockQuerier{ + getTournamentByIDFunc: func(_ context.Context, _ int32) (db.Tournament, error) { + return db.Tournament{ + TournamentID: 1, + DisplayName: "Bye Test", + BracketSize: 4, + NumRounds: 2, + }, nil }, - txm: &mockTxManager{}, - hub: &mockGameHub{}, - auth: &mockAuthenticator{}, - conf: &config.Config{}, - } + 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 + }, + }) user := &db.User{UserID: 1} resp, err := h.GetTournament(context.Background(), GetTournamentRequestObject{TournamentID: 1}, user) if err != nil { @@ -1181,7 +895,6 @@ func TestGetTournament_ByeAutoWinner(t *testing.T) { } 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") diff --git a/backend/api/handler_wrapper.go b/backend/api/handler_wrapper.go index 1c8bc83..6a6c724 100644 --- a/backend/api/handler_wrapper.go +++ b/backend/api/handler_wrapper.go @@ -7,6 +7,9 @@ import ( "albatross-2026-backend/config" "albatross-2026-backend/db" + "albatross-2026-backend/game" + "albatross-2026-backend/session" + "albatross-2026-backend/tournament" ) var _ StrictServerInterface = (*HandlerWrapper)(nil) @@ -15,25 +18,25 @@ type HandlerWrapper struct { impl Handler } -func NewHandler(queries db.Querier, txm db.TxManager, hub GameHubInterface, auth AuthenticatorInterface, conf *config.Config) *HandlerWrapper { +func NewHandler(gameSvc *game.Service, tournamentSvc *tournament.Service, auth AuthenticatorInterface, queries db.Querier, conf *config.Config) *HandlerWrapper { return &HandlerWrapper{ impl: Handler{ - q: queries, - txm: txm, - hub: hub, - auth: auth, - conf: conf, + gameSvc: gameSvc, + tournamentSvc: tournamentSvc, + auth: auth, + conf: conf, + q: queries, }, } } func (h *HandlerWrapper) GetGame(ctx context.Context, request GetGameRequestObject) (GetGameResponseObject, error) { - user, _ := GetUserFromContext(ctx) + user, _ := session.GetUserFromContext(ctx) return h.impl.GetGame(ctx, request, user) } func (h *HandlerWrapper) GetGamePlayLatestState(ctx context.Context, request GetGamePlayLatestStateRequestObject) (GetGamePlayLatestStateResponseObject, error) { - user, ok := GetUserFromContext(ctx) + user, ok := session.GetUserFromContext(ctx) if !ok { return GetGamePlayLatestState401JSONResponse{ Message: "Unauthorized", @@ -43,7 +46,7 @@ func (h *HandlerWrapper) GetGamePlayLatestState(ctx context.Context, request Get } func (h *HandlerWrapper) GetGamePlaySubmissions(ctx context.Context, request GetGamePlaySubmissionsRequestObject) (GetGamePlaySubmissionsResponseObject, error) { - user, ok := GetUserFromContext(ctx) + user, ok := session.GetUserFromContext(ctx) if !ok { return GetGamePlaySubmissions401JSONResponse{ Message: "Unauthorized", @@ -53,22 +56,22 @@ func (h *HandlerWrapper) GetGamePlaySubmissions(ctx context.Context, request Get } func (h *HandlerWrapper) GetGameWatchLatestStates(ctx context.Context, request GetGameWatchLatestStatesRequestObject) (GetGameWatchLatestStatesResponseObject, error) { - user, _ := GetUserFromContext(ctx) + user, _ := session.GetUserFromContext(ctx) return h.impl.GetGameWatchLatestStates(ctx, request, user) } func (h *HandlerWrapper) GetGameWatchRanking(ctx context.Context, request GetGameWatchRankingRequestObject) (GetGameWatchRankingResponseObject, error) { - user, _ := GetUserFromContext(ctx) + user, _ := session.GetUserFromContext(ctx) return h.impl.GetGameWatchRanking(ctx, request, user) } func (h *HandlerWrapper) GetGames(ctx context.Context, request GetGamesRequestObject) (GetGamesResponseObject, error) { - user, _ := GetUserFromContext(ctx) + user, _ := session.GetUserFromContext(ctx) return h.impl.GetGames(ctx, request, user) } func (h *HandlerWrapper) GetMe(ctx context.Context, request GetMeRequestObject) (GetMeResponseObject, error) { - user, ok := GetUserFromContext(ctx) + user, ok := session.GetUserFromContext(ctx) if !ok { return GetMe401JSONResponse{ Message: "Unauthorized", @@ -78,12 +81,12 @@ func (h *HandlerWrapper) GetMe(ctx context.Context, request GetMeRequestObject) } func (h *HandlerWrapper) GetTournament(ctx context.Context, request GetTournamentRequestObject) (GetTournamentResponseObject, error) { - user, _ := GetUserFromContext(ctx) + user, _ := session.GetUserFromContext(ctx) return h.impl.GetTournament(ctx, request, user) } func (h *HandlerWrapper) PostGamePlayCode(ctx context.Context, request PostGamePlayCodeRequestObject) (PostGamePlayCodeResponseObject, error) { - user, ok := GetUserFromContext(ctx) + user, ok := session.GetUserFromContext(ctx) if !ok { return PostGamePlayCode401JSONResponse{ Message: "Unauthorized", @@ -93,7 +96,7 @@ func (h *HandlerWrapper) PostGamePlayCode(ctx context.Context, request PostGameP } func (h *HandlerWrapper) PostGamePlaySubmit(ctx context.Context, request PostGamePlaySubmitRequestObject) (PostGamePlaySubmitResponseObject, error) { - user, ok := GetUserFromContext(ctx) + user, ok := session.GetUserFromContext(ctx) if !ok { return PostGamePlaySubmit401JSONResponse{ Message: "Unauthorized", @@ -107,7 +110,7 @@ func (h *HandlerWrapper) PostLogin(ctx context.Context, request PostLoginRequest } func (h *HandlerWrapper) PostLogout(ctx context.Context, request PostLogoutRequestObject) (PostLogoutResponseObject, error) { - user, ok := GetUserFromContext(ctx) + user, ok := session.GetUserFromContext(ctx) if !ok { return PostLogout401JSONResponse{ Message: "Unauthorized", diff --git a/backend/game/errors.go b/backend/game/errors.go new file mode 100644 index 0000000..9f7505a --- /dev/null +++ b/backend/game/errors.go @@ -0,0 +1,9 @@ +package game + +import "errors" + +var ( + ErrNotFound = errors.New("not found") + ErrGameNotRunning = errors.New("game is not running") + ErrForbidden = errors.New("forbidden") +) diff --git a/backend/game/service.go b/backend/game/service.go new file mode 100644 index 0000000..86e0eb3 --- /dev/null +++ b/backend/game/service.go @@ -0,0 +1,406 @@ +package game + +import ( + "context" + "errors" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "albatross-2026-backend/db" +) + +type GameHubInterface interface { + CalcCodeSize(code string, language string) int + EnqueueTestTasks(ctx context.Context, submissionID, gameID, userID int, language, code string) error +} + +type Service struct { + q db.Querier + txm db.TxManager + hub GameHubInterface +} + +func NewService(q db.Querier, txm db.TxManager, hub GameHubInterface) *Service { + return &Service{q: q, txm: txm, hub: hub} +} + +// Domain types + +type Player struct { + UserID int + Username string + DisplayName string + IconPath *string + IsAdmin bool + Label *string +} + +type ProblemDetail struct { + ProblemID int + Title string + Description string + Language string + SampleCode string +} + +type GameDetail struct { + GameID int + GameType string + IsPublic bool + DisplayName string + DurationSeconds int + StartedAt *time.Time + Problem ProblemDetail + MainPlayers []Player +} + +type LatestState struct { + Code string + Score *int + BestScoreSubmittedAt *int64 + Status string +} + +type RankingEntry struct { + Player Player + Score int + SubmittedAt int64 + Code *string +} + +type SubmissionDetail struct { + SubmissionID int + GameID int + Code string + CodeSize int + Status string + CreatedAt int64 +} + +// Helper functions + +func IsGameRunning(startedAt pgtype.Timestamp, durationSeconds int32) bool { + if !startedAt.Valid { + return false + } + endTime := startedAt.Time.Add(time.Duration(durationSeconds) * time.Second) + return time.Now().Before(endTime) +} + +func IsGameFinished(startedAt pgtype.Timestamp, durationSeconds int32) bool { + if !startedAt.Valid { + return false + } + endTime := startedAt.Time.Add(time.Duration(durationSeconds) * time.Second) + return !time.Now().Before(endTime) +} + +func playerFromMainPlayerRow(row db.ListMainPlayersRow) Player { + return Player{ + UserID: int(row.UserID), + Username: row.Username, + DisplayName: row.DisplayName, + IconPath: row.IconPath, + IsAdmin: row.IsAdmin, + Label: row.Label, + } +} + +func gameDetailFromPublicRow(row db.ListPublicGamesRow) GameDetail { + var startedAt *time.Time + if row.StartedAt.Valid { + t := row.StartedAt.Time + startedAt = &t + } + return GameDetail{ + GameID: int(row.GameID), + GameType: row.GameType, + IsPublic: row.IsPublic, + DisplayName: row.DisplayName, + DurationSeconds: int(row.DurationSeconds), + StartedAt: startedAt, + Problem: ProblemDetail{ + ProblemID: int(row.ProblemID), + Title: row.Title, + Description: row.Description, + Language: row.Language, + SampleCode: row.SampleCode, + }, + } +} + +func gameDetailFromGetRow(row db.GetGameByIDRow) GameDetail { + var startedAt *time.Time + if row.StartedAt.Valid { + t := row.StartedAt.Time + startedAt = &t + } + return GameDetail{ + GameID: int(row.GameID), + GameType: row.GameType, + IsPublic: row.IsPublic, + DisplayName: row.DisplayName, + DurationSeconds: int(row.DurationSeconds), + StartedAt: startedAt, + Problem: ProblemDetail{ + ProblemID: int(row.ProblemID), + Title: row.Title, + Description: row.Description, + Language: row.Language, + SampleCode: row.SampleCode, + }, + } +} + +// Service methods + +func (s *Service) ListPublicGames(ctx context.Context) ([]GameDetail, error) { + gameRows, err := s.q.ListPublicGames(ctx) + if err != nil { + return nil, err + } + games := make([]GameDetail, len(gameRows)) + gameIDs := make([]int32, len(gameRows)) + gameID2Index := make(map[int32]int, len(gameRows)) + for i, row := range gameRows { + games[i] = gameDetailFromPublicRow(row) + gameIDs[i] = row.GameID + gameID2Index[row.GameID] = i + } + mainPlayerRows, err := s.q.ListMainPlayers(ctx, gameIDs) + if err != nil { + return nil, err + } + for _, row := range mainPlayerRows { + idx := gameID2Index[row.GameID] + games[idx].MainPlayers = append(games[idx].MainPlayers, playerFromMainPlayerRow(row)) + } + return games, nil +} + +func (s *Service) GetGameByID(ctx context.Context, gameID int, isAdmin bool) (GameDetail, error) { + row, err := s.q.GetGameByID(ctx, int32(gameID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return GameDetail{}, ErrNotFound + } + return GameDetail{}, err + } + if !row.IsPublic && !isAdmin { + return GameDetail{}, ErrNotFound + } + game := gameDetailFromGetRow(row) + mainPlayerRows, err := s.q.ListMainPlayers(ctx, []int32{int32(gameID)}) + if err != nil { + return GameDetail{}, err + } + for _, playerRow := range mainPlayerRows { + game.MainPlayers = append(game.MainPlayers, playerFromMainPlayerRow(playerRow)) + } + return game, nil +} + +func (s *Service) SaveCode(ctx context.Context, gameID int, userID int32, code string) error { + gameRow, err := s.q.GetGameByID(ctx, int32(gameID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + return err + } + if !IsGameRunning(gameRow.StartedAt, gameRow.DurationSeconds) { + return ErrGameNotRunning + } + return s.q.UpdateCode(ctx, db.UpdateCodeParams{ + GameID: int32(gameID), + UserID: userID, + Code: code, + Status: "none", + }) +} + +func (s *Service) SubmitCode(ctx context.Context, gameID int, userID int32, code string) error { + gameRow, err := s.q.GetGameByID(ctx, int32(gameID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + return err + } + + language := gameRow.Language + codeSize := s.hub.CalcCodeSize(code, language) + + if !IsGameRunning(gameRow.StartedAt, gameRow.DurationSeconds) { + return ErrGameNotRunning + } + + var submissionID int32 + err = s.txm.RunInTx(ctx, func(qtx db.Querier) error { + if err := qtx.UpdateCodeAndStatus(ctx, db.UpdateCodeAndStatusParams{ + GameID: int32(gameID), + UserID: userID, + Code: code, + Status: "running", + }); err != nil { + return err + } + var err error + submissionID, err = qtx.CreateSubmission(ctx, db.CreateSubmissionParams{ + GameID: int32(gameID), + UserID: userID, + Code: code, + CodeSize: int32(codeSize), + }) + return err + }) + if err != nil { + return err + } + + return s.hub.EnqueueTestTasks(ctx, int(submissionID), gameID, int(userID), language, code) +} + +func (s *Service) GetLatestState(ctx context.Context, gameID int, userID int32) (LatestState, error) { + row, err := s.q.GetLatestState(ctx, db.GetLatestStateParams{ + GameID: int32(gameID), + UserID: userID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return LatestState{Status: "none"}, nil + } + return LatestState{}, err + } + var score *int + if row.CodeSize != nil { + s := int(*row.CodeSize) + score = &s + } + var submittedAt *int64 + if row.CreatedAt.Valid { + ts := row.CreatedAt.Time.Unix() + submittedAt = &ts + } + return LatestState{ + Code: row.Code, + Score: score, + BestScoreSubmittedAt: submittedAt, + Status: row.Status, + }, nil +} + +func (s *Service) GetWatchLatestStates(ctx context.Context, gameID int, userID *int32, isAdmin bool) (map[int]LatestState, error) { + rows, err := s.q.GetLatestStatesOfMainPlayers(ctx, int32(gameID)) + if err != nil { + return nil, err + } + states := make(map[int]LatestState, len(rows)) + for _, row := range rows { + var code string + if row.Code != nil { + code = *row.Code + } + var status string + if row.Status != nil { + status = *row.Status + } else { + status = "none" + } + var score *int + if row.CodeSize != nil { + s := int(*row.CodeSize) + score = &s + } + var submittedAt *int64 + if row.CreatedAt.Valid { + ts := row.CreatedAt.Time.Unix() + submittedAt = &ts + } + + if userID != nil && row.UserID == *userID && !isAdmin { + return nil, ErrForbidden + } + + states[int(row.UserID)] = LatestState{ + Code: code, + Score: score, + BestScoreSubmittedAt: submittedAt, + Status: status, + } + } + return states, nil +} + +func (s *Service) GetRanking(ctx context.Context, gameID int) ([]RankingEntry, bool, error) { + gameRow, err := s.q.GetGameByID(ctx, int32(gameID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, false, ErrNotFound + } + return nil, false, err + } + finished := IsGameFinished(gameRow.StartedAt, gameRow.DurationSeconds) + + rows, err := s.q.GetRanking(ctx, int32(gameID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, finished, nil + } + return nil, false, err + } + ranking := make([]RankingEntry, len(rows)) + for i, row := range rows { + var code *string + if finished { + code = &row.Submission.Code + } + ranking[i] = RankingEntry{ + Player: Player{ + UserID: int(row.User.UserID), + Username: row.User.Username, + DisplayName: row.User.DisplayName, + IconPath: row.User.IconPath, + IsAdmin: row.User.IsAdmin, + Label: row.User.Label, + }, + Score: int(row.Submission.CodeSize), + SubmittedAt: row.Submission.CreatedAt.Time.Unix(), + Code: code, + } + } + return ranking, finished, nil +} + +func (s *Service) GetSubmissions(ctx context.Context, gameID int, userID int32) ([]SubmissionDetail, error) { + _, err := s.q.GetGameByID(ctx, int32(gameID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + + rows, err := s.q.GetSubmissionsByGameIDAndUserID(ctx, db.GetSubmissionsByGameIDAndUserIDParams{ + GameID: int32(gameID), + UserID: userID, + }) + if err != nil { + return nil, err + } + + submissions := make([]SubmissionDetail, len(rows)) + for i, row := range rows { + submissions[i] = SubmissionDetail{ + SubmissionID: int(row.SubmissionID), + GameID: int(row.GameID), + Code: row.Code, + CodeSize: int(row.CodeSize), + Status: row.Status, + CreatedAt: row.CreatedAt.Time.Unix(), + } + } + return submissions, nil +} diff --git a/backend/game/service_test.go b/backend/game/service_test.go new file mode 100644 index 0000000..95ceef6 --- /dev/null +++ b/backend/game/service_test.go @@ -0,0 +1,82 @@ +package game + +import ( + "testing" + "time" + + "github.com/jackc/pgx/v5/pgtype" +) + +func TestIsGameRunning(t *testing.T) { + now := time.Now() + tests := []struct { + name string + startedAt pgtype.Timestamp + durationSeconds int32 + want bool + }{ + { + name: "not started", + startedAt: pgtype.Timestamp{Valid: false}, + durationSeconds: 300, + want: false, + }, + { + name: "running", + startedAt: pgtype.Timestamp{Time: now.Add(-1 * time.Minute), Valid: true}, + durationSeconds: 300, + want: true, + }, + { + name: "finished", + startedAt: pgtype.Timestamp{Time: now.Add(-10 * time.Minute), Valid: true}, + durationSeconds: 300, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsGameRunning(tt.startedAt, tt.durationSeconds) + if got != tt.want { + t.Errorf("IsGameRunning() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIsGameFinished(t *testing.T) { + now := time.Now() + tests := []struct { + name string + startedAt pgtype.Timestamp + durationSeconds int32 + want bool + }{ + { + name: "not started", + startedAt: pgtype.Timestamp{Valid: false}, + durationSeconds: 300, + want: false, + }, + { + name: "still running", + startedAt: pgtype.Timestamp{Time: now.Add(-1 * time.Minute), Valid: true}, + durationSeconds: 300, + want: false, + }, + { + name: "finished", + startedAt: pgtype.Timestamp{Time: now.Add(-10 * time.Minute), Valid: true}, + durationSeconds: 300, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsGameFinished(tt.startedAt, tt.durationSeconds) + if got != tt.want { + t.Errorf("IsGameFinished() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/backend/gen/api/handler_wrapper_gen.go b/backend/gen/api/handler_wrapper_gen.go index 88f55c6..ef2baa1 100644 --- a/backend/gen/api/handler_wrapper_gen.go +++ b/backend/gen/api/handler_wrapper_gen.go @@ -117,6 +117,9 @@ import ( "albatross-2026-backend/config" "albatross-2026-backend/db" + "albatross-2026-backend/game" + "albatross-2026-backend/session" + "albatross-2026-backend/tournament" ) var _ StrictServerInterface = (*HandlerWrapper)(nil) @@ -125,14 +128,14 @@ type HandlerWrapper struct { impl Handler } -func NewHandler(queries db.Querier, txm db.TxManager, hub GameHubInterface, auth AuthenticatorInterface, conf *config.Config) *HandlerWrapper { +func NewHandler(gameSvc *game.Service, tournamentSvc *tournament.Service, auth AuthenticatorInterface, queries db.Querier, conf *config.Config) *HandlerWrapper { return &HandlerWrapper{ impl: Handler{ - q: queries, - txm: txm, - hub: hub, - auth: auth, - conf: conf, + gameSvc: gameSvc, + tournamentSvc: tournamentSvc, + auth: auth, + conf: conf, + q: queries, }, } } @@ -140,7 +143,7 @@ func NewHandler(queries db.Querier, txm db.TxManager, hub GameHubInterface, auth {{ range . }} func (h *HandlerWrapper) {{ .Name }}(ctx context.Context, request {{ .Name }}RequestObject) ({{ .Name }}ResponseObject, error) { {{ if .RequiresLogin -}} - user, ok := GetUserFromContext(ctx) + user, ok := session.GetUserFromContext(ctx) if !ok { return {{ .Name }}401JSONResponse{ Message: "Unauthorized", @@ -155,7 +158,7 @@ func NewHandler(queries db.Querier, txm db.TxManager, hub GameHubInterface, auth {{ end -}} return h.impl.{{ .Name }}(ctx, request, user) {{ else if .LoginOptional -}} - user, _ := GetUserFromContext(ctx) + user, _ := session.GetUserFromContext(ctx) return h.impl.{{ .Name }}(ctx, request, user) {{ else -}} return h.impl.{{ .Name }}(ctx, request) diff --git a/backend/main.go b/backend/main.go index 2825b84..7220ca6 100644 --- a/backend/main.go +++ b/backend/main.go @@ -24,6 +24,7 @@ import ( "albatross-2026-backend/game" "albatross-2026-backend/ratelimit" "albatross-2026-backend/taskqueue" + "albatross-2026-backend/tournament" ) func connectDB(ctx context.Context, dsn string) (*pgxpool.Pool, error) { @@ -105,7 +106,9 @@ func main() { apiGroup.Use(ratelimit.LoginRateLimitMiddleware(loginRL)) apiGroup.Use(api.SessionCookieMiddleware(queries)) apiGroup.Use(oapimiddleware.OapiRequestValidator(openAPISpec)) - apiHandler := api.NewHandler(queries, txm, gameHub, authenticator, conf) + gameSvc := game.NewService(queries, txm, gameHub) + tournamentSvc := tournament.NewService(queries) + apiHandler := api.NewHandler(gameSvc, tournamentSvc, authenticator, queries, conf) api.RegisterHandlers(apiGroup, api.NewStrictHandler(apiHandler, nil)) adminHandler := admin.NewHandler(queries, txm, gameHub, conf) diff --git a/backend/session/context.go b/backend/session/context.go new file mode 100644 index 0000000..60f88b4 --- /dev/null +++ b/backend/session/context.go @@ -0,0 +1,39 @@ +package session + +import ( + "context" + + "albatross-2026-backend/db" +) + +type sessionIDContextKey struct{} +type userContextKey struct{} +type clientIPContextKey struct{} + +func GetSessionIDFromContext(ctx context.Context) (string, bool) { + sessionID, ok := ctx.Value(sessionIDContextKey{}).(string) + return sessionID, ok +} + +func SetSessionIDInContext(ctx context.Context, sessionID string) context.Context { + return context.WithValue(ctx, sessionIDContextKey{}, sessionID) +} + +func GetUserFromContext(ctx context.Context) (*db.User, bool) { + user, ok := ctx.Value(userContextKey{}).(*db.User) + return user, ok +} + +// SetUserInContext sets a user in the context. Intended for testing. +func SetUserInContext(ctx context.Context, user *db.User) context.Context { + return context.WithValue(ctx, userContextKey{}, user) +} + +func GetClientIPFromContext(ctx context.Context) string { + ip, _ := ctx.Value(clientIPContextKey{}).(string) + return ip +} + +func SetClientIPInContext(ctx context.Context, ip string) context.Context { + return context.WithValue(ctx, clientIPContextKey{}, ip) +} diff --git a/backend/tournament/service.go b/backend/tournament/service.go new file mode 100644 index 0000000..1bc8aeb --- /dev/null +++ b/backend/tournament/service.go @@ -0,0 +1,303 @@ +package tournament + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + + "albatross-2026-backend/db" + "albatross-2026-backend/game" +) + +type Service struct { + q db.Querier +} + +func NewService(q db.Querier) *Service { + return &Service{q: q} +} + +// Domain types + +type Player struct { + UserID int + Username string + DisplayName string + IconPath *string + IsAdmin bool + Label *string +} + +type TournamentEntry struct { + User Player + Seed int +} + +type TournamentMatch struct { + TournamentMatchID int + Round int + Position int + GameID *int + Player1 *Player + Player2 *Player + Player1Score *int + Player2Score *int + WinnerUserID *int + IsBye bool +} + +type TournamentBracket struct { + TournamentID int + DisplayName string + BracketSize int + NumRounds int + Entries []TournamentEntry + Matches []TournamentMatch +} + +// StandardBracketSeeds returns the seed assignments for each slot in a standard +// single-elimination bracket. +func StandardBracketSeeds(bracketSize int) []int { + seeds := make([]int, bracketSize) + seeds[0] = 1 + for size := 2; size <= bracketSize; size *= 2 { + 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 findSeedByUserID(entries []TournamentEntry, userID int) int { + for _, e := range entries { + if e.User.UserID == userID { + return e.Seed + } + } + return 0 +} + +func (s *Service) GetTournament(ctx context.Context, tournamentID int) (TournamentBracket, error) { + t, err := s.q.GetTournamentByID(ctx, int32(tournamentID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return TournamentBracket{}, game.ErrNotFound + } + return TournamentBracket{}, err + } + + entryRows, err := s.q.ListTournamentEntries(ctx, int32(tournamentID)) + if err != nil { + return TournamentBracket{}, err + } + + seedToUser := make(map[int]Player) + entries := make([]TournamentEntry, len(entryRows)) + for i, e := range entryRows { + u := Player{ + UserID: int(e.UserID), + Username: e.Username, + DisplayName: e.DisplayName, + IconPath: e.IconPath, + IsAdmin: e.IsAdmin, + Label: e.Label, + } + seedToUser[int(e.Seed)] = u + entries[i] = TournamentEntry{ + User: u, + Seed: int(e.Seed), + } + } + + matchRows, err := s.q.ListTournamentMatches(ctx, int32(tournamentID)) + if err != nil { + return TournamentBracket{}, err + } + + bracketSize := int(t.BracketSize) + numRounds := int(t.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 + } + } + + // 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 := s.q.GetGameByID(ctx, gid) + if err != nil { + continue + } + if !gameRow.StartedAt.Valid { + continue + } + rankingRows, err := s.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) + } + } + gameRankings[gid] = rr + } + + // Build match results bottom-up + type matchResult struct { + player1 *Player + player2 *Player + p1Score *int + p2Score *int + winnerUID *int + isBye bool + } + resultByKey := make(map[matchKey]*matchResult) + + for round := range numRounds { + numPositions := bracketSize / (1 << (round + 1)) + for pos := range numPositions { + m, exists := matchByKey[matchKey{round, pos}] + mr := &matchResult{} + + if round == 0 { + 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 { + child1 := resultByKey[matchKey{round - 1, pos * 2}] + child2 := resultByKey[matchKey{round - 1, pos*2 + 1}] + + if child1 != nil && child1.winnerUID != nil { + if u, ok := seedToUser[findSeedByUserID(entries, *child1.winnerUID)]; ok { + mr.player1 = &u + } + } + if child2 != nil && child2.winnerUID != nil { + if u, ok := seedToUser[findSeedByUserID(entries, *child2.winnerUID)]; ok { + mr.player2 = &u + } + } + } + + // Check for bye + if mr.player1 == nil && mr.player2 != nil { + mr.isBye = true + uid := mr.player2.UserID + mr.winnerUID = &uid + } else if mr.player1 != nil && mr.player2 == nil { + mr.isBye = true + uid := mr.player1.UserID + mr.winnerUID = &uid + } + + // 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 mr.player1 != nil && mr.player2 != nil { + if rr.winnerID == mr.player1.UserID || rr.winnerID == mr.player2.UserID { + w := rr.winnerID + mr.winnerUID = &w + } else { + if mr.p1Score != nil && mr.p2Score != nil { + if *mr.p1Score <= *mr.p2Score { + w := mr.player1.UserID + mr.winnerUID = &w + } else { + w := mr.player2.UserID + mr.winnerUID = &w + } + } + } + } + } + } + + resultByKey[matchKey{round, pos}] = mr + } + } + + // Build 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, + }) + } + } + + return TournamentBracket{ + TournamentID: int(t.TournamentID), + DisplayName: t.DisplayName, + BracketSize: bracketSize, + NumRounds: numRounds, + Entries: entries, + Matches: apiMatches, + }, nil +} diff --git a/backend/tournament/service_test.go b/backend/tournament/service_test.go new file mode 100644 index 0000000..d1ca78c --- /dev/null +++ b/backend/tournament/service_test.go @@ -0,0 +1,97 @@ +package tournament + +import "testing" + +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) + 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: Player{UserID: 10}, Seed: 1}, + {User: Player{UserID: 20}, Seed: 2}, + {User: Player{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) + } +} -- cgit v1.3.1