6d6a384bee
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>
465 lines
16 KiB
Go
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
|
|
}
|
|
|