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 }