feat: game lobby service
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user