feat: backend service
This commit is contained in:
@@ -0,0 +1,446 @@
|
||||
package lobby
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/cronutil"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// CreateGameInput is the parameter struct for Service.CreateGame.
|
||||
type CreateGameInput struct {
|
||||
OwnerUserID *uuid.UUID
|
||||
Visibility string
|
||||
GameName string
|
||||
Description string
|
||||
MinPlayers int32
|
||||
MaxPlayers int32
|
||||
StartGapHours int32
|
||||
StartGapPlayers int32
|
||||
EnrollmentEndsAt time.Time
|
||||
TurnSchedule string
|
||||
TargetEngineVersion string
|
||||
}
|
||||
|
||||
// Validate normalises the request and rejects malformed values. It is
|
||||
// called by Service.CreateGame before any Postgres write.
|
||||
func (in *CreateGameInput) Validate(now time.Time) error {
|
||||
in.GameName = strings.TrimSpace(in.GameName)
|
||||
in.TurnSchedule = strings.TrimSpace(in.TurnSchedule)
|
||||
in.TargetEngineVersion = strings.TrimSpace(in.TargetEngineVersion)
|
||||
if in.GameName == "" {
|
||||
return fmt.Errorf("%w: game_name must not be empty", ErrInvalidInput)
|
||||
}
|
||||
if in.Visibility != VisibilityPublic && in.Visibility != VisibilityPrivate {
|
||||
return fmt.Errorf("%w: visibility must be 'public' or 'private'", ErrInvalidInput)
|
||||
}
|
||||
if in.Visibility == VisibilityPrivate && in.OwnerUserID == nil {
|
||||
return fmt.Errorf("%w: private games require owner_user_id", ErrInvalidInput)
|
||||
}
|
||||
if in.Visibility == VisibilityPublic && in.OwnerUserID != nil {
|
||||
return fmt.Errorf("%w: public games must not carry an owner_user_id", ErrInvalidInput)
|
||||
}
|
||||
if in.MinPlayers <= 0 || in.MaxPlayers <= 0 {
|
||||
return fmt.Errorf("%w: min_players and max_players must be positive", ErrInvalidInput)
|
||||
}
|
||||
if in.MinPlayers > in.MaxPlayers {
|
||||
return fmt.Errorf("%w: min_players must not exceed max_players", ErrInvalidInput)
|
||||
}
|
||||
if in.StartGapHours < 0 || in.StartGapPlayers < 0 {
|
||||
return fmt.Errorf("%w: start_gap_hours and start_gap_players must be non-negative", ErrInvalidInput)
|
||||
}
|
||||
if in.EnrollmentEndsAt.Before(now) {
|
||||
return fmt.Errorf("%w: enrollment_ends_at must be in the future", ErrInvalidInput)
|
||||
}
|
||||
if in.TurnSchedule == "" {
|
||||
return fmt.Errorf("%w: turn_schedule must not be empty", ErrInvalidInput)
|
||||
}
|
||||
if _, err := cronutil.Parse(in.TurnSchedule); err != nil {
|
||||
return fmt.Errorf("%w: turn_schedule must parse as a five-field cron expression: %v", ErrInvalidInput, err)
|
||||
}
|
||||
if in.TargetEngineVersion == "" {
|
||||
return fmt.Errorf("%w: target_engine_version must not be empty", ErrInvalidInput)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateGame persists a fresh `draft` game and returns it. The caller
|
||||
// is responsible for setting OwnerUserID = nil (public games) or the
|
||||
// authenticated user_id (private games).
|
||||
func (s *Service) CreateGame(ctx context.Context, in CreateGameInput) (GameRecord, error) {
|
||||
now := s.deps.Now().UTC()
|
||||
if err := (&in).Validate(now); err != nil {
|
||||
return GameRecord{}, err
|
||||
}
|
||||
rec, err := s.deps.Store.InsertGame(ctx, gameInsert{
|
||||
GameID: uuid.New(),
|
||||
OwnerUserID: in.OwnerUserID,
|
||||
Visibility: in.Visibility,
|
||||
GameName: in.GameName,
|
||||
Description: in.Description,
|
||||
MinPlayers: in.MinPlayers,
|
||||
MaxPlayers: in.MaxPlayers,
|
||||
StartGapHours: in.StartGapHours,
|
||||
StartGapPlayers: in.StartGapPlayers,
|
||||
EnrollmentEndsAt: in.EnrollmentEndsAt.UTC(),
|
||||
TurnSchedule: in.TurnSchedule,
|
||||
TargetEngineVersion: in.TargetEngineVersion,
|
||||
})
|
||||
if err != nil {
|
||||
return GameRecord{}, err
|
||||
}
|
||||
s.deps.Cache.PutGame(rec)
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// UpdateGameInput is the parameter struct for Service.UpdateGame. Nil
|
||||
// pointers leave the corresponding column alone.
|
||||
type UpdateGameInput struct {
|
||||
GameName *string
|
||||
Description *string
|
||||
EnrollmentEndsAt *time.Time
|
||||
TurnSchedule *string
|
||||
TargetEngineVersion *string
|
||||
MinPlayers *int32
|
||||
MaxPlayers *int32
|
||||
StartGapHours *int32
|
||||
StartGapPlayers *int32
|
||||
}
|
||||
|
||||
// UpdateGame patches the supplied fields on a game. Only the owner of a
|
||||
// private game (or admin via callerIsAdmin=true) can run this.
|
||||
func (s *Service) UpdateGame(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID uuid.UUID, in UpdateGameInput) (GameRecord, error) {
|
||||
game, err := s.GetGame(ctx, gameID)
|
||||
if err != nil {
|
||||
return GameRecord{}, err
|
||||
}
|
||||
if err := s.checkOwner(game, callerUserID, callerIsAdmin); err != nil {
|
||||
return GameRecord{}, err
|
||||
}
|
||||
now := s.deps.Now().UTC()
|
||||
patch := gameUpdate{
|
||||
Description: in.Description,
|
||||
MinPlayers: in.MinPlayers,
|
||||
MaxPlayers: in.MaxPlayers,
|
||||
StartGapHours: in.StartGapHours,
|
||||
StartGapPlayers: in.StartGapPlayers,
|
||||
}
|
||||
if in.GameName != nil {
|
||||
trimmed := strings.TrimSpace(*in.GameName)
|
||||
if trimmed == "" {
|
||||
return GameRecord{}, fmt.Errorf("%w: game_name must not be empty", ErrInvalidInput)
|
||||
}
|
||||
patch.GameName = &trimmed
|
||||
}
|
||||
if in.TurnSchedule != nil {
|
||||
trimmed := strings.TrimSpace(*in.TurnSchedule)
|
||||
if trimmed == "" {
|
||||
return GameRecord{}, fmt.Errorf("%w: turn_schedule must not be empty", ErrInvalidInput)
|
||||
}
|
||||
if _, err := cronutil.Parse(trimmed); err != nil {
|
||||
return GameRecord{}, fmt.Errorf("%w: turn_schedule must parse: %v", ErrInvalidInput, err)
|
||||
}
|
||||
patch.TurnSchedule = &trimmed
|
||||
}
|
||||
if in.TargetEngineVersion != nil {
|
||||
trimmed := strings.TrimSpace(*in.TargetEngineVersion)
|
||||
if trimmed == "" {
|
||||
return GameRecord{}, fmt.Errorf("%w: target_engine_version must not be empty", ErrInvalidInput)
|
||||
}
|
||||
patch.TargetEngineVersion = &trimmed
|
||||
}
|
||||
if in.EnrollmentEndsAt != nil {
|
||||
t := in.EnrollmentEndsAt.UTC()
|
||||
patch.EnrollmentEndsAt = &t
|
||||
}
|
||||
if patch.MinPlayers != nil && patch.MaxPlayers != nil && *patch.MinPlayers > *patch.MaxPlayers {
|
||||
return GameRecord{}, fmt.Errorf("%w: min_players must not exceed max_players", ErrInvalidInput)
|
||||
}
|
||||
if patch.MinPlayers != nil && patch.MaxPlayers == nil && *patch.MinPlayers > game.MaxPlayers {
|
||||
return GameRecord{}, fmt.Errorf("%w: min_players must not exceed max_players", ErrInvalidInput)
|
||||
}
|
||||
if patch.MaxPlayers != nil && patch.MinPlayers == nil && *patch.MaxPlayers < game.MinPlayers {
|
||||
return GameRecord{}, fmt.Errorf("%w: max_players must not be less than min_players", ErrInvalidInput)
|
||||
}
|
||||
updated, err := s.deps.Store.UpdateGame(ctx, gameID, patch, now)
|
||||
if err != nil {
|
||||
return GameRecord{}, err
|
||||
}
|
||||
s.deps.Cache.PutGame(updated)
|
||||
_ = now
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// GetGame returns the game record for gameID. Cache-first; falls back
|
||||
// to Postgres on miss.
|
||||
func (s *Service) GetGame(ctx context.Context, gameID uuid.UUID) (GameRecord, error) {
|
||||
if rec, ok := s.deps.Cache.GetGame(gameID); ok {
|
||||
return rec, nil
|
||||
}
|
||||
rec, err := s.deps.Store.LoadGame(ctx, gameID)
|
||||
if err != nil {
|
||||
return GameRecord{}, err
|
||||
}
|
||||
s.deps.Cache.PutGame(rec)
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// ListPublicGames returns the requested page of public games.
|
||||
type GamePage struct {
|
||||
Items []GameRecord
|
||||
Page int
|
||||
PageSize int
|
||||
Total int
|
||||
}
|
||||
|
||||
func (s *Service) ListPublicGames(ctx context.Context, page, pageSize int) (GamePage, error) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 50
|
||||
}
|
||||
games, total, err := s.deps.Store.ListPublicGames(ctx, page, pageSize)
|
||||
if err != nil {
|
||||
return GamePage{}, err
|
||||
}
|
||||
return GamePage{Items: games, Page: page, PageSize: pageSize, Total: total}, nil
|
||||
}
|
||||
|
||||
// ListAdminGames returns the requested page of every game (admin view).
|
||||
func (s *Service) ListAdminGames(ctx context.Context, page, pageSize int) (GamePage, error) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 50
|
||||
}
|
||||
games, total, err := s.deps.Store.ListAdminGames(ctx, page, pageSize)
|
||||
if err != nil {
|
||||
return GamePage{}, err
|
||||
}
|
||||
return GamePage{Items: games, Page: page, PageSize: pageSize, Total: total}, nil
|
||||
}
|
||||
|
||||
// ListMyGames returns the games where the caller has an active
|
||||
// membership.
|
||||
func (s *Service) ListMyGames(ctx context.Context, userID uuid.UUID) ([]GameRecord, error) {
|
||||
return s.deps.Store.ListMyGames(ctx, userID)
|
||||
}
|
||||
|
||||
// State-machine transition handlers below take the same shape: load the
|
||||
// game (cache or store), check owner, validate the current status, run
|
||||
// the transition write, refresh the cache, optionally tell the runtime
|
||||
// gateway, and return the updated record.
|
||||
|
||||
// OpenEnrollment moves a `draft` game to `enrollment_open`.
|
||||
func (s *Service) OpenEnrollment(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID uuid.UUID) (GameRecord, error) {
|
||||
return s.transition(ctx, callerUserID, callerIsAdmin, gameID, transitionRule{
|
||||
From: []string{GameStatusDraft},
|
||||
To: GameStatusEnrollmentOpen,
|
||||
Reason: "open enrollment",
|
||||
Notification: nil,
|
||||
})
|
||||
}
|
||||
|
||||
// ReadyToStart moves an `enrollment_open` game to `ready_to_start`. The
|
||||
// transition succeeds only when the game has at least `min_players`
|
||||
// active memberships.
|
||||
func (s *Service) ReadyToStart(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID uuid.UUID) (GameRecord, error) {
|
||||
return s.transition(ctx, callerUserID, callerIsAdmin, gameID, transitionRule{
|
||||
From: []string{GameStatusEnrollmentOpen},
|
||||
To: GameStatusReadyToStart,
|
||||
Reason: "ready to start",
|
||||
Precondition: func(ctx context.Context, game GameRecord) error {
|
||||
active, err := s.deps.Store.CountActiveMemberships(ctx, game.GameID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if int32(active) < game.MinPlayers {
|
||||
return fmt.Errorf("%w: approved_count (%d) must be >= min_players (%d)", ErrConflict, active, game.MinPlayers)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Start kicks off the engine container; the lobby flips status to
|
||||
// `starting` and asks RuntimeGateway. The implementation will transition the
|
||||
// game to `running` via OnRuntimeSnapshot.
|
||||
func (s *Service) Start(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID uuid.UUID) (GameRecord, error) {
|
||||
return s.transition(ctx, callerUserID, callerIsAdmin, gameID, transitionRule{
|
||||
From: []string{GameStatusReadyToStart},
|
||||
To: GameStatusStarting,
|
||||
Reason: "start",
|
||||
PostCommit: func(ctx context.Context, game GameRecord) error {
|
||||
if err := s.deps.Runtime.StartGame(ctx, game.GameID); err != nil {
|
||||
return fmt.Errorf("runtime start: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Pause moves a `running` game to `paused`.
|
||||
func (s *Service) Pause(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID uuid.UUID) (GameRecord, error) {
|
||||
return s.transition(ctx, callerUserID, callerIsAdmin, gameID, transitionRule{
|
||||
From: []string{GameStatusRunning},
|
||||
To: GameStatusPaused,
|
||||
Reason: "pause",
|
||||
PostCommit: func(ctx context.Context, game GameRecord) error {
|
||||
return s.deps.Runtime.PauseGame(ctx, game.GameID)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Resume moves a `paused` game back to `running`.
|
||||
func (s *Service) Resume(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID uuid.UUID) (GameRecord, error) {
|
||||
return s.transition(ctx, callerUserID, callerIsAdmin, gameID, transitionRule{
|
||||
From: []string{GameStatusPaused},
|
||||
To: GameStatusRunning,
|
||||
Reason: "resume",
|
||||
PostCommit: func(ctx context.Context, game GameRecord) error {
|
||||
return s.deps.Runtime.ResumeGame(ctx, game.GameID)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Cancel moves any non-terminal game to `cancelled`. The runtime is
|
||||
// asked to stop a running container if any.
|
||||
func (s *Service) Cancel(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID uuid.UUID) (GameRecord, error) {
|
||||
return s.transition(ctx, callerUserID, callerIsAdmin, gameID, transitionRule{
|
||||
From: []string{
|
||||
GameStatusDraft, GameStatusEnrollmentOpen, GameStatusReadyToStart,
|
||||
GameStatusStarting, GameStatusStartFailed, GameStatusRunning, GameStatusPaused,
|
||||
},
|
||||
To: GameStatusCancelled,
|
||||
Reason: "cancel",
|
||||
PostCommit: func(ctx context.Context, game GameRecord) error {
|
||||
switch game.Status {
|
||||
case GameStatusRunning, GameStatusPaused, GameStatusStarting:
|
||||
return s.deps.Runtime.StopGame(ctx, game.GameID)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// RetryStart moves a `start_failed` game back to `ready_to_start` so a
|
||||
// subsequent /start call can re-attempt the runtime job.
|
||||
func (s *Service) RetryStart(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID uuid.UUID) (GameRecord, error) {
|
||||
return s.transition(ctx, callerUserID, callerIsAdmin, gameID, transitionRule{
|
||||
From: []string{GameStatusStartFailed},
|
||||
To: GameStatusReadyToStart,
|
||||
Reason: "retry start",
|
||||
})
|
||||
}
|
||||
|
||||
// AdminForceStart moves any pre-running game to `starting`, bypassing
|
||||
// the owner-only and min_players precondition checks.
|
||||
func (s *Service) AdminForceStart(ctx context.Context, gameID uuid.UUID) (GameRecord, error) {
|
||||
return s.transition(ctx, nil, true, gameID, transitionRule{
|
||||
From: []string{
|
||||
GameStatusDraft, GameStatusEnrollmentOpen, GameStatusReadyToStart,
|
||||
GameStatusStartFailed,
|
||||
},
|
||||
To: GameStatusStarting,
|
||||
Reason: "admin force-start",
|
||||
PostCommit: func(ctx context.Context, game GameRecord) error {
|
||||
return s.deps.Runtime.StartGame(ctx, game.GameID)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// AdminForceStop moves a running/paused game to `cancelled`.
|
||||
func (s *Service) AdminForceStop(ctx context.Context, gameID uuid.UUID) (GameRecord, error) {
|
||||
return s.transition(ctx, nil, true, gameID, transitionRule{
|
||||
From: []string{GameStatusRunning, GameStatusPaused, GameStatusStarting},
|
||||
To: GameStatusCancelled,
|
||||
Reason: "admin force-stop",
|
||||
PostCommit: func(ctx context.Context, game GameRecord) error {
|
||||
return s.deps.Runtime.StopGame(ctx, game.GameID)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// transitionRule captures the inputs to Service.transition so the
|
||||
// per-handler code stays declarative. From is the set of statuses the
|
||||
// transition accepts; To is the target status. Precondition runs
|
||||
// before the write (e.g., approved_count >= min_players); PostCommit
|
||||
// runs after a successful write/cache update (e.g., RuntimeGateway).
|
||||
// Errors from PostCommit are joined into the returned error so the
|
||||
// caller can decide whether to surface them; the canonical state
|
||||
// remains the post-commit row.
|
||||
type transitionRule struct {
|
||||
From []string
|
||||
To string
|
||||
Reason string
|
||||
Precondition func(ctx context.Context, game GameRecord) error
|
||||
PostCommit func(ctx context.Context, game GameRecord) error
|
||||
Notification *LobbyNotification
|
||||
}
|
||||
|
||||
func (s *Service) transition(ctx context.Context, callerUserID *uuid.UUID, callerIsAdmin bool, gameID uuid.UUID, rule transitionRule) (GameRecord, error) {
|
||||
game, err := s.GetGame(ctx, gameID)
|
||||
if err != nil {
|
||||
return GameRecord{}, err
|
||||
}
|
||||
if err := s.checkOwner(game, callerUserID, callerIsAdmin); err != nil {
|
||||
return GameRecord{}, err
|
||||
}
|
||||
if !slices.Contains(rule.From, game.Status) {
|
||||
return GameRecord{}, fmt.Errorf("%w: cannot %s game in status %q", ErrConflict, rule.Reason, game.Status)
|
||||
}
|
||||
if rule.Precondition != nil {
|
||||
if err := rule.Precondition(ctx, game); err != nil {
|
||||
return GameRecord{}, err
|
||||
}
|
||||
}
|
||||
now := s.deps.Now().UTC()
|
||||
upd := statusUpdate{NewStatus: rule.To, UpdatedAt: now}
|
||||
switch rule.To {
|
||||
case GameStatusRunning:
|
||||
if game.StartedAt == nil {
|
||||
upd.SetStarted = true
|
||||
upd.StartedAt = now
|
||||
}
|
||||
case GameStatusFinished:
|
||||
upd.SetFinished = true
|
||||
upd.FinishedAt = now
|
||||
}
|
||||
updated, err := s.deps.Store.UpdateGameStatus(ctx, gameID, upd)
|
||||
if err != nil {
|
||||
return GameRecord{}, err
|
||||
}
|
||||
s.deps.Cache.PutGame(updated)
|
||||
if rule.PostCommit != nil {
|
||||
if err := rule.PostCommit(ctx, updated); err != nil {
|
||||
return updated, fmt.Errorf("post-commit %s: %w", rule.Reason, err)
|
||||
}
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// checkOwner enforces ownership semantics:
|
||||
//
|
||||
// - callerIsAdmin == true → always allowed (admin force-start, etc.).
|
||||
// - private games → callerUserID must equal game.OwnerUserID.
|
||||
// - public games → callerIsAdmin is required.
|
||||
func (s *Service) checkOwner(game GameRecord, callerUserID *uuid.UUID, callerIsAdmin bool) error {
|
||||
if callerIsAdmin {
|
||||
return nil
|
||||
}
|
||||
if game.Visibility == VisibilityPublic {
|
||||
return fmt.Errorf("%w: public games require admin authority", ErrForbidden)
|
||||
}
|
||||
if callerUserID == nil || game.OwnerUserID == nil || *game.OwnerUserID != *callerUserID {
|
||||
return fmt.Errorf("%w: caller is not the owner", ErrForbidden)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user