Files
galaxy-game/lobby/internal/domain/game/model.go
T
2026-04-25 23:20:55 +02:00

423 lines
12 KiB
Go

// 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
}