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
+125
View File
@@ -0,0 +1,125 @@
package ports
import (
"context"
"encoding/json"
"errors"
)
//go:generate go run go.uber.org/mock/mockgen -destination=../adapters/mocks/mock_engineclient.go -package=mocks galaxy/gamemaster/internal/ports EngineClient
// EngineClient is the narrow surface Game Master uses against a running
// engine container. The production adapter (Stage 12) speaks REST/JSON
// against the engine routes documented in `galaxy/game/openapi.yaml`:
//
// - admin paths under `/api/v1/admin/*` (init, status, turn,
// race/banish);
// - player paths under `/api/v1/{command, order, report}`.
//
// The admin-path responses are typed (Init, Status, Turn) because GM
// reads structured fields out of them (`current_turn`, `finished`,
// per-player stats). The player-path payloads are forwarded verbatim:
// the gateway transcodes FlatBuffers to JSON, GM passes the JSON
// through, and the engine response is returned to the gateway
// unchanged.
type EngineClient interface {
// Init calls POST /api/v1/admin/init. The returned StateResponse
// carries the initial player roster used to install
// `player_mappings`.
Init(ctx context.Context, baseURL string, request InitRequest) (StateResponse, error)
// Status calls GET /api/v1/admin/status. Used by inspect surfaces
// and by recovery flows.
Status(ctx context.Context, baseURL string) (StateResponse, error)
// Turn calls PUT /api/v1/admin/turn. The returned StateResponse
// carries the new turn number, the per-player stats projected into
// `player_turn_stats`, and the `finished` flag.
Turn(ctx context.Context, baseURL string) (StateResponse, error)
// BanishRace calls POST /api/v1/admin/race/banish with body
// `{race_name}`. The engine returns 204 on success.
BanishRace(ctx context.Context, baseURL, raceName string) error
// ExecuteCommands calls PUT /api/v1/command. The request payload
// is forwarded verbatim; the engine response body is returned
// verbatim.
ExecuteCommands(ctx context.Context, baseURL string, payload json.RawMessage) (json.RawMessage, error)
// PutOrders calls PUT /api/v1/order with the same forwarding
// semantics as ExecuteCommands.
PutOrders(ctx context.Context, baseURL string, payload json.RawMessage) (json.RawMessage, error)
// GetReport calls GET /api/v1/report?player=<raceName>&turn=<turn>.
// The engine response body is returned verbatim.
GetReport(ctx context.Context, baseURL, raceName string, turn int) (json.RawMessage, error)
}
// InitRequest carries the race roster sent to the engine `/admin/init`
// route. The shape mirrors `galaxy/game/openapi.yaml`'s `InitRequest`.
type InitRequest struct {
// Races stores the per-player race entries in the order returned
// by Lobby's roster.
Races []InitRace
}
// InitRace stores one entry of an InitRequest.
type InitRace struct {
// RaceName stores the in-game race name reserved for the player.
RaceName string
}
// StateResponse is the typed projection of the engine's `StateResponse`
// payload (`galaxy/game/openapi.yaml`). GM reads only the fields it
// needs; the adapter is allowed to discard the rest.
type StateResponse struct {
// Turn stores the engine's current turn number.
Turn int
// Players stores the per-player state entries returned by the
// engine. Each entry is mapped into `player_turn_stats[]` by
// resolving `RaceName` through `playermappingstore.ListByGame` to
// the platform `user_id`.
Players []PlayerState
// Finished reports whether the engine considers the game finished.
// Becomes true on a turn-generation response when the engine's
// finish condition is satisfied.
Finished bool
}
// PlayerState stores one entry of StateResponse.Players. The set of
// fields is the minimum GM needs from the engine surface; the adapter
// may decode additional fields and discard them.
type PlayerState struct {
// RaceName stores the in-game race name.
RaceName string
// EnginePlayerUUID stores the engine-side player handle. Populated
// from `/admin/init` and `/admin/status`.
EnginePlayerUUID string
// Planets stores the planet count reported for this player on the
// most recent turn.
Planets int
// Population stores the population count reported for this player
// on the most recent turn.
Population int
}
// ErrEngineUnreachable reports that the engine returned a transport
// error or 5xx status code. Surfaced to callers as `engine_unreachable`.
var ErrEngineUnreachable = errors.New("engine unreachable")
// ErrEngineProtocolViolation reports that the engine responded with a
// payload that did not match the expected schema (missing required
// fields, malformed JSON, unexpected types). Surfaced as
// `engine_protocol_violation`.
var ErrEngineProtocolViolation = errors.New("engine protocol violation")
// ErrEngineValidation reports that the engine returned 4xx with a
// per-command result. Surfaced as `engine_validation_error`; the
// engine's body is returned verbatim to the caller through the player
// command/order forwarding paths.
var ErrEngineValidation = errors.New("engine validation error")
@@ -0,0 +1,127 @@
package ports
import (
"context"
"fmt"
"strings"
"time"
"galaxy/gamemaster/internal/domain/engineversion"
)
//go:generate go run go.uber.org/mock/mockgen -destination=../adapters/mocks/mock_engineversionstore.go -package=mocks galaxy/gamemaster/internal/ports EngineVersionStore
// EngineVersionStore stores the engine version registry rows used by
// Game Lobby's start flow and by GM's admin patch and registry CRUD
// surface. Adapters must preserve domain semantics:
//
// - Get returns engineversion.ErrNotFound when no row exists for
// version.
// - List with a nil status filter returns every row; with a non-nil
// filter, only rows whose status matches are returned.
// - Insert installs a fresh row and returns engineversion.ErrConflict
// when a row with the same `version` already exists. Adapters
// surface PostgreSQL unique violations through that sentinel so
// the service layer maps them to a `conflict` REST envelope.
// - Update applies a partial update; only fields whose pointer is
// non-nil are mutated. The `updated_at` column is always refreshed
// from input.Now.
// - Deprecate sets `status=deprecated` for an existing version with
// `updated_at = now`. It returns engineversion.ErrNotFound when no
// row exists. The call is idempotent: deprecating an already
// deprecated row succeeds with no further mutation.
// - Delete removes the row identified by version. Returns
// engineversion.ErrNotFound when no row matches. The service layer
// gates Delete behind an explicit IsReferencedByActiveRuntime probe
// so referenced rows surface engineversion.ErrInUse before the
// adapter is touched; adapters do not enforce that guard themselves.
// - IsReferencedByActiveRuntime reports whether any non-finished
// `runtime_records` row currently references the version through
// `current_engine_version`.
type EngineVersionStore interface {
// Get returns the row identified by version. Returns
// engineversion.ErrNotFound when no row exists.
Get(ctx context.Context, version string) (engineversion.EngineVersion, error)
// List returns every row whose status matches statusFilter when
// non-nil, or every row when nil. The order is adapter-defined.
List(ctx context.Context, statusFilter *engineversion.Status) ([]engineversion.EngineVersion, error)
// Insert installs record into the registry.
Insert(ctx context.Context, record engineversion.EngineVersion) error
// Update applies a partial update to the row identified by
// input.Version. Only fields whose pointer is non-nil are mutated.
// Returns engineversion.ErrNotFound when no row exists.
Update(ctx context.Context, input UpdateEngineVersionInput) error
// Deprecate sets `status=deprecated` for version and refreshes
// `updated_at` from now. Returns engineversion.ErrNotFound when no
// row exists. Calling Deprecate on an already-deprecated row
// succeeds with no mutation (idempotent).
Deprecate(ctx context.Context, version string, now time.Time) error
// Delete removes the row identified by version. Returns
// engineversion.ErrNotFound when no row matches. Adapters do not
// inspect runtime references; the service layer probes
// IsReferencedByActiveRuntime first and surfaces
// engineversion.ErrInUse independently.
Delete(ctx context.Context, version string) error
// IsReferencedByActiveRuntime reports whether any non-finished
// runtime row currently references version through
// `current_engine_version`. Used by the registry hard-delete path
// to surface engineversion.ErrInUse.
IsReferencedByActiveRuntime(ctx context.Context, version string) (bool, error)
}
// UpdateEngineVersionInput stores the arguments required to PATCH one
// engine version row. Pointer fields communicate «leave alone» (nil)
// vs. «write the value» (non-nil). At least one optional field must be
// set; otherwise the call is a no-op and Validate rejects it.
type UpdateEngineVersionInput struct {
// Version identifies the row to mutate.
Version string
// ImageRef is the new image reference. Nil leaves the column
// unchanged; non-nil must be non-empty.
ImageRef *string
// Options is the new options document (raw JSON). Nil leaves the
// column unchanged; non-nil writes the value verbatim.
Options *[]byte
// Status is the new status. Nil leaves the column unchanged;
// non-nil must be a known status.
Status *engineversion.Status
// Now stores the wall-clock used to refresh the `updated_at`
// column on every successful update.
Now time.Time
}
// Validate reports whether input contains a structurally valid PATCH
// request. Adapters call Validate before touching the store.
func (input UpdateEngineVersionInput) Validate() error {
if strings.TrimSpace(input.Version) == "" {
return fmt.Errorf("update engine version: version must not be empty")
}
if input.ImageRef == nil && input.Options == nil && input.Status == nil {
return fmt.Errorf("update engine version: at least one field must be set")
}
if input.ImageRef != nil && strings.TrimSpace(*input.ImageRef) == "" {
return fmt.Errorf(
"update engine version: image ref must not be empty when set",
)
}
if input.Status != nil && !input.Status.IsKnown() {
return fmt.Errorf(
"update engine version: status %q is unsupported",
*input.Status,
)
}
if input.Now.IsZero() {
return fmt.Errorf("update engine version: now must not be zero")
}
return nil
}
@@ -0,0 +1,101 @@
package ports
import (
"testing"
"time"
"galaxy/gamemaster/internal/domain/engineversion"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// fixedNow returns a stable wall-clock used by the input-validation
// fixtures. Adapters use the value verbatim to refresh the `updated_at`
// column.
func fixedNow() time.Time {
return time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
}
func TestUpdateEngineVersionInputValidateHappy(t *testing.T) {
imageRef := "ghcr.io/galaxy/game:v1.2.4"
input := UpdateEngineVersionInput{
Version: "v1.2.3",
ImageRef: &imageRef,
Now: fixedNow(),
}
require.NoError(t, input.Validate())
}
func TestUpdateEngineVersionInputValidateAcceptsStatusOnly(t *testing.T) {
status := engineversion.StatusDeprecated
input := UpdateEngineVersionInput{
Version: "v1.2.3",
Status: &status,
Now: fixedNow(),
}
assert.NoError(t, input.Validate())
}
func TestUpdateEngineVersionInputValidateAcceptsOptionsOnly(t *testing.T) {
options := []byte(`{"max_planets":120}`)
input := UpdateEngineVersionInput{
Version: "v1.2.3",
Options: &options,
Now: fixedNow(),
}
assert.NoError(t, input.Validate())
}
func TestUpdateEngineVersionInputValidateRejects(t *testing.T) {
emptyImage := ""
imageRef := "ghcr.io/galaxy/game:v1.2.4"
unknownStatus := engineversion.Status("exotic")
tests := []struct {
name string
input UpdateEngineVersionInput
}{
{
name: "empty version",
input: UpdateEngineVersionInput{
Version: "",
ImageRef: &imageRef,
Now: fixedNow(),
},
},
{
name: "no fields set",
input: UpdateEngineVersionInput{Version: "v1.2.3", Now: fixedNow()},
},
{
name: "empty image ref pointer",
input: UpdateEngineVersionInput{
Version: "v1.2.3",
ImageRef: &emptyImage,
Now: fixedNow(),
},
},
{
name: "unknown status pointer",
input: UpdateEngineVersionInput{
Version: "v1.2.3",
Status: &unknownStatus,
Now: fixedNow(),
},
},
{
name: "zero now",
input: UpdateEngineVersionInput{
Version: "v1.2.3",
ImageRef: &imageRef,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Error(t, tt.input.Validate())
})
}
}
+93
View File
@@ -0,0 +1,93 @@
package ports
import (
"context"
"errors"
"time"
)
//go:generate go run go.uber.org/mock/mockgen -destination=../adapters/mocks/mock_lobbyclient.go -package=mocks galaxy/gamemaster/internal/ports LobbyClient
// LobbyClient executes synchronous calls to Game Lobby. The port
// surfaces two operations:
//
// - GetMemberships — used by the membership cache to authorise player
// commands on the hot path.
// - GetGameSummary — used by the turn-generation orchestrator to
// resolve the human-readable `game_name` consumed by
// `notification:intents` payloads (`game.turn.ready`,
// `game.finished`, `game.generation_failed`). Failure is fail-soft:
// callers fall back to `game_id` rather than block the runtime
// mutation.
//
// Membership data and the game record are owned by Game Lobby; GM
// treats them as remote projections. Consequently the Membership and
// GameSummary types live on the port file rather than as domain types,
// mirroring rtmanager's `LobbyGameRecord` precedent.
type LobbyClient interface {
// GetMemberships returns every membership of gameID, in any
// status. The cache layer filters to `active` for authorisation.
// Implementations wrap any non-success outcome (transport error,
// timeout, non-2xx response) with ErrLobbyUnavailable so callers
// can branch with errors.Is.
GetMemberships(ctx context.Context, gameID string) ([]Membership, error)
// GetGameSummary returns the narrow projection of Lobby's
// `GameRecord` GM needs to populate notification payloads with a
// human-readable `game_name`. Implementations wrap any non-success
// outcome (transport error, timeout, non-2xx response, malformed
// payload) with ErrLobbyUnavailable.
GetGameSummary(ctx context.Context, gameID string) (GameSummary, error)
}
// Membership stores one row of the membership projection returned by
// `Lobby /api/v1/internal/games/{game_id}/memberships`. The shape
// mirrors `MembershipRecord` in
// `galaxy/lobby/api/internal-openapi.yaml`.
type Membership struct {
// UserID identifies the platform user.
UserID string
// RaceName stores the in-game race reserved for the user.
RaceName string
// Status reports `active`, `removed`, or `blocked`. GM authorises
// only `active` callers on the hot path.
Status string
// JoinedAt stores the wall-clock at which the membership entered
// active.
JoinedAt time.Time
// RemovedAt stores the wall-clock at which the membership left
// active. Nil while the membership is still active.
RemovedAt *time.Time
}
// GameSummary stores the narrow projection of Lobby's `GameRecord` GM
// consumes today: the platform game id, the human-readable
// `game_name`, and the platform-level lifecycle status. Additional
// fields can be added without breaking consumers because every caller
// reads through the typed fields directly.
type GameSummary struct {
// GameID identifies the platform game. Echoed back from Lobby as a
// sanity check.
GameID string
// GameName stores the human-readable game name maintained by
// Lobby. Used by the turn-generation orchestrator to populate
// `game_name` on `notification:intents` payloads.
GameName string
// Status stores Lobby's platform-level lifecycle status (`draft`,
// `enrollment_open`, `running`, `finished`, etc.). GM does not act
// on the value today; it is captured for future audit/log use.
Status string
}
// ErrLobbyUnavailable signals that a Lobby call could not be completed
// because the upstream service was unreachable, returned an error
// response, or timed out. GM's hot-path callers treat any non-success
// outcome uniformly: the player command is rejected with
// `service_unavailable` and the cache TTL eventually retries.
var ErrLobbyUnavailable = errors.New("lobby unavailable")
@@ -0,0 +1,166 @@
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_lobbyeventspublisher.go -package=mocks galaxy/gamemaster/internal/ports LobbyEventsPublisher
// LobbyEventsPublisher is the producer port for the `gm:lobby_events`
// Redis Stream consumed by Game Lobby. Two message shapes share the
// stream, discriminated by `event_type` per
// `galaxy/gamemaster/api/runtime-events-asyncapi.yaml`:
//
// - runtime_snapshot_update — every turn generation outcome and every
// status / health-summary transition.
// - game_finished — the terminal event published once per game when
// the engine reports `finished:true`.
type LobbyEventsPublisher interface {
// PublishSnapshotUpdate appends a `runtime_snapshot_update` message
// to the stream. Adapters validate msg through msg.Validate before
// touching Redis.
PublishSnapshotUpdate(ctx context.Context, msg RuntimeSnapshotUpdate) error
// PublishGameFinished appends a `game_finished` message to the
// stream. Adapters validate msg through msg.Validate before
// touching Redis.
PublishGameFinished(ctx context.Context, msg GameFinished) error
}
// PlayerTurnStats stores the per-player projection carried on every
// `runtime_snapshot_update` and `game_finished` message. The shape is
// frozen in the AsyncAPI spec.
type PlayerTurnStats struct {
// UserID identifies the platform user.
UserID string
// Planets stores the planet count reported for this user on the
// most recent turn.
Planets int
// Population stores the population count reported for this user
// on the most recent turn.
Population int
}
// Validate reports whether stats carries valid per-player projection
// values.
func (stats PlayerTurnStats) Validate() error {
if strings.TrimSpace(stats.UserID) == "" {
return fmt.Errorf("player turn stats: user id must not be empty")
}
if stats.Planets < 0 {
return fmt.Errorf("player turn stats: planets must not be negative")
}
if stats.Population < 0 {
return fmt.Errorf("player turn stats: population must not be negative")
}
return nil
}
// RuntimeSnapshotUpdate stores the body of a `runtime_snapshot_update`
// message.
type RuntimeSnapshotUpdate struct {
// GameID identifies the game the snapshot belongs to.
GameID string
// CurrentTurn stores the latest completed turn number.
CurrentTurn int
// RuntimeStatus stores the latest GM-side status of the runtime.
RuntimeStatus runtime.Status
// EngineHealthSummary stores the current health summary string.
// Empty when no observation has been processed yet.
EngineHealthSummary string
// PlayerTurnStats stores the per-active-member projection. Empty
// when the snapshot is published for a status transition with no
// new turn payload.
PlayerTurnStats []PlayerTurnStats
// OccurredAt stores the wall-clock at which the snapshot was
// produced. Always UTC.
OccurredAt time.Time
}
// Validate reports whether msg satisfies the AsyncAPI-frozen invariants.
func (msg RuntimeSnapshotUpdate) Validate() error {
if strings.TrimSpace(msg.GameID) == "" {
return fmt.Errorf("runtime snapshot update: game id must not be empty")
}
if msg.CurrentTurn < 0 {
return fmt.Errorf("runtime snapshot update: current turn must not be negative")
}
if !msg.RuntimeStatus.IsKnown() {
return fmt.Errorf(
"runtime snapshot update: runtime status %q is unsupported",
msg.RuntimeStatus,
)
}
if msg.OccurredAt.IsZero() {
return fmt.Errorf("runtime snapshot update: occurred at must not be zero")
}
for i, stats := range msg.PlayerTurnStats {
if err := stats.Validate(); err != nil {
return fmt.Errorf(
"runtime snapshot update: player turn stats[%d]: %w",
i, err,
)
}
}
return nil
}
// GameFinished stores the body of a `game_finished` message.
type GameFinished struct {
// GameID identifies the game that finished.
GameID string
// FinalTurnNumber stores the turn number on which the engine
// reported `finished:true`.
FinalTurnNumber int
// RuntimeStatus is always runtime.StatusFinished. Carried in the
// message body so consumers can apply the same decoder to both
// stream shapes.
RuntimeStatus runtime.Status
// PlayerTurnStats stores the final per-player projection used by
// Lobby's capability evaluation.
PlayerTurnStats []PlayerTurnStats
// FinishedAt stores the wall-clock at which the engine returned
// the finished response. Always UTC.
FinishedAt time.Time
}
// Validate reports whether msg satisfies the AsyncAPI-frozen invariants.
func (msg GameFinished) Validate() error {
if strings.TrimSpace(msg.GameID) == "" {
return fmt.Errorf("game finished: game id must not be empty")
}
if msg.FinalTurnNumber < 0 {
return fmt.Errorf("game finished: final turn number must not be negative")
}
if msg.RuntimeStatus != runtime.StatusFinished {
return fmt.Errorf(
"game finished: runtime status must be %q, got %q",
runtime.StatusFinished, msg.RuntimeStatus,
)
}
if msg.FinishedAt.IsZero() {
return fmt.Errorf("game finished: finished at must not be zero")
}
for i, stats := range msg.PlayerTurnStats {
if err := stats.Validate(); err != nil {
return fmt.Errorf("game finished: player turn stats[%d]: %w", i, err)
}
}
return nil
}
@@ -0,0 +1,112 @@
package ports
import (
"testing"
"time"
"galaxy/gamemaster/internal/domain/runtime"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func validSnapshotUpdate() RuntimeSnapshotUpdate {
return RuntimeSnapshotUpdate{
GameID: "game-1",
CurrentTurn: 3,
RuntimeStatus: runtime.StatusRunning,
EngineHealthSummary: "healthy",
PlayerTurnStats: []PlayerTurnStats{
{UserID: "user-1", Planets: 1, Population: 100},
{UserID: "user-2", Planets: 2, Population: 200},
},
OccurredAt: time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC),
}
}
func validGameFinished() GameFinished {
return GameFinished{
GameID: "game-1",
FinalTurnNumber: 42,
RuntimeStatus: runtime.StatusFinished,
PlayerTurnStats: []PlayerTurnStats{
{UserID: "user-1", Planets: 5, Population: 500},
},
FinishedAt: time.Date(2026, 4, 27, 18, 30, 0, 0, time.UTC),
}
}
func TestRuntimeSnapshotUpdateValidateHappy(t *testing.T) {
require.NoError(t, validSnapshotUpdate().Validate())
}
func TestRuntimeSnapshotUpdateValidateAcceptsEmptyStats(t *testing.T) {
msg := validSnapshotUpdate()
msg.PlayerTurnStats = nil
assert.NoError(t, msg.Validate())
}
func TestRuntimeSnapshotUpdateValidateRejects(t *testing.T) {
tests := []struct {
name string
mutate func(*RuntimeSnapshotUpdate)
}{
{"empty game id", func(m *RuntimeSnapshotUpdate) { m.GameID = "" }},
{"negative turn", func(m *RuntimeSnapshotUpdate) { m.CurrentTurn = -1 }},
{"unknown status", func(m *RuntimeSnapshotUpdate) { m.RuntimeStatus = "exotic" }},
{"zero occurred at", func(m *RuntimeSnapshotUpdate) { m.OccurredAt = time.Time{} }},
{"bad stats entry", func(m *RuntimeSnapshotUpdate) {
m.PlayerTurnStats = append(m.PlayerTurnStats, PlayerTurnStats{
UserID: "", Planets: 0, Population: 0,
})
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
msg := validSnapshotUpdate()
tt.mutate(&msg)
assert.Error(t, msg.Validate())
})
}
}
func TestGameFinishedValidateHappy(t *testing.T) {
require.NoError(t, validGameFinished().Validate())
}
func TestGameFinishedValidateRejects(t *testing.T) {
tests := []struct {
name string
mutate func(*GameFinished)
}{
{"empty game id", func(m *GameFinished) { m.GameID = "" }},
{"negative final turn", func(m *GameFinished) { m.FinalTurnNumber = -1 }},
{"non-finished status", func(m *GameFinished) { m.RuntimeStatus = runtime.StatusRunning }},
{"zero finished at", func(m *GameFinished) { m.FinishedAt = time.Time{} }},
{"bad stats entry", func(m *GameFinished) {
m.PlayerTurnStats = append(m.PlayerTurnStats, PlayerTurnStats{
UserID: "user-bad", Planets: -1, Population: 0,
})
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
msg := validGameFinished()
tt.mutate(&msg)
assert.Error(t, msg.Validate())
})
}
}
func TestPlayerTurnStatsValidateRejects(t *testing.T) {
bad := PlayerTurnStats{UserID: "", Planets: 0, Population: 0}
assert.Error(t, bad.Validate())
bad = PlayerTurnStats{UserID: "u", Planets: -1, Population: 0}
assert.Error(t, bad.Validate())
bad = PlayerTurnStats{UserID: "u", Planets: 0, Population: -1}
assert.Error(t, bad.Validate())
}
@@ -0,0 +1,24 @@
package ports
import (
"context"
"galaxy/notificationintent"
)
//go:generate go run go.uber.org/mock/mockgen -destination=../adapters/mocks/mock_notificationpublisher.go -package=mocks galaxy/gamemaster/internal/ports NotificationIntentPublisher
// NotificationIntentPublisher is the producer port Game Master uses to
// publish notification intents to Notification Service. The production
// adapter is a thin wrapper around `notificationintent.Publisher`.
//
// A failed Publish call is a notification degradation per
// `galaxy/gamemaster/README.md §Notification Contracts` and must not
// roll back already committed runtime state. Callers log the error
// and proceed.
type NotificationIntentPublisher interface {
// Publish normalises intent and appends it to the configured
// Redis Stream. Validation failures and transport errors are
// returned verbatim.
Publish(ctx context.Context, intent notificationintent.Intent) error
}
+24
View File
@@ -0,0 +1,24 @@
package ports
import (
"context"
"galaxy/gamemaster/internal/domain/operation"
)
//go:generate go run go.uber.org/mock/mockgen -destination=../adapters/mocks/mock_operationlog.go -package=mocks galaxy/gamemaster/internal/ports OperationLogStore
// OperationLogStore stores append-only audit entries for every
// operation Game Master performs. Adapters must persist entry verbatim
// and return the generated bigserial id from Append.
type OperationLogStore interface {
// Append inserts entry into the operation log and returns the
// generated bigserial id. Adapters validate entry through
// operation.OperationEntry.Validate before touching the store.
Append(ctx context.Context, entry operation.OperationEntry) (id int64, err error)
// ListByGame returns the most recent entries for gameID, ordered
// by started_at descending and capped by limit. A non-positive
// limit is rejected as invalid input by adapters.
ListByGame(ctx context.Context, gameID string, limit int) ([]operation.OperationEntry, error)
}
@@ -0,0 +1,47 @@
package ports
import (
"context"
"galaxy/gamemaster/internal/domain/playermapping"
)
//go:generate go run go.uber.org/mock/mockgen -destination=../adapters/mocks/mock_playermappingstore.go -package=mocks galaxy/gamemaster/internal/ports PlayerMappingStore
// PlayerMappingStore stores the (game_id, user_id) → race_name +
// engine_player_uuid projection installed at register-runtime. Adapters
// must preserve the storage-level invariants enforced by
// `00001_init.sql`:
//
// - composite primary key on `(game_id, user_id)`;
// - UNIQUE on `(game_id, race_name)` (one race per game).
//
// BulkInsert is the only ingestion path: register-runtime inserts every
// row for a game in one batch. Per-row mutation is intentionally not
// exposed; rosters are immutable for the lifetime of the runtime.
type PlayerMappingStore interface {
// BulkInsert installs every mapping in records. Adapters validate
// each record through playermapping.PlayerMapping.Validate before
// touching the store. Adapters may use a single multi-row INSERT
// or one transaction with N rows; either way the operation is
// atomic.
BulkInsert(ctx context.Context, records []playermapping.PlayerMapping) error
// Get returns the mapping identified by (gameID, userID). Returns
// playermapping.ErrNotFound when no row exists.
Get(ctx context.Context, gameID, userID string) (playermapping.PlayerMapping, error)
// GetByRace returns the mapping identified by (gameID, raceName).
// Used by the admin banish flow (Stage 17) to resolve the engine
// player UUID for the engine /admin/race/banish call. Returns
// playermapping.ErrNotFound when no row exists.
GetByRace(ctx context.Context, gameID, raceName string) (playermapping.PlayerMapping, error)
// ListByGame returns every mapping owned by gameID. The order is
// adapter-defined; callers may reorder as needed.
ListByGame(ctx context.Context, gameID string) ([]playermapping.PlayerMapping, error)
// DeleteByGame removes every mapping owned by gameID. Returns nil
// even when no rows were deleted (idempotent).
DeleteByGame(ctx context.Context, gameID string) error
}
+34
View File
@@ -0,0 +1,34 @@
package ports
import (
"context"
"errors"
)
//go:generate go run go.uber.org/mock/mockgen -destination=../adapters/mocks/mock_rtmclient.go -package=mocks galaxy/gamemaster/internal/ports RTMClient
// RTMClient executes synchronous calls to Runtime Manager over the
// trusted internal REST surface documented in
// `galaxy/rtmanager/api/internal-openapi.yaml`. GM uses RTM only for
// stop and patch lifecycle actions in v1.
//
// `Restart` is reserved per `gamemaster/PLAN.md` Stage 10 («reserved;
// not in v1 feature scope») and is intentionally absent from the v1
// surface. It will be added in a later iteration if a use case
// emerges.
type RTMClient interface {
// Stop calls POST /api/v1/internal/runtimes/{game_id}/stop with
// body `{reason}`. Implementations wrap any non-success outcome
// with ErrRTMUnavailable so callers can branch with errors.Is.
Stop(ctx context.Context, gameID, reason string) error
// Patch calls POST /api/v1/internal/runtimes/{game_id}/patch with
// body `{image_ref}`. Implementations wrap any non-success outcome
// with ErrRTMUnavailable so callers can branch with errors.Is.
Patch(ctx context.Context, gameID, imageRef string) error
}
// ErrRTMUnavailable signals that a Runtime Manager call could not be
// completed because the upstream service was unreachable, returned an
// error response, or timed out.
var ErrRTMUnavailable = errors.New("runtime manager unavailable")
@@ -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
}
@@ -0,0 +1,122 @@
package ports
import (
"errors"
"testing"
"time"
"galaxy/gamemaster/internal/domain/runtime"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func validUpdateStatusInput() UpdateStatusInput {
return UpdateStatusInput{
GameID: "game-1",
ExpectedFrom: runtime.StatusRunning,
To: runtime.StatusGenerationInProgress,
Now: time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC),
}
}
func validUpdateSchedulingInput() UpdateSchedulingInput {
next := time.Date(2026, 4, 27, 18, 0, 0, 0, time.UTC)
return UpdateSchedulingInput{
GameID: "game-1",
NextGenerationAt: &next,
SkipNextTick: false,
CurrentTurn: 1,
Now: time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC),
}
}
func TestUpdateStatusInputValidateHappy(t *testing.T) {
require.NoError(t, validUpdateStatusInput().Validate())
}
func TestUpdateStatusInputValidateAcceptsOptionalFields(t *testing.T) {
imageRef := "ghcr.io/galaxy/game:v1.2.4"
version := "v1.2.4"
summary := "healthy"
input := validUpdateStatusInput()
input.CurrentImageRef = &imageRef
input.CurrentEngineVersion = &version
input.EngineHealthSummary = &summary
assert.NoError(t, input.Validate())
}
func TestUpdateStatusInputValidateRejects(t *testing.T) {
emptyImageRef := ""
emptyVersion := ""
tests := []struct {
name string
mutate func(*UpdateStatusInput)
}{
{"empty game id", func(i *UpdateStatusInput) { i.GameID = "" }},
{"unknown expected from", func(i *UpdateStatusInput) { i.ExpectedFrom = "exotic" }},
{"unknown to", func(i *UpdateStatusInput) { i.To = "exotic" }},
{"zero now", func(i *UpdateStatusInput) { i.Now = time.Time{} }},
{"empty image ref pointer", func(i *UpdateStatusInput) {
i.CurrentImageRef = &emptyImageRef
}},
{"empty engine version pointer", func(i *UpdateStatusInput) {
i.CurrentEngineVersion = &emptyVersion
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
input := validUpdateStatusInput()
tt.mutate(&input)
assert.Error(t, input.Validate())
})
}
}
func TestUpdateStatusInputValidateRejectsForbiddenTransition(t *testing.T) {
input := validUpdateStatusInput()
input.ExpectedFrom = runtime.StatusFinished
input.To = runtime.StatusRunning
err := input.Validate()
require.Error(t, err)
assert.True(t, errors.Is(err, runtime.ErrInvalidTransition))
}
func TestUpdateSchedulingInputValidateHappy(t *testing.T) {
require.NoError(t, validUpdateSchedulingInput().Validate())
}
func TestUpdateSchedulingInputValidateAcceptsNullNextGen(t *testing.T) {
input := validUpdateSchedulingInput()
input.NextGenerationAt = nil
assert.NoError(t, input.Validate())
}
func TestUpdateSchedulingInputValidateRejects(t *testing.T) {
zero := time.Time{}
tests := []struct {
name string
mutate func(*UpdateSchedulingInput)
}{
{"empty game id", func(i *UpdateSchedulingInput) { i.GameID = "" }},
{"negative current turn", func(i *UpdateSchedulingInput) { i.CurrentTurn = -1 }},
{"zero next gen pointer", func(i *UpdateSchedulingInput) {
i.NextGenerationAt = &zero
}},
{"zero now", func(i *UpdateSchedulingInput) { i.Now = time.Time{} }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
input := validUpdateSchedulingInput()
tt.mutate(&input)
assert.Error(t, input.Validate())
})
}
}
@@ -0,0 +1,25 @@
package ports
import "context"
//go:generate go run go.uber.org/mock/mockgen -destination=../adapters/mocks/mock_streamoffsetstore.go -package=mocks galaxy/gamemaster/internal/ports StreamOffsetStore
// StreamOffsetStore persists the last successfully processed Redis
// Stream entry id per consumer label. Workers call Load on startup to
// resume from the persisted offset and Save after every successful
// message handling so the next iteration advances past the
// just-processed entry. The label is the short logical identifier of
// the consumer (e.g., `health_events`), not the full stream name; it
// stays stable when the underlying stream key is renamed.
type StreamOffsetStore interface {
// Load returns the last processed entry id for the consumer
// labelled stream when one is stored. The boolean return reports
// whether a value was present; implementations must not return an
// error for a missing key.
Load(ctx context.Context, stream string) (entryID string, found bool, err error)
// Save stores entryID as the new last processed offset for the
// consumer labelled stream. Implementations overwrite any previous
// value unconditionally.
Save(ctx context.Context, stream, entryID string) error
}