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