feat: game lobby service

This commit is contained in:
Ilia Denisov
2026-04-25 23:20:55 +02:00
committed by GitHub
parent 32dc29359a
commit 48b0056b49
336 changed files with 57074 additions and 1418 deletions
+44
View File
@@ -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
}
+422
View File
@@ -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
}
+234
View File
@@ -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")
}
+251
View File
@@ -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
}
+177
View File
@@ -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)
}
}
}