Files
galaxy-game/gamemaster/internal/domain/runtime/model.go
T
2026-05-03 07:59:03 +02:00

255 lines
8.2 KiB
Go

// Package runtime defines the runtime-record domain model, status
// machine, and sentinel errors owned by Game Master.
//
// The package mirrors the durable shape of the `runtime_records`
// PostgreSQL table (see
// `galaxy/gamemaster/internal/adapters/postgres/migrations/00001_init.sql`).
// Every status / transition / required-field rule already documented in
// `galaxy/gamemaster/README.md` lives here as code so adapter and service
// layers do not re-derive it.
package runtime
import (
"fmt"
"strings"
"time"
)
// Status identifies one runtime-record lifecycle state.
type Status string
const (
// StatusStarting reports that register-runtime has persisted the row
// but the engine /admin/init call has not yet succeeded.
StatusStarting Status = "starting"
// StatusRunning reports that the runtime is healthy and accepting
// player commands and turn generation.
StatusRunning Status = "running"
// StatusGenerationInProgress reports that the scheduler or admin
// force-next-turn flow has CAS'd the row to drive turn generation.
StatusGenerationInProgress Status = "generation_in_progress"
// StatusGenerationFailed reports that turn generation surfaced an
// engine error and the runtime is awaiting manual recovery.
StatusGenerationFailed Status = "generation_failed"
// StatusStopped reports that an admin stop has completed; the row
// stays in PostgreSQL for audit.
StatusStopped Status = "stopped"
// StatusEngineUnreachable reports that runtime:health_events observed
// an engine container failure (exited, OOM, disappeared, or repeated
// probe failures).
StatusEngineUnreachable Status = "engine_unreachable"
// StatusFinished reports that the engine returned `finished:true` on
// a turn-generation response. The state is terminal: the row stays
// here indefinitely; operator cleanup is the only path out.
StatusFinished Status = "finished"
)
// IsKnown reports whether status belongs to the frozen runtime status
// vocabulary.
func (status Status) IsKnown() bool {
switch status {
case StatusStarting,
StatusRunning,
StatusGenerationInProgress,
StatusGenerationFailed,
StatusStopped,
StatusEngineUnreachable,
StatusFinished:
return true
default:
return false
}
}
// IsTerminal reports whether status can no longer accept lifecycle
// transitions. Per `gamemaster/README.md §Game Master status model`, only
// `finished` is terminal; `stopped` may still be observed but is treated
// as a non-terminal end-state for admin replay purposes (no transitions
// out of it are wired in v1, but the state machine does not forbid them
// architecturally).
func (status Status) IsTerminal() bool {
return status == StatusFinished
}
// AllStatuses returns the frozen list of every runtime status value. The
// slice order is stable across calls and matches the README §Persistence
// Layout listing.
func AllStatuses() []Status {
return []Status{
StatusStarting,
StatusRunning,
StatusGenerationInProgress,
StatusGenerationFailed,
StatusStopped,
StatusEngineUnreachable,
StatusFinished,
}
}
// RuntimeRecord stores one durable runtime record owned by Game Master.
// It mirrors one row of the `runtime_records` table.
//
// NextGenerationAt is *time.Time so a missing tick (e.g., a row that has
// just entered with status=starting) is unambiguous. StartedAt, StoppedAt,
// and FinishedAt are *time.Time for the same reason and align with the
// jet-generated model.
type RuntimeRecord struct {
// GameID identifies the platform game owning this runtime record.
GameID string
// Status stores the current lifecycle state.
Status Status
// EngineEndpoint stores the stable URL Game Master uses to reach the
// engine container, in `http://galaxy-game-{game_id}:8080` form.
EngineEndpoint string
// CurrentImageRef stores the Docker reference of the running engine
// image (or the most recent one for stopped/finished records).
CurrentImageRef string
// CurrentEngineVersion stores the semver of the currently-bound
// engine version (registered in `engine_versions`).
CurrentEngineVersion string
// TurnSchedule stores the five-field cron expression governing turn
// generation, copied from the platform game record at
// register-runtime time.
TurnSchedule string
// CurrentTurn stores the last completed turn number; zero until the
// first turn generates.
CurrentTurn int
// NextGenerationAt stores the next due tick. Nil when no tick is
// scheduled (e.g., status=starting, finished, stopped).
NextGenerationAt *time.Time
// SkipNextTick is true when force-next-turn has set the skip flag
// for the next regular tick. Cleared by the scheduler after the
// first scheduled step is skipped.
SkipNextTick bool
// EngineHealth stores the short text summary derived from
// runtime:health_events; empty until the first health observation.
EngineHealth string
// CreatedAt stores the wall-clock at which the record was created.
CreatedAt time.Time
// UpdatedAt stores the wall-clock of the most recent mutation.
UpdatedAt time.Time
// StartedAt stores the wall-clock at which the runtime first
// transitioned to running. Non-nil once the status leaves starting.
StartedAt *time.Time
// StoppedAt stores the wall-clock at which the runtime was stopped.
// Non-nil when status is stopped.
StoppedAt *time.Time
// FinishedAt stores the wall-clock at which the engine reported
// finish. Non-nil when status is finished.
FinishedAt *time.Time
}
// Validate reports whether record satisfies the runtime-record invariants
// implied by README §Lifecycles and the SQL CHECK on `runtime_records`.
func (record RuntimeRecord) Validate() error {
if strings.TrimSpace(record.GameID) == "" {
return fmt.Errorf("game id must not be empty")
}
if !record.Status.IsKnown() {
return fmt.Errorf("status %q is unsupported", record.Status)
}
if strings.TrimSpace(record.EngineEndpoint) == "" {
return fmt.Errorf("engine endpoint must not be empty")
}
if strings.TrimSpace(record.CurrentImageRef) == "" {
return fmt.Errorf("current image ref must not be empty")
}
if strings.TrimSpace(record.CurrentEngineVersion) == "" {
return fmt.Errorf("current engine version must not be empty")
}
if strings.TrimSpace(record.TurnSchedule) == "" {
return fmt.Errorf("turn schedule must not be empty")
}
if record.CurrentTurn < 0 {
return fmt.Errorf("current turn must not be negative")
}
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.NextGenerationAt != nil && record.NextGenerationAt.IsZero() {
return fmt.Errorf("next generation at must not be zero when present")
}
switch record.Status {
case StatusStarting:
if record.StartedAt != nil {
return fmt.Errorf("started at must be nil for starting records")
}
case StatusRunning,
StatusGenerationInProgress,
StatusGenerationFailed,
StatusEngineUnreachable:
if record.StartedAt == nil {
return fmt.Errorf(
"started at must not be nil for %s records",
record.Status,
)
}
if record.StartedAt.IsZero() {
return fmt.Errorf("started at must not be zero when present")
}
case StatusStopped:
if record.StartedAt == nil {
return fmt.Errorf("started at must not be nil for stopped records")
}
if record.StoppedAt == nil {
return fmt.Errorf("stopped at must not be nil for stopped records")
}
if record.StoppedAt.IsZero() {
return fmt.Errorf("stopped at must not be zero when present")
}
if record.StoppedAt.Before(*record.StartedAt) {
return fmt.Errorf("stopped at must not be before started at")
}
case StatusFinished:
if record.StartedAt == nil {
return fmt.Errorf("started at must not be nil for finished records")
}
if record.FinishedAt == nil {
return fmt.Errorf("finished at must not be nil for finished records")
}
if record.FinishedAt.IsZero() {
return fmt.Errorf("finished at must not be zero when present")
}
if record.FinishedAt.Before(*record.StartedAt) {
return fmt.Errorf("finished at must not be before started at")
}
}
if record.StartedAt != nil && record.StartedAt.Before(record.CreatedAt) {
return fmt.Errorf("started at must not be before created at")
}
return nil
}