feat: game lobby service
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
package creategame_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/adapters/gamestub"
|
||||
"galaxy/lobby/internal/adapters/idgen"
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
"galaxy/lobby/internal/domain/game"
|
||||
"galaxy/lobby/internal/ports"
|
||||
"galaxy/lobby/internal/service/creategame"
|
||||
"galaxy/lobby/internal/service/shared"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type stubIDGenerator struct {
|
||||
next common.GameID
|
||||
err error
|
||||
}
|
||||
|
||||
func (g *stubIDGenerator) NewGameID() (common.GameID, error) {
|
||||
if g.err != nil {
|
||||
return "", g.err
|
||||
}
|
||||
return g.next, nil
|
||||
}
|
||||
|
||||
func (g *stubIDGenerator) NewApplicationID() (common.ApplicationID, error) {
|
||||
return "application-stub", nil
|
||||
}
|
||||
|
||||
func (g *stubIDGenerator) NewInviteID() (common.InviteID, error) {
|
||||
return "invite-stub", nil
|
||||
}
|
||||
|
||||
func (g *stubIDGenerator) NewMembershipID() (common.MembershipID, error) {
|
||||
return "membership-stub", nil
|
||||
}
|
||||
|
||||
func newFixedClock(at time.Time) func() time.Time {
|
||||
return func() time.Time { return at }
|
||||
}
|
||||
|
||||
func silentLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func validPublicInput(now time.Time) creategame.Input {
|
||||
return creategame.Input{
|
||||
Actor: shared.NewAdminActor(),
|
||||
GameName: "Spring Classic",
|
||||
Description: "",
|
||||
GameType: game.GameTypePublic,
|
||||
MinPlayers: 2,
|
||||
MaxPlayers: 4,
|
||||
StartGapHours: 4,
|
||||
StartGapPlayers: 1,
|
||||
EnrollmentEndsAt: now.Add(24 * time.Hour),
|
||||
TurnSchedule: "0 */6 * * *",
|
||||
TargetEngineVersion: "1.0.0",
|
||||
}
|
||||
}
|
||||
|
||||
func validPrivateInput(now time.Time, userID string) creategame.Input {
|
||||
return creategame.Input{
|
||||
Actor: shared.NewUserActor(userID),
|
||||
GameName: "Friends only",
|
||||
GameType: game.GameTypePrivate,
|
||||
MinPlayers: 2,
|
||||
MaxPlayers: 4,
|
||||
StartGapHours: 4,
|
||||
StartGapPlayers: 1,
|
||||
EnrollmentEndsAt: now.Add(12 * time.Hour),
|
||||
TurnSchedule: "0 0 * * *",
|
||||
TargetEngineVersion: "1.0.0",
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServiceRequiresStoreAndIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := creategame.NewService(creategame.Dependencies{})
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = creategame.NewService(creategame.Dependencies{Games: gamestub.NewStore()})
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = creategame.NewService(creategame.Dependencies{
|
||||
Games: gamestub.NewStore(),
|
||||
IDs: &stubIDGenerator{next: "game-ok"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestHandleAdminCreatesPublicGame(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
service, err := creategame.NewService(creategame.Dependencies{
|
||||
Games: store,
|
||||
IDs: &stubIDGenerator{next: "game-alpha"},
|
||||
Clock: newFixedClock(now),
|
||||
Logger: silentLogger(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
record, err := service.Handle(context.Background(), validPublicInput(now))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, common.GameID("game-alpha"), record.GameID)
|
||||
require.Equal(t, game.GameTypePublic, record.GameType)
|
||||
require.Equal(t, game.StatusDraft, record.Status)
|
||||
require.Equal(t, "", record.OwnerUserID)
|
||||
require.Equal(t, now.UTC(), record.CreatedAt)
|
||||
require.Equal(t, now.UTC(), record.UpdatedAt)
|
||||
|
||||
stored, err := store.Get(context.Background(), record.GameID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, record, stored)
|
||||
}
|
||||
|
||||
func TestHandleUserCreatesPrivateGame(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 11, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
service, err := creategame.NewService(creategame.Dependencies{
|
||||
Games: store,
|
||||
IDs: &stubIDGenerator{next: "game-beta"},
|
||||
Clock: newFixedClock(now),
|
||||
Logger: silentLogger(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
record, err := service.Handle(context.Background(), validPrivateInput(now, "user-42"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, common.GameID("game-beta"), record.GameID)
|
||||
require.Equal(t, game.GameTypePrivate, record.GameType)
|
||||
require.Equal(t, "user-42", record.OwnerUserID)
|
||||
}
|
||||
|
||||
func TestHandleAdminForbiddenForPrivateGame(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
service, err := creategame.NewService(creategame.Dependencies{
|
||||
Games: gamestub.NewStore(),
|
||||
IDs: &stubIDGenerator{next: "game-x"},
|
||||
Clock: newFixedClock(now),
|
||||
Logger: silentLogger(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
input := validPrivateInput(now, "user-1")
|
||||
input.Actor = shared.NewAdminActor()
|
||||
|
||||
_, err = service.Handle(context.Background(), input)
|
||||
require.ErrorIs(t, err, shared.ErrForbidden)
|
||||
}
|
||||
|
||||
func TestHandleUserForbiddenForPublicGame(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
service, err := creategame.NewService(creategame.Dependencies{
|
||||
Games: gamestub.NewStore(),
|
||||
IDs: &stubIDGenerator{next: "game-x"},
|
||||
Clock: newFixedClock(now),
|
||||
Logger: silentLogger(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
input := validPublicInput(now)
|
||||
input.Actor = shared.NewUserActor("user-1")
|
||||
|
||||
_, err = service.Handle(context.Background(), input)
|
||||
require.ErrorIs(t, err, shared.ErrForbidden)
|
||||
}
|
||||
|
||||
func TestHandleInvalidActorReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
service, err := creategame.NewService(creategame.Dependencies{
|
||||
Games: gamestub.NewStore(),
|
||||
IDs: &stubIDGenerator{next: "game-x"},
|
||||
Clock: newFixedClock(now),
|
||||
Logger: silentLogger(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
input := validPublicInput(now)
|
||||
input.Actor = shared.Actor{Kind: shared.ActorKindUser} // missing user id
|
||||
|
||||
_, err = service.Handle(context.Background(), input)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "actor")
|
||||
}
|
||||
|
||||
func TestHandleDomainValidationFailurePropagates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
service, err := creategame.NewService(creategame.Dependencies{
|
||||
Games: gamestub.NewStore(),
|
||||
IDs: &stubIDGenerator{next: "game-bad-cron"},
|
||||
Clock: newFixedClock(now),
|
||||
Logger: silentLogger(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
input := validPublicInput(now)
|
||||
input.TurnSchedule = "not a cron"
|
||||
|
||||
_, err = service.Handle(context.Background(), input)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "turn schedule")
|
||||
}
|
||||
|
||||
func TestHandleEnrollmentDeadlineInPastFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
service, err := creategame.NewService(creategame.Dependencies{
|
||||
Games: gamestub.NewStore(),
|
||||
IDs: &stubIDGenerator{next: "game-past"},
|
||||
Clock: newFixedClock(now),
|
||||
Logger: silentLogger(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
input := validPublicInput(now)
|
||||
input.EnrollmentEndsAt = now.Add(-time.Hour)
|
||||
|
||||
_, err = service.Handle(context.Background(), input)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "enrollment ends at")
|
||||
}
|
||||
|
||||
func TestHandleIDGeneratorErrorPropagates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
boom := errors.New("entropy exhausted")
|
||||
service, err := creategame.NewService(creategame.Dependencies{
|
||||
Games: gamestub.NewStore(),
|
||||
IDs: &stubIDGenerator{err: boom},
|
||||
Clock: newFixedClock(now),
|
||||
Logger: silentLogger(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Handle(context.Background(), validPublicInput(now))
|
||||
require.ErrorIs(t, err, boom)
|
||||
}
|
||||
|
||||
func TestHandleStoreErrorPropagates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 10, 0, 0, 0, time.UTC)
|
||||
boom := errors.New("redis down")
|
||||
service, err := creategame.NewService(creategame.Dependencies{
|
||||
Games: failingStore{err: boom},
|
||||
IDs: &stubIDGenerator{next: "game-fail"},
|
||||
Clock: newFixedClock(now),
|
||||
Logger: silentLogger(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Handle(context.Background(), validPublicInput(now))
|
||||
require.ErrorIs(t, err, boom)
|
||||
}
|
||||
|
||||
// failingStore is a ports.GameStore whose mutating methods always fail.
|
||||
type failingStore struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (s failingStore) Save(context.Context, game.Game) error { return s.err }
|
||||
func (s failingStore) Get(context.Context, common.GameID) (game.Game, error) {
|
||||
return game.Game{}, s.err
|
||||
}
|
||||
func (s failingStore) GetByStatus(context.Context, game.Status) ([]game.Game, error) {
|
||||
return nil, s.err
|
||||
}
|
||||
func (s failingStore) GetByOwner(context.Context, string) ([]game.Game, error) {
|
||||
return nil, s.err
|
||||
}
|
||||
func (s failingStore) UpdateStatus(context.Context, ports.UpdateStatusInput) error {
|
||||
return s.err
|
||||
}
|
||||
func (s failingStore) UpdateRuntimeSnapshot(context.Context, ports.UpdateRuntimeSnapshotInput) error {
|
||||
return s.err
|
||||
}
|
||||
func (s failingStore) UpdateRuntimeBinding(context.Context, ports.UpdateRuntimeBindingInput) error {
|
||||
return s.err
|
||||
}
|
||||
func (s failingStore) CountByStatus(context.Context) (map[game.Status]int, error) {
|
||||
return nil, s.err
|
||||
}
|
||||
|
||||
func TestHandleUsesRealIDGeneratorShape(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Date(2026, 4, 24, 12, 0, 0, 0, time.UTC)
|
||||
store := gamestub.NewStore()
|
||||
service, err := creategame.NewService(creategame.Dependencies{
|
||||
Games: store,
|
||||
IDs: idgen.NewGenerator(),
|
||||
Clock: newFixedClock(now),
|
||||
Logger: silentLogger(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
record, err := service.Handle(context.Background(), validPublicInput(now))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, record.GameID.Validate())
|
||||
}
|
||||
Reference in New Issue
Block a user