feat: gamemaster
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user