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,307 @@
// Package ports defines the stable interfaces that connect Game Master
// use cases to external state and external services.
package ports
import (
"context"
"fmt"
"strings"
"time"
"galaxy/gamemaster/internal/domain/runtime"
)
//go:generate go run go.uber.org/mock/mockgen -destination=../adapters/mocks/mock_runtimerecordstore.go -package=mocks galaxy/gamemaster/internal/ports RuntimeRecordStore
// RuntimeRecordStore stores runtime records and exposes the operations
// used by the service layer (Stages 13+) and the workers (Stages 15-18).
// Adapters must preserve domain semantics:
//
// - Get returns runtime.ErrNotFound when no record exists for gameID.
// - Insert installs a fresh record and returns runtime.ErrConflict
// when a row already exists.
// - UpdateStatus applies one transition through a compare-and-swap
// guard on the stored status and returns runtime.ErrConflict on a
// stale CAS.
// - UpdateScheduling mutates `next_generation_at`, `skip_next_tick`,
// and `current_turn` together; the destination status is unaffected.
// - UpdateImage rotates `current_image_ref` and
// `current_engine_version` under a compare-and-swap guard on the
// stored status and returns runtime.ErrConflict on a stale CAS.
// - UpdateEngineHealth rotates the `engine_health` column without
// touching status. The call applies from any status (including
// stopped and finished) so late-arriving health observations still
// bookkeep correctly. Returns runtime.ErrNotFound when no row
// matches.
// - Delete removes the record identified by gameID. The call is
// idempotent: it returns nil even when no row matches.
// - ListDueRunning returns every running record with
// `next_generation_at <= now`.
// - ListByStatus returns every record currently indexed under status.
// - List returns every record ordered by `created_at` descending. Used
// by the `internalListRuntimes` REST handler when no status filter
// is supplied.
type RuntimeRecordStore interface {
// Get returns the record identified by gameID. It returns
// runtime.ErrNotFound when no record exists.
Get(ctx context.Context, gameID string) (runtime.RuntimeRecord, error)
// Insert installs record into the store. It returns
// runtime.ErrConflict when a row already exists for record.GameID.
Insert(ctx context.Context, record runtime.RuntimeRecord) error
// UpdateStatus applies one status transition in a compare-and-swap
// fashion. The adapter must first call runtime.Transition to reject
// invalid pairs without touching the store, then verify that the
// stored status equals input.ExpectedFrom. Optional fields on the
// input (CurrentImageRef, CurrentEngineVersion, EngineHealthSummary)
// are persisted only when non-nil.
UpdateStatus(ctx context.Context, input UpdateStatusInput) error
// UpdateScheduling mutates the scheduling columns
// (`next_generation_at`, `skip_next_tick`, `current_turn`) of the
// record identified by input.GameID. The store does not validate
// the runtime status; callers issue UpdateScheduling alongside an
// UpdateStatus when the destination status changes.
UpdateScheduling(ctx context.Context, input UpdateSchedulingInput) error
// UpdateImage rotates `current_image_ref` and
// `current_engine_version` of the record identified by
// input.GameID under a compare-and-swap guard on the stored status.
// The destination status is unchanged. Used by the admin patch
// flow (Stage 17) where the runtime stays `running` while the
// engine container is recreated by Runtime Manager with a new
// image. Returns runtime.ErrNotFound when no row matches and
// runtime.ErrConflict when the stored status differs from
// input.ExpectedStatus.
UpdateImage(ctx context.Context, input UpdateImageInput) error
// UpdateEngineHealth rotates the `engine_health` column of the
// record identified by input.GameID without touching status. Used
// by the runtime:health_events consumer (Stage 18) when an
// observation should refresh the summary regardless of the current
// runtime status (including stopped and finished, so late-arriving
// events still bookkeep correctly). Returns runtime.ErrNotFound
// when no row matches.
UpdateEngineHealth(ctx context.Context, input UpdateEngineHealthInput) error
// Delete removes the record identified by gameID. The call is
// idempotent: it returns nil even when no row matches. Used by the
// register-runtime rollback path (Stage 13) when the engine
// /admin/init call or any later setup step fails after the row has
// been installed with status=starting.
Delete(ctx context.Context, gameID string) error
// ListDueRunning returns every record whose status is `running`
// and whose `next_generation_at <= now`. The order is
// adapter-defined; callers may reorder as needed.
ListDueRunning(ctx context.Context, now time.Time) ([]runtime.RuntimeRecord, error)
// ListByStatus returns every record currently indexed under status.
// The order is adapter-defined; callers may reorder as needed.
ListByStatus(ctx context.Context, status runtime.Status) ([]runtime.RuntimeRecord, error)
// List returns every record in the store, ordered by `created_at`
// descending. Used by the `internalListRuntimes` REST handler when no
// status filter is supplied.
List(ctx context.Context) ([]runtime.RuntimeRecord, error)
}
// UpdateStatusInput stores the arguments required to apply one status
// transition through a RuntimeRecordStore. The optional fields are
// pointers so the adapter can distinguish «leave alone» from «write
// the zero value».
type UpdateStatusInput struct {
// GameID identifies the record to mutate.
GameID string
// ExpectedFrom stores the status the caller believes the record
// currently has. A mismatch results in runtime.ErrConflict.
ExpectedFrom runtime.Status
// To stores the destination status.
To runtime.Status
// Now stores the wall-clock used to derive the lifecycle timestamps
// (started_at, stopped_at, finished_at, updated_at) according to
// To.
Now time.Time
// EngineHealthSummary is the new value of the `engine_health`
// column. Nil leaves the column unchanged.
EngineHealthSummary *string
// CurrentImageRef is the new value of the `current_image_ref`
// column. Nil leaves the column unchanged. Used by the patch flow
// (Stage 17) when the image reference rotates together with the
// status update.
CurrentImageRef *string
// CurrentEngineVersion is the new value of the
// `current_engine_version` column. Nil leaves the column unchanged.
// Used by the patch flow when the engine version rotates together
// with the status update.
CurrentEngineVersion *string
}
// Validate reports whether input contains a structurally valid status
// transition request. Adapters call Validate before touching the store.
func (input UpdateStatusInput) Validate() error {
if strings.TrimSpace(input.GameID) == "" {
return fmt.Errorf("update runtime status: game id must not be empty")
}
if !input.ExpectedFrom.IsKnown() {
return fmt.Errorf(
"update runtime status: expected from status %q is unsupported",
input.ExpectedFrom,
)
}
if !input.To.IsKnown() {
return fmt.Errorf(
"update runtime status: to status %q is unsupported",
input.To,
)
}
if err := runtime.Transition(input.ExpectedFrom, input.To); err != nil {
return fmt.Errorf("update runtime status: %w", err)
}
if input.Now.IsZero() {
return fmt.Errorf("update runtime status: now must not be zero")
}
if input.CurrentImageRef != nil && strings.TrimSpace(*input.CurrentImageRef) == "" {
return fmt.Errorf(
"update runtime status: current image ref must not be empty when set",
)
}
if input.CurrentEngineVersion != nil && strings.TrimSpace(*input.CurrentEngineVersion) == "" {
return fmt.Errorf(
"update runtime status: current engine version must not be empty when set",
)
}
return nil
}
// UpdateSchedulingInput stores the arguments required to mutate the
// scheduling columns of one runtime record. The status enum is
// deliberately absent: scheduling and status updates are independent
// operations and the service layer composes them when both must change.
type UpdateSchedulingInput struct {
// GameID identifies the record to mutate.
GameID string
// NextGenerationAt is the new value of the column. Nil writes SQL
// NULL (used to clear the tick when the runtime leaves running).
NextGenerationAt *time.Time
// SkipNextTick is the new value of the column. The store overwrites
// the column unconditionally.
SkipNextTick bool
// CurrentTurn is the new value of the column. Must be non-negative.
CurrentTurn int
// Now stores the wall-clock used to refresh `updated_at`.
Now time.Time
}
// Validate reports whether input contains structurally valid scheduling
// arguments. Adapters call Validate before touching the store.
func (input UpdateSchedulingInput) Validate() error {
if strings.TrimSpace(input.GameID) == "" {
return fmt.Errorf("update runtime scheduling: game id must not be empty")
}
if input.CurrentTurn < 0 {
return fmt.Errorf("update runtime scheduling: current turn must not be negative")
}
if input.NextGenerationAt != nil && input.NextGenerationAt.IsZero() {
return fmt.Errorf(
"update runtime scheduling: next generation at must not be zero when set",
)
}
if input.Now.IsZero() {
return fmt.Errorf("update runtime scheduling: now must not be zero")
}
return nil
}
// UpdateImageInput stores the arguments required to rotate the engine
// image reference and version of one runtime record without changing
// its status. The store applies a compare-and-swap guard on
// `(game_id, status)` so callers can reject the update if the runtime
// has drifted out of the expected status.
type UpdateImageInput struct {
// GameID identifies the record to mutate.
GameID string
// ExpectedStatus stores the status the caller believes the record
// currently has. A mismatch results in runtime.ErrConflict.
ExpectedStatus runtime.Status
// CurrentImageRef stores the new value of the
// `current_image_ref` column. Must not be empty.
CurrentImageRef string
// CurrentEngineVersion stores the new value of the
// `current_engine_version` column. Must not be empty.
CurrentEngineVersion string
// Now stores the wall-clock used to refresh `updated_at`.
Now time.Time
}
// Validate reports whether input contains structurally valid image
// rotation arguments. Adapters call Validate before touching the store.
func (input UpdateImageInput) Validate() error {
if strings.TrimSpace(input.GameID) == "" {
return fmt.Errorf("update runtime image: game id must not be empty")
}
if !input.ExpectedStatus.IsKnown() {
return fmt.Errorf(
"update runtime image: expected status %q is unsupported",
input.ExpectedStatus,
)
}
if strings.TrimSpace(input.CurrentImageRef) == "" {
return fmt.Errorf("update runtime image: current image ref must not be empty")
}
if strings.TrimSpace(input.CurrentEngineVersion) == "" {
return fmt.Errorf("update runtime image: current engine version must not be empty")
}
if input.Now.IsZero() {
return fmt.Errorf("update runtime image: now must not be zero")
}
return nil
}
// UpdateEngineHealthInput stores the arguments required to rotate the
// `engine_health` column of one runtime record without touching its
// status. The store performs no compare-and-swap so callers can apply
// the update from any runtime status (including stopped and finished)
// to keep the summary current for late-arriving runtime:health_events.
type UpdateEngineHealthInput struct {
// GameID identifies the record to mutate.
GameID string
// EngineHealthSummary stores the new value of the `engine_health`
// column. The summary is a free-form short string drawn from the
// vocabulary documented in
// `gamemaster/README.md §Persistence Layout` and produced by the
// Stage 18 consumer.
EngineHealthSummary string
// Now stores the wall-clock used to refresh `updated_at`.
Now time.Time
}
// Validate reports whether input carries structurally valid arguments
// for an engine-health update. Adapters call Validate before touching
// the store.
func (input UpdateEngineHealthInput) Validate() error {
if strings.TrimSpace(input.GameID) == "" {
return fmt.Errorf("update runtime engine health: game id must not be empty")
}
if input.Now.IsZero() {
return fmt.Errorf("update runtime engine health: now must not be zero")
}
return nil
}