aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--backend/admin/handler.go314
-rw-r--r--backend/admin/handler_test.go194
-rw-r--r--backend/api/handler_test.go8
-rw-r--r--backend/game/errors.go1
-rw-r--r--backend/game/service.go159
-rw-r--r--backend/main.go4
-rw-r--r--backend/tournament/service.go168
-rw-r--r--backend/tournament/service_test.go41
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)
+ }
+ }
+}