diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-21 10:46:02 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-21 10:46:02 +0900 |
| commit | 642d3b4e1d33afd521f315b9aa99b8993d252902 (patch) | |
| tree | f39e7c191033354db56675d9d3dc4c4be17d7aba | |
| parent | e8db174d3e464a5764a9f4bfd82172261bd50519 (diff) | |
| download | phperkaigi-2026-albatross-642d3b4e1d33afd521f315b9aa99b8993d252902.tar.gz phperkaigi-2026-albatross-642d3b4e1d33afd521f315b9aa99b8993d252902.tar.zst phperkaigi-2026-albatross-642d3b4e1d33afd521f315b9aa99b8993d252902.zip | |
refactor(admin): separate business logic into game and tournament services
Move transaction handling, rejudge workflow, tournament bracket creation,
and data repair logic from admin handler into game.Service and
tournament.Service, mirroring the earlier api package separation.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| -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) + } + } +} |
