// 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/cronutil" "galaxy/lobby/internal/domain/common" "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 `-` 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 } // 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 := cronutil.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 }