feat: gamemaster

This commit is contained in:
Ilia Denisov
2026-05-03 07:59:03 +02:00
committed by GitHub
parent a7cee15115
commit 3e2622757e
229 changed files with 41521 additions and 1098 deletions
@@ -0,0 +1,121 @@
// Package engineversion defines the engine version registry domain
// model owned by Game Master.
//
// The registry mirrors the durable shape of the `engine_versions`
// PostgreSQL table (see
// `galaxy/gamemaster/internal/adapters/postgres/migrations/00001_init.sql`)
// and the user-visible status enum frozen in
// `galaxy/gamemaster/api/internal-openapi.yaml`.
//
// `Options` is intentionally kept opaque ([]byte holding raw JSON) so
// the v1 service does not impose a Go-side schema on the engine-owned
// document. Schema-aware handling lands when an engine version actually
// requires it; until then the registry is a pass-through store.
package engineversion
import (
"errors"
"fmt"
"strings"
"time"
)
// Status identifies one engine-version registry state.
type Status string
const (
// StatusActive marks a version as deployable. Lobby's start flow
// resolves image refs only against active versions.
StatusActive Status = "active"
// StatusDeprecated marks a version as no longer offered for new
// starts. Already-running games on a deprecated version are
// unaffected; the runtime stays bound to the version it started on.
StatusDeprecated Status = "deprecated"
)
// IsKnown reports whether status belongs to the frozen engine-version
// status vocabulary.
func (status Status) IsKnown() bool {
switch status {
case StatusActive, StatusDeprecated:
return true
default:
return false
}
}
// AllStatuses returns the frozen list of every engine-version status
// value. The slice order is stable across calls.
func AllStatuses() []Status {
return []Status{StatusActive, StatusDeprecated}
}
// EngineVersion stores one row of the `engine_versions` registry table.
// Options carries the raw `jsonb` document verbatim so the registry
// stays decoupled from any engine-side schema.
type EngineVersion struct {
// Version stores the canonical semver string (primary key).
Version string
// ImageRef stores the Docker reference of the engine image.
ImageRef string
// Options stores the engine-side options document as raw JSON. Empty
// is treated as `{}` by adapters that hydrate the column.
Options []byte
// Status reports whether the version is deployable (`active`) or
// no longer offered for new starts (`deprecated`).
Status Status
// CreatedAt stores the wall-clock at which the row was created.
CreatedAt time.Time
// UpdatedAt stores the wall-clock of the most recent mutation.
UpdatedAt time.Time
}
// Validate reports whether record satisfies the engine-version
// invariants implied by `engine_versions_status_chk` and the README
// §Engine Version Registry surface.
func (record EngineVersion) Validate() error {
if strings.TrimSpace(record.Version) == "" {
return fmt.Errorf("version must not be empty")
}
if strings.TrimSpace(record.ImageRef) == "" {
return fmt.Errorf("image ref must not be empty")
}
if !record.Status.IsKnown() {
return fmt.Errorf("status %q is unsupported", record.Status)
}
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")
}
return nil
}
// ErrNotFound reports that an engine-version lookup failed because no
// matching row exists.
var ErrNotFound = errors.New("engine version not found")
// ErrInUse reports that a hard-delete or deprecate operation was
// rejected because the version is still referenced by a non-finished
// runtime record.
var ErrInUse = errors.New("engine version in use")
// ErrConflict reports that an engine-version mutation could not be
// applied because a row with the same primary key already exists.
// Adapters surface a PostgreSQL unique-violation through this sentinel
// so the service layer maps it to a `conflict` REST envelope.
var ErrConflict = errors.New("engine version already exists")
// ErrInvalidSemver reports that a semver string did not parse against
// `golang.org/x/mod/semver`'s grammar.
var ErrInvalidSemver = errors.New("invalid semver")
@@ -0,0 +1,63 @@
package engineversion
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func validVersion() EngineVersion {
created := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
return EngineVersion{
Version: "v1.2.3",
ImageRef: "ghcr.io/galaxy/game:v1.2.3",
Options: []byte(`{"max_planets":120}`),
Status: StatusActive,
CreatedAt: created,
UpdatedAt: created,
}
}
func TestStatusIsKnown(t *testing.T) {
for _, status := range AllStatuses() {
assert.True(t, status.IsKnown(), "want known: %q", status)
}
assert.False(t, Status("retired").IsKnown())
assert.False(t, Status("").IsKnown())
}
func TestEngineVersionValidateHappy(t *testing.T) {
require.NoError(t, validVersion().Validate())
}
func TestEngineVersionValidateAcceptsEmptyOptions(t *testing.T) {
record := validVersion()
record.Options = nil
assert.NoError(t, record.Validate())
}
func TestEngineVersionValidateRejects(t *testing.T) {
tests := []struct {
name string
mutate func(*EngineVersion)
}{
{"empty version", func(v *EngineVersion) { v.Version = "" }},
{"empty image ref", func(v *EngineVersion) { v.ImageRef = "" }},
{"unknown status", func(v *EngineVersion) { v.Status = "exotic" }},
{"zero created at", func(v *EngineVersion) { v.CreatedAt = time.Time{} }},
{"zero updated at", func(v *EngineVersion) { v.UpdatedAt = time.Time{} }},
{"updated before created", func(v *EngineVersion) {
v.UpdatedAt = v.CreatedAt.Add(-time.Minute)
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
record := validVersion()
tt.mutate(&record)
assert.Error(t, record.Validate())
})
}
}
@@ -0,0 +1,60 @@
package engineversion
import (
"fmt"
"strings"
"golang.org/x/mod/semver"
)
// ParseSemver normalises version into the canonical "vMAJOR.MINOR.PATCH"
// form expected by `golang.org/x/mod/semver` and reports a wrapped
// ErrInvalidSemver when the resulting string is not a valid full semver.
//
// Whitespace is trimmed; a missing leading "v" is added before the
// validity check so callers may pass either "1.2.3" or "v1.2.3". The
// stripped base must carry exactly three dot-separated numeric
// components — `golang.org/x/mod/semver` accepts shortened forms such
// as "v1" or "v1.2", but the engine-version registry requires the full
// triple, so this function rejects anything narrower.
func ParseSemver(version string) (string, error) {
candidate := strings.TrimSpace(version)
if candidate == "" {
return "", fmt.Errorf("%w: empty", ErrInvalidSemver)
}
if !strings.HasPrefix(candidate, "v") {
candidate = "v" + candidate
}
if !semver.IsValid(candidate) {
return "", fmt.Errorf("%w: %q", ErrInvalidSemver, version)
}
base := candidate
if i := strings.IndexAny(base, "-+"); i >= 0 {
base = base[:i]
}
if strings.Count(base, ".") != 2 {
return "", fmt.Errorf(
"%w: %q (need vMAJOR.MINOR.PATCH)",
ErrInvalidSemver, version,
)
}
return candidate, nil
}
// IsPatchUpgrade reports whether next is a same-major.minor upgrade of
// current. Both inputs are parsed through ParseSemver so callers may
// pass either bare or `v`-prefixed forms. A wrapped ErrInvalidSemver is
// returned when either argument fails to parse; the boolean result is
// undefined in that case.
func IsPatchUpgrade(current, next string) (bool, error) {
curr, err := ParseSemver(current)
if err != nil {
return false, fmt.Errorf("current: %w", err)
}
nxt, err := ParseSemver(next)
if err != nil {
return false, fmt.Errorf("next: %w", err)
}
return semver.MajorMinor(curr) == semver.MajorMinor(nxt), nil
}
@@ -0,0 +1,85 @@
package engineversion
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseSemverNormalises(t *testing.T) {
tests := []struct {
input string
want string
}{
{"1.2.3", "v1.2.3"},
{"v1.2.3", "v1.2.3"},
{" v0.4.0 ", "v0.4.0"},
{"v2.0.0-rc.1", "v2.0.0-rc.1"},
{"v2.0.0+build.7", "v2.0.0+build.7"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got, err := ParseSemver(tt.input)
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
func TestParseSemverRejects(t *testing.T) {
tests := []string{
"",
" ",
"latest",
"1",
"1.2",
"v1.2",
"1.2.3.4",
"v1.2.x",
}
for _, input := range tests {
t.Run(input, func(t *testing.T) {
_, err := ParseSemver(input)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrInvalidSemver))
})
}
}
func TestIsPatchUpgrade(t *testing.T) {
tests := []struct {
name string
current string
next string
want bool
}{
{"same patch", "v1.2.3", "v1.2.3", true},
{"patch bump", "v1.2.3", "v1.2.4", true},
{"patch downgrade", "1.2.4", "1.2.0", true},
{"prerelease patch", "v1.2.3", "v1.2.3-rc.1", true},
{"minor bump", "v1.2.3", "v1.3.0", false},
{"minor downgrade", "v1.2.3", "v1.1.9", false},
{"major bump", "v1.2.3", "v2.0.0", false},
{"major downgrade", "v2.0.0", "v1.9.9", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := IsPatchUpgrade(tt.current, tt.next)
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
func TestIsPatchUpgradeRejectsBadInputs(t *testing.T) {
_, err := IsPatchUpgrade("garbage", "v1.2.3")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrInvalidSemver))
_, err = IsPatchUpgrade("v1.2.3", "")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrInvalidSemver))
}
+244
View File
@@ -0,0 +1,244 @@
// Package operation defines the runtime-operation audit-log domain
// types owned by Game Master.
//
// One OperationEntry maps to one row of the `operation_log` PostgreSQL
// table (see
// `galaxy/gamemaster/internal/adapters/postgres/migrations/00001_init.sql`).
// The OpKind / OpSource / Outcome enums match the SQL CHECK constraints
// verbatim and feed the telemetry counters declared in
// `galaxy/gamemaster/README.md §Observability`.
package operation
import (
"fmt"
"strings"
"time"
)
// OpKind identifies the kind of operation Game Master performed.
type OpKind string
const (
// OpKindRegisterRuntime records a register-runtime operation
// (engine init plus first transition to running).
OpKindRegisterRuntime OpKind = "register_runtime"
// OpKindTurnGeneration records a turn-generation operation
// (scheduler ticker or admin force).
OpKindTurnGeneration OpKind = "turn_generation"
// OpKindForceNextTurn records the admin force-next-turn driver
// (separate from the turn-generation entry it produces, so audit
// callers can tell scheduler ticks from manual ones).
OpKindForceNextTurn OpKind = "force_next_turn"
// OpKindBanish records a /admin/race/banish call against the
// engine container.
OpKindBanish OpKind = "banish"
// OpKindStop records the admin stop driver (the underlying RTM
// stop call is recorded in Runtime Manager's own operation log).
OpKindStop OpKind = "stop"
// OpKindPatch records the admin patch driver.
OpKindPatch OpKind = "patch"
// OpKindEngineVersionCreate records a registry CREATE.
OpKindEngineVersionCreate OpKind = "engine_version_create"
// OpKindEngineVersionUpdate records a registry PATCH.
OpKindEngineVersionUpdate OpKind = "engine_version_update"
// OpKindEngineVersionDeprecate records a registry DELETE / soft
// deprecate.
OpKindEngineVersionDeprecate OpKind = "engine_version_deprecate"
// OpKindEngineVersionDelete records a registry hard delete: the
// row is removed from `engine_versions` after the service layer
// confirms no non-finished runtime still references it.
OpKindEngineVersionDelete OpKind = "engine_version_delete"
)
// IsKnown reports whether kind belongs to the frozen op-kind vocabulary.
func (kind OpKind) IsKnown() bool {
switch kind {
case OpKindRegisterRuntime,
OpKindTurnGeneration,
OpKindForceNextTurn,
OpKindBanish,
OpKindStop,
OpKindPatch,
OpKindEngineVersionCreate,
OpKindEngineVersionUpdate,
OpKindEngineVersionDeprecate,
OpKindEngineVersionDelete:
return true
default:
return false
}
}
// AllOpKinds returns the frozen list of every op-kind value. The slice
// order is stable across calls.
func AllOpKinds() []OpKind {
return []OpKind{
OpKindRegisterRuntime,
OpKindTurnGeneration,
OpKindForceNextTurn,
OpKindBanish,
OpKindStop,
OpKindPatch,
OpKindEngineVersionCreate,
OpKindEngineVersionUpdate,
OpKindEngineVersionDeprecate,
OpKindEngineVersionDelete,
}
}
// OpSource identifies where one operation entered Game Master.
type OpSource string
const (
// OpSourceGatewayPlayer identifies entries triggered by a verified
// player command, order, or report read forwarded through Edge
// Gateway.
OpSourceGatewayPlayer OpSource = "gateway_player"
// OpSourceLobbyInternal identifies entries triggered by Game Lobby
// over the trusted internal REST surface (register-runtime,
// memberships invalidate, banish, liveness).
OpSourceLobbyInternal OpSource = "lobby_internal"
// OpSourceAdminRest identifies entries triggered by Admin Service
// (or system administrators today). The default when the
// `X-Galaxy-Caller` header is missing or unrecognised.
OpSourceAdminRest OpSource = "admin_rest"
)
// IsKnown reports whether source belongs to the frozen op-source
// vocabulary.
func (source OpSource) IsKnown() bool {
switch source {
case OpSourceGatewayPlayer,
OpSourceLobbyInternal,
OpSourceAdminRest:
return true
default:
return false
}
}
// AllOpSources returns the frozen list of every op-source value. The
// slice order is stable across calls.
func AllOpSources() []OpSource {
return []OpSource{
OpSourceGatewayPlayer,
OpSourceLobbyInternal,
OpSourceAdminRest,
}
}
// Outcome reports the high-level outcome of one operation.
type Outcome string
const (
// OutcomeSuccess reports that the operation completed without
// surfacing an error.
OutcomeSuccess Outcome = "success"
// OutcomeFailure reports that the operation surfaced a stable
// error code recorded in OperationEntry.ErrorCode.
OutcomeFailure Outcome = "failure"
)
// IsKnown reports whether outcome belongs to the frozen outcome
// vocabulary.
func (outcome Outcome) IsKnown() bool {
switch outcome {
case OutcomeSuccess, OutcomeFailure:
return true
default:
return false
}
}
// AllOutcomes returns the frozen list of every outcome value. The slice
// order is stable across calls.
func AllOutcomes() []Outcome {
return []Outcome{OutcomeSuccess, OutcomeFailure}
}
// OperationEntry stores one append-only audit row of the `operation_log`
// table. ID is zero on records that have not been persisted yet; the
// store assigns it from the table's bigserial column. FinishedAt is a
// pointer because the column is nullable for in-flight rows even though
// the service layer finalises the row in the same transaction.
type OperationEntry struct {
// ID identifies the persisted row. Zero before persistence.
ID int64
// GameID identifies the platform game this operation acted on.
GameID string
// OpKind classifies what the operation did.
OpKind OpKind
// OpSource classifies how the operation entered Game Master.
OpSource OpSource
// SourceRef stores an opaque per-source reference such as a request
// id, a Redis Stream entry id, or an admin user id. Empty when the
// source does not provide one.
SourceRef string
// Outcome reports whether the operation succeeded or failed.
Outcome Outcome
// ErrorCode stores the stable error code on failure. Empty on
// success.
ErrorCode string
// ErrorMessage stores the operator-readable detail on failure.
// Empty on success.
ErrorMessage string
// StartedAt stores the wall-clock at which the operation began.
StartedAt time.Time
// FinishedAt stores the wall-clock at which the operation
// finalised. Nil for in-flight rows.
FinishedAt *time.Time
}
// Validate reports whether entry satisfies the operation-log invariants
// implied by the SQL CHECK constraints and the README §Persistence
// Layout listing.
func (entry OperationEntry) Validate() error {
if strings.TrimSpace(entry.GameID) == "" {
return fmt.Errorf("game id must not be empty")
}
if !entry.OpKind.IsKnown() {
return fmt.Errorf("op kind %q is unsupported", entry.OpKind)
}
if !entry.OpSource.IsKnown() {
return fmt.Errorf("op source %q is unsupported", entry.OpSource)
}
if !entry.Outcome.IsKnown() {
return fmt.Errorf("outcome %q is unsupported", entry.Outcome)
}
if entry.StartedAt.IsZero() {
return fmt.Errorf("started at must not be zero")
}
if entry.FinishedAt != nil {
if entry.FinishedAt.IsZero() {
return fmt.Errorf("finished at must not be zero when present")
}
if entry.FinishedAt.Before(entry.StartedAt) {
return fmt.Errorf("finished at must not be before started at")
}
}
if entry.Outcome == OutcomeFailure && strings.TrimSpace(entry.ErrorCode) == "" {
return fmt.Errorf("error code must not be empty for failure entries")
}
return nil
}
@@ -0,0 +1,100 @@
package operation
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func validSuccessEntry() OperationEntry {
started := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
finished := started.Add(time.Second)
return OperationEntry{
GameID: "game-1",
OpKind: OpKindRegisterRuntime,
OpSource: OpSourceLobbyInternal,
Outcome: OutcomeSuccess,
StartedAt: started,
FinishedAt: &finished,
}
}
func validFailureEntry() OperationEntry {
entry := validSuccessEntry()
entry.Outcome = OutcomeFailure
entry.ErrorCode = "engine_unreachable"
entry.ErrorMessage = "engine returned 502"
return entry
}
func TestOpKindIsKnown(t *testing.T) {
for _, kind := range AllOpKinds() {
assert.True(t, kind.IsKnown(), "want known: %q", kind)
}
assert.False(t, OpKind("exotic").IsKnown())
assert.Len(t, AllOpKinds(), 10)
}
func TestOpSourceIsKnown(t *testing.T) {
for _, src := range AllOpSources() {
assert.True(t, src.IsKnown(), "want known: %q", src)
}
assert.False(t, OpSource("exotic").IsKnown())
assert.Len(t, AllOpSources(), 3)
}
func TestOutcomeIsKnown(t *testing.T) {
for _, outcome := range AllOutcomes() {
assert.True(t, outcome.IsKnown(), "want known: %q", outcome)
}
assert.False(t, Outcome("exotic").IsKnown())
assert.Len(t, AllOutcomes(), 2)
}
func TestOperationEntryValidateHappy(t *testing.T) {
require.NoError(t, validSuccessEntry().Validate())
require.NoError(t, validFailureEntry().Validate())
}
func TestOperationEntryValidateAcceptsInFlight(t *testing.T) {
entry := validSuccessEntry()
entry.FinishedAt = nil
assert.NoError(t, entry.Validate())
}
func TestOperationEntryValidateRejects(t *testing.T) {
tests := []struct {
name string
mutate func(*OperationEntry)
}{
{"empty game id", func(e *OperationEntry) { e.GameID = "" }},
{"unknown op kind", func(e *OperationEntry) { e.OpKind = "exotic" }},
{"unknown op source", func(e *OperationEntry) { e.OpSource = "exotic" }},
{"unknown outcome", func(e *OperationEntry) { e.Outcome = "exotic" }},
{"zero started at", func(e *OperationEntry) { e.StartedAt = time.Time{} }},
{"zero finished at when present", func(e *OperationEntry) {
zero := time.Time{}
e.FinishedAt = &zero
}},
{"finished before started", func(e *OperationEntry) {
before := e.StartedAt.Add(-time.Second)
e.FinishedAt = &before
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
entry := validSuccessEntry()
tt.mutate(&entry)
assert.Error(t, entry.Validate())
})
}
}
func TestOperationEntryValidateRejectsFailureWithoutCode(t *testing.T) {
entry := validFailureEntry()
entry.ErrorCode = ""
assert.Error(t, entry.Validate())
}
@@ -0,0 +1,71 @@
// Package playermapping defines the durable mapping between platform
// users and engine player handles owned by Game Master.
//
// One PlayerMapping mirrors one row of the `player_mappings` PostgreSQL
// table (see
// `galaxy/gamemaster/internal/adapters/postgres/migrations/00001_init.sql`).
// The composite primary key `(game_id, user_id)` and the unique
// `(game_id, race_name)` index live in the SQL schema; the domain model
// captures the per-row invariants enforced from the application side.
package playermapping
import (
"errors"
"fmt"
"strings"
"time"
)
// PlayerMapping stores one (game_id, user_id) → (race_name,
// engine_player_uuid) projection installed at register-runtime.
type PlayerMapping struct {
// GameID identifies the game owning this mapping.
GameID string
// UserID identifies the platform user this mapping refers to.
UserID string
// RaceName stores the in-game race name reserved for the user in
// the original casing presented by the engine.
RaceName string
// EnginePlayerUUID stores the engine-side player handle returned by
// the engine /admin/init response.
EnginePlayerUUID string
// CreatedAt stores the wall-clock at which the row was inserted.
CreatedAt time.Time
}
// Validate reports whether mapping satisfies the player-mapping
// invariants implied by the README §Persistence Layout / player_mappings
// columns and the SQL primary-key + unique-index constraints.
func (mapping PlayerMapping) Validate() error {
if strings.TrimSpace(mapping.GameID) == "" {
return fmt.Errorf("game id must not be empty")
}
if strings.TrimSpace(mapping.UserID) == "" {
return fmt.Errorf("user id must not be empty")
}
if strings.TrimSpace(mapping.RaceName) == "" {
return fmt.Errorf("race name must not be empty")
}
if strings.TrimSpace(mapping.EnginePlayerUUID) == "" {
return fmt.Errorf("engine player uuid must not be empty")
}
if mapping.CreatedAt.IsZero() {
return fmt.Errorf("created at must not be zero")
}
return nil
}
// ErrNotFound reports that a player-mapping lookup failed because no
// matching row exists.
var ErrNotFound = errors.New("player mapping not found")
// ErrConflict reports that a player-mapping insert could not be applied
// because a row with the same `(game_id, user_id)` primary key or with
// the same `(game_id, race_name)` unique pair already exists. Adapters
// surface PostgreSQL unique-violations through this sentinel so the
// service layer maps it to a `conflict` REST envelope.
var ErrConflict = errors.New("player mapping already exists")
@@ -0,0 +1,44 @@
package playermapping
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func validMapping() PlayerMapping {
return PlayerMapping{
GameID: "game-1",
UserID: "user-1",
RaceName: "Aelinari",
EnginePlayerUUID: "00000000-0000-0000-0000-000000000001",
CreatedAt: time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC),
}
}
func TestPlayerMappingValidateHappy(t *testing.T) {
require.NoError(t, validMapping().Validate())
}
func TestPlayerMappingValidateRejects(t *testing.T) {
tests := []struct {
name string
mutate func(*PlayerMapping)
}{
{"empty game id", func(m *PlayerMapping) { m.GameID = "" }},
{"empty user id", func(m *PlayerMapping) { m.UserID = "" }},
{"empty race name", func(m *PlayerMapping) { m.RaceName = "" }},
{"empty engine uuid", func(m *PlayerMapping) { m.EnginePlayerUUID = "" }},
{"zero created at", func(m *PlayerMapping) { m.CreatedAt = time.Time{} }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mapping := validMapping()
tt.mutate(&mapping)
assert.Error(t, mapping.Validate())
})
}
}
@@ -0,0 +1,43 @@
package runtime
import (
"errors"
"fmt"
)
// ErrNotFound reports that a runtime record was requested but does not
// exist in the store.
var ErrNotFound = errors.New("runtime record not found")
// ErrConflict reports that a runtime mutation could not be applied
// because the record changed concurrently or failed a compare-and-swap
// guard.
var ErrConflict = errors.New("runtime record conflict")
// ErrInvalidTransition is the sentinel returned when Transition rejects
// a `(from, to)` pair.
var ErrInvalidTransition = errors.New("invalid runtime status transition")
// InvalidTransitionError stores the rejected `(from, to)` pair and wraps
// ErrInvalidTransition so callers can match it with errors.Is.
type InvalidTransitionError struct {
// From stores the source status that was attempted to leave.
From Status
// To stores the destination status that was attempted to enter.
To Status
}
// Error reports a human-readable summary of the rejected pair.
func (err *InvalidTransitionError) Error() string {
return fmt.Sprintf(
"invalid runtime status transition from %q to %q",
err.From, err.To,
)
}
// Unwrap returns ErrInvalidTransition so errors.Is recognizes the
// sentinel.
func (err *InvalidTransitionError) Unwrap() error {
return ErrInvalidTransition
}
+254
View File
@@ -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
}
@@ -0,0 +1,130 @@
package runtime
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func validRunningRecord() RuntimeRecord {
created := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
started := created.Add(time.Minute)
updated := started.Add(time.Minute)
next := updated.Add(time.Hour)
return RuntimeRecord{
GameID: "game-1",
Status: StatusRunning,
EngineEndpoint: "http://galaxy-game-1:8080",
CurrentImageRef: "ghcr.io/galaxy/game:v1.2.3",
CurrentEngineVersion: "v1.2.3",
TurnSchedule: "0 18 * * *",
CurrentTurn: 0,
NextGenerationAt: &next,
CreatedAt: created,
UpdatedAt: updated,
StartedAt: &started,
}
}
func TestStatusIsKnown(t *testing.T) {
for _, status := range AllStatuses() {
assert.True(t, status.IsKnown(), "want known: %q", status)
}
assert.False(t, Status("exotic").IsKnown())
assert.False(t, Status("").IsKnown())
}
func TestStatusIsTerminal(t *testing.T) {
assert.True(t, StatusFinished.IsTerminal())
for _, status := range AllStatuses() {
if status == StatusFinished {
continue
}
assert.False(t, status.IsTerminal(), "%q must not be terminal", status)
}
}
func TestAllStatusesStable(t *testing.T) {
first := AllStatuses()
second := AllStatuses()
assert.Equal(t, first, second)
assert.Len(t, first, 7)
}
func TestRuntimeRecordValidateHappy(t *testing.T) {
require.NoError(t, validRunningRecord().Validate())
}
func TestRuntimeRecordValidateAcceptsStarting(t *testing.T) {
record := validRunningRecord()
record.Status = StatusStarting
record.StartedAt = nil
record.NextGenerationAt = nil
assert.NoError(t, record.Validate())
}
func TestRuntimeRecordValidateRequiresFinishedAt(t *testing.T) {
record := validRunningRecord()
record.Status = StatusFinished
record.FinishedAt = nil
assert.Error(t, record.Validate())
finished := record.UpdatedAt.Add(time.Minute)
record.FinishedAt = &finished
assert.NoError(t, record.Validate())
}
func TestRuntimeRecordValidateRequiresStoppedAtForStopped(t *testing.T) {
record := validRunningRecord()
record.Status = StatusStopped
assert.Error(t, record.Validate())
stopped := record.UpdatedAt.Add(time.Minute)
record.StoppedAt = &stopped
assert.NoError(t, record.Validate())
}
func TestRuntimeRecordValidateRejects(t *testing.T) {
tests := []struct {
name string
mutate func(*RuntimeRecord)
}{
{"empty game id", func(r *RuntimeRecord) { r.GameID = "" }},
{"unknown status", func(r *RuntimeRecord) { r.Status = "exotic" }},
{"empty engine endpoint", func(r *RuntimeRecord) { r.EngineEndpoint = "" }},
{"empty image ref", func(r *RuntimeRecord) { r.CurrentImageRef = "" }},
{"empty engine version", func(r *RuntimeRecord) { r.CurrentEngineVersion = "" }},
{"empty turn schedule", func(r *RuntimeRecord) { r.TurnSchedule = "" }},
{"negative turn", func(r *RuntimeRecord) { r.CurrentTurn = -1 }},
{"zero created at", func(r *RuntimeRecord) { r.CreatedAt = time.Time{} }},
{"zero updated at", func(r *RuntimeRecord) { r.UpdatedAt = time.Time{} }},
{"updated before created", func(r *RuntimeRecord) {
r.UpdatedAt = r.CreatedAt.Add(-time.Minute)
}},
{"started before created", func(r *RuntimeRecord) {
before := r.CreatedAt.Add(-time.Minute)
r.StartedAt = &before
}},
{"running missing started at", func(r *RuntimeRecord) { r.StartedAt = nil }},
{"starting with started at", func(r *RuntimeRecord) {
r.Status = StatusStarting
// keep StartedAt set
}},
{"zero next generation at", func(r *RuntimeRecord) {
zero := time.Time{}
r.NextGenerationAt = &zero
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
record := validRunningRecord()
tt.mutate(&record)
assert.Error(t, record.Validate())
})
}
}
@@ -0,0 +1,77 @@
package runtime
// transitionKey stores one `(from, to)` pair in the allowed-transitions
// table.
type transitionKey struct {
from Status
to Status
}
// allowedTransitions enumerates the runtime-status transitions Game
// Master is allowed to apply. The set mirrors the lifecycle flows frozen
// in `galaxy/gamemaster/README.md §Lifecycles`:
//
// - starting → running: register-runtime CAS after a successful
// engine /admin/init.
// - running → generation_in_progress: scheduler ticker or admin
// force-next-turn enters turn generation.
// - generation_in_progress → running: turn generation succeeded with
// `finished=false`.
// - generation_in_progress → generation_failed: engine timeout or
// 5xx during turn generation.
// - generation_in_progress → finished: engine returned
// `finished=true`; the state is terminal.
// - generation_failed → generation_in_progress: admin force-next-turn
// after manual recovery.
// - running → engine_unreachable: runtime:health_events observed an
// engine container failure (Stage 18 consumer).
// - engine_unreachable → running: runtime:health_events observed a
// recovery; reserved for the Stage 18 consumer; declared here so
// Stage 18 needs no transitions edit.
// - running → stopped, generation_in_progress → stopped,
// generation_failed → stopped, engine_unreachable → stopped: admin
// stop is allowed from every non-terminal status (README §Stop:
// «CAS `runtime_records.status: * → stopped`»).
var allowedTransitions = map[transitionKey]struct{}{
{StatusStarting, StatusRunning}: {},
{StatusRunning, StatusGenerationInProgress}: {},
{StatusGenerationInProgress, StatusRunning}: {},
{StatusGenerationInProgress, StatusGenerationFailed}: {},
{StatusGenerationInProgress, StatusFinished}: {},
{StatusGenerationFailed, StatusGenerationInProgress}: {},
{StatusRunning, StatusEngineUnreachable}: {},
{StatusEngineUnreachable, StatusRunning}: {},
{StatusRunning, StatusStopped}: {},
{StatusGenerationInProgress, StatusStopped}: {},
{StatusGenerationFailed, StatusStopped}: {},
{StatusEngineUnreachable, StatusStopped}: {},
}
// AllowedTransitions returns a copy of the `(from, to)` allowed
// transitions table used by Transition. The returned map is safe to
// mutate; callers should not rely on iteration order.
func AllowedTransitions() map[Status][]Status {
result := make(map[Status][]Status)
for key := range allowedTransitions {
result[key.from] = append(result[key.from], key.to)
}
return result
}
// Transition reports whether from may transition to next. The function
// returns nil when the pair is permitted, and an *InvalidTransitionError
// wrapping ErrInvalidTransition otherwise. It does not touch any store
// and is safe to call from any layer.
func Transition(from Status, next Status) error {
if !from.IsKnown() || !next.IsKnown() {
return &InvalidTransitionError{From: from, To: next}
}
if _, ok := allowedTransitions[transitionKey{from: from, to: next}]; !ok {
return &InvalidTransitionError{From: from, To: next}
}
return nil
}
@@ -0,0 +1,90 @@
package runtime
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTransitionAcceptsAllAllowedPairs(t *testing.T) {
for from, tos := range AllowedTransitions() {
for _, to := range tos {
t.Run(string(from)+"->"+string(to), func(t *testing.T) {
assert.NoError(t, Transition(from, to))
})
}
}
}
func TestTransitionRejectsForbiddenPairs(t *testing.T) {
allowed := AllowedTransitions()
allowedSet := make(map[transitionKey]struct{})
for from, tos := range allowed {
for _, to := range tos {
allowedSet[transitionKey{from: from, to: to}] = struct{}{}
}
}
for _, from := range AllStatuses() {
for _, to := range AllStatuses() {
if _, ok := allowedSet[transitionKey{from: from, to: to}]; ok {
continue
}
t.Run(string(from)+"->"+string(to), func(t *testing.T) {
err := Transition(from, to)
require.Error(t, err)
var typed *InvalidTransitionError
assert.True(t, errors.As(err, &typed))
assert.Equal(t, from, typed.From)
assert.Equal(t, to, typed.To)
assert.True(t, errors.Is(err, ErrInvalidTransition))
})
}
}
}
func TestTransitionRejectsUnknownStatus(t *testing.T) {
tests := []struct {
name string
from Status
to Status
}{
{"unknown from", "exotic", StatusRunning},
{"unknown to", StatusRunning, "exotic"},
{"both unknown", "from-x", "to-y"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := Transition(tt.from, tt.to)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrInvalidTransition))
})
}
}
func TestAllowedTransitionsIncludesExpectedFlows(t *testing.T) {
allowed := AllowedTransitions()
must := func(from Status, expected Status) {
t.Helper()
got := allowed[from]
assert.Containsf(t, got, expected,
"expected %q in transitions from %q, got %v",
expected, from, got)
}
must(StatusStarting, StatusRunning)
must(StatusRunning, StatusGenerationInProgress)
must(StatusGenerationInProgress, StatusRunning)
must(StatusGenerationInProgress, StatusGenerationFailed)
must(StatusGenerationInProgress, StatusFinished)
must(StatusGenerationFailed, StatusGenerationInProgress)
must(StatusRunning, StatusEngineUnreachable)
must(StatusEngineUnreachable, StatusRunning)
must(StatusRunning, StatusStopped)
must(StatusGenerationInProgress, StatusStopped)
must(StatusGenerationFailed, StatusStopped)
must(StatusEngineUnreachable, StatusStopped)
}
@@ -0,0 +1,59 @@
// Package schedule wraps `pkg/cronutil` with the force-next-turn skip
// rule used by Game Master's scheduler.
//
// The wrapper is pure: callers pass the current `skip_next_tick` flag
// and the wrapper returns both the next firing time and a boolean that
// reports whether the flag was consumed. The runtime-record store is
// responsible for persisting the cleared flag; this package never
// touches it.
//
// `gamemaster/README.md §Force-next-turn` describes the rule:
//
// If `skip_next_tick=true`, advance by one extra cron step and clear
// the flag.
package schedule
import (
"time"
"galaxy/cronutil"
)
// Schedule wraps `cronutil.Schedule` with the GM-specific
// skip-next-tick semantics. The zero value is not usable; callers
// obtain a Schedule from Parse.
type Schedule struct {
inner cronutil.Schedule
}
// Parse parses expr as a five-field cron expression and returns the
// resulting Schedule. Parse returns an error if expr is rejected by the
// underlying cronutil parser.
func Parse(expr string) (Schedule, error) {
inner, err := cronutil.Parse(expr)
if err != nil {
return Schedule{}, err
}
return Schedule{inner: inner}, nil
}
// Next returns the next firing time strictly after `after`, honouring
// the skip flag.
//
// When `skip` is false, Next returns `cronutil.Schedule.Next(after)`
// and reports `skipConsumed=false`.
//
// When `skip` is true, Next computes the cron step immediately after
// `after`, then advances by one further cron step and returns that
// time with `skipConsumed=true`. The caller is responsible for
// persisting the cleared flag after observing `skipConsumed`.
//
// All returned times are in UTC; cronutil.Schedule already enforces
// UTC normalisation on its inputs and outputs.
func (s Schedule) Next(after time.Time, skip bool) (time.Time, bool) {
first := s.inner.Next(after)
if !skip {
return first, false
}
return s.inner.Next(first), true
}
@@ -0,0 +1,67 @@
package schedule
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseRejectsBadExpr(t *testing.T) {
_, err := Parse("")
assert.Error(t, err)
_, err = Parse("0 0 31 2 *") // valid syntactically but never fires; cronutil accepts it
// cronutil only validates syntax; an impossible date is still parsed.
// We assert by separately rejecting clearly invalid syntax:
_, err = Parse("not-a-cron")
assert.Error(t, err)
_, err = Parse("0 18 * *") // four fields
assert.Error(t, err)
_, err = Parse("0 0 * * * *") // six fields
assert.Error(t, err)
}
func TestNextNoSkip(t *testing.T) {
// Fires every day at 18:00 UTC.
sched, err := Parse("0 18 * * *")
require.NoError(t, err)
after := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
got, skipped := sched.Next(after, false)
assert.False(t, skipped)
assert.Equal(t, time.Date(2026, 4, 27, 18, 0, 0, 0, time.UTC), got)
assert.Equal(t, time.UTC, got.Location())
}
func TestNextWithSkipAdvancesOneStep(t *testing.T) {
sched, err := Parse("0 18 * * *")
require.NoError(t, err)
after := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
got, skipped := sched.Next(after, true)
assert.True(t, skipped)
// First slot would be 2026-04-27 18:00 UTC; the skip rule advances
// to 2026-04-28 18:00 UTC.
assert.Equal(t, time.Date(2026, 4, 28, 18, 0, 0, 0, time.UTC), got)
}
func TestNextNormalisesNonUTCInput(t *testing.T) {
sched, err := Parse("*/15 * * * *")
require.NoError(t, err)
moscow := time.FixedZone("MSK", 3*60*60)
// 2026-04-27 15:30 MSK = 2026-04-27 12:30 UTC; next 15-minute slot
// in UTC is 12:45.
after := time.Date(2026, 4, 27, 15, 30, 0, 0, moscow)
got, skipped := sched.Next(after, false)
assert.False(t, skipped)
assert.Equal(t, time.Date(2026, 4, 27, 12, 45, 0, 0, time.UTC), got)
assert.Equal(t, time.UTC, got.Location())
}