feat: game lobby service
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ErrNotFound reports that a game record was requested but does not exist
|
||||
// in the store.
|
||||
var ErrNotFound = errors.New("game not found")
|
||||
|
||||
// ErrConflict reports that a game mutation could not be applied because the
|
||||
// record changed concurrently or failed a compare-and-swap guard.
|
||||
var ErrConflict = errors.New("game conflict")
|
||||
|
||||
// ErrInvalidTransition is the sentinel returned when Transition rejects a
|
||||
// `(from, to, trigger)` triplet.
|
||||
var ErrInvalidTransition = errors.New("invalid game status transition")
|
||||
|
||||
// InvalidTransitionError stores the rejected `(from, to, trigger)` triplet
|
||||
// and wraps ErrInvalidTransition so callers can match it with errors.Is.
|
||||
type InvalidTransitionError struct {
|
||||
// From stores the source status that was attempted to leave.
|
||||
From Status
|
||||
|
||||
// To stores the destination status that was attempted to enter.
|
||||
To Status
|
||||
|
||||
// Trigger stores the transition trigger that was attempted.
|
||||
Trigger Trigger
|
||||
}
|
||||
|
||||
// Error reports a human-readable summary of the rejected triplet.
|
||||
func (err *InvalidTransitionError) Error() string {
|
||||
return fmt.Sprintf(
|
||||
"invalid game status transition from %q to %q with trigger %q",
|
||||
err.From, err.To, err.Trigger,
|
||||
)
|
||||
}
|
||||
|
||||
// Unwrap returns ErrInvalidTransition so errors.Is recognizes the sentinel.
|
||||
func (err *InvalidTransitionError) Unwrap() error {
|
||||
return ErrInvalidTransition
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
// Package game defines the game record domain model, status machine, and
|
||||
// sentinel errors owned by Game Lobby Service.
|
||||
package game
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
|
||||
cron "github.com/robfig/cron/v3"
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
// GameType identifies the admission model of one game record.
|
||||
type GameType string
|
||||
|
||||
const (
|
||||
// GameTypePublic reports that the game uses the application flow and
|
||||
// is administered by system administrators.
|
||||
GameTypePublic GameType = "public"
|
||||
|
||||
// GameTypePrivate reports that the game uses the invite flow and is
|
||||
// administered by its owner.
|
||||
GameTypePrivate GameType = "private"
|
||||
)
|
||||
|
||||
// IsKnown reports whether value belongs to the frozen game type
|
||||
// vocabulary.
|
||||
func (value GameType) IsKnown() bool {
|
||||
switch value {
|
||||
case GameTypePublic, GameTypePrivate:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// RuntimeSnapshot stores the denormalized runtime snapshot imported from
|
||||
// Game Master.
|
||||
type RuntimeSnapshot struct {
|
||||
// CurrentTurn stores the last observed turn number. Zero means not
|
||||
// yet running.
|
||||
CurrentTurn int
|
||||
|
||||
// RuntimeStatus stores the last observed runtime status string from
|
||||
// Game Master. Empty means not yet running.
|
||||
RuntimeStatus string
|
||||
|
||||
// EngineHealthSummary stores the last observed engine health summary
|
||||
// string from Game Master. Empty means not yet running.
|
||||
EngineHealthSummary string
|
||||
}
|
||||
|
||||
// RuntimeBinding stores the runtime binding metadata produced by Runtime
|
||||
// Manager after a successful container start. The binding is required to
|
||||
// register the running game with Game Master and to correlate the game
|
||||
// record with the source job-result event for audit purposes.
|
||||
type RuntimeBinding struct {
|
||||
// ContainerID identifies the engine container assigned by Runtime
|
||||
// Manager.
|
||||
ContainerID string
|
||||
|
||||
// EngineEndpoint stores the network address Game Master uses to
|
||||
// reach the engine container.
|
||||
EngineEndpoint string
|
||||
|
||||
// RuntimeJobID stores the source `runtime:job_results` Redis Stream
|
||||
// message id (in `<ms>-<seq>` form) that produced this binding. It
|
||||
// gives operators a back-reference from the game record to the
|
||||
// originating Runtime Manager event when investigating incidents.
|
||||
RuntimeJobID string
|
||||
|
||||
// BoundAt stores when the binding was persisted.
|
||||
BoundAt time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether binding contains the structural invariants
|
||||
// required for a runtime binding produced by success-path
|
||||
// processing.
|
||||
func (binding RuntimeBinding) Validate() error {
|
||||
if strings.TrimSpace(binding.ContainerID) == "" {
|
||||
return fmt.Errorf("runtime binding container id must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(binding.EngineEndpoint) == "" {
|
||||
return fmt.Errorf("runtime binding engine endpoint must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(binding.RuntimeJobID) == "" {
|
||||
return fmt.Errorf("runtime binding runtime job id must not be empty")
|
||||
}
|
||||
if binding.BoundAt.IsZero() {
|
||||
return fmt.Errorf("runtime binding bound at must not be zero")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Game stores one durable game record owned by Game Lobby Service.
|
||||
type Game struct {
|
||||
// GameID identifies the record.
|
||||
GameID common.GameID
|
||||
|
||||
// GameName stores the human-readable game name.
|
||||
GameName string
|
||||
|
||||
// Description stores the optional human-readable description.
|
||||
Description string
|
||||
|
||||
// GameType stores the admission model.
|
||||
GameType GameType
|
||||
|
||||
// OwnerUserID stores the platform user id of the private-game owner.
|
||||
// It must be empty for public games.
|
||||
OwnerUserID string
|
||||
|
||||
// Status stores the current lifecycle state.
|
||||
Status Status
|
||||
|
||||
// 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 length of the gap window in hours after
|
||||
// max_players is reached.
|
||||
StartGapHours int
|
||||
|
||||
// StartGapPlayers stores the number of additional participants
|
||||
// admitted during the gap window.
|
||||
StartGapPlayers int
|
||||
|
||||
// EnrollmentEndsAt stores the UTC deadline at which enrollment closes
|
||||
// automatically.
|
||||
EnrollmentEndsAt time.Time
|
||||
|
||||
// TurnSchedule stores the five-field cron expression passed to
|
||||
// Game Master at registration.
|
||||
TurnSchedule string
|
||||
|
||||
// TargetEngineVersion stores the semver string of the engine to
|
||||
// launch.
|
||||
TargetEngineVersion string
|
||||
|
||||
// CreatedAt stores when the record was created.
|
||||
CreatedAt time.Time
|
||||
|
||||
// UpdatedAt stores when the record was last mutated.
|
||||
UpdatedAt time.Time
|
||||
|
||||
// StartedAt stores when the record entered the running status.
|
||||
StartedAt *time.Time
|
||||
|
||||
// FinishedAt stores when the record entered the finished status.
|
||||
FinishedAt *time.Time
|
||||
|
||||
// RuntimeSnapshot stores the denormalized runtime snapshot from
|
||||
// Game Master.
|
||||
RuntimeSnapshot RuntimeSnapshot
|
||||
|
||||
// RuntimeBinding stores the runtime binding metadata produced by
|
||||
// Runtime Manager after a successful container start. It is nil
|
||||
// before the start succeeds and non-nil afterwards.
|
||||
RuntimeBinding *RuntimeBinding
|
||||
}
|
||||
|
||||
// NewGameInput groups all fields required to create a draft game record.
|
||||
type NewGameInput struct {
|
||||
// GameID identifies the draft record.
|
||||
GameID common.GameID
|
||||
|
||||
// GameName stores the human-readable game name.
|
||||
GameName string
|
||||
|
||||
// Description stores the optional human-readable description.
|
||||
Description string
|
||||
|
||||
// GameType stores the admission model.
|
||||
GameType GameType
|
||||
|
||||
// OwnerUserID stores the owner id for private games. It must be empty
|
||||
// for public games.
|
||||
OwnerUserID string
|
||||
|
||||
// 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
|
||||
|
||||
// Now stores the creation wall-clock used for CreatedAt and
|
||||
// UpdatedAt.
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
// standardCronParser parses the frozen five-field cron expression grammar
|
||||
// used by turn_schedule.
|
||||
var standardCronParser = cron.NewParser(
|
||||
cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow,
|
||||
)
|
||||
|
||||
// New validates input and returns a draft Game record. Validation errors
|
||||
// are returned verbatim so callers can surface them as invalid_request.
|
||||
func New(input NewGameInput) (Game, error) {
|
||||
if err := input.Validate(); err != nil {
|
||||
return Game{}, err
|
||||
}
|
||||
|
||||
record := Game{
|
||||
GameID: input.GameID,
|
||||
GameName: input.GameName,
|
||||
Description: input.Description,
|
||||
GameType: input.GameType,
|
||||
OwnerUserID: input.OwnerUserID,
|
||||
Status: StatusDraft,
|
||||
MinPlayers: input.MinPlayers,
|
||||
MaxPlayers: input.MaxPlayers,
|
||||
StartGapHours: input.StartGapHours,
|
||||
StartGapPlayers: input.StartGapPlayers,
|
||||
EnrollmentEndsAt: input.EnrollmentEndsAt.UTC(),
|
||||
TurnSchedule: input.TurnSchedule,
|
||||
TargetEngineVersion: input.TargetEngineVersion,
|
||||
CreatedAt: input.Now.UTC(),
|
||||
UpdatedAt: input.Now.UTC(),
|
||||
}
|
||||
|
||||
if err := record.Validate(); err != nil {
|
||||
return Game{}, err
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// Validate reports whether input satisfies the frozen game-record
|
||||
// invariants required to construct a draft record.
|
||||
func (input NewGameInput) Validate() error {
|
||||
if err := input.GameID.Validate(); err != nil {
|
||||
return fmt.Errorf("game id: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(input.GameName) == "" {
|
||||
return fmt.Errorf("game name must not be empty")
|
||||
}
|
||||
if !input.GameType.IsKnown() {
|
||||
return fmt.Errorf("game type %q is unsupported", input.GameType)
|
||||
}
|
||||
switch input.GameType {
|
||||
case GameTypePrivate:
|
||||
if strings.TrimSpace(input.OwnerUserID) == "" {
|
||||
return fmt.Errorf("owner user id must not be empty for private games")
|
||||
}
|
||||
case GameTypePublic:
|
||||
if input.OwnerUserID != "" {
|
||||
return fmt.Errorf("owner user id must be empty for public games")
|
||||
}
|
||||
}
|
||||
if input.MinPlayers <= 0 {
|
||||
return fmt.Errorf("min players must be positive")
|
||||
}
|
||||
if input.MaxPlayers <= 0 {
|
||||
return fmt.Errorf("max players must be positive")
|
||||
}
|
||||
if input.MaxPlayers < input.MinPlayers {
|
||||
return fmt.Errorf("max players must not be less than min players")
|
||||
}
|
||||
if input.StartGapHours <= 0 {
|
||||
return fmt.Errorf("start gap hours must be positive")
|
||||
}
|
||||
if input.StartGapPlayers <= 0 {
|
||||
return fmt.Errorf("start gap players must be positive")
|
||||
}
|
||||
if input.Now.IsZero() {
|
||||
return fmt.Errorf("now must not be zero")
|
||||
}
|
||||
if input.EnrollmentEndsAt.IsZero() {
|
||||
return fmt.Errorf("enrollment ends at must not be zero")
|
||||
}
|
||||
if !input.EnrollmentEndsAt.After(input.Now) {
|
||||
return fmt.Errorf("enrollment ends at must be after now")
|
||||
}
|
||||
if err := validateCronExpression(input.TurnSchedule); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSemver(input.TargetEngineVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate reports whether record satisfies the full invariants.
|
||||
// Every marshal and unmarshal round-trip calls Validate to guarantee that
|
||||
// the Redis store never exposes malformed records.
|
||||
func (record Game) Validate() error {
|
||||
if err := record.GameID.Validate(); err != nil {
|
||||
return fmt.Errorf("game id: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(record.GameName) == "" {
|
||||
return fmt.Errorf("game name must not be empty")
|
||||
}
|
||||
if !record.GameType.IsKnown() {
|
||||
return fmt.Errorf("game type %q is unsupported", record.GameType)
|
||||
}
|
||||
switch record.GameType {
|
||||
case GameTypePrivate:
|
||||
if strings.TrimSpace(record.OwnerUserID) == "" {
|
||||
return fmt.Errorf("owner user id must not be empty for private games")
|
||||
}
|
||||
case GameTypePublic:
|
||||
if record.OwnerUserID != "" {
|
||||
return fmt.Errorf("owner user id must be empty for public games")
|
||||
}
|
||||
}
|
||||
if !record.Status.IsKnown() {
|
||||
return fmt.Errorf("status %q is unsupported", record.Status)
|
||||
}
|
||||
if record.MinPlayers <= 0 {
|
||||
return fmt.Errorf("min players must be positive")
|
||||
}
|
||||
if record.MaxPlayers <= 0 {
|
||||
return fmt.Errorf("max players must be positive")
|
||||
}
|
||||
if record.MaxPlayers < record.MinPlayers {
|
||||
return fmt.Errorf("max players must not be less than min players")
|
||||
}
|
||||
if record.StartGapHours <= 0 {
|
||||
return fmt.Errorf("start gap hours must be positive")
|
||||
}
|
||||
if record.StartGapPlayers <= 0 {
|
||||
return fmt.Errorf("start gap players must be positive")
|
||||
}
|
||||
if record.EnrollmentEndsAt.IsZero() {
|
||||
return fmt.Errorf("enrollment ends at must not be zero")
|
||||
}
|
||||
if err := validateCronExpression(record.TurnSchedule); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSemver(record.TargetEngineVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.CreatedAt.IsZero() {
|
||||
return fmt.Errorf("created at must not be zero")
|
||||
}
|
||||
if record.UpdatedAt.IsZero() {
|
||||
return fmt.Errorf("updated at must not be zero")
|
||||
}
|
||||
if record.UpdatedAt.Before(record.CreatedAt) {
|
||||
return fmt.Errorf("updated at must not be before created at")
|
||||
}
|
||||
if record.StartedAt != nil {
|
||||
if record.StartedAt.IsZero() {
|
||||
return fmt.Errorf("started at must not be zero when present")
|
||||
}
|
||||
if record.StartedAt.Before(record.CreatedAt) {
|
||||
return fmt.Errorf("started at must not be before created at")
|
||||
}
|
||||
}
|
||||
if record.FinishedAt != nil {
|
||||
if record.FinishedAt.IsZero() {
|
||||
return fmt.Errorf("finished at must not be zero when present")
|
||||
}
|
||||
if record.FinishedAt.Before(record.CreatedAt) {
|
||||
return fmt.Errorf("finished at must not be before created at")
|
||||
}
|
||||
}
|
||||
if record.RuntimeSnapshot.CurrentTurn < 0 {
|
||||
return fmt.Errorf("runtime snapshot current turn must not be negative")
|
||||
}
|
||||
if record.RuntimeBinding != nil {
|
||||
if err := record.RuntimeBinding.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.RuntimeBinding.BoundAt.Before(record.CreatedAt) {
|
||||
return fmt.Errorf("runtime binding bound at must not be before created at")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCronExpression(value string) error {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fmt.Errorf("turn schedule must not be empty")
|
||||
}
|
||||
if _, err := standardCronParser.Parse(value); err != nil {
|
||||
return fmt.Errorf("turn schedule must be a valid five-field cron expression: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSemver(value string) error {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fmt.Errorf("target engine version must not be empty")
|
||||
}
|
||||
candidate := value
|
||||
if !strings.HasPrefix(candidate, "v") {
|
||||
candidate = "v" + candidate
|
||||
}
|
||||
if !semver.IsValid(candidate) {
|
||||
return fmt.Errorf("target engine version %q must be a valid semver string", value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/lobby/internal/domain/common"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func validNewGameInput(now time.Time) NewGameInput {
|
||||
return NewGameInput{
|
||||
GameID: common.GameID("game-42"),
|
||||
GameName: "Spring Classic",
|
||||
Description: "optional",
|
||||
GameType: GameTypePublic,
|
||||
OwnerUserID: "",
|
||||
MinPlayers: 4,
|
||||
MaxPlayers: 8,
|
||||
StartGapHours: 24,
|
||||
StartGapPlayers: 2,
|
||||
EnrollmentEndsAt: now.Add(7 * 24 * time.Hour),
|
||||
TurnSchedule: "0 18 * * *",
|
||||
TargetEngineVersion: "v1.2.3",
|
||||
Now: now,
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewGameSucceedsOnHappyPath(t *testing.T) {
|
||||
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
|
||||
input := validNewGameInput(now)
|
||||
|
||||
record, err := New(input)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, StatusDraft, record.Status)
|
||||
assert.Equal(t, input.GameID, record.GameID)
|
||||
assert.Equal(t, now.UTC(), record.CreatedAt)
|
||||
assert.Equal(t, now.UTC(), record.UpdatedAt)
|
||||
assert.Nil(t, record.StartedAt)
|
||||
assert.Nil(t, record.FinishedAt)
|
||||
assert.Equal(t, 0, record.RuntimeSnapshot.CurrentTurn)
|
||||
assert.Empty(t, record.RuntimeSnapshot.RuntimeStatus)
|
||||
assert.Empty(t, record.RuntimeSnapshot.EngineHealthSummary)
|
||||
}
|
||||
|
||||
func TestNewGameValidatesOwnerBinding(t *testing.T) {
|
||||
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
privateMissingOwner := validNewGameInput(now)
|
||||
privateMissingOwner.GameType = GameTypePrivate
|
||||
privateMissingOwner.OwnerUserID = ""
|
||||
|
||||
_, err := New(privateMissingOwner)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "owner user id must not be empty for private games")
|
||||
|
||||
publicWithOwner := validNewGameInput(now)
|
||||
publicWithOwner.OwnerUserID = "user-1"
|
||||
|
||||
_, err = New(publicWithOwner)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "owner user id must be empty for public games")
|
||||
|
||||
privateWithOwner := validNewGameInput(now)
|
||||
privateWithOwner.GameType = GameTypePrivate
|
||||
privateWithOwner.OwnerUserID = "user-1"
|
||||
|
||||
_, err = New(privateWithOwner)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestNewGameRejectsInvalidSizing(t *testing.T) {
|
||||
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
cases := map[string]func(*NewGameInput){
|
||||
"min_players_zero": func(i *NewGameInput) { i.MinPlayers = 0 },
|
||||
"min_players_negative": func(i *NewGameInput) { i.MinPlayers = -1 },
|
||||
"max_players_zero": func(i *NewGameInput) { i.MaxPlayers = 0 },
|
||||
"max_less_than_min": func(i *NewGameInput) { i.MaxPlayers = i.MinPlayers - 1 },
|
||||
"start_gap_hours_zero": func(i *NewGameInput) { i.StartGapHours = 0 },
|
||||
"start_gap_players_zero": func(i *NewGameInput) { i.StartGapPlayers = 0 },
|
||||
}
|
||||
|
||||
for name, mutate := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
input := validNewGameInput(now)
|
||||
mutate(&input)
|
||||
_, err := New(input)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewGameRejectsInvalidEnrollmentDeadline(t *testing.T) {
|
||||
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
past := validNewGameInput(now)
|
||||
past.EnrollmentEndsAt = now.Add(-time.Hour)
|
||||
_, err := New(past)
|
||||
require.Error(t, err)
|
||||
|
||||
zero := validNewGameInput(now)
|
||||
zero.EnrollmentEndsAt = time.Time{}
|
||||
_, err = New(zero)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestNewGameRejectsInvalidTurnSchedule(t *testing.T) {
|
||||
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
input := validNewGameInput(now)
|
||||
input.TurnSchedule = "not a cron"
|
||||
|
||||
_, err := New(input)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "turn schedule")
|
||||
}
|
||||
|
||||
func TestNewGameRejectsInvalidEngineVersion(t *testing.T) {
|
||||
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
input := validNewGameInput(now)
|
||||
input.TargetEngineVersion = "not-semver"
|
||||
_, err := New(input)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "target engine version")
|
||||
|
||||
input = validNewGameInput(now)
|
||||
input.TargetEngineVersion = ""
|
||||
_, err = New(input)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "target engine version")
|
||||
}
|
||||
|
||||
func TestNewGameRejectsEmptyName(t *testing.T) {
|
||||
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
input := validNewGameInput(now)
|
||||
input.GameName = " "
|
||||
_, err := New(input)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "game name must not be empty")
|
||||
}
|
||||
|
||||
func TestNewGameRejectsInvalidGameID(t *testing.T) {
|
||||
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
input := validNewGameInput(now)
|
||||
input.GameID = common.GameID("bogus")
|
||||
_, err := New(input)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "game id")
|
||||
}
|
||||
|
||||
func TestGameValidateAcceptsCanonicalSemverWithoutPrefix(t *testing.T) {
|
||||
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
input := validNewGameInput(now)
|
||||
input.TargetEngineVersion = "2.0.0"
|
||||
|
||||
record, err := New(input)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "2.0.0", record.TargetEngineVersion)
|
||||
}
|
||||
|
||||
func TestGameValidateRuntimeBinding(t *testing.T) {
|
||||
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
|
||||
input := validNewGameInput(now)
|
||||
record, err := New(input)
|
||||
require.NoError(t, err)
|
||||
|
||||
bound := now.Add(time.Minute)
|
||||
record.RuntimeBinding = &RuntimeBinding{
|
||||
ContainerID: "container-1",
|
||||
EngineEndpoint: "engine.local:9000",
|
||||
RuntimeJobID: "1700000000000-0",
|
||||
BoundAt: bound,
|
||||
}
|
||||
require.NoError(t, record.Validate())
|
||||
|
||||
cases := map[string]func(binding *RuntimeBinding){
|
||||
"empty_container_id": func(b *RuntimeBinding) { b.ContainerID = "" },
|
||||
"empty_engine_endpoint": func(b *RuntimeBinding) { b.EngineEndpoint = "" },
|
||||
"empty_runtime_job_id": func(b *RuntimeBinding) { b.RuntimeJobID = "" },
|
||||
"zero_bound_at": func(b *RuntimeBinding) { b.BoundAt = time.Time{} },
|
||||
}
|
||||
for name, mutate := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
input := validNewGameInput(now)
|
||||
rec, err := New(input)
|
||||
require.NoError(t, err)
|
||||
binding := RuntimeBinding{
|
||||
ContainerID: "container-1",
|
||||
EngineEndpoint: "engine.local:9000",
|
||||
RuntimeJobID: "1700000000000-0",
|
||||
BoundAt: bound,
|
||||
}
|
||||
mutate(&binding)
|
||||
rec.RuntimeBinding = &binding
|
||||
require.Error(t, rec.Validate())
|
||||
})
|
||||
}
|
||||
|
||||
beforeCreated := validNewGameInput(now)
|
||||
rec, err := New(beforeCreated)
|
||||
require.NoError(t, err)
|
||||
earlier := now.Add(-time.Hour)
|
||||
rec.RuntimeBinding = &RuntimeBinding{
|
||||
ContainerID: "container-1",
|
||||
EngineEndpoint: "engine.local:9000",
|
||||
RuntimeJobID: "1700000000000-0",
|
||||
BoundAt: earlier,
|
||||
}
|
||||
err = rec.Validate()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, strings.ToLower(err.Error()), "runtime binding bound at must not be before created at")
|
||||
}
|
||||
|
||||
func TestGameValidateDetectsStartedBeforeCreated(t *testing.T) {
|
||||
now := time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC)
|
||||
input := validNewGameInput(now)
|
||||
record, err := New(input)
|
||||
require.NoError(t, err)
|
||||
|
||||
earlier := now.Add(-time.Minute)
|
||||
record.StartedAt = &earlier
|
||||
err = record.Validate()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, strings.ToLower(err.Error()), "started at")
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package game
|
||||
|
||||
// Status identifies one platform-level lifecycle state of a game record.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
// StatusDraft reports that the record was created but enrollment is
|
||||
// not yet open.
|
||||
StatusDraft Status = "draft"
|
||||
|
||||
// StatusEnrollmentOpen reports that applications (public game) or
|
||||
// invite redemptions (private game) are being accepted.
|
||||
StatusEnrollmentOpen Status = "enrollment_open"
|
||||
|
||||
// StatusReadyToStart reports that enrollment closed and the start
|
||||
// command is accepted.
|
||||
StatusReadyToStart Status = "ready_to_start"
|
||||
|
||||
// StatusStarting reports that a start job has been submitted to
|
||||
// Runtime Manager and Lobby is waiting for the result.
|
||||
StatusStarting Status = "starting"
|
||||
|
||||
// StatusStartFailed reports that the container start or metadata
|
||||
// persistence step failed.
|
||||
StatusStartFailed Status = "start_failed"
|
||||
|
||||
// StatusRunning reports that the engine container is live and normal
|
||||
// gameplay has begun.
|
||||
StatusRunning Status = "running"
|
||||
|
||||
// StatusPaused reports a platform-level pause; the engine container
|
||||
// may still be alive.
|
||||
StatusPaused Status = "paused"
|
||||
|
||||
// StatusFinished reports that the game ended; the record is terminal.
|
||||
StatusFinished Status = "finished"
|
||||
|
||||
// StatusCancelled reports that the game was cancelled before start;
|
||||
// the record is terminal.
|
||||
StatusCancelled Status = "cancelled"
|
||||
)
|
||||
|
||||
// IsKnown reports whether status belongs to the frozen Lobby status
|
||||
// vocabulary.
|
||||
func (status Status) IsKnown() bool {
|
||||
switch status {
|
||||
case StatusDraft,
|
||||
StatusEnrollmentOpen,
|
||||
StatusReadyToStart,
|
||||
StatusStarting,
|
||||
StatusStartFailed,
|
||||
StatusRunning,
|
||||
StatusPaused,
|
||||
StatusFinished,
|
||||
StatusCancelled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// AllStatuses returns the frozen list of every Lobby status value. The
|
||||
// slice order is stable across calls and matches the README §Status
|
||||
// vocabulary listing.
|
||||
func AllStatuses() []Status {
|
||||
return []Status{
|
||||
StatusDraft,
|
||||
StatusEnrollmentOpen,
|
||||
StatusReadyToStart,
|
||||
StatusStarting,
|
||||
StatusStartFailed,
|
||||
StatusRunning,
|
||||
StatusPaused,
|
||||
StatusFinished,
|
||||
StatusCancelled,
|
||||
}
|
||||
}
|
||||
|
||||
// IsTerminal reports whether status can no longer accept lifecycle
|
||||
// transitions.
|
||||
func (status Status) IsTerminal() bool {
|
||||
switch status {
|
||||
case StatusFinished, StatusCancelled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger identifies what caused a status transition. The value is used by
|
||||
// the domain transition gate and by later observability layers.
|
||||
type Trigger string
|
||||
|
||||
const (
|
||||
// TriggerCommand reports an explicit owner or admin command such as
|
||||
// start, pause, resume, cancel, open-enrollment, or retry-start.
|
||||
TriggerCommand Trigger = "command"
|
||||
|
||||
// TriggerManual reports that an admin or owner manually closed
|
||||
// enrollment while min_players was already satisfied.
|
||||
TriggerManual Trigger = "manual"
|
||||
|
||||
// TriggerDeadline reports that the enrollment automation worker
|
||||
// detected enrollment_ends_at had passed with min_players satisfied.
|
||||
TriggerDeadline Trigger = "deadline"
|
||||
|
||||
// TriggerGap reports that the enrollment automation worker detected
|
||||
// the gap window exhausted its time or player budget.
|
||||
TriggerGap Trigger = "gap"
|
||||
|
||||
// TriggerRuntimeEvent reports that the transition was caused by a
|
||||
// Runtime Manager job result or a Game Master runtime event.
|
||||
TriggerRuntimeEvent Trigger = "runtime_event"
|
||||
|
||||
// TriggerExternalBlock reports that the transition was caused by the
|
||||
// user-lifecycle cascade reacting to a permanent_block or DeleteUser
|
||||
// event on the game owner. The trigger is the only path that
|
||||
// cancels a game from in-flight statuses
|
||||
// (`starting`, `running`, `paused`).
|
||||
TriggerExternalBlock Trigger = "external_block"
|
||||
)
|
||||
|
||||
// IsKnown reports whether trigger belongs to the frozen trigger
|
||||
// vocabulary.
|
||||
func (trigger Trigger) IsKnown() bool {
|
||||
switch trigger {
|
||||
case TriggerCommand,
|
||||
TriggerManual,
|
||||
TriggerDeadline,
|
||||
TriggerGap,
|
||||
TriggerRuntimeEvent,
|
||||
TriggerExternalBlock:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// transitionKey stores one `(from, to)` pair in the allowed-transitions
|
||||
// table.
|
||||
type transitionKey struct {
|
||||
from Status
|
||||
to Status
|
||||
}
|
||||
|
||||
// allowedTransitions maps each permitted `(from, to)` pair to the set of
|
||||
// triggers that may drive it. The table mirrors the status transition
|
||||
// table frozen in lobby/README.md.
|
||||
var allowedTransitions = map[transitionKey]map[Trigger]struct{}{
|
||||
{StatusDraft, StatusEnrollmentOpen}: {
|
||||
TriggerCommand: {},
|
||||
},
|
||||
{StatusEnrollmentOpen, StatusReadyToStart}: {
|
||||
TriggerManual: {},
|
||||
TriggerDeadline: {},
|
||||
TriggerGap: {},
|
||||
},
|
||||
{StatusReadyToStart, StatusStarting}: {
|
||||
TriggerCommand: {},
|
||||
},
|
||||
{StatusStarting, StatusRunning}: {
|
||||
TriggerRuntimeEvent: {},
|
||||
},
|
||||
{StatusStarting, StatusPaused}: {
|
||||
TriggerRuntimeEvent: {},
|
||||
},
|
||||
{StatusStarting, StatusStartFailed}: {
|
||||
TriggerRuntimeEvent: {},
|
||||
},
|
||||
{StatusStartFailed, StatusReadyToStart}: {
|
||||
TriggerCommand: {},
|
||||
},
|
||||
{StatusRunning, StatusPaused}: {
|
||||
TriggerCommand: {},
|
||||
},
|
||||
{StatusRunning, StatusFinished}: {
|
||||
TriggerRuntimeEvent: {},
|
||||
},
|
||||
{StatusPaused, StatusRunning}: {
|
||||
TriggerCommand: {},
|
||||
},
|
||||
{StatusPaused, StatusFinished}: {
|
||||
TriggerRuntimeEvent: {},
|
||||
},
|
||||
{StatusDraft, StatusCancelled}: {
|
||||
TriggerCommand: {},
|
||||
TriggerExternalBlock: {},
|
||||
},
|
||||
{StatusEnrollmentOpen, StatusCancelled}: {
|
||||
TriggerCommand: {},
|
||||
TriggerExternalBlock: {},
|
||||
},
|
||||
{StatusReadyToStart, StatusCancelled}: {
|
||||
TriggerCommand: {},
|
||||
TriggerExternalBlock: {},
|
||||
},
|
||||
{StatusStartFailed, StatusCancelled}: {
|
||||
TriggerCommand: {},
|
||||
TriggerExternalBlock: {},
|
||||
},
|
||||
{StatusStarting, StatusCancelled}: {
|
||||
TriggerExternalBlock: {},
|
||||
},
|
||||
{StatusRunning, StatusCancelled}: {
|
||||
TriggerExternalBlock: {},
|
||||
},
|
||||
{StatusPaused, StatusCancelled}: {
|
||||
TriggerExternalBlock: {},
|
||||
},
|
||||
}
|
||||
|
||||
// AllowedTransitions returns a copy of the `(from, to) -> {triggers}` table
|
||||
// used by Transition. The returned map is safe to mutate; callers should
|
||||
// not rely on iteration order.
|
||||
func AllowedTransitions() map[Status]map[Status][]Trigger {
|
||||
result := make(map[Status]map[Status][]Trigger, len(allowedTransitions))
|
||||
for key, triggers := range allowedTransitions {
|
||||
inner, ok := result[key.from]
|
||||
if !ok {
|
||||
inner = make(map[Status][]Trigger)
|
||||
result[key.from] = inner
|
||||
}
|
||||
copied := make([]Trigger, 0, len(triggers))
|
||||
for trigger := range triggers {
|
||||
copied = append(copied, trigger)
|
||||
}
|
||||
inner[key.to] = copied
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Transition reports whether from may transition to next under trigger.
|
||||
// The function returns nil when the triplet is permitted, and an
|
||||
// *InvalidTransitionError wrapping ErrInvalidTransition otherwise. It does
|
||||
// not touch any store and is safe to call from any layer.
|
||||
func Transition(from Status, next Status, trigger Trigger) error {
|
||||
if !from.IsKnown() || !next.IsKnown() || !trigger.IsKnown() {
|
||||
return &InvalidTransitionError{From: from, To: next, Trigger: trigger}
|
||||
}
|
||||
|
||||
triggers, ok := allowedTransitions[transitionKey{from: from, to: next}]
|
||||
if !ok {
|
||||
return &InvalidTransitionError{From: from, To: next, Trigger: trigger}
|
||||
}
|
||||
if _, ok := triggers[trigger]; !ok {
|
||||
return &InvalidTransitionError{From: from, To: next, Trigger: trigger}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestStatusIsKnown(t *testing.T) {
|
||||
for _, status := range []Status{
|
||||
StatusDraft,
|
||||
StatusEnrollmentOpen,
|
||||
StatusReadyToStart,
|
||||
StatusStarting,
|
||||
StatusStartFailed,
|
||||
StatusRunning,
|
||||
StatusPaused,
|
||||
StatusFinished,
|
||||
StatusCancelled,
|
||||
} {
|
||||
assert.Truef(t, status.IsKnown(), "expected %q known", status)
|
||||
}
|
||||
|
||||
assert.False(t, Status("").IsKnown())
|
||||
assert.False(t, Status("unknown").IsKnown())
|
||||
}
|
||||
|
||||
func TestStatusIsTerminal(t *testing.T) {
|
||||
assert.True(t, StatusFinished.IsTerminal())
|
||||
assert.True(t, StatusCancelled.IsTerminal())
|
||||
|
||||
for _, status := range []Status{
|
||||
StatusDraft,
|
||||
StatusEnrollmentOpen,
|
||||
StatusReadyToStart,
|
||||
StatusStarting,
|
||||
StatusStartFailed,
|
||||
StatusRunning,
|
||||
StatusPaused,
|
||||
} {
|
||||
assert.Falsef(t, status.IsTerminal(), "expected %q non-terminal", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerIsKnown(t *testing.T) {
|
||||
for _, trigger := range []Trigger{
|
||||
TriggerCommand,
|
||||
TriggerManual,
|
||||
TriggerDeadline,
|
||||
TriggerGap,
|
||||
TriggerRuntimeEvent,
|
||||
TriggerExternalBlock,
|
||||
} {
|
||||
assert.Truef(t, trigger.IsKnown(), "expected %q known", trigger)
|
||||
}
|
||||
|
||||
assert.False(t, Trigger("").IsKnown())
|
||||
assert.False(t, Trigger("bogus").IsKnown())
|
||||
}
|
||||
|
||||
func TestTransitionHappyPathsCoverFrozenTable(t *testing.T) {
|
||||
cases := []struct {
|
||||
from Status
|
||||
to Status
|
||||
triggers []Trigger
|
||||
}{
|
||||
{StatusDraft, StatusEnrollmentOpen, []Trigger{TriggerCommand}},
|
||||
{StatusEnrollmentOpen, StatusReadyToStart, []Trigger{TriggerManual, TriggerDeadline, TriggerGap}},
|
||||
{StatusReadyToStart, StatusStarting, []Trigger{TriggerCommand}},
|
||||
{StatusStarting, StatusRunning, []Trigger{TriggerRuntimeEvent}},
|
||||
{StatusStarting, StatusPaused, []Trigger{TriggerRuntimeEvent}},
|
||||
{StatusStarting, StatusStartFailed, []Trigger{TriggerRuntimeEvent}},
|
||||
{StatusStartFailed, StatusReadyToStart, []Trigger{TriggerCommand}},
|
||||
{StatusRunning, StatusPaused, []Trigger{TriggerCommand}},
|
||||
{StatusRunning, StatusFinished, []Trigger{TriggerRuntimeEvent}},
|
||||
{StatusPaused, StatusRunning, []Trigger{TriggerCommand}},
|
||||
{StatusPaused, StatusFinished, []Trigger{TriggerRuntimeEvent}},
|
||||
{StatusDraft, StatusCancelled, []Trigger{TriggerCommand, TriggerExternalBlock}},
|
||||
{StatusEnrollmentOpen, StatusCancelled, []Trigger{TriggerCommand, TriggerExternalBlock}},
|
||||
{StatusReadyToStart, StatusCancelled, []Trigger{TriggerCommand, TriggerExternalBlock}},
|
||||
{StatusStartFailed, StatusCancelled, []Trigger{TriggerCommand, TriggerExternalBlock}},
|
||||
{StatusStarting, StatusCancelled, []Trigger{TriggerExternalBlock}},
|
||||
{StatusRunning, StatusCancelled, []Trigger{TriggerExternalBlock}},
|
||||
{StatusPaused, StatusCancelled, []Trigger{TriggerExternalBlock}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
for _, trigger := range tc.triggers {
|
||||
t.Run(string(tc.from)+"->"+string(tc.to)+"/"+string(trigger), func(t *testing.T) {
|
||||
require.NoError(t, Transition(tc.from, tc.to, trigger))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransitionRejectsUnknownPair(t *testing.T) {
|
||||
err := Transition(StatusDraft, StatusRunning, TriggerCommand)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrInvalidTransition))
|
||||
|
||||
var typed *InvalidTransitionError
|
||||
require.True(t, errors.As(err, &typed))
|
||||
assert.Equal(t, StatusDraft, typed.From)
|
||||
assert.Equal(t, StatusRunning, typed.To)
|
||||
assert.Equal(t, TriggerCommand, typed.Trigger)
|
||||
}
|
||||
|
||||
func TestTransitionRejectsWrongTrigger(t *testing.T) {
|
||||
err := Transition(StatusDraft, StatusEnrollmentOpen, TriggerDeadline)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrInvalidTransition))
|
||||
}
|
||||
|
||||
func TestTransitionRejectsUnknownStatusOrTrigger(t *testing.T) {
|
||||
require.Error(t, Transition(Status("bogus"), StatusEnrollmentOpen, TriggerCommand))
|
||||
require.Error(t, Transition(StatusDraft, Status("bogus"), TriggerCommand))
|
||||
require.Error(t, Transition(StatusDraft, StatusEnrollmentOpen, Trigger("bogus")))
|
||||
}
|
||||
|
||||
func TestTransitionsOutOfTerminalStatusAllRejected(t *testing.T) {
|
||||
triggers := []Trigger{
|
||||
TriggerCommand,
|
||||
TriggerManual,
|
||||
TriggerDeadline,
|
||||
TriggerGap,
|
||||
TriggerRuntimeEvent,
|
||||
TriggerExternalBlock,
|
||||
}
|
||||
|
||||
for _, from := range []Status{StatusFinished, StatusCancelled} {
|
||||
for _, to := range []Status{
|
||||
StatusDraft,
|
||||
StatusEnrollmentOpen,
|
||||
StatusReadyToStart,
|
||||
StatusStarting,
|
||||
StatusStartFailed,
|
||||
StatusRunning,
|
||||
StatusPaused,
|
||||
StatusFinished,
|
||||
StatusCancelled,
|
||||
} {
|
||||
for _, trigger := range triggers {
|
||||
if from == to {
|
||||
continue
|
||||
}
|
||||
err := Transition(from, to, trigger)
|
||||
require.Errorf(t, err, "%s->%s via %s should be rejected", from, to, trigger)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowedTransitionsSnapshotMatchesTable(t *testing.T) {
|
||||
snapshot := AllowedTransitions()
|
||||
|
||||
count := 0
|
||||
for _, inner := range snapshot {
|
||||
count += len(inner)
|
||||
}
|
||||
assert.Equal(t, len(allowedTransitions), count)
|
||||
|
||||
for key, triggers := range allowedTransitions {
|
||||
inner, ok := snapshot[key.from]
|
||||
require.Truef(t, ok, "expected from=%s in snapshot", key.from)
|
||||
|
||||
list, ok := inner[key.to]
|
||||
require.Truef(t, ok, "expected to=%s under from=%s", key.to, key.from)
|
||||
|
||||
for trigger := range triggers {
|
||||
assert.Containsf(t, list, trigger, "missing trigger %q for %s->%s", trigger, key.from, key.to)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user