diff options
Diffstat (limited to 'backend/game')
| -rw-r--r-- | backend/game/errors.go | 9 | ||||
| -rw-r--r-- | backend/game/service.go | 406 | ||||
| -rw-r--r-- | backend/game/service_test.go | 82 |
3 files changed, 497 insertions, 0 deletions
diff --git a/backend/game/errors.go b/backend/game/errors.go new file mode 100644 index 0000000..9f7505a --- /dev/null +++ b/backend/game/errors.go @@ -0,0 +1,9 @@ +package game + +import "errors" + +var ( + ErrNotFound = errors.New("not found") + ErrGameNotRunning = errors.New("game is not running") + ErrForbidden = errors.New("forbidden") +) diff --git a/backend/game/service.go b/backend/game/service.go new file mode 100644 index 0000000..86e0eb3 --- /dev/null +++ b/backend/game/service.go @@ -0,0 +1,406 @@ +package game + +import ( + "context" + "errors" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "albatross-2026-backend/db" +) + +type GameHubInterface interface { + CalcCodeSize(code string, language string) int + EnqueueTestTasks(ctx context.Context, submissionID, gameID, userID int, language, code string) error +} + +type Service struct { + q db.Querier + txm db.TxManager + hub GameHubInterface +} + +func NewService(q db.Querier, txm db.TxManager, hub GameHubInterface) *Service { + return &Service{q: q, txm: txm, hub: hub} +} + +// Domain types + +type Player struct { + UserID int + Username string + DisplayName string + IconPath *string + IsAdmin bool + Label *string +} + +type ProblemDetail struct { + ProblemID int + Title string + Description string + Language string + SampleCode string +} + +type GameDetail struct { + GameID int + GameType string + IsPublic bool + DisplayName string + DurationSeconds int + StartedAt *time.Time + Problem ProblemDetail + MainPlayers []Player +} + +type LatestState struct { + Code string + Score *int + BestScoreSubmittedAt *int64 + Status string +} + +type RankingEntry struct { + Player Player + Score int + SubmittedAt int64 + Code *string +} + +type SubmissionDetail struct { + SubmissionID int + GameID int + Code string + CodeSize int + Status string + CreatedAt int64 +} + +// Helper functions + +func IsGameRunning(startedAt pgtype.Timestamp, durationSeconds int32) bool { + if !startedAt.Valid { + return false + } + endTime := startedAt.Time.Add(time.Duration(durationSeconds) * time.Second) + return time.Now().Before(endTime) +} + +func IsGameFinished(startedAt pgtype.Timestamp, durationSeconds int32) bool { + if !startedAt.Valid { + return false + } + endTime := startedAt.Time.Add(time.Duration(durationSeconds) * time.Second) + return !time.Now().Before(endTime) +} + +func playerFromMainPlayerRow(row db.ListMainPlayersRow) Player { + return Player{ + UserID: int(row.UserID), + Username: row.Username, + DisplayName: row.DisplayName, + IconPath: row.IconPath, + IsAdmin: row.IsAdmin, + Label: row.Label, + } +} + +func gameDetailFromPublicRow(row db.ListPublicGamesRow) GameDetail { + var startedAt *time.Time + if row.StartedAt.Valid { + t := row.StartedAt.Time + startedAt = &t + } + return GameDetail{ + GameID: int(row.GameID), + GameType: row.GameType, + IsPublic: row.IsPublic, + DisplayName: row.DisplayName, + DurationSeconds: int(row.DurationSeconds), + StartedAt: startedAt, + Problem: ProblemDetail{ + ProblemID: int(row.ProblemID), + Title: row.Title, + Description: row.Description, + Language: row.Language, + SampleCode: row.SampleCode, + }, + } +} + +func gameDetailFromGetRow(row db.GetGameByIDRow) GameDetail { + var startedAt *time.Time + if row.StartedAt.Valid { + t := row.StartedAt.Time + startedAt = &t + } + return GameDetail{ + GameID: int(row.GameID), + GameType: row.GameType, + IsPublic: row.IsPublic, + DisplayName: row.DisplayName, + DurationSeconds: int(row.DurationSeconds), + StartedAt: startedAt, + Problem: ProblemDetail{ + ProblemID: int(row.ProblemID), + Title: row.Title, + Description: row.Description, + Language: row.Language, + SampleCode: row.SampleCode, + }, + } +} + +// Service methods + +func (s *Service) ListPublicGames(ctx context.Context) ([]GameDetail, error) { + gameRows, err := s.q.ListPublicGames(ctx) + if err != nil { + return nil, err + } + games := make([]GameDetail, len(gameRows)) + gameIDs := make([]int32, len(gameRows)) + gameID2Index := make(map[int32]int, len(gameRows)) + for i, row := range gameRows { + games[i] = gameDetailFromPublicRow(row) + gameIDs[i] = row.GameID + gameID2Index[row.GameID] = i + } + mainPlayerRows, err := s.q.ListMainPlayers(ctx, gameIDs) + if err != nil { + return nil, err + } + for _, row := range mainPlayerRows { + idx := gameID2Index[row.GameID] + games[idx].MainPlayers = append(games[idx].MainPlayers, playerFromMainPlayerRow(row)) + } + return games, nil +} + +func (s *Service) GetGameByID(ctx context.Context, gameID int, isAdmin bool) (GameDetail, error) { + row, err := s.q.GetGameByID(ctx, int32(gameID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return GameDetail{}, ErrNotFound + } + return GameDetail{}, err + } + if !row.IsPublic && !isAdmin { + return GameDetail{}, ErrNotFound + } + game := gameDetailFromGetRow(row) + mainPlayerRows, err := s.q.ListMainPlayers(ctx, []int32{int32(gameID)}) + if err != nil { + return GameDetail{}, err + } + for _, playerRow := range mainPlayerRows { + game.MainPlayers = append(game.MainPlayers, playerFromMainPlayerRow(playerRow)) + } + return game, nil +} + +func (s *Service) SaveCode(ctx context.Context, gameID int, userID int32, code string) error { + gameRow, err := s.q.GetGameByID(ctx, int32(gameID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + return err + } + if !IsGameRunning(gameRow.StartedAt, gameRow.DurationSeconds) { + return ErrGameNotRunning + } + return s.q.UpdateCode(ctx, db.UpdateCodeParams{ + GameID: int32(gameID), + UserID: userID, + Code: code, + Status: "none", + }) +} + +func (s *Service) SubmitCode(ctx context.Context, gameID int, userID int32, code string) error { + gameRow, err := s.q.GetGameByID(ctx, int32(gameID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + return err + } + + language := gameRow.Language + codeSize := s.hub.CalcCodeSize(code, language) + + if !IsGameRunning(gameRow.StartedAt, gameRow.DurationSeconds) { + return ErrGameNotRunning + } + + var submissionID int32 + err = s.txm.RunInTx(ctx, func(qtx db.Querier) error { + if err := qtx.UpdateCodeAndStatus(ctx, db.UpdateCodeAndStatusParams{ + GameID: int32(gameID), + UserID: userID, + Code: code, + Status: "running", + }); err != nil { + return err + } + var err error + submissionID, err = qtx.CreateSubmission(ctx, db.CreateSubmissionParams{ + GameID: int32(gameID), + UserID: userID, + Code: code, + CodeSize: int32(codeSize), + }) + return err + }) + if err != nil { + return err + } + + return s.hub.EnqueueTestTasks(ctx, int(submissionID), gameID, int(userID), language, code) +} + +func (s *Service) GetLatestState(ctx context.Context, gameID int, userID int32) (LatestState, error) { + row, err := s.q.GetLatestState(ctx, db.GetLatestStateParams{ + GameID: int32(gameID), + UserID: userID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return LatestState{Status: "none"}, nil + } + return LatestState{}, err + } + var score *int + if row.CodeSize != nil { + s := int(*row.CodeSize) + score = &s + } + var submittedAt *int64 + if row.CreatedAt.Valid { + ts := row.CreatedAt.Time.Unix() + submittedAt = &ts + } + return LatestState{ + Code: row.Code, + Score: score, + BestScoreSubmittedAt: submittedAt, + Status: row.Status, + }, nil +} + +func (s *Service) GetWatchLatestStates(ctx context.Context, gameID int, userID *int32, isAdmin bool) (map[int]LatestState, error) { + rows, err := s.q.GetLatestStatesOfMainPlayers(ctx, int32(gameID)) + if err != nil { + return nil, err + } + states := make(map[int]LatestState, len(rows)) + for _, row := range rows { + var code string + if row.Code != nil { + code = *row.Code + } + var status string + if row.Status != nil { + status = *row.Status + } else { + status = "none" + } + var score *int + if row.CodeSize != nil { + s := int(*row.CodeSize) + score = &s + } + var submittedAt *int64 + if row.CreatedAt.Valid { + ts := row.CreatedAt.Time.Unix() + submittedAt = &ts + } + + if userID != nil && row.UserID == *userID && !isAdmin { + return nil, ErrForbidden + } + + states[int(row.UserID)] = LatestState{ + Code: code, + Score: score, + BestScoreSubmittedAt: submittedAt, + Status: status, + } + } + return states, nil +} + +func (s *Service) GetRanking(ctx context.Context, gameID int) ([]RankingEntry, bool, error) { + gameRow, err := s.q.GetGameByID(ctx, int32(gameID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, false, ErrNotFound + } + return nil, false, err + } + finished := IsGameFinished(gameRow.StartedAt, gameRow.DurationSeconds) + + rows, err := s.q.GetRanking(ctx, int32(gameID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, finished, nil + } + return nil, false, err + } + ranking := make([]RankingEntry, len(rows)) + for i, row := range rows { + var code *string + if finished { + code = &row.Submission.Code + } + ranking[i] = RankingEntry{ + Player: Player{ + UserID: int(row.User.UserID), + Username: row.User.Username, + DisplayName: row.User.DisplayName, + IconPath: row.User.IconPath, + IsAdmin: row.User.IsAdmin, + Label: row.User.Label, + }, + Score: int(row.Submission.CodeSize), + SubmittedAt: row.Submission.CreatedAt.Time.Unix(), + Code: code, + } + } + return ranking, finished, nil +} + +func (s *Service) GetSubmissions(ctx context.Context, gameID int, userID int32) ([]SubmissionDetail, error) { + _, err := s.q.GetGameByID(ctx, int32(gameID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + + rows, err := s.q.GetSubmissionsByGameIDAndUserID(ctx, db.GetSubmissionsByGameIDAndUserIDParams{ + GameID: int32(gameID), + UserID: userID, + }) + if err != nil { + return nil, err + } + + submissions := make([]SubmissionDetail, len(rows)) + for i, row := range rows { + submissions[i] = SubmissionDetail{ + SubmissionID: int(row.SubmissionID), + GameID: int(row.GameID), + Code: row.Code, + CodeSize: int(row.CodeSize), + Status: row.Status, + CreatedAt: row.CreatedAt.Time.Unix(), + } + } + return submissions, nil +} diff --git a/backend/game/service_test.go b/backend/game/service_test.go new file mode 100644 index 0000000..95ceef6 --- /dev/null +++ b/backend/game/service_test.go @@ -0,0 +1,82 @@ +package game + +import ( + "testing" + "time" + + "github.com/jackc/pgx/v5/pgtype" +) + +func TestIsGameRunning(t *testing.T) { + now := time.Now() + tests := []struct { + name string + startedAt pgtype.Timestamp + durationSeconds int32 + want bool + }{ + { + name: "not started", + startedAt: pgtype.Timestamp{Valid: false}, + durationSeconds: 300, + want: false, + }, + { + name: "running", + startedAt: pgtype.Timestamp{Time: now.Add(-1 * time.Minute), Valid: true}, + durationSeconds: 300, + want: true, + }, + { + name: "finished", + startedAt: pgtype.Timestamp{Time: now.Add(-10 * time.Minute), Valid: true}, + durationSeconds: 300, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsGameRunning(tt.startedAt, tt.durationSeconds) + if got != tt.want { + t.Errorf("IsGameRunning() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIsGameFinished(t *testing.T) { + now := time.Now() + tests := []struct { + name string + startedAt pgtype.Timestamp + durationSeconds int32 + want bool + }{ + { + name: "not started", + startedAt: pgtype.Timestamp{Valid: false}, + durationSeconds: 300, + want: false, + }, + { + name: "still running", + startedAt: pgtype.Timestamp{Time: now.Add(-1 * time.Minute), Valid: true}, + durationSeconds: 300, + want: false, + }, + { + name: "finished", + startedAt: pgtype.Timestamp{Time: now.Add(-10 * time.Minute), Valid: true}, + durationSeconds: 300, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsGameFinished(tt.startedAt, tt.durationSeconds) + if got != tt.want { + t.Errorf("IsGameFinished() = %v, want %v", got, tt.want) + } + }) + } +} |
