Files
galaxy-game/backend/internal/lobby/games.go
T
Ilia Denisov 6d6a384bee local-dev: auto-purge terminal Dev Sandbox games on every boot
Previously a cancelled / finished / start_failed sandbox game would
hang in the dev user's lobby until manually cleaned up — `make up`
would create a new running game alongside it but the dead tiles
piled up. Now backend's `devsandbox.Bootstrap` deletes every
terminal sandbox game owned by the dev user before find-or-create
runs, so the lobby always shows exactly one running tile.

Schema: `runtime_records` and `player_mappings` gain
`ON DELETE CASCADE` on their `game_id` foreign keys so a single
`DELETE FROM games` cleans every referencing row in one write.
Pre-prod migration rule applies — change goes into
`00001_init.sql`, not a new migration.

API: `lobby.Service.DeleteGame` is the new destructive helper that
backs the bootstrap purge. It bypasses the cancel-cascade-notify
pipeline; production callers must stay on the regular lifecycle.
The dev-sandbox docs in `tools/local-dev/README.md` spell out the
new behaviour.

Tests:
- backend/internal/lobby/lobby_e2e_test.go gains
  `TestDeleteGameCascadesEverything` proving CASCADE works
  end-to-end against a real Postgres testcontainer.
- backend/internal/devsandbox keeps its existing terminal-status
  contract test; the new `purgeTerminalSandboxGames` helper rides
  on the same `terminalSandboxStatus` predicate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 14:06:04 +02:00

465 lines
16 KiB
Go

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)
}
// DeleteGame removes the game and every referencing row (memberships,
// applications, invites, runtime_records, player_mappings) via the
// `ON DELETE CASCADE` constraints declared in `00001_init.sql`.
// Idempotent: returns nil when no game matches.
//
// Phase 14 introduces this method for the dev-sandbox bootstrap so a
// terminal "Dev Sandbox" tile from a previous local-dev session can
// be scrubbed before a fresh game spawns. Production callers must
// stay on the regular cancel / finish lifecycle — `DeleteGame` is
// destructive and bypasses the cascade-notification machinery.
func (s *Service) DeleteGame(ctx context.Context, gameID uuid.UUID) error {
if err := s.deps.Store.DeleteGame(ctx, gameID); err != nil {
return err
}
s.deps.Cache.RemoveGame(gameID)
return nil
}
// 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
}