aboutsummaryrefslogtreecommitdiffhomepage
path: root/backend/game/service.go
diff options
context:
space:
mode:
Diffstat (limited to 'backend/game/service.go')
-rw-r--r--backend/game/service.go406
1 files changed, 406 insertions, 0 deletions
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
+}