Files
Ilia Denisov 362f92e520
Tests · Go / test (pull_request) Successful in 1m59s
Tests · Go / test (push) Successful in 1m59s
Tests · Integration / integration (pull_request) Successful in 1m36s
diplomail (Stage C): paid-tier broadcast + multi-game + cleanup
Closes out the producer-side of the diplomail surface. Paid-tier
players can fan out one personal message to the rest of the active
roster (gated on entitlement_snapshots.is_paid). Site admins gain a
multi-game broadcast (POST /admin/mail/broadcast with `selected` /
`all_running` scopes) and the bulk-purge endpoint that wipes
diplomail rows tied to games finished more than N years ago. An
admin listing (GET /admin/mail/messages) rounds out the
observability surface.

EntitlementReader and GameLookup are new narrow deps wired from
`*user.Service` and `*lobby.Service` in cmd/backend/main; the lobby
service grows a one-off `ListFinishedGamesBefore` helper for the
cleanup path (the cache evicts terminal-state games so the cache
walk is not enough). Stage D will swap LangUndetermined for an
actual body-language detector and add the translation cache.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:02:46 +02:00

535 lines
18 KiB
Go

package lobby
import (
"context"
"fmt"
"slices"
"strings"
"time"
"galaxy/cronutil"
"github.com/google/uuid"
"go.uber.org/zap"
)
// 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)
}
// ListFinishedGamesBefore returns every game whose status is
// `finished` or `cancelled` and whose `finished_at` is strictly older
// than cutoff. The result walks the store through the admin-paged
// query with a 200-row batch size; the caller is expected to invoke
// this from rare admin workflows (diplomail bulk cleanup) rather
// than hot-path reads.
func (s *Service) ListFinishedGamesBefore(ctx context.Context, cutoff time.Time) ([]GameRecord, error) {
const pageSize = 200
page := 1
var out []GameRecord
for {
batch, _, err := s.deps.Store.ListAdminGames(ctx, page, pageSize)
if err != nil {
return nil, fmt.Errorf("lobby: list finished games before %s: %w", cutoff, err)
}
if len(batch) == 0 {
break
}
for _, g := range batch {
if g.Status != GameStatusFinished && g.Status != GameStatusCancelled {
continue
}
if g.FinishedAt == nil || !g.FinishedAt.Before(cutoff) {
continue
}
out = append(out, g)
}
if len(batch) < pageSize {
break
}
page++
}
return out, nil
}
// 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)
}
}
s.emitGameLifecycleMail(ctx, updated, callerIsAdmin, rule)
return updated, nil
}
// emitGameLifecycleMail asks the diplomail publisher to drop a
// system-mail entry whenever a state change is user-visible. Only
// the `paused` and `cancelled` transitions emit mail today (the spec
// names them explicitly); `running`/`finished`/etc. are signalled by
// other channels and do not need a durable inbox entry.
func (s *Service) emitGameLifecycleMail(ctx context.Context, game GameRecord, callerIsAdmin bool, rule transitionRule) {
var kind string
switch rule.To {
case GameStatusPaused:
kind = LifecycleKindGamePaused
case GameStatusCancelled:
kind = LifecycleKindGameCancelled
default:
return
}
actor := "the game owner"
if callerIsAdmin {
actor = "an administrator"
}
ev := LifecycleEvent{
GameID: game.GameID,
Kind: kind,
Actor: actor,
Reason: rule.Reason,
}
if err := s.deps.Diplomail.PublishLifecycle(ctx, ev); err != nil {
s.deps.Logger.Warn("publish lifecycle mail failed",
zap.String("game_id", game.GameID.String()),
zap.String("kind", kind),
zap.Error(err))
}
}
// 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
}