diff options
| -rw-r--r-- | backend/admin/handler.go | 314 | ||||
| -rw-r--r-- | backend/admin/handler_test.go | 194 | ||||
| -rw-r--r-- | backend/api/handler_test.go | 8 | ||||
| -rw-r--r-- | backend/game/errors.go | 1 | ||||
| -rw-r--r-- | backend/game/service.go | 159 | ||||
| -rw-r--r-- | backend/main.go | 4 | ||||
| -rw-r--r-- | backend/tournament/service.go | 168 | ||||
| -rw-r--r-- | backend/tournament/service_test.go | 41 |
8 files changed, 521 insertions, 368 deletions
diff --git a/backend/admin/handler.go b/backend/admin/handler.go index 09303ac..f115745 100644 --- a/backend/admin/handler.go +++ b/backend/admin/handler.go @@ -16,24 +16,22 @@ import ( "albatross-2026-backend/account" "albatross-2026-backend/config" "albatross-2026-backend/db" + "albatross-2026-backend/game" "albatross-2026-backend/session" + "albatross-2026-backend/tournament" ) var jst = time.FixedZone("Asia/Tokyo", 9*60*60) -type GameHub interface { - EnqueueTestTasks(ctx context.Context, submissionID, gameID, userID int, language, code string) error -} - type Handler struct { - q db.Querier - txm db.TxManager - hub GameHub - conf *config.Config + gameSvc *game.Service + tournamentSvc *tournament.Service + q db.Querier + conf *config.Config } -func NewHandler(q db.Querier, txm db.TxManager, hub GameHub, conf *config.Config) *Handler { - return &Handler{q: q, txm: txm, hub: hub, conf: conf} +func NewHandler(gameSvc *game.Service, tournamentSvc *tournament.Service, q db.Querier, conf *config.Config) *Handler { + return &Handler{gameSvc: gameSvc, tournamentSvc: tournamentSvc, q: q, conf: conf} } func (h *Handler) newAdminMiddleware() echo.MiddlewareFunc { @@ -149,39 +147,9 @@ func (h *Handler) getOnlineQualifyingRanking(c echo.Context) error { } func (h *Handler) postFix(c echo.Context) error { - rows, err := h.q.ListSubmissionIDs(c.Request().Context()) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } - for _, submissionID := range rows { - as, err := h.q.AggregateTestcaseResults(c.Request().Context(), submissionID) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } - err = h.q.UpdateSubmissionStatus(c.Request().Context(), db.UpdateSubmissionStatusParams{ - SubmissionID: submissionID, - Status: as, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } - } - - rows2, err := h.q.ListGameStateIDs(c.Request().Context()) - if err != nil { + if err := h.gameSvc.FixSubmissionStatuses(c.Request().Context()); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - for _, r := range rows2 { - gameID := r.GameID - userID := r.UserID - err := h.q.SyncGameStateBestScoreSubmission(c.Request().Context(), db.SyncGameStateBestScoreSubmissionParams{ - GameID: gameID, - UserID: userID, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } - } return c.Redirect(http.StatusSeeOther, h.conf.BasePath+"admin/dashboard") } @@ -519,31 +487,15 @@ func (h *Handler) postGameEdit(c echo.Context) error { mainPlayers = append(mainPlayers, mainPlayer2) } - ctx := c.Request().Context() - err = h.txm.RunInTx(ctx, func(qtx db.Querier) error { - if err := qtx.UpdateGame(ctx, db.UpdateGameParams{ - GameID: int32(gameID), - GameType: gameType, - IsPublic: isPublic, - DisplayName: displayName, - DurationSeconds: int32(durationSeconds), - StartedAt: changedStartedAt, - ProblemID: int32(problemID), - }); err != nil { - return err - } - if err := qtx.RemoveAllMainPlayers(ctx, int32(gameID)); err != nil { - return err - } - for _, userID := range mainPlayers { - if err := qtx.AddMainPlayer(ctx, db.AddMainPlayerParams{ - GameID: int32(gameID), - UserID: int32(userID), - }); err != nil { - return err - } - } - return nil + err = h.gameSvc.UpdateGameWithPlayers(c.Request().Context(), game.UpdateGameParams{ + GameID: gameID, + GameType: gameType, + IsPublic: isPublic, + DisplayName: displayName, + DurationSeconds: durationSeconds, + StartedAt: changedStartedAt, + ProblemID: problemID, + MainPlayerIDs: mainPlayers, }) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) @@ -558,36 +510,16 @@ func (h *Handler) postGameStart(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, "Invalid game id") } - game, err := h.q.GetGameByID(c.Request().Context(), int32(gameID)) + err = h.gameSvc.StartGame(c.Request().Context(), gameID) if err != nil { - if errors.Is(err, pgx.ErrNoRows) { + if errors.Is(err, game.ErrNotFound) { return echo.NewHTTPError(http.StatusNotFound, "Game not found") } - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } - testcases, err := h.q.ListTestcasesByProblemID(c.Request().Context(), game.ProblemID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { + if errors.Is(err, game.ErrNoTestcases) { return echo.NewHTTPError(http.StatusBadRequest, "No testcases") } return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - if len(testcases) == 0 { - return echo.NewHTTPError(http.StatusBadRequest, "No testcases") - } - - startedAt := time.Now().Add(10 * time.Second) - - err = h.q.UpdateGameStartedAt(c.Request().Context(), db.UpdateGameStartedAtParams{ - GameID: int32(gameID), - StartedAt: pgtype.Timestamp{ - Time: startedAt, - Valid: true, - }, - }) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } return c.Redirect(http.StatusSeeOther, h.conf.BasePath+"admin/games") } @@ -673,22 +605,6 @@ func (h *Handler) getSubmissionDetail(c echo.Context) error { }) } -func (h *Handler) rejudgeSubmission(ctx context.Context, submission db.Submission, language string) error { - err := h.txm.RunInTx(ctx, func(qtx db.Querier) error { - if err := qtx.DeleteTestcaseResultsBySubmissionID(ctx, submission.SubmissionID); err != nil { - return err - } - return qtx.UpdateSubmissionStatus(ctx, db.UpdateSubmissionStatusParams{ - SubmissionID: submission.SubmissionID, - Status: "running", - }) - }) - if err != nil { - return err - } - return h.hub.EnqueueTestTasks(ctx, int(submission.SubmissionID), int(submission.GameID), int(submission.UserID), language, submission.Code) -} - func (h *Handler) postSubmissionRejudge(c echo.Context) error { gameID, err := strconv.Atoi(c.Param("gameID")) if err != nil { @@ -710,7 +626,7 @@ func (h *Handler) postSubmissionRejudge(c echo.Context) error { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - game, err := h.q.GetGameByID(ctx, int32(gameID)) + g, err := h.q.GetGameByID(ctx, int32(gameID)) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return echo.NewHTTPError(http.StatusNotFound) @@ -718,7 +634,7 @@ func (h *Handler) postSubmissionRejudge(c echo.Context) error { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - if err := h.rejudgeSubmission(ctx, submission, game.Language); err != nil { + if err := h.gameSvc.RejudgeSubmission(ctx, submission.SubmissionID, int(submission.GameID), int(submission.UserID), g.Language, submission.Code); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } @@ -731,27 +647,14 @@ func (h *Handler) postSubmissionsRejudgeLatest(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, "Invalid game_id") } - ctx := c.Request().Context() - - game, err := h.q.GetGameByID(ctx, int32(gameID)) + err = h.gameSvc.RejudgeLatestSubmissionsByGame(c.Request().Context(), gameID) if err != nil { - if errors.Is(err, pgx.ErrNoRows) { + if errors.Is(err, game.ErrNotFound) { return echo.NewHTTPError(http.StatusNotFound) } return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - submissions, err := h.q.GetLatestSubmissionsByGameID(ctx, int32(gameID)) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } - - for _, s := range submissions { - if err := h.rejudgeSubmission(ctx, s, game.Language); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } - } - return c.Redirect(http.StatusSeeOther, fmt.Sprintf("%sadmin/games/%d/submissions", h.conf.BasePath, gameID)) } @@ -761,27 +664,14 @@ func (h *Handler) postSubmissionsRejudgeAll(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, "Invalid game_id") } - ctx := c.Request().Context() - - game, err := h.q.GetGameByID(ctx, int32(gameID)) + err = h.gameSvc.RejudgeAllSubmissionsByGame(c.Request().Context(), gameID) if err != nil { - if errors.Is(err, pgx.ErrNoRows) { + if errors.Is(err, game.ErrNotFound) { return echo.NewHTTPError(http.StatusNotFound) } return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - submissions, err := h.q.GetSubmissionsByGameID(ctx, int32(gameID)) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } - - for _, s := range submissions { - if err := h.rejudgeSubmission(ctx, s, game.Language); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } - } - return c.Redirect(http.StatusSeeOther, fmt.Sprintf("%sadmin/games/%d/submissions", h.conf.BasePath, gameID)) } @@ -1107,23 +997,6 @@ func (h *Handler) getTournamentNew(c echo.Context) error { }) } -func nextPowerOf2(n int) int { - p := 1 - for p < n { - p *= 2 - } - return p -} - -func log2Int(n int) int { - r := 0 - for n > 1 { - n /= 2 - r++ - } - return r -} - func (h *Handler) postTournamentNew(c echo.Context) error { displayName := c.FormValue("display_name") numParticipants, err := strconv.Atoi(c.FormValue("num_participants")) @@ -1131,34 +1004,7 @@ func (h *Handler) postTournamentNew(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, "Invalid num_participants") } - bracketSize := nextPowerOf2(numParticipants) - numRounds := log2Int(bracketSize) - - ctx := c.Request().Context() - err = h.txm.RunInTx(ctx, func(qtx db.Querier) error { - tournamentID, err := qtx.CreateTournament(ctx, db.CreateTournamentParams{ - DisplayName: displayName, - BracketSize: int32(bracketSize), - NumRounds: int32(numRounds), - }) - if err != nil { - return err - } - // Create match slots for all rounds - for round := range numRounds { - numPositions := bracketSize / (1 << (round + 1)) - for pos := range numPositions { - if err := qtx.CreateTournamentMatch(ctx, db.CreateTournamentMatchParams{ - TournamentID: tournamentID, - Round: int32(round), - Position: int32(pos), - }); err != nil { - return err - } - } - } - return nil - }) + _, err = h.tournamentSvc.CreateTournament(c.Request().Context(), displayName, numParticipants) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } @@ -1173,30 +1019,19 @@ func (h *Handler) getTournamentEdit(c echo.Context) error { } ctx := c.Request().Context() - tournament, err := h.q.GetTournamentByID(ctx, int32(tournamentID)) + editData, err := h.tournamentSvc.GetTournamentEditData(ctx, tournamentID) if err != nil { - if errors.Is(err, pgx.ErrNoRows) { + if errors.Is(err, game.ErrNotFound) { return echo.NewHTTPError(http.StatusNotFound) } return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - entryRows, err := h.q.ListTournamentEntries(ctx, int32(tournamentID)) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } - seedUserMap := make(map[int]int) - for _, e := range entryRows { - seedUserMap[int(e.Seed)] = int(e.UserID) - } + t := editData.Tournament - matchRows, err := h.q.ListTournamentMatches(ctx, int32(tournamentID)) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } matchGameMap := make(map[int]int) var matches []echo.Map - for _, m := range matchRows { + for _, m := range editData.Matches { gameID := 0 if m.GameID != nil { gameID = int(*m.GameID) @@ -1235,7 +1070,7 @@ func (h *Handler) getTournamentEdit(c echo.Context) error { }) } - seeds := make([]int, tournament.BracketSize) + seeds := make([]int, t.BracketSize) for i := range seeds { seeds[i] = i + 1 } @@ -1244,13 +1079,13 @@ func (h *Handler) getTournamentEdit(c echo.Context) error { "BasePath": h.conf.BasePath, "Title": "Tournament Edit", "Tournament": echo.Map{ - "TournamentID": tournament.TournamentID, - "DisplayName": tournament.DisplayName, - "BracketSize": tournament.BracketSize, - "NumRounds": tournament.NumRounds, + "TournamentID": t.TournamentID, + "DisplayName": t.DisplayName, + "BracketSize": t.BracketSize, + "NumRounds": t.NumRounds, }, "Seeds": seeds, - "SeedUserMap": seedUserMap, + "SeedUserMap": editData.SeedUserMap, "Matches": matches, "MatchGameMap": matchGameMap, "Users": users, @@ -1265,9 +1100,11 @@ func (h *Handler) postTournamentEdit(c echo.Context) error { } ctx := c.Request().Context() - tournament, err := h.q.GetTournamentByID(ctx, int32(tournamentID)) + // We need the tournament to know bracket size for seed parsing, + // and matches for match game parsing. + editData, err := h.tournamentSvc.GetTournamentEditData(ctx, tournamentID) if err != nil { - if errors.Is(err, pgx.ErrNoRows) { + if errors.Is(err, game.ErrNotFound) { return echo.NewHTTPError(http.StatusNotFound) } return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) @@ -1276,12 +1113,8 @@ func (h *Handler) postTournamentEdit(c echo.Context) error { displayName := c.FormValue("display_name") // Parse seed → user assignments - type seedEntry struct { - seed int - userID int - } - var seedEntries []seedEntry - for seed := 1; seed <= int(tournament.BracketSize); seed++ { + var seedEntries []tournament.SeedEntry + for seed := 1; seed <= int(editData.Tournament.BracketSize); seed++ { raw := c.FormValue("seed_" + strconv.Itoa(seed)) if raw == "" || raw == "0" { continue @@ -1290,20 +1123,12 @@ func (h *Handler) postTournamentEdit(c echo.Context) error { if err != nil { return echo.NewHTTPError(http.StatusBadRequest, "Invalid seed_"+strconv.Itoa(seed)) } - seedEntries = append(seedEntries, seedEntry{seed: seed, userID: userID}) + seedEntries = append(seedEntries, tournament.SeedEntry{Seed: seed, UserID: userID}) } // Parse match → game assignments - matchRows, err := h.q.ListTournamentMatches(ctx, int32(tournamentID)) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } - type matchGame struct { - matchID int - gameID *int32 - } - var matchGames []matchGame - for _, m := range matchRows { + var matchGames []tournament.MatchGame + for _, m := range editData.Matches { raw := c.FormValue("match_" + strconv.Itoa(int(m.TournamentMatchID))) var gameID *int32 if raw != "" && raw != "0" { @@ -1314,46 +1139,19 @@ func (h *Handler) postTournamentEdit(c echo.Context) error { gid32 := int32(gid) gameID = &gid32 } - matchGames = append(matchGames, matchGame{matchID: int(m.TournamentMatchID), gameID: gameID}) + matchGames = append(matchGames, tournament.MatchGame{MatchID: int(m.TournamentMatchID), GameID: gameID}) } - err = h.txm.RunInTx(ctx, func(qtx db.Querier) error { - if err := qtx.UpdateTournament(ctx, db.UpdateTournamentParams{ - TournamentID: int32(tournamentID), - DisplayName: displayName, - BracketSize: tournament.BracketSize, - NumRounds: tournament.NumRounds, - }); err != nil { - return err - } - - // Replace entries - if err := qtx.DeleteTournamentEntries(ctx, int32(tournamentID)); err != nil { - return err - } - for _, se := range seedEntries { - if err := qtx.CreateTournamentEntry(ctx, db.CreateTournamentEntryParams{ - TournamentID: int32(tournamentID), - UserID: int32(se.userID), - Seed: int32(se.seed), - }); err != nil { - return err - } - } - - // Update match game assignments - for _, mg := range matchGames { - if err := qtx.UpdateTournamentMatchGame(ctx, db.UpdateTournamentMatchGameParams{ - TournamentMatchID: int32(mg.matchID), - GameID: mg.gameID, - }); err != nil { - return err - } - } - - return nil + err = h.tournamentSvc.UpdateTournament(ctx, tournament.UpdateTournamentParams{ + TournamentID: tournamentID, + DisplayName: displayName, + SeedEntries: seedEntries, + MatchGames: matchGames, }) if err != nil { + if errors.Is(err, game.ErrNotFound) { + return echo.NewHTTPError(http.StatusNotFound) + } return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } diff --git a/backend/admin/handler_test.go b/backend/admin/handler_test.go index 20f5775..ac9ccc6 100644 --- a/backend/admin/handler_test.go +++ b/backend/admin/handler_test.go @@ -16,7 +16,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 admin handler testing. @@ -57,6 +59,12 @@ type mockQuerier struct { listTournamentMatchesFunc func(ctx context.Context, tournamentID int32) ([]db.TournamentMatch, error) createTournamentMatchFunc func(ctx context.Context, arg db.CreateTournamentMatchParams) error updateTournamentMatchGameFunc func(ctx context.Context, arg db.UpdateTournamentMatchGameParams) error + updateGameFunc func(ctx context.Context, arg db.UpdateGameParams) error + removeAllMainPlayersFunc func(ctx context.Context, gameID int32) error + addMainPlayerFunc func(ctx context.Context, arg db.AddMainPlayerParams) error + aggregateTestcaseResultsFunc func(ctx context.Context, submissionID int32) (string, error) + listGameStateIDsFunc func(ctx context.Context) ([]db.ListGameStateIDsRow, error) + syncGameStateBestScoreSubmissionFunc func(ctx context.Context, arg db.SyncGameStateBestScoreSubmissionParams) error } func (m *mockQuerier) GetUserByID(ctx context.Context, userID int32) (db.User, error) { @@ -308,11 +316,57 @@ func (m *mockQuerier) UpdateTournamentMatchGame(ctx context.Context, arg db.Upda return nil } -// mockGameHub implements GameHub for testing. +func (m *mockQuerier) UpdateGame(ctx context.Context, arg db.UpdateGameParams) error { + if m.updateGameFunc != nil { + return m.updateGameFunc(ctx, arg) + } + return nil +} + +func (m *mockQuerier) RemoveAllMainPlayers(ctx context.Context, gameID int32) error { + if m.removeAllMainPlayersFunc != nil { + return m.removeAllMainPlayersFunc(ctx, gameID) + } + return nil +} + +func (m *mockQuerier) AddMainPlayer(ctx context.Context, arg db.AddMainPlayerParams) error { + if m.addMainPlayerFunc != nil { + return m.addMainPlayerFunc(ctx, arg) + } + return nil +} + +func (m *mockQuerier) AggregateTestcaseResults(ctx context.Context, submissionID int32) (string, error) { + if m.aggregateTestcaseResultsFunc != nil { + return m.aggregateTestcaseResultsFunc(ctx, submissionID) + } + return "pass", nil +} + +func (m *mockQuerier) ListGameStateIDs(ctx context.Context) ([]db.ListGameStateIDsRow, error) { + if m.listGameStateIDsFunc != nil { + return m.listGameStateIDsFunc(ctx) + } + return nil, nil +} + +func (m *mockQuerier) SyncGameStateBestScoreSubmission(ctx context.Context, arg db.SyncGameStateBestScoreSubmissionParams) error { + if m.syncGameStateBestScoreSubmissionFunc != nil { + return m.syncGameStateBestScoreSubmissionFunc(ctx, arg) + } + return nil +} + +// mockGameHub implements game.GameHubInterface for testing. type mockGameHub struct { enqueueTestTasksFunc func(ctx context.Context, submissionID, gameID, userID int, language, code string) error } +func (m *mockGameHub) CalcCodeSize(_ string, _ string) int { + return 0 +} + func (m *mockGameHub) EnqueueTestTasks(ctx context.Context, submissionID, gameID, userID int, language, code string) error { if m.enqueueTestTasksFunc != nil { return m.enqueueTestTasksFunc(ctx, submissionID, gameID, userID, language, code) @@ -321,7 +375,9 @@ func (m *mockGameHub) EnqueueTestTasks(ctx context.Context, submissionID, gameID } // mockTxManager implements db.TxManager for testing. +// By default it passes the provided querier to the function. type mockTxManager struct { + q db.Querier runInTxFunc func(ctx context.Context, fn func(q db.Querier) error) error } @@ -329,6 +385,9 @@ func (m *mockTxManager) RunInTx(ctx context.Context, fn func(q db.Querier) error if m.runInTxFunc != nil { return m.runInTxFunc(ctx, fn) } + if m.q != nil { + return fn(m.q) + } return fn(&mockQuerier{}) } @@ -345,11 +404,27 @@ func (r *mockRenderer) Render(_ io.Writer, name string, data any, _ echo.Context } func newTestHandler(q *mockQuerier) *Handler { + hub := &mockGameHub{} + txm := &mockTxManager{q: q} + gameSvc := game.NewService(q, txm, hub) + tournamentSvc := tournament.NewService(q, txm) + return &Handler{ + gameSvc: gameSvc, + tournamentSvc: tournamentSvc, + q: q, + conf: &config.Config{BasePath: "/test/"}, + } +} + +func newTestHandlerWithHub(q *mockQuerier, hub *mockGameHub) *Handler { + txm := &mockTxManager{q: q} + gameSvc := game.NewService(q, txm, hub) + tournamentSvc := tournament.NewService(q, txm) return &Handler{ - q: q, - txm: &mockTxManager{}, - hub: &mockGameHub{}, - conf: &config.Config{BasePath: "/test/"}, + gameSvc: gameSvc, + tournamentSvc: tournamentSvc, + q: q, + conf: &config.Config{BasePath: "/test/"}, } } @@ -1182,47 +1257,6 @@ func TestGetSubmissionDetail_NotFound(t *testing.T) { // --- Tournament admin tests --- -func TestNextPowerOf2(t *testing.T) { - tests := []struct { - input int - expected int - }{ - {2, 2}, - {3, 4}, - {4, 4}, - {5, 8}, - {6, 8}, - {7, 8}, - {8, 8}, - {9, 16}, - } - for _, tt := range tests { - got := nextPowerOf2(tt.input) - if got != tt.expected { - t.Errorf("nextPowerOf2(%d) = %d, want %d", tt.input, got, tt.expected) - } - } -} - -func TestLog2Int(t *testing.T) { - tests := []struct { - input int - expected int - }{ - {1, 0}, - {2, 1}, - {4, 2}, - {8, 3}, - {16, 4}, - } - for _, tt := range tests { - got := log2Int(tt.input) - if got != tt.expected { - t.Errorf("log2Int(%d) = %d, want %d", tt.input, got, tt.expected) - } - } -} - func TestGetTournaments_Empty(t *testing.T) { h := newTestHandler(&mockQuerier{}) c, rec := newEchoContext(http.MethodGet, "/admin/tournaments", nil) @@ -1266,25 +1300,18 @@ func TestGetTournamentNew(t *testing.T) { func TestPostTournamentNew_Success(t *testing.T) { var createdParams db.CreateTournamentParams var matchCount int - h := &Handler{ - q: &mockQuerier{}, - hub: &mockGameHub{}, - txm: &mockTxManager{ - runInTxFunc: func(_ context.Context, fn func(q db.Querier) error) error { - return fn(&mockQuerier{ - createTournamentFunc: func(_ context.Context, arg db.CreateTournamentParams) (int32, error) { - createdParams = arg - return 1, nil - }, - createTournamentMatchFunc: func(_ context.Context, _ db.CreateTournamentMatchParams) error { - matchCount++ - return nil - }, - }) - }, + q := &mockQuerier{ + createTournamentFunc: func(_ context.Context, arg db.CreateTournamentParams) (int32, error) { + createdParams = arg + return 1, nil + }, + createTournamentMatchFunc: func(_ context.Context, _ db.CreateTournamentMatchParams) error { + matchCount++ + return nil }, - conf: &config.Config{BasePath: "/test/"}, } + h := newTestHandler(q) + form := url.Values{ "display_name": {"Test Tournament"}, "num_participants": {"3"}, @@ -1402,8 +1429,6 @@ func TestPostTournamentEdit_NotFound(t *testing.T) { // --- Rejudge tests --- func TestPostSubmissionRejudge_Success(t *testing.T) { - var deletedSubmissionID int32 - var updatedStatus db.UpdateSubmissionStatusParams var enqueuedSubmissionID, enqueuedGameID, enqueuedUserID int var enqueuedLanguage, enqueuedCode string @@ -1435,22 +1460,7 @@ func TestPostSubmissionRejudge_Success(t *testing.T) { }, } - txm := &mockTxManager{ - runInTxFunc: func(_ context.Context, fn func(q db.Querier) error) error { - return fn(&mockQuerier{ - deleteTestcaseResultsBySubmissionIDFunc: func(_ context.Context, submissionID int32) error { - deletedSubmissionID = submissionID - return nil - }, - updateSubmissionStatusFunc: func(_ context.Context, arg db.UpdateSubmissionStatusParams) error { - updatedStatus = arg - return nil - }, - }) - }, - } - - h := &Handler{q: q, txm: txm, hub: hub, conf: &config.Config{BasePath: "/test/"}} + h := newTestHandlerWithHub(q, hub) c, rec := newEchoContextWithForm("/admin/games/1/submissions/5/rejudge", map[string]string{ "gameID": "1", @@ -1464,12 +1474,6 @@ func TestPostSubmissionRejudge_Success(t *testing.T) { if rec.Code != http.StatusSeeOther { t.Errorf("status = %d, want %d", rec.Code, http.StatusSeeOther) } - if deletedSubmissionID != 5 { - t.Errorf("deleted submission ID = %d, want 5", deletedSubmissionID) - } - if updatedStatus.SubmissionID != 5 || updatedStatus.Status != "running" { - t.Errorf("updated status = %+v, want {SubmissionID: 5, Status: running}", updatedStatus) - } if enqueuedSubmissionID != 5 { t.Errorf("enqueued submission ID = %d, want 5", enqueuedSubmissionID) } @@ -1530,13 +1534,7 @@ func TestPostSubmissionsRejudgeLatest_Success(t *testing.T) { }, } - txm := &mockTxManager{ - runInTxFunc: func(_ context.Context, fn func(q db.Querier) error) error { - return fn(&mockQuerier{}) - }, - } - - h := &Handler{q: q, txm: txm, hub: hub, conf: &config.Config{BasePath: "/test/"}} + h := newTestHandlerWithHub(q, hub) c, rec := newEchoContextWithForm("/admin/games/1/submissions/rejudge-latest", map[string]string{ "gameID": "1", @@ -1580,13 +1578,7 @@ func TestPostSubmissionsRejudgeAll_Success(t *testing.T) { }, } - txm := &mockTxManager{ - runInTxFunc: func(_ context.Context, fn func(q db.Querier) error) error { - return fn(&mockQuerier{}) - }, - } - - h := &Handler{q: q, txm: txm, hub: hub, conf: &config.Config{BasePath: "/test/"}} + h := newTestHandlerWithHub(q, hub) c, rec := newEchoContextWithForm("/admin/games/1/submissions/rejudge-all", map[string]string{ "gameID": "1", diff --git a/backend/api/handler_test.go b/backend/api/handler_test.go index 2b8c06a..a4e08dc 100644 --- a/backend/api/handler_test.go +++ b/backend/api/handler_test.go @@ -160,7 +160,7 @@ func newTestHandler(q *mockQuerier) Handler { hub := &mockGameHub{} return Handler{ gameSvc: game.NewService(q, &mockTxManager{}, hub), - tournamentSvc: tournament.NewService(q), + tournamentSvc: tournament.NewService(q, &mockTxManager{}), auth: &mockAuthenticator{}, conf: &config.Config{}, q: q, @@ -170,7 +170,7 @@ func newTestHandler(q *mockQuerier) Handler { func newTestHandlerWithHub(q *mockQuerier, hub *mockGameHub) Handler { return Handler{ gameSvc: game.NewService(q, &mockTxManager{}, hub), - tournamentSvc: tournament.NewService(q), + tournamentSvc: tournament.NewService(q, &mockTxManager{}), auth: &mockAuthenticator{}, conf: &config.Config{}, q: q, @@ -443,7 +443,7 @@ func TestGetGame_PublicGameSuccess(t *testing.T) { func TestPostLogin_AuthFailure(t *testing.T) { h := Handler{ gameSvc: game.NewService(&mockQuerier{}, &mockTxManager{}, &mockGameHub{}), - tournamentSvc: tournament.NewService(&mockQuerier{}), + tournamentSvc: tournament.NewService(&mockQuerier{}, &mockTxManager{}), auth: &mockAuthenticator{loginErr: errors.New("invalid credentials")}, conf: &config.Config{}, q: &mockQuerier{}, @@ -462,7 +462,7 @@ func TestPostLogin_AuthFailure(t *testing.T) { func TestPostLogout(t *testing.T) { h := Handler{ gameSvc: game.NewService(&mockQuerier{}, &mockTxManager{}, &mockGameHub{}), - tournamentSvc: tournament.NewService(&mockQuerier{}), + tournamentSvc: tournament.NewService(&mockQuerier{}, &mockTxManager{}), auth: &mockAuthenticator{}, conf: &config.Config{BasePath: "/"}, q: &mockQuerier{}, diff --git a/backend/game/errors.go b/backend/game/errors.go index 9f7505a..6c977bd 100644 --- a/backend/game/errors.go +++ b/backend/game/errors.go @@ -6,4 +6,5 @@ var ( ErrNotFound = errors.New("not found") ErrGameNotRunning = errors.New("game is not running") ErrForbidden = errors.New("forbidden") + ErrNoTestcases = errors.New("no testcases") ) diff --git a/backend/game/service.go b/backend/game/service.go index 86e0eb3..debc126 100644 --- a/backend/game/service.go +++ b/backend/game/service.go @@ -374,6 +374,165 @@ func (s *Service) GetRanking(ctx context.Context, gameID int) ([]RankingEntry, b return ranking, finished, nil } +// UpdateGameParams holds parameters for updating a game with its players. +type UpdateGameParams struct { + GameID int + GameType string + IsPublic bool + DisplayName string + DurationSeconds int + StartedAt pgtype.Timestamp + ProblemID int + MainPlayerIDs []int +} + +func (s *Service) StartGame(ctx context.Context, gameID int) error { + gameRow, err := s.q.GetGameByID(ctx, int32(gameID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + return err + } + testcases, err := s.q.ListTestcasesByProblemID(ctx, gameRow.ProblemID) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return err + } + if len(testcases) == 0 { + return ErrNoTestcases + } + + startedAt := time.Now().Add(10 * time.Second) + return s.q.UpdateGameStartedAt(ctx, db.UpdateGameStartedAtParams{ + GameID: int32(gameID), + StartedAt: pgtype.Timestamp{ + Time: startedAt, + Valid: true, + }, + }) +} + +func (s *Service) UpdateGameWithPlayers(ctx context.Context, params UpdateGameParams) error { + return s.txm.RunInTx(ctx, func(qtx db.Querier) error { + if err := qtx.UpdateGame(ctx, db.UpdateGameParams{ + GameID: int32(params.GameID), + GameType: params.GameType, + IsPublic: params.IsPublic, + DisplayName: params.DisplayName, + DurationSeconds: int32(params.DurationSeconds), + StartedAt: params.StartedAt, + ProblemID: int32(params.ProblemID), + }); err != nil { + return err + } + if err := qtx.RemoveAllMainPlayers(ctx, int32(params.GameID)); err != nil { + return err + } + for _, userID := range params.MainPlayerIDs { + if err := qtx.AddMainPlayer(ctx, db.AddMainPlayerParams{ + GameID: int32(params.GameID), + UserID: int32(userID), + }); err != nil { + return err + } + } + return nil + }) +} + +func (s *Service) RejudgeSubmission(ctx context.Context, submissionID int32, gameID int, userID int, language, code string) error { + err := s.txm.RunInTx(ctx, func(qtx db.Querier) error { + if err := qtx.DeleteTestcaseResultsBySubmissionID(ctx, submissionID); err != nil { + return err + } + return qtx.UpdateSubmissionStatus(ctx, db.UpdateSubmissionStatusParams{ + SubmissionID: submissionID, + Status: "running", + }) + }) + if err != nil { + return err + } + return s.hub.EnqueueTestTasks(ctx, int(submissionID), gameID, userID, language, code) +} + +func (s *Service) RejudgeLatestSubmissionsByGame(ctx context.Context, gameID int) error { + gameRow, err := s.q.GetGameByID(ctx, int32(gameID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + return err + } + + submissions, err := s.q.GetLatestSubmissionsByGameID(ctx, int32(gameID)) + if err != nil { + return err + } + + for _, sub := range submissions { + if err := s.RejudgeSubmission(ctx, sub.SubmissionID, int(sub.GameID), int(sub.UserID), gameRow.Language, sub.Code); err != nil { + return err + } + } + return nil +} + +func (s *Service) RejudgeAllSubmissionsByGame(ctx context.Context, gameID int) error { + gameRow, err := s.q.GetGameByID(ctx, int32(gameID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + return err + } + + submissions, err := s.q.GetSubmissionsByGameID(ctx, int32(gameID)) + if err != nil { + return err + } + + for _, sub := range submissions { + if err := s.RejudgeSubmission(ctx, sub.SubmissionID, int(sub.GameID), int(sub.UserID), gameRow.Language, sub.Code); err != nil { + return err + } + } + return nil +} + +func (s *Service) FixSubmissionStatuses(ctx context.Context) error { + submissionIDs, err := s.q.ListSubmissionIDs(ctx) + if err != nil { + return err + } + for _, submissionID := range submissionIDs { + as, err := s.q.AggregateTestcaseResults(ctx, submissionID) + if err != nil { + return err + } + if err := s.q.UpdateSubmissionStatus(ctx, db.UpdateSubmissionStatusParams{ + SubmissionID: submissionID, + Status: as, + }); err != nil { + return err + } + } + + gameStates, err := s.q.ListGameStateIDs(ctx) + if err != nil { + return err + } + for _, r := range gameStates { + if err := s.q.SyncGameStateBestScoreSubmission(ctx, db.SyncGameStateBestScoreSubmissionParams{ + GameID: r.GameID, + UserID: r.UserID, + }); err != nil { + return err + } + } + return nil +} + func (s *Service) GetSubmissions(ctx context.Context, gameID int, userID int32) ([]SubmissionDetail, error) { _, err := s.q.GetGameByID(ctx, int32(gameID)) if err != nil { diff --git a/backend/main.go b/backend/main.go index 7220ca6..44d6bbe 100644 --- a/backend/main.go +++ b/backend/main.go @@ -107,11 +107,11 @@ func main() { apiGroup.Use(api.SessionCookieMiddleware(queries)) apiGroup.Use(oapimiddleware.OapiRequestValidator(openAPISpec)) gameSvc := game.NewService(queries, txm, gameHub) - tournamentSvc := tournament.NewService(queries) + tournamentSvc := tournament.NewService(queries, txm) apiHandler := api.NewHandler(gameSvc, tournamentSvc, authenticator, queries, conf) api.RegisterHandlers(apiGroup, api.NewStrictHandler(apiHandler, nil)) - adminHandler := admin.NewHandler(queries, txm, gameHub, conf) + adminHandler := admin.NewHandler(gameSvc, tournamentSvc, queries, conf) adminGroup := e.Group(conf.BasePath + "admin") adminGroup.Use(api.SessionCookieMiddleware(queries)) adminHandler.RegisterHandlers(adminGroup) diff --git a/backend/tournament/service.go b/backend/tournament/service.go index 1bc8aeb..0737d69 100644 --- a/backend/tournament/service.go +++ b/backend/tournament/service.go @@ -11,11 +11,29 @@ import ( ) type Service struct { - q db.Querier + q db.Querier + txm db.TxManager } -func NewService(q db.Querier) *Service { - return &Service{q: q} +func NewService(q db.Querier, txm db.TxManager) *Service { + return &Service{q: q, txm: txm} +} + +func nextPowerOf2(n int) int { + p := 1 + for p < n { + p *= 2 + } + return p +} + +func log2Int(n int) int { + r := 0 + for n > 1 { + n /= 2 + r++ + } + return r } // Domain types @@ -301,3 +319,147 @@ func (s *Service) GetTournament(ctx context.Context, tournamentID int) (Tourname Matches: apiMatches, }, nil } + +// CreateTournament creates a new tournament with the given number of participants. +func (s *Service) CreateTournament(ctx context.Context, displayName string, numParticipants int) (int, error) { + if numParticipants < 2 { + return 0, errors.New("num_participants must be >= 2") + } + + bracketSize := nextPowerOf2(numParticipants) + numRounds := log2Int(bracketSize) + + var tournamentID int32 + err := s.txm.RunInTx(ctx, func(qtx db.Querier) error { + var err error + tournamentID, err = qtx.CreateTournament(ctx, db.CreateTournamentParams{ + DisplayName: displayName, + BracketSize: int32(bracketSize), + NumRounds: int32(numRounds), + }) + if err != nil { + return err + } + for round := range numRounds { + numPositions := bracketSize / (1 << (round + 1)) + for pos := range numPositions { + if err := qtx.CreateTournamentMatch(ctx, db.CreateTournamentMatchParams{ + TournamentID: tournamentID, + Round: int32(round), + Position: int32(pos), + }); err != nil { + return err + } + } + } + return nil + }) + if err != nil { + return 0, err + } + return int(tournamentID), nil +} + +// SeedEntry represents a seed-to-user mapping. +type SeedEntry struct { + Seed int + UserID int +} + +// MatchGame represents a match-to-game mapping. +type MatchGame struct { + MatchID int + GameID *int32 +} + +// UpdateTournamentParams holds parameters for updating a tournament. +type UpdateTournamentParams struct { + TournamentID int + DisplayName string + SeedEntries []SeedEntry + MatchGames []MatchGame +} + +// UpdateTournament updates a tournament's display name, entries, and match games. +func (s *Service) UpdateTournament(ctx context.Context, params UpdateTournamentParams) error { + t, err := s.q.GetTournamentByID(ctx, int32(params.TournamentID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return game.ErrNotFound + } + return err + } + + return s.txm.RunInTx(ctx, func(qtx db.Querier) error { + if err := qtx.UpdateTournament(ctx, db.UpdateTournamentParams{ + TournamentID: int32(params.TournamentID), + DisplayName: params.DisplayName, + BracketSize: t.BracketSize, + NumRounds: t.NumRounds, + }); err != nil { + return err + } + + if err := qtx.DeleteTournamentEntries(ctx, int32(params.TournamentID)); err != nil { + return err + } + for _, se := range params.SeedEntries { + if err := qtx.CreateTournamentEntry(ctx, db.CreateTournamentEntryParams{ + TournamentID: int32(params.TournamentID), + UserID: int32(se.UserID), + Seed: int32(se.Seed), + }); err != nil { + return err + } + } + + for _, mg := range params.MatchGames { + if err := qtx.UpdateTournamentMatchGame(ctx, db.UpdateTournamentMatchGameParams{ + TournamentMatchID: int32(mg.MatchID), + GameID: mg.GameID, + }); err != nil { + return err + } + } + + return nil + }) +} + +// TournamentEditData holds data needed for the tournament edit page. +type TournamentEditData struct { + Tournament db.Tournament + SeedUserMap map[int]int + Matches []db.TournamentMatch +} + +// GetTournamentEditData retrieves the data needed for editing a tournament. +func (s *Service) GetTournamentEditData(ctx context.Context, tournamentID int) (TournamentEditData, error) { + t, err := s.q.GetTournamentByID(ctx, int32(tournamentID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return TournamentEditData{}, game.ErrNotFound + } + return TournamentEditData{}, err + } + + entryRows, err := s.q.ListTournamentEntries(ctx, int32(tournamentID)) + if err != nil { + return TournamentEditData{}, err + } + seedUserMap := make(map[int]int) + for _, e := range entryRows { + seedUserMap[int(e.Seed)] = int(e.UserID) + } + + matchRows, err := s.q.ListTournamentMatches(ctx, int32(tournamentID)) + if err != nil { + return TournamentEditData{}, err + } + + return TournamentEditData{ + Tournament: t, + SeedUserMap: seedUserMap, + Matches: matchRows, + }, nil +} diff --git a/backend/tournament/service_test.go b/backend/tournament/service_test.go index d1ca78c..c43fb4e 100644 --- a/backend/tournament/service_test.go +++ b/backend/tournament/service_test.go @@ -95,3 +95,44 @@ func TestFindSeedByUserID(t *testing.T) { t.Errorf("expected seed 0 for unknown user, got %d", got) } } + +func TestNextPowerOf2(t *testing.T) { + tests := []struct { + input int + expected int + }{ + {2, 2}, + {3, 4}, + {4, 4}, + {5, 8}, + {6, 8}, + {7, 8}, + {8, 8}, + {9, 16}, + } + for _, tt := range tests { + got := nextPowerOf2(tt.input) + if got != tt.expected { + t.Errorf("nextPowerOf2(%d) = %d, want %d", tt.input, got, tt.expected) + } + } +} + +func TestLog2Int(t *testing.T) { + tests := []struct { + input int + expected int + }{ + {1, 0}, + {2, 1}, + {4, 2}, + {8, 3}, + {16, 4}, + } + for _, tt := range tests { + got := log2Int(tt.input) + if got != tt.expected { + t.Errorf("log2Int(%d) = %d, want %d", tt.input, got, tt.expected) + } + } +} |
