201 lines
5.7 KiB
Go
201 lines
5.7 KiB
Go
// 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)
|
|
}
|
|
}
|