aboutsummaryrefslogtreecommitdiffhomepage
path: root/backend/tournament
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-21 10:46:02 +0900
committernsfisis <nsfisis@gmail.com>2026-02-21 10:46:02 +0900
commit642d3b4e1d33afd521f315b9aa99b8993d252902 (patch)
treef39e7c191033354db56675d9d3dc4c4be17d7aba /backend/tournament
parente8db174d3e464a5764a9f4bfd82172261bd50519 (diff)
downloadphperkaigi-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>
Diffstat (limited to 'backend/tournament')
-rw-r--r--backend/tournament/service.go168
-rw-r--r--backend/tournament/service_test.go41
2 files changed, 206 insertions, 3 deletions
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)
+ }
+ }
+}