// Package creategame implements the `lobby.game.create` message type. It // validates the request, enforces the actor-to-game-type pairing rule // (admin creates public games; user creates private games), generates a new // game identifier, and persists the record in `draft` status via the // GameStore port. package creategame import ( "context" "errors" "fmt" "log/slog" "time" "galaxy/lobby/internal/domain/game" "galaxy/lobby/internal/ports" "galaxy/lobby/internal/service/shared" ) // Service executes the create-game use case on the frozen surface. type Service struct { games ports.GameStore ids ports.IDGenerator clock func() time.Time logger *slog.Logger } // Dependencies groups the collaborators used by Service. Logger is optional; // nil falls back to slog.Default. Clock is optional; nil falls back to // time.Now. type Dependencies struct { // Games persists the created record. Games ports.GameStore // IDs generates the opaque game identifier. IDs ports.IDGenerator // Clock supplies the wall-clock used for CreatedAt and UpdatedAt. It // defaults to time.Now when nil. Clock func() time.Time // Logger records structured service-level events. It defaults to // slog.Default when nil. Logger *slog.Logger } // NewService constructs one Service with deps. It returns an error when // mandatory collaborators are missing. func NewService(deps Dependencies) (*Service, error) { if deps.Games == nil { return nil, errors.New("new create game service: nil game store") } if deps.IDs == nil { return nil, errors.New("new create game service: nil id generator") } clock := deps.Clock if clock == nil { clock = time.Now } logger := deps.Logger if logger == nil { logger = slog.Default() } return &Service{ games: deps.Games, ids: deps.IDs, clock: clock, logger: logger.With("service", "lobby.creategame"), }, nil } // Input stores the arguments required to create one draft game record. type Input struct { // Actor identifies the caller. Actor shared.Actor // GameName stores the human-readable game name. Must be non-empty after // trim. GameName string // Description stores the optional human-readable game description. Description string // GameType stores the admission model. Must pair with Actor.Kind: // admin ⇒ public, user ⇒ private. GameType game.GameType // MinPlayers stores the minimum approved participants required before // the game may start. MinPlayers int // MaxPlayers stores the target roster size that activates the gap // window. MaxPlayers int // StartGapHours stores the gap window length in hours. StartGapHours int // StartGapPlayers stores the number of additional participants // admitted during the gap window. StartGapPlayers int // EnrollmentEndsAt stores the enrollment deadline. EnrollmentEndsAt time.Time // TurnSchedule stores the five-field cron expression. TurnSchedule string // TargetEngineVersion stores the semver of the engine to launch. TargetEngineVersion string } // Handle validates input, authorizes the actor, and persists the new draft // record. On success it returns the persisted game. The following error // classes are surfaced and relied on by transport mapping: // // - shared.ErrForbidden — actor-to-game-type pairing mismatch. // - game input validation errors from game.New — translated to // invalid_request at the transport layer. func (service *Service) Handle(ctx context.Context, input Input) (game.Game, error) { if service == nil { return game.Game{}, errors.New("create game: nil service") } if ctx == nil { return game.Game{}, errors.New("create game: nil context") } if err := input.Actor.Validate(); err != nil { return game.Game{}, fmt.Errorf("create game: actor: %w", err) } ownerUserID, err := authorize(input.Actor, input.GameType) if err != nil { return game.Game{}, err } gameID, err := service.ids.NewGameID() if err != nil { return game.Game{}, fmt.Errorf("create game: %w", err) } now := service.clock().UTC() record, err := game.New(game.NewGameInput{ GameID: gameID, GameName: input.GameName, Description: input.Description, GameType: input.GameType, OwnerUserID: ownerUserID, MinPlayers: input.MinPlayers, MaxPlayers: input.MaxPlayers, StartGapHours: input.StartGapHours, StartGapPlayers: input.StartGapPlayers, EnrollmentEndsAt: input.EnrollmentEndsAt, TurnSchedule: input.TurnSchedule, TargetEngineVersion: input.TargetEngineVersion, Now: now, }) if err != nil { return game.Game{}, fmt.Errorf("create game: %w", err) } if err := service.games.Save(ctx, record); err != nil { return game.Game{}, fmt.Errorf("create game: %w", err) } service.logger.InfoContext(ctx, "game created", "game_id", record.GameID.String(), "game_type", string(record.GameType), "actor_kind", string(input.Actor.Kind), ) return record, nil } // authorize enforces the actor-to-game-type pairing rule and returns the // owner_user_id value appropriate for the requested game type. func authorize(actor shared.Actor, gameType game.GameType) (string, error) { switch actor.Kind { case shared.ActorKindAdmin: if gameType != game.GameTypePublic { return "", fmt.Errorf( "%w: admin caller may only create public games, got %q", shared.ErrForbidden, gameType, ) } return "", nil case shared.ActorKindUser: if gameType != game.GameTypePrivate { return "", fmt.Errorf( "%w: user caller may only create private games, got %q", shared.ErrForbidden, gameType, ) } return actor.UserID, nil default: return "", fmt.Errorf("%w: actor kind %q is unsupported", shared.ErrForbidden, actor.Kind) } }