feat: gamemaster
This commit is contained in:
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user