feat: gamemaster

This commit is contained in:
Ilia Denisov
2026-05-03 07:59:03 +02:00
committed by GitHub
parent a7cee15115
commit 3e2622757e
229 changed files with 41521 additions and 1098 deletions
@@ -0,0 +1,441 @@
// Package engineclient provides the trusted-internal HTTP client Game
// Master uses to talk to the engine container. The adapter implements
// `ports.EngineClient` over the 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 engine endpoint URL is per-call (Game Master keeps it on
// `runtime_records.engine_endpoint`), so the client does not bind a
// base URL at construction time. Only the per-call timeouts are wired
// through `Config`: `CallTimeout` covers turn-generation-class
// operations, `ProbeTimeout` covers inspect-style reads.
package engineclient
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"galaxy/gamemaster/internal/ports"
)
const (
pathAdminInit = "/api/v1/admin/init"
pathAdminStatus = "/api/v1/admin/status"
pathAdminTurn = "/api/v1/admin/turn"
pathAdminRaceBanish = "/api/v1/admin/race/banish"
pathPlayerCommand = "/api/v1/command"
pathPlayerOrder = "/api/v1/order"
pathPlayerReport = "/api/v1/report"
)
// Config configures one HTTP-backed engine client.
type Config struct {
// CallTimeout bounds turn-generation-class operations: init, turn,
// banish, command, order. Mirrors `GAMEMASTER_ENGINE_CALL_TIMEOUT`.
CallTimeout time.Duration
// ProbeTimeout bounds inspect-style reads: status, report. Mirrors
// `GAMEMASTER_ENGINE_PROBE_TIMEOUT`.
ProbeTimeout time.Duration
}
// Client speaks REST/JSON to the engine container.
type Client struct {
callTimeout time.Duration
probeTimeout time.Duration
httpClient *http.Client
closeIdleConnections func()
}
// NewClient constructs an engine client with `otelhttp`-instrumented
// transport cloned from `http.DefaultTransport`. The returned `Close`
// hook releases idle connections owned by that transport.
func NewClient(cfg Config) (*Client, error) {
transport, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return nil, errors.New("new engine client: default transport is not *http.Transport")
}
cloned := transport.Clone()
return newClient(cfg, &http.Client{Transport: otelhttp.NewTransport(cloned)}, cloned.CloseIdleConnections)
}
func newClient(cfg Config, httpClient *http.Client, closeIdleConnections func()) (*Client, error) {
switch {
case cfg.CallTimeout <= 0:
return nil, errors.New("new engine client: call timeout must be positive")
case cfg.ProbeTimeout <= 0:
return nil, errors.New("new engine client: probe timeout must be positive")
case httpClient == nil:
return nil, errors.New("new engine client: http client must not be nil")
}
return &Client{
callTimeout: cfg.CallTimeout,
probeTimeout: cfg.ProbeTimeout,
httpClient: httpClient,
closeIdleConnections: closeIdleConnections,
}, nil
}
// Close releases idle HTTP connections owned by the underlying
// transport. Safe to call multiple times.
func (client *Client) Close() error {
if client == nil || client.closeIdleConnections == nil {
return nil
}
client.closeIdleConnections()
return nil
}
// Init calls POST /api/v1/admin/init.
func (client *Client) Init(ctx context.Context, baseURL string, request ports.InitRequest) (ports.StateResponse, error) {
if err := client.validateBase(baseURL); err != nil {
return ports.StateResponse{}, err
}
if len(request.Races) == 0 {
return ports.StateResponse{}, errors.New("engine init: races must not be empty")
}
body, err := encodeInitRequest(request)
if err != nil {
return ports.StateResponse{}, fmt.Errorf("engine init: encode request: %w", err)
}
payload, status, doErr := client.doRequest(ctx, http.MethodPost, baseURL+pathAdminInit, body, client.callTimeout)
if doErr != nil {
return ports.StateResponse{}, fmt.Errorf("%w: engine init: %w", ports.ErrEngineUnreachable, doErr)
}
switch status {
case http.StatusOK, http.StatusCreated:
return decodeStateResponse(payload, "engine init")
case http.StatusBadRequest:
return ports.StateResponse{}, fmt.Errorf("%w: engine init: %s", ports.ErrEngineValidation, summariseEngineError(payload, status))
default:
return ports.StateResponse{}, fmt.Errorf("%w: engine init: %s", ports.ErrEngineUnreachable, summariseEngineError(payload, status))
}
}
// Status calls GET /api/v1/admin/status.
func (client *Client) Status(ctx context.Context, baseURL string) (ports.StateResponse, error) {
if err := client.validateBase(baseURL); err != nil {
return ports.StateResponse{}, err
}
payload, status, doErr := client.doRequest(ctx, http.MethodGet, baseURL+pathAdminStatus, nil, client.probeTimeout)
if doErr != nil {
return ports.StateResponse{}, fmt.Errorf("%w: engine status: %w", ports.ErrEngineUnreachable, doErr)
}
switch status {
case http.StatusOK:
return decodeStateResponse(payload, "engine status")
case http.StatusBadRequest:
return ports.StateResponse{}, fmt.Errorf("%w: engine status: %s", ports.ErrEngineValidation, summariseEngineError(payload, status))
default:
return ports.StateResponse{}, fmt.Errorf("%w: engine status: %s", ports.ErrEngineUnreachable, summariseEngineError(payload, status))
}
}
// Turn calls PUT /api/v1/admin/turn.
func (client *Client) Turn(ctx context.Context, baseURL string) (ports.StateResponse, error) {
if err := client.validateBase(baseURL); err != nil {
return ports.StateResponse{}, err
}
payload, status, doErr := client.doRequest(ctx, http.MethodPut, baseURL+pathAdminTurn, nil, client.callTimeout)
if doErr != nil {
return ports.StateResponse{}, fmt.Errorf("%w: engine turn: %w", ports.ErrEngineUnreachable, doErr)
}
switch status {
case http.StatusOK:
return decodeStateResponse(payload, "engine turn")
case http.StatusBadRequest:
return ports.StateResponse{}, fmt.Errorf("%w: engine turn: %s", ports.ErrEngineValidation, summariseEngineError(payload, status))
default:
return ports.StateResponse{}, fmt.Errorf("%w: engine turn: %s", ports.ErrEngineUnreachable, summariseEngineError(payload, status))
}
}
// BanishRace calls POST /api/v1/admin/race/banish with body
// `{race_name}`. Engine returns 204 on success.
func (client *Client) BanishRace(ctx context.Context, baseURL, raceName string) error {
if err := client.validateBase(baseURL); err != nil {
return err
}
if strings.TrimSpace(raceName) == "" {
return errors.New("engine banish: race name must not be empty")
}
body, err := json.Marshal(banishRequestEnvelope{RaceName: raceName})
if err != nil {
return fmt.Errorf("engine banish: encode request: %w", err)
}
payload, status, doErr := client.doRequest(ctx, http.MethodPost, baseURL+pathAdminRaceBanish, body, client.callTimeout)
if doErr != nil {
return fmt.Errorf("%w: engine banish: %w", ports.ErrEngineUnreachable, doErr)
}
switch status {
case http.StatusNoContent, http.StatusOK:
return nil
case http.StatusBadRequest:
return fmt.Errorf("%w: engine banish: %s", ports.ErrEngineValidation, summariseEngineError(payload, status))
default:
return fmt.Errorf("%w: engine banish: %s", ports.ErrEngineUnreachable, summariseEngineError(payload, status))
}
}
// ExecuteCommands calls PUT /api/v1/command with payload forwarded
// verbatim. The engine response body is returned verbatim; on 4xx the
// body is returned alongside `ports.ErrEngineValidation` so callers can
// forward the per-command errors.
func (client *Client) ExecuteCommands(ctx context.Context, baseURL string, payload json.RawMessage) (json.RawMessage, error) {
return client.forwardPlayerWrite(ctx, baseURL, pathPlayerCommand, payload, "engine command")
}
// PutOrders calls PUT /api/v1/order with the same forwarding semantics
// as ExecuteCommands.
func (client *Client) PutOrders(ctx context.Context, baseURL string, payload json.RawMessage) (json.RawMessage, error) {
return client.forwardPlayerWrite(ctx, baseURL, pathPlayerOrder, payload, "engine order")
}
// GetReport calls GET /api/v1/report?player=<raceName>&turn=<turn> and
// returns the engine response body verbatim.
func (client *Client) GetReport(ctx context.Context, baseURL, raceName string, turn int) (json.RawMessage, error) {
if err := client.validateBase(baseURL); err != nil {
return nil, err
}
if strings.TrimSpace(raceName) == "" {
return nil, errors.New("engine report: race name must not be empty")
}
if turn < 0 {
return nil, fmt.Errorf("engine report: turn must not be negative, got %d", turn)
}
values := url.Values{}
values.Set("player", raceName)
values.Set("turn", strconv.Itoa(turn))
target := baseURL + pathPlayerReport + "?" + values.Encode()
body, status, doErr := client.doRequest(ctx, http.MethodGet, target, nil, client.probeTimeout)
if doErr != nil {
return nil, fmt.Errorf("%w: engine report: %w", ports.ErrEngineUnreachable, doErr)
}
switch status {
case http.StatusOK:
if len(body) == 0 {
return nil, fmt.Errorf("%w: engine report: empty response body", ports.ErrEngineProtocolViolation)
}
return json.RawMessage(body), nil
case http.StatusBadRequest:
return json.RawMessage(body), fmt.Errorf("%w: engine report: %s", ports.ErrEngineValidation, summariseEngineError(body, status))
default:
return nil, fmt.Errorf("%w: engine report: %s", ports.ErrEngineUnreachable, summariseEngineError(body, status))
}
}
func (client *Client) forwardPlayerWrite(ctx context.Context, baseURL, requestPath string, payload json.RawMessage, opLabel string) (json.RawMessage, error) {
if err := client.validateBase(baseURL); err != nil {
return nil, err
}
if len(bytes.TrimSpace(payload)) == 0 {
return nil, fmt.Errorf("%s: payload must not be empty", opLabel)
}
body, status, doErr := client.doRequest(ctx, http.MethodPut, baseURL+requestPath, []byte(payload), client.callTimeout)
if doErr != nil {
return nil, fmt.Errorf("%w: %s: %w", ports.ErrEngineUnreachable, opLabel, doErr)
}
switch status {
case http.StatusNoContent, http.StatusOK:
if len(body) == 0 {
return nil, nil
}
return json.RawMessage(body), nil
case http.StatusBadRequest:
return json.RawMessage(body), fmt.Errorf("%w: %s: %s", ports.ErrEngineValidation, opLabel, summariseEngineError(body, status))
default:
return nil, fmt.Errorf("%w: %s: %s", ports.ErrEngineUnreachable, opLabel, summariseEngineError(body, status))
}
}
// validateBase rejects nil clients, nil/cancelled contexts, and
// malformed engine endpoints up-front so transport-layer plumbing does
// not need to handle them.
func (client *Client) validateBase(baseURL string) error {
if client == nil || client.httpClient == nil {
return errors.New("engine client: nil client")
}
if strings.TrimSpace(baseURL) == "" {
return errors.New("engine client: base url must not be empty")
}
parsed, err := url.Parse(baseURL)
if err != nil {
return fmt.Errorf("engine client: parse base url: %w", err)
}
if parsed.Scheme == "" || parsed.Host == "" {
return fmt.Errorf("engine client: base url %q must be absolute", baseURL)
}
return nil
}
func (client *Client) doRequest(ctx context.Context, method, target string, body []byte, timeout time.Duration) ([]byte, int, error) {
if ctx == nil {
return nil, 0, errors.New("nil context")
}
if err := ctx.Err(); err != nil {
return nil, 0, err
}
attemptCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
var reader io.Reader
if len(body) > 0 {
reader = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(attemptCtx, method, target, reader)
if err != nil {
return nil, 0, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Accept", "application/json")
if len(body) > 0 {
req.Header.Set("Content-Type", "application/json")
}
resp, err := client.httpClient.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, fmt.Errorf("read response body: %w", err)
}
return respBody, resp.StatusCode, nil
}
// encodeInitRequest serialises ports.InitRequest into the engine spec
// shape (`InitRequest`/`InitRace`).
func encodeInitRequest(request ports.InitRequest) ([]byte, error) {
envelope := initRequestEnvelope{Races: make([]initRaceEnvelope, 0, len(request.Races))}
for _, race := range request.Races {
if strings.TrimSpace(race.RaceName) == "" {
return nil, errors.New("init race: race name must not be empty")
}
envelope.Races = append(envelope.Races, initRaceEnvelope{RaceName: race.RaceName})
}
return json.Marshal(envelope)
}
// decodeStateResponse decodes the engine StateResponse payload into the
// port-level StateResponse projection. Unknown fields are tolerated;
// missing required ones surface as ErrEngineProtocolViolation.
func decodeStateResponse(payload []byte, opLabel string) (ports.StateResponse, error) {
if len(payload) == 0 {
return ports.StateResponse{}, fmt.Errorf("%w: %s: empty response body", ports.ErrEngineProtocolViolation, opLabel)
}
var envelope stateResponseEnvelope
decoder := json.NewDecoder(bytes.NewReader(payload))
if err := decoder.Decode(&envelope); err != nil {
return ports.StateResponse{}, fmt.Errorf("%w: %s: decode body: %w", ports.ErrEngineProtocolViolation, opLabel, err)
}
if strings.TrimSpace(envelope.ID) == "" {
return ports.StateResponse{}, fmt.Errorf("%w: %s: missing id", ports.ErrEngineProtocolViolation, opLabel)
}
if envelope.Player == nil {
return ports.StateResponse{}, fmt.Errorf("%w: %s: missing player array", ports.ErrEngineProtocolViolation, opLabel)
}
state := ports.StateResponse{
Turn: envelope.Turn,
Finished: envelope.Finished,
Players: make([]ports.PlayerState, 0, len(envelope.Player)),
}
for index, player := range envelope.Player {
if strings.TrimSpace(player.RaceName) == "" {
return ports.StateResponse{}, fmt.Errorf("%w: %s: player[%d] missing raceName", ports.ErrEngineProtocolViolation, opLabel, index)
}
if strings.TrimSpace(player.ID) == "" {
return ports.StateResponse{}, fmt.Errorf("%w: %s: player[%d] missing id", ports.ErrEngineProtocolViolation, opLabel, index)
}
if player.Planets < 0 {
return ports.StateResponse{}, fmt.Errorf("%w: %s: player[%d] negative planets", ports.ErrEngineProtocolViolation, opLabel, index)
}
if math.IsNaN(player.Population) || math.IsInf(player.Population, 0) || player.Population < 0 {
return ports.StateResponse{}, fmt.Errorf("%w: %s: player[%d] invalid population", ports.ErrEngineProtocolViolation, opLabel, index)
}
state.Players = append(state.Players, ports.PlayerState{
RaceName: player.RaceName,
EnginePlayerUUID: player.ID,
Planets: player.Planets,
Population: int(math.Round(player.Population)),
})
}
return state, nil
}
// summariseEngineError extracts a short, human-readable summary from
// the engine's validation/internal-error envelopes for the wrapped
// error message.
func summariseEngineError(payload []byte, status int) string {
trimmed := bytes.TrimSpace(payload)
if len(trimmed) == 0 {
return fmt.Sprintf("status=%d", status)
}
var envelope engineErrorEnvelope
if err := json.Unmarshal(trimmed, &envelope); err == nil {
switch {
case envelope.GenericError != "":
return fmt.Sprintf("status=%d generic_error=%q code=%d", status, envelope.GenericError, envelope.Code)
case envelope.Error != "":
return fmt.Sprintf("status=%d error=%q", status, envelope.Error)
}
}
return fmt.Sprintf("status=%d", status)
}
// stateResponseEnvelope mirrors `StateResponse` from
// `game/openapi.yaml`. Unknown fields are tolerated by encoding/json.
type stateResponseEnvelope struct {
ID string `json:"id"`
Turn int `json:"turn"`
Stage int `json:"stage"`
Player []playerStateEnvelope `json:"player"`
Finished bool `json:"finished"`
}
// playerStateEnvelope mirrors `PlayerState`. Population is `number`
// per the engine spec, so the adapter decodes into float64 and rounds
// to the port-level int (engine in practice always returns whole
// numbers; rounding is a defensive guard against floating-point
// noise).
type playerStateEnvelope struct {
ID string `json:"id"`
RaceName string `json:"raceName"`
Planets int `json:"planets"`
Population float64 `json:"population"`
Extinct bool `json:"extinct"`
}
type initRequestEnvelope struct {
Races []initRaceEnvelope `json:"races"`
}
type initRaceEnvelope struct {
RaceName string `json:"raceName"`
}
type banishRequestEnvelope struct {
RaceName string `json:"race_name"`
}
type engineErrorEnvelope struct {
Error string `json:"error"`
GenericError string `json:"generic_error"`
Code int `json:"code"`
}
// Compile-time assertion: Client implements ports.EngineClient.
var _ ports.EngineClient = (*Client)(nil)
@@ -0,0 +1,363 @@
package engineclient
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"galaxy/gamemaster/internal/ports"
)
func newTestClient(t *testing.T, callTimeout, probeTimeout time.Duration) *Client {
t.Helper()
client, err := NewClient(Config{CallTimeout: callTimeout, ProbeTimeout: probeTimeout})
require.NoError(t, err)
t.Cleanup(func() { _ = client.Close() })
return client
}
func TestNewClientValidatesConfig(t *testing.T) {
cases := map[string]Config{
"non-positive call timeout": {CallTimeout: 0, ProbeTimeout: time.Second},
"non-positive probe timeout": {CallTimeout: time.Second, ProbeTimeout: 0},
}
for name, cfg := range cases {
t.Run(name, func(t *testing.T) {
_, err := NewClient(cfg)
require.Error(t, err)
})
}
}
func TestInitHappyPath(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/api/v1/admin/init", r.URL.Path)
require.Equal(t, "application/json", r.Header.Get("Content-Type"))
body, err := io.ReadAll(r.Body)
require.NoError(t, err)
var got initRequestEnvelope
require.NoError(t, json.Unmarshal(body, &got))
require.Equal(t, []initRaceEnvelope{{RaceName: "Human"}, {RaceName: "Klingon"}}, got.Races)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{
"id": "00000000-0000-0000-0000-000000000001",
"turn": 0,
"stage": 0,
"finished": false,
"player": [
{"id":"00000000-0000-0000-0000-000000000010","raceName":"Human","planets":3,"population":1500,"extinct":false},
{"id":"00000000-0000-0000-0000-000000000011","raceName":"Klingon","planets":3,"population":1500,"extinct":false}
]
}`))
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
state, err := client.Init(context.Background(), server.URL, ports.InitRequest{
Races: []ports.InitRace{{RaceName: "Human"}, {RaceName: "Klingon"}},
})
require.NoError(t, err)
assert.Equal(t, 0, state.Turn)
assert.False(t, state.Finished)
require.Len(t, state.Players, 2)
assert.Equal(t, "Human", state.Players[0].RaceName)
assert.Equal(t, "00000000-0000-0000-0000-000000000010", state.Players[0].EnginePlayerUUID)
assert.Equal(t, 3, state.Players[0].Planets)
assert.Equal(t, 1500, state.Players[0].Population)
}
func TestInitRejectsEmptyRaces(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("must not contact engine on empty races")
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
_, err := client.Init(context.Background(), server.URL, ports.InitRequest{})
require.Error(t, err)
}
func TestInitValidationErrorMapsToEngineValidation(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"races must contain at least 10 entries"}`))
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
_, err := client.Init(context.Background(), server.URL, ports.InitRequest{
Races: []ports.InitRace{{RaceName: "X"}},
})
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrEngineValidation))
assert.Contains(t, err.Error(), "must contain at least 10")
}
func TestInitInternalErrorMapsToUnreachable(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"generic_error":"boom","code":42}`))
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
_, err := client.Init(context.Background(), server.URL, ports.InitRequest{Races: []ports.InitRace{{RaceName: "X"}}})
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrEngineUnreachable))
assert.Contains(t, err.Error(), "code=42")
}
func TestStatusHappyPath(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
require.Equal(t, "/api/v1/admin/status", r.URL.Path)
_, _ = w.Write([]byte(`{
"id": "g-1",
"turn": 5,
"stage": 0,
"finished": false,
"player": [
{"id":"p-1","raceName":"Human","planets":4,"population":1700.0,"extinct":false}
]
}`))
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
state, err := client.Status(context.Background(), server.URL)
require.NoError(t, err)
assert.Equal(t, 5, state.Turn)
require.Len(t, state.Players, 1)
assert.Equal(t, "Human", state.Players[0].RaceName)
assert.Equal(t, 1700, state.Players[0].Population)
}
func TestStatusUsesProbeTimeout(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(120 * time.Millisecond)
_, _ = w.Write([]byte(`{}`))
}))
defer server.Close()
client := newTestClient(t, time.Second, 30*time.Millisecond)
_, err := client.Status(context.Background(), server.URL)
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrEngineUnreachable))
}
func TestTurnFinishedFlagPropagates(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPut, r.Method)
require.Equal(t, "/api/v1/admin/turn", r.URL.Path)
_, _ = w.Write([]byte(`{
"id":"g","turn":42,"stage":0,"finished":true,
"player":[{"id":"p1","raceName":"Human","planets":0,"population":0,"extinct":true}]
}`))
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
state, err := client.Turn(context.Background(), server.URL)
require.NoError(t, err)
assert.Equal(t, 42, state.Turn)
assert.True(t, state.Finished)
}
func TestDecodeProtocolViolations(t *testing.T) {
cases := map[string]string{
"missing id": `{"turn":0,"stage":0,"finished":false,"player":[]}`,
"missing player": `{"id":"g","turn":0,"stage":0,"finished":false}`,
"missing race name": `{"id":"g","turn":0,"stage":0,"finished":false,"player":[{"id":"p","planets":0,"population":0,"extinct":false}]}`,
"missing player id": `{"id":"g","turn":0,"stage":0,"finished":false,"player":[{"raceName":"X","planets":0,"population":0,"extinct":false}]}`,
"negative planets": `{"id":"g","turn":0,"stage":0,"finished":false,"player":[{"id":"p","raceName":"X","planets":-1,"population":0,"extinct":false}]}`,
"infinite population": `{"id":"g","turn":0,"stage":0,"finished":false,"player":[{"id":"p","raceName":"X","planets":1,"population":1e400,"extinct":false}]}`,
}
for name, body := range cases {
t.Run(name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(body))
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
_, err := client.Status(context.Background(), server.URL)
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrEngineProtocolViolation), "case %q: %v", name, err)
})
}
}
func TestBanishRaceHappyPath(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/api/v1/admin/race/banish", r.URL.Path)
var got banishRequestEnvelope
require.NoError(t, json.NewDecoder(r.Body).Decode(&got))
assert.Equal(t, "Klingon", got.RaceName)
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
require.NoError(t, client.BanishRace(context.Background(), server.URL, "Klingon"))
}
func TestBanishRaceRejectsBlankName(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Fatal("must not contact engine on blank race name")
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
require.Error(t, client.BanishRace(context.Background(), server.URL, " "))
}
func TestBanishRaceValidationError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"unknown race"}`))
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
err := client.BanishRace(context.Background(), server.URL, "Vulcan")
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrEngineValidation))
}
func TestExecuteCommandsHappyPath(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPut, r.Method)
require.Equal(t, "/api/v1/command", r.URL.Path)
body, _ := io.ReadAll(r.Body)
assert.JSONEq(t, `{"actor":"Human","cmd":[]}`, string(body))
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
body, err := client.ExecuteCommands(context.Background(), server.URL, json.RawMessage(`{"actor":"Human","cmd":[]}`))
require.NoError(t, err)
assert.Nil(t, body)
}
func TestExecuteCommandsValidationReturnsBody(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"bad command"}`))
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
body, err := client.ExecuteCommands(context.Background(), server.URL, json.RawMessage(`{"actor":"Human","cmd":[{}]}`))
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrEngineValidation))
assert.JSONEq(t, `{"error":"bad command"}`, string(body))
}
func TestExecuteCommandsRejectsEmptyPayload(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Fatal("must not contact engine with empty payload")
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
_, err := client.ExecuteCommands(context.Background(), server.URL, json.RawMessage(` `))
require.Error(t, err)
}
func TestPutOrdersHappyPath(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPut, r.Method)
require.Equal(t, "/api/v1/order", r.URL.Path)
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
body, err := client.PutOrders(context.Background(), server.URL, json.RawMessage(`{"actor":"Human","cmd":[]}`))
require.NoError(t, err)
assert.Nil(t, body)
}
func TestGetReportHappyPath(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
require.Equal(t, "/api/v1/report", r.URL.Path)
assert.Equal(t, "Human", r.URL.Query().Get("player"))
assert.Equal(t, "7", r.URL.Query().Get("turn"))
_, _ = w.Write([]byte(`{"version":"1","turn":7,"race":"Human"}`))
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
body, err := client.GetReport(context.Background(), server.URL, "Human", 7)
require.NoError(t, err)
assert.JSONEq(t, `{"version":"1","turn":7,"race":"Human"}`, string(body))
}
func TestGetReportEmptyBodyIsProtocolViolation(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
_, err := client.GetReport(context.Background(), server.URL, "Human", 0)
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrEngineProtocolViolation))
}
func TestGetReportRejectsBadInput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Fatal("must not contact engine on bad input")
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
_, err := client.GetReport(context.Background(), server.URL, " ", 0)
require.Error(t, err)
_, err = client.GetReport(context.Background(), server.URL, "Human", -1)
require.Error(t, err)
}
func TestValidateBaseRejectsBadURLs(t *testing.T) {
client := newTestClient(t, time.Second, time.Second)
_, err := client.Status(context.Background(), "")
require.Error(t, err)
_, err = client.Status(context.Background(), "engine:8080")
require.Error(t, err)
require.Contains(t, err.Error(), "absolute")
}
func TestCancelledContextSurfaces(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Fatal("must not contact engine with cancelled context")
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := client.Status(ctx, server.URL)
require.Error(t, err)
assert.True(t, errors.Is(err, context.Canceled))
}
func TestSummariseEngineErrorFallback(t *testing.T) {
got := summariseEngineError([]byte("not json"), 502)
assert.True(t, strings.Contains(got, "status=502"))
}
func TestCloseIsIdempotent(t *testing.T) {
client := newTestClient(t, time.Second, time.Second)
require.NoError(t, client.Close())
require.NoError(t, client.Close())
}
@@ -0,0 +1,343 @@
// Package lobbyclient provides the trusted-internal Lobby REST client
// Game Master uses to fetch membership lists for the in-process
// authorization cache and to resolve the human-readable `game_name`
// consumed by notification intents.
//
// Two endpoints are mounted today:
//
// - `GET /api/v1/internal/games/{game_id}/memberships` — pagination is
// handled internally so callers always receive every membership of
// the game;
// - `GET /api/v1/internal/games/{game_id}` — single read used by the
// turn-generation orchestrator to resolve `game_name` per
// notification.
package lobbyclient
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"galaxy/gamemaster/internal/ports"
)
const (
membershipsPathTemplate = "/api/v1/internal/games/%s/memberships"
gameRecordPathTemplate = "/api/v1/internal/games/%s"
// pageSize is the per-call page size; matches the Lobby spec
// maximum (200) so we walk fewer pages on large rosters.
pageSize = 200
// maxPages caps the page walk to defend against an upstream that
// keeps returning a `next_page_token` indefinitely. 64 pages of
// 200 items each cover 12_800 memberships per game — orders of
// magnitude beyond any realistic Galaxy roster.
maxPages = 64
)
// Config configures one HTTP-backed Lobby internal client.
type Config struct {
// BaseURL stores the absolute base URL of the Lobby internal HTTP
// listener (e.g. `http://lobby:8095`).
BaseURL string
// RequestTimeout bounds one outbound page request. The total
// wall-clock for `GetMemberships` is at most
// `RequestTimeout * <pages>`, capped indirectly by the per-page
// limit and `maxPages`.
RequestTimeout time.Duration
}
// Client resolves Lobby memberships through the trusted internal HTTP
// API.
type Client struct {
baseURL string
requestTimeout time.Duration
httpClient *http.Client
closeIdleConnections func()
}
type membershipListEnvelope struct {
Items []membershipRecordEnvelope `json:"items"`
NextPageToken string `json:"next_page_token"`
}
type membershipRecordEnvelope struct {
MembershipID string `json:"membership_id"`
GameID string `json:"game_id"`
UserID string `json:"user_id"`
RaceName string `json:"race_name"`
Status string `json:"status"`
JoinedAt int64 `json:"joined_at"`
RemovedAt *int64 `json:"removed_at,omitempty"`
}
// gameRecordEnvelope captures the fields GM consumes from Lobby's
// `GameRecord` schema. Lobby may carry additional fields; the JSON
// decoder ignores them.
type gameRecordEnvelope struct {
GameID string `json:"game_id"`
GameName string `json:"game_name"`
Status string `json:"status"`
}
type errorEnvelope struct {
Error *errorBody `json:"error"`
}
type errorBody struct {
Code string `json:"code"`
Message string `json:"message"`
}
// NewClient constructs a Lobby internal client with otelhttp-wrapped
// transport cloned from `http.DefaultTransport`. Call `Close` to
// release idle connections at shutdown.
func NewClient(cfg Config) (*Client, error) {
transport, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return nil, errors.New("new lobby client: default transport is not *http.Transport")
}
cloned := transport.Clone()
return newClient(cfg, &http.Client{Transport: otelhttp.NewTransport(cloned)}, cloned.CloseIdleConnections)
}
func newClient(cfg Config, httpClient *http.Client, closeIdleConnections func()) (*Client, error) {
switch {
case strings.TrimSpace(cfg.BaseURL) == "":
return nil, errors.New("new lobby client: base url must not be empty")
case cfg.RequestTimeout <= 0:
return nil, errors.New("new lobby client: request timeout must be positive")
case httpClient == nil:
return nil, errors.New("new lobby client: http client must not be nil")
}
parsed, err := url.Parse(strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/"))
if err != nil {
return nil, fmt.Errorf("new lobby client: parse base url: %w", err)
}
if parsed.Scheme == "" || parsed.Host == "" {
return nil, errors.New("new lobby client: base url must be absolute")
}
return &Client{
baseURL: parsed.String(),
requestTimeout: cfg.RequestTimeout,
httpClient: httpClient,
closeIdleConnections: closeIdleConnections,
}, nil
}
// Close releases idle HTTP connections owned by the underlying
// transport. Safe to call multiple times.
func (client *Client) Close() error {
if client == nil || client.closeIdleConnections == nil {
return nil
}
client.closeIdleConnections()
return nil
}
// GetMemberships returns every membership of gameID, walking the
// pagination chain transparently. Transport faults, non-2xx responses,
// malformed payloads, and pagination overflow all surface as
// `ports.ErrLobbyUnavailable` so callers can branch with `errors.Is`.
func (client *Client) GetMemberships(ctx context.Context, gameID string) ([]ports.Membership, error) {
if client == nil || client.httpClient == nil {
return nil, errors.New("lobby get memberships: nil client")
}
if ctx == nil {
return nil, errors.New("lobby get memberships: nil context")
}
if err := ctx.Err(); err != nil {
return nil, err
}
if strings.TrimSpace(gameID) == "" {
return nil, errors.New("lobby get memberships: game id must not be empty")
}
var memberships []ports.Membership
pathPrefix := fmt.Sprintf(membershipsPathTemplate, url.PathEscape(gameID))
pageToken := ""
for range maxPages {
payload, statusCode, err := client.doRequest(ctx, http.MethodGet, buildPagedQuery(pathPrefix, pageToken))
if err != nil {
return nil, fmt.Errorf("%w: %w", ports.ErrLobbyUnavailable, err)
}
if statusCode != http.StatusOK {
errorCode := decodeErrorCode(payload)
if errorCode != "" {
return nil, fmt.Errorf("%w: unexpected status %d (error_code=%s)", ports.ErrLobbyUnavailable, statusCode, errorCode)
}
return nil, fmt.Errorf("%w: unexpected status %d", ports.ErrLobbyUnavailable, statusCode)
}
var envelope membershipListEnvelope
if err := decodeJSONPayload(payload, &envelope); err != nil {
return nil, fmt.Errorf("%w: decode response: %w", ports.ErrLobbyUnavailable, err)
}
for index, item := range envelope.Items {
converted, err := toMembership(item)
if err != nil {
return nil, fmt.Errorf("%w: items[%d]: %w", ports.ErrLobbyUnavailable, index, err)
}
memberships = append(memberships, converted)
}
if strings.TrimSpace(envelope.NextPageToken) == "" {
return memberships, nil
}
pageToken = envelope.NextPageToken
}
return nil, fmt.Errorf("%w: pagination overflow after %d pages", ports.ErrLobbyUnavailable, maxPages)
}
// GetGameSummary returns the narrow projection of Lobby's GameRecord
// (game id, game name, lifecycle status) for gameID. Transport faults,
// non-2xx responses, malformed payloads, and missing required fields
// surface as `ports.ErrLobbyUnavailable` so callers can branch with
// `errors.Is`.
func (client *Client) GetGameSummary(ctx context.Context, gameID string) (ports.GameSummary, error) {
if client == nil || client.httpClient == nil {
return ports.GameSummary{}, errors.New("lobby get game summary: nil client")
}
if ctx == nil {
return ports.GameSummary{}, errors.New("lobby get game summary: nil context")
}
if err := ctx.Err(); err != nil {
return ports.GameSummary{}, err
}
if strings.TrimSpace(gameID) == "" {
return ports.GameSummary{}, errors.New("lobby get game summary: game id must not be empty")
}
requestPath := fmt.Sprintf(gameRecordPathTemplate, url.PathEscape(gameID))
payload, statusCode, err := client.doRequest(ctx, http.MethodGet, requestPath)
if err != nil {
return ports.GameSummary{}, fmt.Errorf("%w: %w", ports.ErrLobbyUnavailable, err)
}
if statusCode != http.StatusOK {
errorCode := decodeErrorCode(payload)
if errorCode != "" {
return ports.GameSummary{}, fmt.Errorf(
"%w: unexpected status %d (error_code=%s)",
ports.ErrLobbyUnavailable, statusCode, errorCode,
)
}
return ports.GameSummary{}, fmt.Errorf(
"%w: unexpected status %d", ports.ErrLobbyUnavailable, statusCode,
)
}
var envelope gameRecordEnvelope
if err := decodeJSONPayload(payload, &envelope); err != nil {
return ports.GameSummary{}, fmt.Errorf("%w: decode response: %w", ports.ErrLobbyUnavailable, err)
}
if strings.TrimSpace(envelope.GameID) == "" {
return ports.GameSummary{}, fmt.Errorf("%w: missing game_id", ports.ErrLobbyUnavailable)
}
if strings.TrimSpace(envelope.GameName) == "" {
return ports.GameSummary{}, fmt.Errorf("%w: missing game_name", ports.ErrLobbyUnavailable)
}
if strings.TrimSpace(envelope.Status) == "" {
return ports.GameSummary{}, fmt.Errorf("%w: missing status", ports.ErrLobbyUnavailable)
}
return ports.GameSummary{
GameID: envelope.GameID,
GameName: envelope.GameName,
Status: envelope.Status,
}, nil
}
func buildPagedQuery(path, pageToken string) string {
params := url.Values{}
params.Set("page_size", strconv.Itoa(pageSize))
if pageToken != "" {
params.Set("page_token", pageToken)
}
return path + "?" + params.Encode()
}
func toMembership(record membershipRecordEnvelope) (ports.Membership, error) {
if strings.TrimSpace(record.UserID) == "" {
return ports.Membership{}, errors.New("missing user_id")
}
if strings.TrimSpace(record.RaceName) == "" {
return ports.Membership{}, errors.New("missing race_name")
}
if strings.TrimSpace(record.Status) == "" {
return ports.Membership{}, errors.New("missing status")
}
membership := ports.Membership{
UserID: record.UserID,
RaceName: record.RaceName,
Status: record.Status,
JoinedAt: time.UnixMilli(record.JoinedAt).UTC(),
}
if record.RemovedAt != nil {
removedAt := time.UnixMilli(*record.RemovedAt).UTC()
membership.RemovedAt = &removedAt
}
return membership, nil
}
func (client *Client) doRequest(ctx context.Context, method, requestPath string) ([]byte, int, error) {
attemptCtx, cancel := context.WithTimeout(ctx, client.requestTimeout)
defer cancel()
req, err := http.NewRequestWithContext(attemptCtx, method, client.baseURL+requestPath, nil)
if err != nil {
return nil, 0, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := client.httpClient.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, 0, fmt.Errorf("read response body: %w", err)
}
return body, resp.StatusCode, nil
}
func decodeJSONPayload(payload []byte, target any) error {
decoder := json.NewDecoder(bytes.NewReader(payload))
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return errors.New("unexpected trailing JSON input")
}
return err
}
return nil
}
func decodeErrorCode(payload []byte) string {
if len(payload) == 0 {
return ""
}
var envelope errorEnvelope
if err := json.Unmarshal(payload, &envelope); err != nil {
return ""
}
if envelope.Error == nil {
return ""
}
return envelope.Error.Code
}
// Compile-time assertion: Client implements ports.LobbyClient.
var _ ports.LobbyClient = (*Client)(nil)
@@ -0,0 +1,344 @@
package lobbyclient
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strconv"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"galaxy/gamemaster/internal/ports"
)
func newTestClient(t *testing.T, baseURL string, timeout time.Duration) *Client {
t.Helper()
client, err := NewClient(Config{BaseURL: baseURL, RequestTimeout: timeout})
require.NoError(t, err)
t.Cleanup(func() { _ = client.Close() })
return client
}
func TestNewClientValidatesConfig(t *testing.T) {
cases := map[string]Config{
"empty base url": {BaseURL: "", RequestTimeout: time.Second},
"non-absolute base url": {BaseURL: "lobby:8095", RequestTimeout: time.Second},
"non-positive timeout": {BaseURL: "http://lobby:8095", RequestTimeout: 0},
}
for name, cfg := range cases {
t.Run(name, func(t *testing.T) {
_, err := NewClient(cfg)
require.Error(t, err)
})
}
}
func TestGetMembershipsHappyPathSinglePage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
require.Equal(t, "/api/v1/internal/games/game-1/memberships", r.URL.Path)
assert.Equal(t, strconv.Itoa(pageSize), r.URL.Query().Get("page_size"))
assert.Empty(t, r.URL.Query().Get("page_token"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"items": [
{"membership_id":"m1","game_id":"game-1","user_id":"u1","race_name":"Human","status":"active","joined_at":1700000000000},
{"membership_id":"m2","game_id":"game-1","user_id":"u2","race_name":"Klingon","status":"removed","joined_at":1700000010000,"removed_at":1700000020000}
]
}`))
}))
defer server.Close()
client := newTestClient(t, server.URL, time.Second)
memberships, err := client.GetMemberships(context.Background(), "game-1")
require.NoError(t, err)
require.Len(t, memberships, 2)
assert.Equal(t, "u1", memberships[0].UserID)
assert.Equal(t, "Human", memberships[0].RaceName)
assert.Equal(t, "active", memberships[0].Status)
assert.Equal(t, time.UnixMilli(1700000000000).UTC(), memberships[0].JoinedAt)
assert.Nil(t, memberships[0].RemovedAt)
assert.Equal(t, "removed", memberships[1].Status)
require.NotNil(t, memberships[1].RemovedAt)
assert.Equal(t, time.UnixMilli(1700000020000).UTC(), *memberships[1].RemovedAt)
}
func TestGetMembershipsFollowsPagination(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
call := calls.Add(1)
w.Header().Set("Content-Type", "application/json")
switch call {
case 1:
assert.Empty(t, r.URL.Query().Get("page_token"))
_, _ = w.Write([]byte(`{
"items":[{"membership_id":"m1","game_id":"g","user_id":"u1","race_name":"Human","status":"active","joined_at":1}],
"next_page_token":"tok-2"
}`))
case 2:
assert.Equal(t, "tok-2", r.URL.Query().Get("page_token"))
_, _ = w.Write([]byte(`{
"items":[{"membership_id":"m2","game_id":"g","user_id":"u2","race_name":"Klingon","status":"active","joined_at":2}],
"next_page_token":"tok-3"
}`))
case 3:
assert.Equal(t, "tok-3", r.URL.Query().Get("page_token"))
_, _ = w.Write([]byte(`{
"items":[{"membership_id":"m3","game_id":"g","user_id":"u3","race_name":"Vulcan","status":"blocked","joined_at":3}]
}`))
default:
t.Fatalf("unexpected extra call %d", call)
}
}))
defer server.Close()
client := newTestClient(t, server.URL, time.Second)
memberships, err := client.GetMemberships(context.Background(), "g")
require.NoError(t, err)
require.Len(t, memberships, 3)
assert.Equal(t, "u1", memberships[0].UserID)
assert.Equal(t, "u2", memberships[1].UserID)
assert.Equal(t, "u3", memberships[2].UserID)
assert.Equal(t, int32(3), calls.Load())
}
func TestGetMembershipsPaginationOverflow(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"items":[],"next_page_token":"never-ends"}`))
}))
defer server.Close()
client := newTestClient(t, server.URL, time.Second)
_, err := client.GetMemberships(context.Background(), "g")
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrLobbyUnavailable))
assert.Contains(t, err.Error(), "pagination overflow")
}
func TestGetMembershipsInternalErrorMapsToUnavailable(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":{"code":"internal_error","message":"boom"}}`))
}))
defer server.Close()
client := newTestClient(t, server.URL, time.Second)
_, err := client.GetMemberships(context.Background(), "g")
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrLobbyUnavailable))
assert.Contains(t, err.Error(), "internal_error")
}
func TestGetMembershipsTimeoutMapsToUnavailable(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(120 * time.Millisecond)
_, _ = w.Write([]byte(`{}`))
}))
defer server.Close()
client := newTestClient(t, server.URL, 30*time.Millisecond)
_, err := client.GetMemberships(context.Background(), "g")
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrLobbyUnavailable))
}
func TestGetMembershipsRejectsBadInput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Fatal("must not contact lobby on bad input")
}))
defer server.Close()
client := newTestClient(t, server.URL, time.Second)
_, err := client.GetMemberships(context.Background(), " ")
require.Error(t, err)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err = client.GetMemberships(ctx, "g")
require.Error(t, err)
assert.True(t, errors.Is(err, context.Canceled))
}
func TestGetMembershipsMalformedPayload(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"items":[{"membership_id":"m","game_id":"g","user_id":"","race_name":"","status":"active","joined_at":1}]}`))
}))
defer server.Close()
client := newTestClient(t, server.URL, time.Second)
_, err := client.GetMemberships(context.Background(), "g")
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrLobbyUnavailable))
}
func TestGetMembershipsEmptyList(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"items":[]}`))
}))
defer server.Close()
client := newTestClient(t, server.URL, time.Second)
memberships, err := client.GetMemberships(context.Background(), "g")
require.NoError(t, err)
assert.Empty(t, memberships)
}
func TestGetMembershipsTrailingJSONIsRejected(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"items":[]}{"items":[]}`))
}))
defer server.Close()
client := newTestClient(t, server.URL, time.Second)
_, err := client.GetMemberships(context.Background(), "g")
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrLobbyUnavailable))
}
func TestGetGameSummaryHappyPath(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
require.Equal(t, "/api/v1/internal/games/game-1", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"game_id":"game-1",
"game_name":"Andromeda Conquest",
"game_type":"public",
"owner_user_id":"",
"status":"running",
"min_players":2,
"max_players":8,
"start_gap_hours":2,
"start_gap_players":4,
"enrollment_ends_at":1700000000,
"turn_schedule":"0 18 * * *",
"target_engine_version":"v1.2.3",
"created_at":1700000000000,
"updated_at":1700000000000,
"current_turn":0,
"runtime_status":"",
"engine_health_summary":""
}`))
}))
defer server.Close()
client := newTestClient(t, server.URL, time.Second)
summary, err := client.GetGameSummary(context.Background(), "game-1")
require.NoError(t, err)
assert.Equal(t, ports.GameSummary{
GameID: "game-1",
GameName: "Andromeda Conquest",
Status: "running",
}, summary)
}
func TestGetGameSummaryNotFoundMapsToUnavailable(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"game not found"}}`))
}))
defer server.Close()
client := newTestClient(t, server.URL, time.Second)
_, err := client.GetGameSummary(context.Background(), "missing")
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrLobbyUnavailable))
assert.Contains(t, err.Error(), "not_found")
}
func TestGetGameSummaryInternalErrorMapsToUnavailable(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":{"code":"internal_error","message":"boom"}}`))
}))
defer server.Close()
client := newTestClient(t, server.URL, time.Second)
_, err := client.GetGameSummary(context.Background(), "g")
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrLobbyUnavailable))
assert.Contains(t, err.Error(), "internal_error")
}
func TestGetGameSummaryTimeoutMapsToUnavailable(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(120 * time.Millisecond)
_, _ = w.Write([]byte(`{}`))
}))
defer server.Close()
client := newTestClient(t, server.URL, 30*time.Millisecond)
_, err := client.GetGameSummary(context.Background(), "g")
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrLobbyUnavailable))
}
func TestGetGameSummaryMalformedJSON(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{not-json}`))
}))
defer server.Close()
client := newTestClient(t, server.URL, time.Second)
_, err := client.GetGameSummary(context.Background(), "g")
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrLobbyUnavailable))
}
func TestGetGameSummaryMissingRequiredFields(t *testing.T) {
cases := map[string]string{
"missing game_id": `{"game_name":"Andromeda","status":"running"}`,
"missing game_name": `{"game_id":"g","status":"running"}`,
"missing status": `{"game_id":"g","game_name":"Andromeda"}`,
}
for name, body := range cases {
t.Run(name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(body))
}))
defer server.Close()
client := newTestClient(t, server.URL, time.Second)
_, err := client.GetGameSummary(context.Background(), "g")
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrLobbyUnavailable))
})
}
}
func TestGetGameSummaryRejectsBadInput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
t.Fatal("must not contact lobby on bad input")
}))
defer server.Close()
client := newTestClient(t, server.URL, time.Second)
_, err := client.GetGameSummary(context.Background(), " ")
require.Error(t, err)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err = client.GetGameSummary(ctx, "g")
require.Error(t, err)
assert.True(t, errors.Is(err, context.Canceled))
}
func TestCloseIsIdempotent(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"items":[]}`))
}))
defer server.Close()
client := newTestClient(t, server.URL, time.Second)
_, _ = client.GetMemberships(context.Background(), "g")
require.NoError(t, client.Close())
require.NoError(t, client.Close())
}
@@ -0,0 +1,180 @@
// Package lobbyeventspublisher provides the Redis-Streams-backed
// publisher for `gm:lobby_events`. The stream carries two distinct
// message types — `runtime_snapshot_update` and `game_finished` —
// discriminated by the `event_type` field as fixed by
// `gamemaster/api/runtime-events-asyncapi.yaml`.
//
// The adapter mirrors `rtmanager/internal/adapters/healtheventspublisher`
// behaviourally: the publisher validates the message before XADDing,
// emits one entry per call, and never trims the stream (consumers own
// their consumer-group offsets).
package lobbyeventspublisher
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"github.com/redis/go-redis/v9"
"galaxy/gamemaster/internal/domain/runtime"
"galaxy/gamemaster/internal/ports"
)
// Wire field names used by the Redis Streams payload. Frozen by
// `gamemaster/api/runtime-events-asyncapi.yaml`; renaming any of them
// breaks Game Lobby's consumer.
const (
fieldEventType = "event_type"
fieldGameID = "game_id"
fieldCurrentTurn = "current_turn"
fieldFinalTurnNumber = "final_turn_number"
fieldRuntimeStatus = "runtime_status"
fieldEngineHealthSummary = "engine_health_summary"
fieldPlayerTurnStats = "player_turn_stats"
fieldOccurredAtMS = "occurred_at_ms"
fieldFinishedAtMS = "finished_at_ms"
eventTypeRuntimeSnapshotUpdate = "runtime_snapshot_update"
eventTypeGameFinished = "game_finished"
emptyPlayerTurnStatsJSON = "[]"
)
// Config groups the dependencies and stream name required to
// construct a Publisher.
type Config struct {
// Client appends entries to Redis Streams. Must be non-nil.
Client *redis.Client
// Stream stores the Redis Stream key events are published to.
// Must not be empty (typically `gm:lobby_events`).
Stream string
}
// Publisher implements `ports.LobbyEventsPublisher` on top of a shared
// Redis client.
type Publisher struct {
client *redis.Client
stream string
}
// NewPublisher constructs a Publisher from cfg. Validation errors
// surface the missing collaborator verbatim.
func NewPublisher(cfg Config) (*Publisher, error) {
if cfg.Client == nil {
return nil, errors.New("new gamemaster lobby events publisher: nil redis client")
}
if cfg.Stream == "" {
return nil, errors.New("new gamemaster lobby events publisher: stream must not be empty")
}
return &Publisher{client: cfg.Client, stream: cfg.Stream}, nil
}
// PublishSnapshotUpdate appends a `runtime_snapshot_update` message to
// the stream after validating msg through msg.Validate.
func (publisher *Publisher) PublishSnapshotUpdate(ctx context.Context, msg ports.RuntimeSnapshotUpdate) error {
if err := publisher.guardCall(ctx); err != nil {
return err
}
if err := msg.Validate(); err != nil {
return fmt.Errorf("publish runtime snapshot update: %w", err)
}
statsJSON, err := encodePlayerTurnStats(msg.PlayerTurnStats)
if err != nil {
return fmt.Errorf("publish runtime snapshot update: %w", err)
}
values := map[string]any{
fieldEventType: eventTypeRuntimeSnapshotUpdate,
fieldGameID: msg.GameID,
fieldCurrentTurn: strconv.Itoa(msg.CurrentTurn),
fieldRuntimeStatus: string(msg.RuntimeStatus),
fieldEngineHealthSummary: msg.EngineHealthSummary,
fieldPlayerTurnStats: statsJSON,
fieldOccurredAtMS: strconv.FormatInt(msg.OccurredAt.UTC().UnixMilli(), 10),
}
if err := publisher.client.XAdd(ctx, &redis.XAddArgs{
Stream: publisher.stream,
Values: values,
}).Err(); err != nil {
return fmt.Errorf("publish runtime snapshot update: xadd: %w", err)
}
return nil
}
// PublishGameFinished appends a `game_finished` message to the stream
// after validating msg through msg.Validate.
func (publisher *Publisher) PublishGameFinished(ctx context.Context, msg ports.GameFinished) error {
if err := publisher.guardCall(ctx); err != nil {
return err
}
if err := msg.Validate(); err != nil {
return fmt.Errorf("publish game finished: %w", err)
}
if msg.RuntimeStatus != runtime.StatusFinished {
return fmt.Errorf("publish game finished: runtime status must be %q, got %q", runtime.StatusFinished, msg.RuntimeStatus)
}
statsJSON, err := encodePlayerTurnStats(msg.PlayerTurnStats)
if err != nil {
return fmt.Errorf("publish game finished: %w", err)
}
values := map[string]any{
fieldEventType: eventTypeGameFinished,
fieldGameID: msg.GameID,
fieldFinalTurnNumber: strconv.Itoa(msg.FinalTurnNumber),
fieldRuntimeStatus: string(msg.RuntimeStatus),
fieldPlayerTurnStats: statsJSON,
fieldFinishedAtMS: strconv.FormatInt(msg.FinishedAt.UTC().UnixMilli(), 10),
}
if err := publisher.client.XAdd(ctx, &redis.XAddArgs{
Stream: publisher.stream,
Values: values,
}).Err(); err != nil {
return fmt.Errorf("publish game finished: xadd: %w", err)
}
return nil
}
func (publisher *Publisher) guardCall(ctx context.Context) error {
if publisher == nil || publisher.client == nil {
return errors.New("nil publisher")
}
if ctx == nil {
return errors.New("nil context")
}
return nil
}
// encodePlayerTurnStats returns the JSON serialisation of the per-player
// stats array. Empty input becomes the literal `[]` so the stream entry
// always carries a valid JSON document for the field.
func encodePlayerTurnStats(stats []ports.PlayerTurnStats) (string, error) {
if len(stats) == 0 {
return emptyPlayerTurnStatsJSON, nil
}
envelope := make([]playerTurnStatEnvelope, 0, len(stats))
for _, item := range stats {
envelope = append(envelope, playerTurnStatEnvelope{
UserID: item.UserID,
Planets: item.Planets,
Population: item.Population,
})
}
encoded, err := json.Marshal(envelope)
if err != nil {
return "", fmt.Errorf("encode player turn stats: %w", err)
}
return string(encoded), nil
}
type playerTurnStatEnvelope struct {
UserID string `json:"user_id"`
Planets int `json:"planets"`
Population int `json:"population"`
}
// Compile-time assertion: Publisher implements
// ports.LobbyEventsPublisher.
var _ ports.LobbyEventsPublisher = (*Publisher)(nil)
@@ -0,0 +1,186 @@
package lobbyeventspublisher
import (
"context"
"encoding/json"
"strconv"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"galaxy/gamemaster/internal/domain/runtime"
"galaxy/gamemaster/internal/ports"
)
const testStream = "gm:lobby_events"
func newTestPublisher(t *testing.T) (*Publisher, *redis.Client) {
t.Helper()
server := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() { _ = client.Close() })
publisher, err := NewPublisher(Config{Client: client, Stream: testStream})
require.NoError(t, err)
return publisher, client
}
func TestNewPublisherValidation(t *testing.T) {
t.Run("nil client", func(t *testing.T) {
_, err := NewPublisher(Config{Stream: testStream})
require.Error(t, err)
})
t.Run("empty stream", func(t *testing.T) {
client := redis.NewClient(&redis.Options{Addr: "127.0.0.1:0"})
t.Cleanup(func() { _ = client.Close() })
_, err := NewPublisher(Config{Client: client})
require.Error(t, err)
})
}
func TestPublishSnapshotUpdateHappyPath(t *testing.T) {
publisher, client := newTestPublisher(t)
occurredAt := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
msg := ports.RuntimeSnapshotUpdate{
GameID: "game-1",
CurrentTurn: 17,
RuntimeStatus: runtime.StatusRunning,
EngineHealthSummary: "healthy",
PlayerTurnStats: []ports.PlayerTurnStats{
{UserID: "user-1", Planets: 4, Population: 12000},
{UserID: "user-2", Planets: 3, Population: 9000},
},
OccurredAt: occurredAt,
}
require.NoError(t, publisher.PublishSnapshotUpdate(context.Background(), msg))
entries, err := client.XRange(context.Background(), testStream, "-", "+").Result()
require.NoError(t, err)
require.Len(t, entries, 1)
values := entries[0].Values
assert.Equal(t, "runtime_snapshot_update", values[fieldEventType])
assert.Equal(t, "game-1", values[fieldGameID])
assert.Equal(t, "17", values[fieldCurrentTurn])
assert.Equal(t, "running", values[fieldRuntimeStatus])
assert.Equal(t, "healthy", values[fieldEngineHealthSummary])
assert.Equal(t, strconv.FormatInt(occurredAt.UnixMilli(), 10), values[fieldOccurredAtMS])
statsRaw, ok := values[fieldPlayerTurnStats].(string)
require.True(t, ok)
var stats []playerTurnStatEnvelope
require.NoError(t, json.Unmarshal([]byte(statsRaw), &stats))
assert.Equal(t, []playerTurnStatEnvelope{
{UserID: "user-1", Planets: 4, Population: 12000},
{UserID: "user-2", Planets: 3, Population: 9000},
}, stats)
}
func TestPublishSnapshotUpdateEmptyStatsBecomesArray(t *testing.T) {
publisher, client := newTestPublisher(t)
msg := ports.RuntimeSnapshotUpdate{
GameID: "g",
CurrentTurn: 0,
RuntimeStatus: runtime.StatusStarting,
EngineHealthSummary: "",
OccurredAt: time.Now().UTC(),
}
require.NoError(t, publisher.PublishSnapshotUpdate(context.Background(), msg))
entries, err := client.XRange(context.Background(), testStream, "-", "+").Result()
require.NoError(t, err)
require.Len(t, entries, 1)
assert.Equal(t, "[]", entries[0].Values[fieldPlayerTurnStats])
}
func TestPublishSnapshotUpdateRejectsInvalid(t *testing.T) {
publisher, client := newTestPublisher(t)
require.Error(t, publisher.PublishSnapshotUpdate(context.Background(), ports.RuntimeSnapshotUpdate{}))
entries, err := client.XRange(context.Background(), testStream, "-", "+").Result()
require.NoError(t, err)
assert.Empty(t, entries, "invalid messages must not reach the stream")
}
func TestPublishGameFinishedHappyPath(t *testing.T) {
publisher, client := newTestPublisher(t)
finishedAt := time.Date(2026, 4, 28, 8, 30, 0, 0, time.UTC)
msg := ports.GameFinished{
GameID: "game-1",
FinalTurnNumber: 42,
RuntimeStatus: runtime.StatusFinished,
PlayerTurnStats: []ports.PlayerTurnStats{
{UserID: "user-1", Planets: 6, Population: 25000},
{UserID: "user-2", Planets: 0, Population: 0},
},
FinishedAt: finishedAt,
}
require.NoError(t, publisher.PublishGameFinished(context.Background(), msg))
entries, err := client.XRange(context.Background(), testStream, "-", "+").Result()
require.NoError(t, err)
require.Len(t, entries, 1)
values := entries[0].Values
assert.Equal(t, "game_finished", values[fieldEventType])
assert.Equal(t, "game-1", values[fieldGameID])
assert.Equal(t, "42", values[fieldFinalTurnNumber])
assert.Equal(t, "finished", values[fieldRuntimeStatus])
assert.Equal(t, strconv.FormatInt(finishedAt.UnixMilli(), 10), values[fieldFinishedAtMS])
_, hasOccurred := values[fieldOccurredAtMS]
assert.False(t, hasOccurred, "game_finished must not carry occurred_at_ms")
_, hasCurrentTurn := values[fieldCurrentTurn]
assert.False(t, hasCurrentTurn, "game_finished must not carry current_turn")
_, hasHealth := values[fieldEngineHealthSummary]
assert.False(t, hasHealth, "game_finished must not carry engine_health_summary")
}
func TestPublishGameFinishedRejectsBadStatus(t *testing.T) {
publisher, client := newTestPublisher(t)
require.Error(t, publisher.PublishGameFinished(context.Background(), ports.GameFinished{
GameID: "g",
FinalTurnNumber: 1,
RuntimeStatus: runtime.StatusRunning, // wrong status
FinishedAt: time.Now().UTC(),
}))
entries, err := client.XRange(context.Background(), testStream, "-", "+").Result()
require.NoError(t, err)
assert.Empty(t, entries)
}
func TestTimestampsNormalisedToUTC(t *testing.T) {
publisher, client := newTestPublisher(t)
loc, err := time.LoadLocation("Asia/Tokyo")
require.NoError(t, err)
msg := ports.RuntimeSnapshotUpdate{
GameID: "g",
CurrentTurn: 1,
RuntimeStatus: runtime.StatusRunning,
OccurredAt: time.Date(2026, 4, 27, 21, 0, 0, 0, loc),
}
require.NoError(t, publisher.PublishSnapshotUpdate(context.Background(), msg))
entries, err := client.XRange(context.Background(), testStream, "-", "+").Result()
require.NoError(t, err)
require.Len(t, entries, 1)
wantMs := msg.OccurredAt.UTC().UnixMilli()
assert.Equal(t, strconv.FormatInt(wantMs, 10), entries[0].Values[fieldOccurredAtMS])
}
func TestRejectsNilContext(t *testing.T) {
publisher, _ := newTestPublisher(t)
//nolint:staticcheck // explicitly testing nil-context rejection.
err := publisher.PublishSnapshotUpdate(nil, ports.RuntimeSnapshotUpdate{
GameID: "g",
CurrentTurn: 0,
RuntimeStatus: runtime.StatusStarting,
OccurredAt: time.Now().UTC(),
})
require.Error(t, err)
}
@@ -0,0 +1,147 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: galaxy/gamemaster/internal/ports (interfaces: EngineClient)
//
// Generated by this command:
//
// mockgen -destination=../adapters/mocks/mock_engineclient.go -package=mocks galaxy/gamemaster/internal/ports EngineClient
//
// Package mocks is a generated GoMock package.
package mocks
import (
context "context"
json "encoding/json"
ports "galaxy/gamemaster/internal/ports"
reflect "reflect"
gomock "go.uber.org/mock/gomock"
)
// MockEngineClient is a mock of EngineClient interface.
type MockEngineClient struct {
ctrl *gomock.Controller
recorder *MockEngineClientMockRecorder
isgomock struct{}
}
// MockEngineClientMockRecorder is the mock recorder for MockEngineClient.
type MockEngineClientMockRecorder struct {
mock *MockEngineClient
}
// NewMockEngineClient creates a new mock instance.
func NewMockEngineClient(ctrl *gomock.Controller) *MockEngineClient {
mock := &MockEngineClient{ctrl: ctrl}
mock.recorder = &MockEngineClientMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockEngineClient) EXPECT() *MockEngineClientMockRecorder {
return m.recorder
}
// BanishRace mocks base method.
func (m *MockEngineClient) BanishRace(ctx context.Context, baseURL, raceName string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "BanishRace", ctx, baseURL, raceName)
ret0, _ := ret[0].(error)
return ret0
}
// BanishRace indicates an expected call of BanishRace.
func (mr *MockEngineClientMockRecorder) BanishRace(ctx, baseURL, raceName any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BanishRace", reflect.TypeOf((*MockEngineClient)(nil).BanishRace), ctx, baseURL, raceName)
}
// ExecuteCommands mocks base method.
func (m *MockEngineClient) ExecuteCommands(ctx context.Context, baseURL string, payload json.RawMessage) (json.RawMessage, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ExecuteCommands", ctx, baseURL, payload)
ret0, _ := ret[0].(json.RawMessage)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ExecuteCommands indicates an expected call of ExecuteCommands.
func (mr *MockEngineClientMockRecorder) ExecuteCommands(ctx, baseURL, payload any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecuteCommands", reflect.TypeOf((*MockEngineClient)(nil).ExecuteCommands), ctx, baseURL, payload)
}
// GetReport mocks base method.
func (m *MockEngineClient) GetReport(ctx context.Context, baseURL, raceName string, turn int) (json.RawMessage, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetReport", ctx, baseURL, raceName, turn)
ret0, _ := ret[0].(json.RawMessage)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetReport indicates an expected call of GetReport.
func (mr *MockEngineClientMockRecorder) GetReport(ctx, baseURL, raceName, turn any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetReport", reflect.TypeOf((*MockEngineClient)(nil).GetReport), ctx, baseURL, raceName, turn)
}
// Init mocks base method.
func (m *MockEngineClient) Init(ctx context.Context, baseURL string, request ports.InitRequest) (ports.StateResponse, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Init", ctx, baseURL, request)
ret0, _ := ret[0].(ports.StateResponse)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Init indicates an expected call of Init.
func (mr *MockEngineClientMockRecorder) Init(ctx, baseURL, request any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Init", reflect.TypeOf((*MockEngineClient)(nil).Init), ctx, baseURL, request)
}
// PutOrders mocks base method.
func (m *MockEngineClient) PutOrders(ctx context.Context, baseURL string, payload json.RawMessage) (json.RawMessage, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "PutOrders", ctx, baseURL, payload)
ret0, _ := ret[0].(json.RawMessage)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// PutOrders indicates an expected call of PutOrders.
func (mr *MockEngineClientMockRecorder) PutOrders(ctx, baseURL, payload any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PutOrders", reflect.TypeOf((*MockEngineClient)(nil).PutOrders), ctx, baseURL, payload)
}
// Status mocks base method.
func (m *MockEngineClient) Status(ctx context.Context, baseURL string) (ports.StateResponse, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Status", ctx, baseURL)
ret0, _ := ret[0].(ports.StateResponse)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Status indicates an expected call of Status.
func (mr *MockEngineClientMockRecorder) Status(ctx, baseURL any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Status", reflect.TypeOf((*MockEngineClient)(nil).Status), ctx, baseURL)
}
// Turn mocks base method.
func (m *MockEngineClient) Turn(ctx context.Context, baseURL string) (ports.StateResponse, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Turn", ctx, baseURL)
ret0, _ := ret[0].(ports.StateResponse)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Turn indicates an expected call of Turn.
func (mr *MockEngineClientMockRecorder) Turn(ctx, baseURL any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Turn", reflect.TypeOf((*MockEngineClient)(nil).Turn), ctx, baseURL)
}
@@ -0,0 +1,145 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: galaxy/gamemaster/internal/ports (interfaces: EngineVersionStore)
//
// Generated by this command:
//
// mockgen -destination=../adapters/mocks/mock_engineversionstore.go -package=mocks galaxy/gamemaster/internal/ports EngineVersionStore
//
// Package mocks is a generated GoMock package.
package mocks
import (
context "context"
engineversion "galaxy/gamemaster/internal/domain/engineversion"
ports "galaxy/gamemaster/internal/ports"
reflect "reflect"
time "time"
gomock "go.uber.org/mock/gomock"
)
// MockEngineVersionStore is a mock of EngineVersionStore interface.
type MockEngineVersionStore struct {
ctrl *gomock.Controller
recorder *MockEngineVersionStoreMockRecorder
isgomock struct{}
}
// MockEngineVersionStoreMockRecorder is the mock recorder for MockEngineVersionStore.
type MockEngineVersionStoreMockRecorder struct {
mock *MockEngineVersionStore
}
// NewMockEngineVersionStore creates a new mock instance.
func NewMockEngineVersionStore(ctrl *gomock.Controller) *MockEngineVersionStore {
mock := &MockEngineVersionStore{ctrl: ctrl}
mock.recorder = &MockEngineVersionStoreMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockEngineVersionStore) EXPECT() *MockEngineVersionStoreMockRecorder {
return m.recorder
}
// Delete mocks base method.
func (m *MockEngineVersionStore) Delete(ctx context.Context, version string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Delete", ctx, version)
ret0, _ := ret[0].(error)
return ret0
}
// Delete indicates an expected call of Delete.
func (mr *MockEngineVersionStoreMockRecorder) Delete(ctx, version any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockEngineVersionStore)(nil).Delete), ctx, version)
}
// Deprecate mocks base method.
func (m *MockEngineVersionStore) Deprecate(ctx context.Context, version string, now time.Time) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Deprecate", ctx, version, now)
ret0, _ := ret[0].(error)
return ret0
}
// Deprecate indicates an expected call of Deprecate.
func (mr *MockEngineVersionStoreMockRecorder) Deprecate(ctx, version, now any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Deprecate", reflect.TypeOf((*MockEngineVersionStore)(nil).Deprecate), ctx, version, now)
}
// Get mocks base method.
func (m *MockEngineVersionStore) Get(ctx context.Context, version string) (engineversion.EngineVersion, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Get", ctx, version)
ret0, _ := ret[0].(engineversion.EngineVersion)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Get indicates an expected call of Get.
func (mr *MockEngineVersionStoreMockRecorder) Get(ctx, version any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockEngineVersionStore)(nil).Get), ctx, version)
}
// Insert mocks base method.
func (m *MockEngineVersionStore) Insert(ctx context.Context, record engineversion.EngineVersion) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Insert", ctx, record)
ret0, _ := ret[0].(error)
return ret0
}
// Insert indicates an expected call of Insert.
func (mr *MockEngineVersionStoreMockRecorder) Insert(ctx, record any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Insert", reflect.TypeOf((*MockEngineVersionStore)(nil).Insert), ctx, record)
}
// IsReferencedByActiveRuntime mocks base method.
func (m *MockEngineVersionStore) IsReferencedByActiveRuntime(ctx context.Context, version string) (bool, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "IsReferencedByActiveRuntime", ctx, version)
ret0, _ := ret[0].(bool)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// IsReferencedByActiveRuntime indicates an expected call of IsReferencedByActiveRuntime.
func (mr *MockEngineVersionStoreMockRecorder) IsReferencedByActiveRuntime(ctx, version any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsReferencedByActiveRuntime", reflect.TypeOf((*MockEngineVersionStore)(nil).IsReferencedByActiveRuntime), ctx, version)
}
// List mocks base method.
func (m *MockEngineVersionStore) List(ctx context.Context, statusFilter *engineversion.Status) ([]engineversion.EngineVersion, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "List", ctx, statusFilter)
ret0, _ := ret[0].([]engineversion.EngineVersion)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// List indicates an expected call of List.
func (mr *MockEngineVersionStoreMockRecorder) List(ctx, statusFilter any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockEngineVersionStore)(nil).List), ctx, statusFilter)
}
// Update mocks base method.
func (m *MockEngineVersionStore) Update(ctx context.Context, input ports.UpdateEngineVersionInput) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Update", ctx, input)
ret0, _ := ret[0].(error)
return ret0
}
// Update indicates an expected call of Update.
func (mr *MockEngineVersionStoreMockRecorder) Update(ctx, input any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockEngineVersionStore)(nil).Update), ctx, input)
}
@@ -0,0 +1,72 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: galaxy/gamemaster/internal/ports (interfaces: LobbyClient)
//
// Generated by this command:
//
// mockgen -destination=../adapters/mocks/mock_lobbyclient.go -package=mocks galaxy/gamemaster/internal/ports LobbyClient
//
// Package mocks is a generated GoMock package.
package mocks
import (
context "context"
ports "galaxy/gamemaster/internal/ports"
reflect "reflect"
gomock "go.uber.org/mock/gomock"
)
// MockLobbyClient is a mock of LobbyClient interface.
type MockLobbyClient struct {
ctrl *gomock.Controller
recorder *MockLobbyClientMockRecorder
isgomock struct{}
}
// MockLobbyClientMockRecorder is the mock recorder for MockLobbyClient.
type MockLobbyClientMockRecorder struct {
mock *MockLobbyClient
}
// NewMockLobbyClient creates a new mock instance.
func NewMockLobbyClient(ctrl *gomock.Controller) *MockLobbyClient {
mock := &MockLobbyClient{ctrl: ctrl}
mock.recorder = &MockLobbyClientMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockLobbyClient) EXPECT() *MockLobbyClientMockRecorder {
return m.recorder
}
// GetGameSummary mocks base method.
func (m *MockLobbyClient) GetGameSummary(ctx context.Context, gameID string) (ports.GameSummary, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetGameSummary", ctx, gameID)
ret0, _ := ret[0].(ports.GameSummary)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetGameSummary indicates an expected call of GetGameSummary.
func (mr *MockLobbyClientMockRecorder) GetGameSummary(ctx, gameID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGameSummary", reflect.TypeOf((*MockLobbyClient)(nil).GetGameSummary), ctx, gameID)
}
// GetMemberships mocks base method.
func (m *MockLobbyClient) GetMemberships(ctx context.Context, gameID string) ([]ports.Membership, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetMemberships", ctx, gameID)
ret0, _ := ret[0].([]ports.Membership)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetMemberships indicates an expected call of GetMemberships.
func (mr *MockLobbyClientMockRecorder) GetMemberships(ctx, gameID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMemberships", reflect.TypeOf((*MockLobbyClient)(nil).GetMemberships), ctx, gameID)
}
@@ -0,0 +1,70 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: galaxy/gamemaster/internal/ports (interfaces: LobbyEventsPublisher)
//
// Generated by this command:
//
// mockgen -destination=../adapters/mocks/mock_lobbyeventspublisher.go -package=mocks galaxy/gamemaster/internal/ports LobbyEventsPublisher
//
// Package mocks is a generated GoMock package.
package mocks
import (
context "context"
ports "galaxy/gamemaster/internal/ports"
reflect "reflect"
gomock "go.uber.org/mock/gomock"
)
// MockLobbyEventsPublisher is a mock of LobbyEventsPublisher interface.
type MockLobbyEventsPublisher struct {
ctrl *gomock.Controller
recorder *MockLobbyEventsPublisherMockRecorder
isgomock struct{}
}
// MockLobbyEventsPublisherMockRecorder is the mock recorder for MockLobbyEventsPublisher.
type MockLobbyEventsPublisherMockRecorder struct {
mock *MockLobbyEventsPublisher
}
// NewMockLobbyEventsPublisher creates a new mock instance.
func NewMockLobbyEventsPublisher(ctrl *gomock.Controller) *MockLobbyEventsPublisher {
mock := &MockLobbyEventsPublisher{ctrl: ctrl}
mock.recorder = &MockLobbyEventsPublisherMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockLobbyEventsPublisher) EXPECT() *MockLobbyEventsPublisherMockRecorder {
return m.recorder
}
// PublishGameFinished mocks base method.
func (m *MockLobbyEventsPublisher) PublishGameFinished(ctx context.Context, msg ports.GameFinished) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "PublishGameFinished", ctx, msg)
ret0, _ := ret[0].(error)
return ret0
}
// PublishGameFinished indicates an expected call of PublishGameFinished.
func (mr *MockLobbyEventsPublisherMockRecorder) PublishGameFinished(ctx, msg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishGameFinished", reflect.TypeOf((*MockLobbyEventsPublisher)(nil).PublishGameFinished), ctx, msg)
}
// PublishSnapshotUpdate mocks base method.
func (m *MockLobbyEventsPublisher) PublishSnapshotUpdate(ctx context.Context, msg ports.RuntimeSnapshotUpdate) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "PublishSnapshotUpdate", ctx, msg)
ret0, _ := ret[0].(error)
return ret0
}
// PublishSnapshotUpdate indicates an expected call of PublishSnapshotUpdate.
func (mr *MockLobbyEventsPublisherMockRecorder) PublishSnapshotUpdate(ctx, msg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishSnapshotUpdate", reflect.TypeOf((*MockLobbyEventsPublisher)(nil).PublishSnapshotUpdate), ctx, msg)
}
@@ -0,0 +1,56 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: galaxy/gamemaster/internal/ports (interfaces: NotificationIntentPublisher)
//
// Generated by this command:
//
// mockgen -destination=../adapters/mocks/mock_notificationpublisher.go -package=mocks galaxy/gamemaster/internal/ports NotificationIntentPublisher
//
// Package mocks is a generated GoMock package.
package mocks
import (
context "context"
notificationintent "galaxy/notificationintent"
reflect "reflect"
gomock "go.uber.org/mock/gomock"
)
// MockNotificationIntentPublisher is a mock of NotificationIntentPublisher interface.
type MockNotificationIntentPublisher struct {
ctrl *gomock.Controller
recorder *MockNotificationIntentPublisherMockRecorder
isgomock struct{}
}
// MockNotificationIntentPublisherMockRecorder is the mock recorder for MockNotificationIntentPublisher.
type MockNotificationIntentPublisherMockRecorder struct {
mock *MockNotificationIntentPublisher
}
// NewMockNotificationIntentPublisher creates a new mock instance.
func NewMockNotificationIntentPublisher(ctrl *gomock.Controller) *MockNotificationIntentPublisher {
mock := &MockNotificationIntentPublisher{ctrl: ctrl}
mock.recorder = &MockNotificationIntentPublisherMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockNotificationIntentPublisher) EXPECT() *MockNotificationIntentPublisherMockRecorder {
return m.recorder
}
// Publish mocks base method.
func (m *MockNotificationIntentPublisher) Publish(ctx context.Context, intent notificationintent.Intent) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Publish", ctx, intent)
ret0, _ := ret[0].(error)
return ret0
}
// Publish indicates an expected call of Publish.
func (mr *MockNotificationIntentPublisherMockRecorder) Publish(ctx, intent any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Publish", reflect.TypeOf((*MockNotificationIntentPublisher)(nil).Publish), ctx, intent)
}
@@ -0,0 +1,72 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: galaxy/gamemaster/internal/ports (interfaces: OperationLogStore)
//
// Generated by this command:
//
// mockgen -destination=../adapters/mocks/mock_operationlog.go -package=mocks galaxy/gamemaster/internal/ports OperationLogStore
//
// Package mocks is a generated GoMock package.
package mocks
import (
context "context"
operation "galaxy/gamemaster/internal/domain/operation"
reflect "reflect"
gomock "go.uber.org/mock/gomock"
)
// MockOperationLogStore is a mock of OperationLogStore interface.
type MockOperationLogStore struct {
ctrl *gomock.Controller
recorder *MockOperationLogStoreMockRecorder
isgomock struct{}
}
// MockOperationLogStoreMockRecorder is the mock recorder for MockOperationLogStore.
type MockOperationLogStoreMockRecorder struct {
mock *MockOperationLogStore
}
// NewMockOperationLogStore creates a new mock instance.
func NewMockOperationLogStore(ctrl *gomock.Controller) *MockOperationLogStore {
mock := &MockOperationLogStore{ctrl: ctrl}
mock.recorder = &MockOperationLogStoreMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockOperationLogStore) EXPECT() *MockOperationLogStoreMockRecorder {
return m.recorder
}
// Append mocks base method.
func (m *MockOperationLogStore) Append(ctx context.Context, entry operation.OperationEntry) (int64, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Append", ctx, entry)
ret0, _ := ret[0].(int64)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Append indicates an expected call of Append.
func (mr *MockOperationLogStoreMockRecorder) Append(ctx, entry any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Append", reflect.TypeOf((*MockOperationLogStore)(nil).Append), ctx, entry)
}
// ListByGame mocks base method.
func (m *MockOperationLogStore) ListByGame(ctx context.Context, gameID string, limit int) ([]operation.OperationEntry, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListByGame", ctx, gameID, limit)
ret0, _ := ret[0].([]operation.OperationEntry)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ListByGame indicates an expected call of ListByGame.
func (mr *MockOperationLogStoreMockRecorder) ListByGame(ctx, gameID, limit any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByGame", reflect.TypeOf((*MockOperationLogStore)(nil).ListByGame), ctx, gameID, limit)
}
@@ -0,0 +1,115 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: galaxy/gamemaster/internal/ports (interfaces: PlayerMappingStore)
//
// Generated by this command:
//
// mockgen -destination=../adapters/mocks/mock_playermappingstore.go -package=mocks galaxy/gamemaster/internal/ports PlayerMappingStore
//
// Package mocks is a generated GoMock package.
package mocks
import (
context "context"
playermapping "galaxy/gamemaster/internal/domain/playermapping"
reflect "reflect"
gomock "go.uber.org/mock/gomock"
)
// MockPlayerMappingStore is a mock of PlayerMappingStore interface.
type MockPlayerMappingStore struct {
ctrl *gomock.Controller
recorder *MockPlayerMappingStoreMockRecorder
isgomock struct{}
}
// MockPlayerMappingStoreMockRecorder is the mock recorder for MockPlayerMappingStore.
type MockPlayerMappingStoreMockRecorder struct {
mock *MockPlayerMappingStore
}
// NewMockPlayerMappingStore creates a new mock instance.
func NewMockPlayerMappingStore(ctrl *gomock.Controller) *MockPlayerMappingStore {
mock := &MockPlayerMappingStore{ctrl: ctrl}
mock.recorder = &MockPlayerMappingStoreMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockPlayerMappingStore) EXPECT() *MockPlayerMappingStoreMockRecorder {
return m.recorder
}
// BulkInsert mocks base method.
func (m *MockPlayerMappingStore) BulkInsert(ctx context.Context, records []playermapping.PlayerMapping) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "BulkInsert", ctx, records)
ret0, _ := ret[0].(error)
return ret0
}
// BulkInsert indicates an expected call of BulkInsert.
func (mr *MockPlayerMappingStoreMockRecorder) BulkInsert(ctx, records any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BulkInsert", reflect.TypeOf((*MockPlayerMappingStore)(nil).BulkInsert), ctx, records)
}
// DeleteByGame mocks base method.
func (m *MockPlayerMappingStore) DeleteByGame(ctx context.Context, gameID string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteByGame", ctx, gameID)
ret0, _ := ret[0].(error)
return ret0
}
// DeleteByGame indicates an expected call of DeleteByGame.
func (mr *MockPlayerMappingStoreMockRecorder) DeleteByGame(ctx, gameID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteByGame", reflect.TypeOf((*MockPlayerMappingStore)(nil).DeleteByGame), ctx, gameID)
}
// Get mocks base method.
func (m *MockPlayerMappingStore) Get(ctx context.Context, gameID, userID string) (playermapping.PlayerMapping, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Get", ctx, gameID, userID)
ret0, _ := ret[0].(playermapping.PlayerMapping)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Get indicates an expected call of Get.
func (mr *MockPlayerMappingStoreMockRecorder) Get(ctx, gameID, userID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockPlayerMappingStore)(nil).Get), ctx, gameID, userID)
}
// GetByRace mocks base method.
func (m *MockPlayerMappingStore) GetByRace(ctx context.Context, gameID, raceName string) (playermapping.PlayerMapping, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetByRace", ctx, gameID, raceName)
ret0, _ := ret[0].(playermapping.PlayerMapping)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetByRace indicates an expected call of GetByRace.
func (mr *MockPlayerMappingStoreMockRecorder) GetByRace(ctx, gameID, raceName any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByRace", reflect.TypeOf((*MockPlayerMappingStore)(nil).GetByRace), ctx, gameID, raceName)
}
// ListByGame mocks base method.
func (m *MockPlayerMappingStore) ListByGame(ctx context.Context, gameID string) ([]playermapping.PlayerMapping, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListByGame", ctx, gameID)
ret0, _ := ret[0].([]playermapping.PlayerMapping)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ListByGame indicates an expected call of ListByGame.
func (mr *MockPlayerMappingStoreMockRecorder) ListByGame(ctx, gameID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByGame", reflect.TypeOf((*MockPlayerMappingStore)(nil).ListByGame), ctx, gameID)
}
@@ -0,0 +1,69 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: galaxy/gamemaster/internal/ports (interfaces: RTMClient)
//
// Generated by this command:
//
// mockgen -destination=../adapters/mocks/mock_rtmclient.go -package=mocks galaxy/gamemaster/internal/ports RTMClient
//
// Package mocks is a generated GoMock package.
package mocks
import (
context "context"
reflect "reflect"
gomock "go.uber.org/mock/gomock"
)
// MockRTMClient is a mock of RTMClient interface.
type MockRTMClient struct {
ctrl *gomock.Controller
recorder *MockRTMClientMockRecorder
isgomock struct{}
}
// MockRTMClientMockRecorder is the mock recorder for MockRTMClient.
type MockRTMClientMockRecorder struct {
mock *MockRTMClient
}
// NewMockRTMClient creates a new mock instance.
func NewMockRTMClient(ctrl *gomock.Controller) *MockRTMClient {
mock := &MockRTMClient{ctrl: ctrl}
mock.recorder = &MockRTMClientMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockRTMClient) EXPECT() *MockRTMClientMockRecorder {
return m.recorder
}
// Patch mocks base method.
func (m *MockRTMClient) Patch(ctx context.Context, gameID, imageRef string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Patch", ctx, gameID, imageRef)
ret0, _ := ret[0].(error)
return ret0
}
// Patch indicates an expected call of Patch.
func (mr *MockRTMClientMockRecorder) Patch(ctx, gameID, imageRef any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Patch", reflect.TypeOf((*MockRTMClient)(nil).Patch), ctx, gameID, imageRef)
}
// Stop mocks base method.
func (m *MockRTMClient) Stop(ctx context.Context, gameID, reason string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Stop", ctx, gameID, reason)
ret0, _ := ret[0].(error)
return ret0
}
// Stop indicates an expected call of Stop.
func (mr *MockRTMClientMockRecorder) Stop(ctx, gameID, reason any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stop", reflect.TypeOf((*MockRTMClient)(nil).Stop), ctx, gameID, reason)
}
@@ -0,0 +1,188 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: galaxy/gamemaster/internal/ports (interfaces: RuntimeRecordStore)
//
// Generated by this command:
//
// mockgen -destination=../adapters/mocks/mock_runtimerecordstore.go -package=mocks galaxy/gamemaster/internal/ports RuntimeRecordStore
//
// Package mocks is a generated GoMock package.
package mocks
import (
context "context"
runtime "galaxy/gamemaster/internal/domain/runtime"
ports "galaxy/gamemaster/internal/ports"
reflect "reflect"
time "time"
gomock "go.uber.org/mock/gomock"
)
// MockRuntimeRecordStore is a mock of RuntimeRecordStore interface.
type MockRuntimeRecordStore struct {
ctrl *gomock.Controller
recorder *MockRuntimeRecordStoreMockRecorder
isgomock struct{}
}
// MockRuntimeRecordStoreMockRecorder is the mock recorder for MockRuntimeRecordStore.
type MockRuntimeRecordStoreMockRecorder struct {
mock *MockRuntimeRecordStore
}
// NewMockRuntimeRecordStore creates a new mock instance.
func NewMockRuntimeRecordStore(ctrl *gomock.Controller) *MockRuntimeRecordStore {
mock := &MockRuntimeRecordStore{ctrl: ctrl}
mock.recorder = &MockRuntimeRecordStoreMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockRuntimeRecordStore) EXPECT() *MockRuntimeRecordStoreMockRecorder {
return m.recorder
}
// Delete mocks base method.
func (m *MockRuntimeRecordStore) Delete(ctx context.Context, gameID string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Delete", ctx, gameID)
ret0, _ := ret[0].(error)
return ret0
}
// Delete indicates an expected call of Delete.
func (mr *MockRuntimeRecordStoreMockRecorder) Delete(ctx, gameID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockRuntimeRecordStore)(nil).Delete), ctx, gameID)
}
// Get mocks base method.
func (m *MockRuntimeRecordStore) Get(ctx context.Context, gameID string) (runtime.RuntimeRecord, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Get", ctx, gameID)
ret0, _ := ret[0].(runtime.RuntimeRecord)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Get indicates an expected call of Get.
func (mr *MockRuntimeRecordStoreMockRecorder) Get(ctx, gameID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockRuntimeRecordStore)(nil).Get), ctx, gameID)
}
// Insert mocks base method.
func (m *MockRuntimeRecordStore) Insert(ctx context.Context, record runtime.RuntimeRecord) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Insert", ctx, record)
ret0, _ := ret[0].(error)
return ret0
}
// Insert indicates an expected call of Insert.
func (mr *MockRuntimeRecordStoreMockRecorder) Insert(ctx, record any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Insert", reflect.TypeOf((*MockRuntimeRecordStore)(nil).Insert), ctx, record)
}
// List mocks base method.
func (m *MockRuntimeRecordStore) List(ctx context.Context) ([]runtime.RuntimeRecord, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "List", ctx)
ret0, _ := ret[0].([]runtime.RuntimeRecord)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// List indicates an expected call of List.
func (mr *MockRuntimeRecordStoreMockRecorder) List(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockRuntimeRecordStore)(nil).List), ctx)
}
// ListByStatus mocks base method.
func (m *MockRuntimeRecordStore) ListByStatus(ctx context.Context, status runtime.Status) ([]runtime.RuntimeRecord, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListByStatus", ctx, status)
ret0, _ := ret[0].([]runtime.RuntimeRecord)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ListByStatus indicates an expected call of ListByStatus.
func (mr *MockRuntimeRecordStoreMockRecorder) ListByStatus(ctx, status any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByStatus", reflect.TypeOf((*MockRuntimeRecordStore)(nil).ListByStatus), ctx, status)
}
// ListDueRunning mocks base method.
func (m *MockRuntimeRecordStore) ListDueRunning(ctx context.Context, now time.Time) ([]runtime.RuntimeRecord, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListDueRunning", ctx, now)
ret0, _ := ret[0].([]runtime.RuntimeRecord)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ListDueRunning indicates an expected call of ListDueRunning.
func (mr *MockRuntimeRecordStoreMockRecorder) ListDueRunning(ctx, now any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListDueRunning", reflect.TypeOf((*MockRuntimeRecordStore)(nil).ListDueRunning), ctx, now)
}
// UpdateEngineHealth mocks base method.
func (m *MockRuntimeRecordStore) UpdateEngineHealth(ctx context.Context, input ports.UpdateEngineHealthInput) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateEngineHealth", ctx, input)
ret0, _ := ret[0].(error)
return ret0
}
// UpdateEngineHealth indicates an expected call of UpdateEngineHealth.
func (mr *MockRuntimeRecordStoreMockRecorder) UpdateEngineHealth(ctx, input any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateEngineHealth", reflect.TypeOf((*MockRuntimeRecordStore)(nil).UpdateEngineHealth), ctx, input)
}
// UpdateImage mocks base method.
func (m *MockRuntimeRecordStore) UpdateImage(ctx context.Context, input ports.UpdateImageInput) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateImage", ctx, input)
ret0, _ := ret[0].(error)
return ret0
}
// UpdateImage indicates an expected call of UpdateImage.
func (mr *MockRuntimeRecordStoreMockRecorder) UpdateImage(ctx, input any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateImage", reflect.TypeOf((*MockRuntimeRecordStore)(nil).UpdateImage), ctx, input)
}
// UpdateScheduling mocks base method.
func (m *MockRuntimeRecordStore) UpdateScheduling(ctx context.Context, input ports.UpdateSchedulingInput) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateScheduling", ctx, input)
ret0, _ := ret[0].(error)
return ret0
}
// UpdateScheduling indicates an expected call of UpdateScheduling.
func (mr *MockRuntimeRecordStoreMockRecorder) UpdateScheduling(ctx, input any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateScheduling", reflect.TypeOf((*MockRuntimeRecordStore)(nil).UpdateScheduling), ctx, input)
}
// UpdateStatus mocks base method.
func (m *MockRuntimeRecordStore) UpdateStatus(ctx context.Context, input ports.UpdateStatusInput) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateStatus", ctx, input)
ret0, _ := ret[0].(error)
return ret0
}
// UpdateStatus indicates an expected call of UpdateStatus.
func (mr *MockRuntimeRecordStoreMockRecorder) UpdateStatus(ctx, input any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateStatus", reflect.TypeOf((*MockRuntimeRecordStore)(nil).UpdateStatus), ctx, input)
}
@@ -0,0 +1,71 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: galaxy/gamemaster/internal/ports (interfaces: StreamOffsetStore)
//
// Generated by this command:
//
// mockgen -destination=../adapters/mocks/mock_streamoffsetstore.go -package=mocks galaxy/gamemaster/internal/ports StreamOffsetStore
//
// Package mocks is a generated GoMock package.
package mocks
import (
context "context"
reflect "reflect"
gomock "go.uber.org/mock/gomock"
)
// MockStreamOffsetStore is a mock of StreamOffsetStore interface.
type MockStreamOffsetStore struct {
ctrl *gomock.Controller
recorder *MockStreamOffsetStoreMockRecorder
isgomock struct{}
}
// MockStreamOffsetStoreMockRecorder is the mock recorder for MockStreamOffsetStore.
type MockStreamOffsetStoreMockRecorder struct {
mock *MockStreamOffsetStore
}
// NewMockStreamOffsetStore creates a new mock instance.
func NewMockStreamOffsetStore(ctrl *gomock.Controller) *MockStreamOffsetStore {
mock := &MockStreamOffsetStore{ctrl: ctrl}
mock.recorder = &MockStreamOffsetStoreMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockStreamOffsetStore) EXPECT() *MockStreamOffsetStoreMockRecorder {
return m.recorder
}
// Load mocks base method.
func (m *MockStreamOffsetStore) Load(ctx context.Context, stream string) (string, bool, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Load", ctx, stream)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(bool)
ret2, _ := ret[2].(error)
return ret0, ret1, ret2
}
// Load indicates an expected call of Load.
func (mr *MockStreamOffsetStoreMockRecorder) Load(ctx, stream any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Load", reflect.TypeOf((*MockStreamOffsetStore)(nil).Load), ctx, stream)
}
// Save mocks base method.
func (m *MockStreamOffsetStore) Save(ctx context.Context, stream, entryID string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Save", ctx, stream, entryID)
ret0, _ := ret[0].(error)
return ret0
}
// Save indicates an expected call of Save.
func (mr *MockStreamOffsetStoreMockRecorder) Save(ctx, stream, entryID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockStreamOffsetStore)(nil).Save), ctx, stream, entryID)
}
@@ -0,0 +1,73 @@
// Package notificationpublisher provides the Redis-Streams-backed
// notification-intent publisher Game Master uses for the three GM-owned
// types listed in `gamemaster/README.md §Notification Contracts`:
// `game.turn.ready`, `game.finished`, `game.generation_failed`.
//
// The adapter is a thin shim over `galaxy/notificationintent.Publisher`
// that drops the entry id at the wrapper boundary; it mirrors
// `rtmanager/internal/adapters/notificationpublisher` byte-for-byte
// (`rtmanager/docs/domain-and-ports.md §7` justifies that decision and
// applies here for the same reason).
package notificationpublisher
import (
"context"
"errors"
"fmt"
"github.com/redis/go-redis/v9"
"galaxy/notificationintent"
"galaxy/gamemaster/internal/ports"
)
// Config groups the dependencies and stream name required to construct
// a Publisher.
type Config struct {
// Client appends entries to Redis Streams. Must be non-nil.
Client *redis.Client
// Stream stores the Redis Stream key intents are published to.
// When empty, `notificationintent.DefaultIntentsStream` is used.
Stream string
}
// Publisher implements `ports.NotificationIntentPublisher` on top of
// the shared `notificationintent.Publisher`.
type Publisher struct {
inner *notificationintent.Publisher
}
// NewPublisher constructs a Publisher from cfg. Validation errors and
// transport errors propagate verbatim.
func NewPublisher(cfg Config) (*Publisher, error) {
if cfg.Client == nil {
return nil, errors.New("new gamemaster notification publisher: nil redis client")
}
inner, err := notificationintent.NewPublisher(notificationintent.PublisherConfig{
Client: cfg.Client,
Stream: cfg.Stream,
})
if err != nil {
return nil, fmt.Errorf("new gamemaster notification publisher: %w", err)
}
return &Publisher{inner: inner}, nil
}
// Publish forwards intent to the underlying notificationintent
// publisher and discards the resulting Redis Stream entry id. A failed
// publish surfaces as the underlying error.
func (publisher *Publisher) Publish(ctx context.Context, intent notificationintent.Intent) error {
if publisher == nil || publisher.inner == nil {
return errors.New("publish notification intent: nil publisher")
}
if _, err := publisher.inner.Publish(ctx, intent); err != nil {
return err
}
return nil
}
// Compile-time assertion: Publisher implements
// ports.NotificationIntentPublisher.
var _ ports.NotificationIntentPublisher = (*Publisher)(nil)
@@ -0,0 +1,167 @@
package notificationpublisher
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"galaxy/notificationintent"
)
func newRedis(t *testing.T) (*redis.Client, *miniredis.Miniredis) {
t.Helper()
server := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() { _ = client.Close() })
return client, server
}
func readStream(t *testing.T, client *redis.Client, stream string) []redis.XMessage {
t.Helper()
messages, err := client.XRange(context.Background(), stream, "-", "+").Result()
require.NoError(t, err)
return messages
}
func TestNewPublisherValidation(t *testing.T) {
t.Run("nil client", func(t *testing.T) {
_, err := NewPublisher(Config{})
require.Error(t, err)
assert.Contains(t, err.Error(), "nil redis client")
})
}
func TestPublishGameTurnReady(t *testing.T) {
client, _ := newRedis(t)
publisher, err := NewPublisher(Config{Client: client, Stream: "notification:intents"})
require.NoError(t, err)
intent, err := notificationintent.NewGameTurnReadyIntent(
notificationintent.Metadata{
IdempotencyKey: "gamemaster:turn:game-1:42",
OccurredAt: time.UnixMilli(1714200000000).UTC(),
},
[]string{"u-2", "u-1"},
notificationintent.GameTurnReadyPayload{
GameID: "game-1",
GameName: "Galaxy",
TurnNumber: 42,
},
)
require.NoError(t, err)
require.NoError(t, publisher.Publish(context.Background(), intent))
messages := readStream(t, client, "notification:intents")
require.Len(t, messages, 1)
values := messages[0].Values
assert.Equal(t, "game.turn.ready", values["notification_type"])
assert.Equal(t, "game_master", values["producer"])
assert.Equal(t, "user", values["audience_kind"])
assert.Equal(t, "gamemaster:turn:game-1:42", values["idempotency_key"])
recipients, ok := values["recipient_user_ids_json"].(string)
require.True(t, ok)
var ids []string
require.NoError(t, json.Unmarshal([]byte(recipients), &ids))
assert.ElementsMatch(t, []string{"u-1", "u-2"}, ids)
payloadRaw, ok := values["payload_json"].(string)
require.True(t, ok)
var payload map[string]any
require.NoError(t, json.Unmarshal([]byte(payloadRaw), &payload))
assert.Equal(t, "game-1", payload["game_id"])
assert.Equal(t, float64(42), payload["turn_number"])
}
func TestPublishGameFinished(t *testing.T) {
client, _ := newRedis(t)
publisher, err := NewPublisher(Config{Client: client, Stream: "notification:intents"})
require.NoError(t, err)
intent, err := notificationintent.NewGameFinishedIntent(
notificationintent.Metadata{
IdempotencyKey: "gamemaster:finished:g-1",
OccurredAt: time.UnixMilli(1714200000000).UTC(),
},
[]string{"u-1"},
notificationintent.GameFinishedPayload{
GameID: "g-1",
GameName: "Galaxy",
FinalTurnNumber: 100,
},
)
require.NoError(t, err)
require.NoError(t, publisher.Publish(context.Background(), intent))
messages := readStream(t, client, "notification:intents")
require.Len(t, messages, 1)
assert.Equal(t, "game.finished", messages[0].Values["notification_type"])
assert.Equal(t, "user", messages[0].Values["audience_kind"])
}
func TestPublishGameGenerationFailed(t *testing.T) {
client, _ := newRedis(t)
publisher, err := NewPublisher(Config{Client: client, Stream: "notification:intents"})
require.NoError(t, err)
intent, err := notificationintent.NewGameGenerationFailedIntent(
notificationintent.Metadata{
IdempotencyKey: "gamemaster:gen-failed:g-1:42",
OccurredAt: time.UnixMilli(1714200000000).UTC(),
},
notificationintent.GameGenerationFailedPayload{
GameID: "g-1",
GameName: "Galaxy",
FailureReason: "engine timeout",
},
)
require.NoError(t, err)
require.NoError(t, publisher.Publish(context.Background(), intent))
messages := readStream(t, client, "notification:intents")
require.Len(t, messages, 1)
values := messages[0].Values
assert.Equal(t, "game.generation_failed", values["notification_type"])
assert.Equal(t, "admin_email", values["audience_kind"])
_, hasRecipients := values["recipient_user_ids_json"]
assert.False(t, hasRecipients, "admin_email audience must not carry recipient ids")
}
func TestPublishForwardsValidationError(t *testing.T) {
client, _ := newRedis(t)
publisher, err := NewPublisher(Config{Client: client})
require.NoError(t, err)
bad := notificationintent.Intent{
NotificationType: notificationintent.NotificationTypeGameTurnReady,
Producer: notificationintent.ProducerGameMaster,
AudienceKind: notificationintent.AudienceKindUser,
IdempotencyKey: "k",
PayloadJSON: `{"game_id":"g","game_name":"x","turn_number":1}`,
}
require.Error(t, publisher.Publish(context.Background(), bad))
}
func TestPublishDefaultStream(t *testing.T) {
client, _ := newRedis(t)
publisher, err := NewPublisher(Config{Client: client, Stream: ""})
require.NoError(t, err)
intent, err := notificationintent.NewGameTurnReadyIntent(
notificationintent.Metadata{IdempotencyKey: "k", OccurredAt: time.UnixMilli(1).UTC()},
[]string{"u-1"},
notificationintent.GameTurnReadyPayload{GameID: "g", GameName: "n", TurnNumber: 1},
)
require.NoError(t, err)
require.NoError(t, publisher.Publish(context.Background(), intent))
messages := readStream(t, client, notificationintent.DefaultIntentsStream)
require.Len(t, messages, 1)
}
@@ -0,0 +1,416 @@
// Package engineversionstore implements the PostgreSQL-backed adapter
// for `ports.EngineVersionStore`.
//
// The package owns the on-disk shape of the `engine_versions` table
// defined in
// `galaxy/gamemaster/internal/adapters/postgres/migrations/00001_init.sql`
// and translates the schema-agnostic `ports.EngineVersionStore`
// interface declared in `internal/ports/engineversionstore.go` into
// concrete go-jet/v2 statements driven by the pgx driver.
//
// Insert maps PostgreSQL unique violations to engineversion.ErrConflict;
// Update applies a partial UPDATE driven by the non-nil pointer fields
// of UpdateEngineVersionInput; Deprecate is idempotent on the
// already-deprecated row; IsReferencedByActiveRuntime probes the
// runtime_records table for non-finished references.
package engineversionstore
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"galaxy/gamemaster/internal/adapters/postgres/internal/sqlx"
pgtable "galaxy/gamemaster/internal/adapters/postgres/jet/gamemaster/table"
"galaxy/gamemaster/internal/domain/engineversion"
"galaxy/gamemaster/internal/domain/runtime"
"galaxy/gamemaster/internal/ports"
pg "github.com/go-jet/jet/v2/postgres"
)
// emptyOptionsJSON is the default value persisted when a caller hands
// us an empty Options slice. It matches the SQL column default.
var emptyOptionsJSON = []byte("{}")
// Config configures one PostgreSQL-backed engine-version store. The
// store does not own the underlying *sql.DB lifecycle.
type Config struct {
DB *sql.DB
OperationTimeout time.Duration
}
// Store persists Game Master engine-version registry rows in
// PostgreSQL.
type Store struct {
db *sql.DB
operationTimeout time.Duration
}
// New constructs one PostgreSQL-backed engine-version store from cfg.
func New(cfg Config) (*Store, error) {
if cfg.DB == nil {
return nil, errors.New("new postgres engine version store: db must not be nil")
}
if cfg.OperationTimeout <= 0 {
return nil, errors.New("new postgres engine version store: operation timeout must be positive")
}
return &Store{
db: cfg.DB,
operationTimeout: cfg.OperationTimeout,
}, nil
}
// engineVersionSelectColumns matches scanRow's column order.
var engineVersionSelectColumns = pg.ColumnList{
pgtable.EngineVersions.Version,
pgtable.EngineVersions.ImageRef,
pgtable.EngineVersions.Options,
pgtable.EngineVersions.Status,
pgtable.EngineVersions.CreatedAt,
pgtable.EngineVersions.UpdatedAt,
}
// Get returns the row identified by version. Returns
// engineversion.ErrNotFound when no row exists.
func (store *Store) Get(ctx context.Context, version string) (engineversion.EngineVersion, error) {
if store == nil || store.db == nil {
return engineversion.EngineVersion{}, errors.New("get engine version: nil store")
}
if strings.TrimSpace(version) == "" {
return engineversion.EngineVersion{}, fmt.Errorf("get engine version: version must not be empty")
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "get engine version", store.operationTimeout)
if err != nil {
return engineversion.EngineVersion{}, err
}
defer cancel()
stmt := pg.SELECT(engineVersionSelectColumns).
FROM(pgtable.EngineVersions).
WHERE(pgtable.EngineVersions.Version.EQ(pg.String(version)))
query, args := stmt.Sql()
row := store.db.QueryRowContext(operationCtx, query, args...)
got, err := scanRow(row)
if sqlx.IsNoRows(err) {
return engineversion.EngineVersion{}, engineversion.ErrNotFound
}
if err != nil {
return engineversion.EngineVersion{}, fmt.Errorf("get engine version: %w", err)
}
return got, nil
}
// List returns every row whose status matches statusFilter (when
// non-nil), ordered by version ASC.
func (store *Store) List(ctx context.Context, statusFilter *engineversion.Status) ([]engineversion.EngineVersion, error) {
if store == nil || store.db == nil {
return nil, errors.New("list engine versions: nil store")
}
if statusFilter != nil && !statusFilter.IsKnown() {
return nil, fmt.Errorf("list engine versions: status %q is unsupported", *statusFilter)
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "list engine versions", store.operationTimeout)
if err != nil {
return nil, err
}
defer cancel()
stmt := pg.SELECT(engineVersionSelectColumns).
FROM(pgtable.EngineVersions)
if statusFilter != nil {
stmt = stmt.WHERE(pgtable.EngineVersions.Status.EQ(pg.String(string(*statusFilter))))
}
stmt = stmt.ORDER_BY(pgtable.EngineVersions.Version.ASC())
query, args := stmt.Sql()
rows, err := store.db.QueryContext(operationCtx, query, args...)
if err != nil {
return nil, fmt.Errorf("list engine versions: %w", err)
}
defer rows.Close()
versions := make([]engineversion.EngineVersion, 0)
for rows.Next() {
got, err := scanRow(rows)
if err != nil {
return nil, fmt.Errorf("list engine versions: scan: %w", err)
}
versions = append(versions, got)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list engine versions: %w", err)
}
if len(versions) == 0 {
return nil, nil
}
return versions, nil
}
// Insert installs record into the registry. Returns
// engineversion.ErrConflict when a row with the same version already
// exists.
func (store *Store) Insert(ctx context.Context, record engineversion.EngineVersion) error {
if store == nil || store.db == nil {
return errors.New("insert engine version: nil store")
}
if err := record.Validate(); err != nil {
return fmt.Errorf("insert engine version: %w", err)
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "insert engine version", store.operationTimeout)
if err != nil {
return err
}
defer cancel()
options := record.Options
if len(options) == 0 {
options = emptyOptionsJSON
}
stmt := pgtable.EngineVersions.INSERT(
pgtable.EngineVersions.Version,
pgtable.EngineVersions.ImageRef,
pgtable.EngineVersions.Options,
pgtable.EngineVersions.Status,
pgtable.EngineVersions.CreatedAt,
pgtable.EngineVersions.UpdatedAt,
).VALUES(
record.Version,
record.ImageRef,
string(options),
string(record.Status),
record.CreatedAt.UTC(),
record.UpdatedAt.UTC(),
)
query, args := stmt.Sql()
if _, err := store.db.ExecContext(operationCtx, query, args...); err != nil {
if sqlx.IsUniqueViolation(err) {
return fmt.Errorf("insert engine version: %w", engineversion.ErrConflict)
}
return fmt.Errorf("insert engine version: %w", err)
}
return nil
}
// Update applies a partial update to one engine-version row.
// updated_at is always refreshed from input.Now. Returns
// engineversion.ErrNotFound when the row is absent.
func (store *Store) Update(ctx context.Context, input ports.UpdateEngineVersionInput) error {
if store == nil || store.db == nil {
return errors.New("update engine version: nil store")
}
if err := input.Validate(); err != nil {
return err
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "update engine version", store.operationTimeout)
if err != nil {
return err
}
defer cancel()
now := input.Now.UTC()
assignments := []any{
pgtable.EngineVersions.UpdatedAt.SET(pg.TimestampzT(now)),
}
if input.ImageRef != nil {
assignments = append(assignments,
pgtable.EngineVersions.ImageRef.SET(pg.String(*input.ImageRef)))
}
if input.Options != nil {
options := *input.Options
if len(options) == 0 {
options = emptyOptionsJSON
}
assignments = append(assignments,
pgtable.EngineVersions.Options.SET(
pg.StringExp(pg.CAST(pg.String(string(options))).AS("jsonb")),
))
}
if input.Status != nil {
assignments = append(assignments,
pgtable.EngineVersions.Status.SET(pg.String(string(*input.Status))))
}
stmt := pgtable.EngineVersions.UPDATE(pgtable.EngineVersions.UpdatedAt).
SET(assignments[0], assignments[1:]...).
WHERE(pgtable.EngineVersions.Version.EQ(pg.String(input.Version)))
query, args := stmt.Sql()
result, err := store.db.ExecContext(operationCtx, query, args...)
if err != nil {
return fmt.Errorf("update engine version: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("update engine version: rows affected: %w", err)
}
if affected == 0 {
return engineversion.ErrNotFound
}
return nil
}
// Deprecate sets `status=deprecated` and refreshes `updated_at` for
// version. Returns engineversion.ErrNotFound when no row exists.
// Calling Deprecate on an already deprecated row succeeds with no
// further mutation (idempotent).
func (store *Store) Deprecate(ctx context.Context, version string, now time.Time) error {
if store == nil || store.db == nil {
return errors.New("deprecate engine version: nil store")
}
if strings.TrimSpace(version) == "" {
return fmt.Errorf("deprecate engine version: version must not be empty")
}
if now.IsZero() {
return fmt.Errorf("deprecate engine version: now must not be zero")
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "deprecate engine version", store.operationTimeout)
if err != nil {
return err
}
defer cancel()
// Pre-check the row's existence so we can surface a precise
// ErrNotFound; a 0-row affected from the UPDATE alone could mean
// "missing" or "already deprecated".
current, err := store.Get(operationCtx, version)
if err != nil {
return err
}
if current.Status == engineversion.StatusDeprecated {
return nil
}
stmt := pgtable.EngineVersions.UPDATE(pgtable.EngineVersions.Status).
SET(
pgtable.EngineVersions.Status.SET(pg.String(string(engineversion.StatusDeprecated))),
pgtable.EngineVersions.UpdatedAt.SET(pg.TimestampzT(now.UTC())),
).
WHERE(pgtable.EngineVersions.Version.EQ(pg.String(version)))
query, args := stmt.Sql()
if _, err := store.db.ExecContext(operationCtx, query, args...); err != nil {
return fmt.Errorf("deprecate engine version: %w", err)
}
return nil
}
// Delete removes the row identified by version. Returns
// engineversion.ErrNotFound when no row matches. The adapter does not
// inspect runtime_records; the service layer guards against active
// references through IsReferencedByActiveRuntime before issuing Delete.
func (store *Store) Delete(ctx context.Context, version string) error {
if store == nil || store.db == nil {
return errors.New("delete engine version: nil store")
}
if strings.TrimSpace(version) == "" {
return fmt.Errorf("delete engine version: version must not be empty")
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "delete engine version", store.operationTimeout)
if err != nil {
return err
}
defer cancel()
stmt := pgtable.EngineVersions.DELETE().
WHERE(pgtable.EngineVersions.Version.EQ(pg.String(version)))
query, args := stmt.Sql()
result, err := store.db.ExecContext(operationCtx, query, args...)
if err != nil {
return fmt.Errorf("delete engine version: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("delete engine version: rows affected: %w", err)
}
if affected == 0 {
return engineversion.ErrNotFound
}
return nil
}
// IsReferencedByActiveRuntime reports whether any non-finished and
// non-stopped runtime row currently references version through
// `current_engine_version`.
func (store *Store) IsReferencedByActiveRuntime(ctx context.Context, version string) (bool, error) {
if store == nil || store.db == nil {
return false, errors.New("is referenced by active runtime: nil store")
}
if strings.TrimSpace(version) == "" {
return false, fmt.Errorf("is referenced by active runtime: version must not be empty")
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "is referenced by active runtime", store.operationTimeout)
if err != nil {
return false, err
}
defer cancel()
stmt := pg.SELECT(pg.Int32(1).AS("present")).
FROM(pgtable.RuntimeRecords).
WHERE(pg.AND(
pgtable.RuntimeRecords.CurrentEngineVersion.EQ(pg.String(version)),
pgtable.RuntimeRecords.Status.NOT_IN(
pg.String(string(runtime.StatusFinished)),
pg.String(string(runtime.StatusStopped)),
),
)).
LIMIT(1)
query, args := stmt.Sql()
row := store.db.QueryRowContext(operationCtx, query, args...)
var present int32
if err := row.Scan(&present); err != nil {
if sqlx.IsNoRows(err) {
return false, nil
}
return false, fmt.Errorf("is referenced by active runtime: %w", err)
}
return true, nil
}
// rowScanner abstracts *sql.Row and *sql.Rows so scanRow can be shared
// across single-row and iterated reads.
type rowScanner interface {
Scan(dest ...any) error
}
// scanRow scans one engine_versions row from rs.
func scanRow(rs rowScanner) (engineversion.EngineVersion, error) {
var (
version string
imageRef string
options string
status string
createdAt time.Time
updatedAt time.Time
)
if err := rs.Scan(&version, &imageRef, &options, &status, &createdAt, &updatedAt); err != nil {
return engineversion.EngineVersion{}, err
}
return engineversion.EngineVersion{
Version: version,
ImageRef: imageRef,
Options: []byte(options),
Status: engineversion.Status(status),
CreatedAt: createdAt.UTC(),
UpdatedAt: updatedAt.UTC(),
}, nil
}
// Ensure Store satisfies the ports.EngineVersionStore interface at
// compile time.
var _ ports.EngineVersionStore = (*Store)(nil)
@@ -0,0 +1,403 @@
package engineversionstore_test
import (
"context"
"database/sql"
"errors"
"testing"
"time"
"galaxy/gamemaster/internal/adapters/postgres/engineversionstore"
"galaxy/gamemaster/internal/adapters/postgres/internal/pgtest"
"galaxy/gamemaster/internal/domain/engineversion"
"galaxy/gamemaster/internal/domain/runtime"
"galaxy/gamemaster/internal/ports"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMain(m *testing.M) { pgtest.RunMain(m) }
func newStore(t *testing.T) *engineversionstore.Store {
t.Helper()
pgtest.TruncateAll(t)
store, err := engineversionstore.New(engineversionstore.Config{
DB: pgtest.Ensure(t).Pool(),
OperationTimeout: pgtest.OperationTimeout,
})
require.NoError(t, err)
return store
}
// poolOnly returns the shared pool for tests that have to seed
// runtime_records directly (e.g. TestIsReferencedByActiveRuntime).
func poolOnly(t *testing.T) *sql.DB {
t.Helper()
pgtest.TruncateAll(t)
return pgtest.Ensure(t).Pool()
}
func validVersion(version string, createdAt time.Time, status engineversion.Status) engineversion.EngineVersion {
return engineversion.EngineVersion{
Version: version,
ImageRef: "ghcr.io/galaxy/game:" + version,
Options: []byte(`{"max_planets":120}`),
Status: status,
CreatedAt: createdAt,
UpdatedAt: createdAt,
}
}
func TestNewRejectsInvalidConfig(t *testing.T) {
_, err := engineversionstore.New(engineversionstore.Config{})
require.Error(t, err)
store, err := engineversionstore.New(engineversionstore.Config{
DB: pgtest.Ensure(t).Pool(),
OperationTimeout: 0,
})
require.Error(t, err)
require.Nil(t, store)
}
func TestInsertGetRoundTrip(t *testing.T) {
ctx := context.Background()
store := newStore(t)
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
record := validVersion("v1.2.3", now, engineversion.StatusActive)
require.NoError(t, store.Insert(ctx, record))
got, err := store.Get(ctx, "v1.2.3")
require.NoError(t, err)
assert.Equal(t, record.Version, got.Version)
assert.Equal(t, record.ImageRef, got.ImageRef)
assert.JSONEq(t, `{"max_planets":120}`, string(got.Options))
assert.Equal(t, engineversion.StatusActive, got.Status)
assert.True(t, got.CreatedAt.Equal(now))
assert.True(t, got.UpdatedAt.Equal(now))
assert.Equal(t, time.UTC, got.CreatedAt.Location())
assert.Equal(t, time.UTC, got.UpdatedAt.Location())
}
func TestInsertEmptyOptionsDefaultsToObject(t *testing.T) {
ctx := context.Background()
store := newStore(t)
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
record := validVersion("v1.2.3", now, engineversion.StatusActive)
record.Options = nil
require.NoError(t, store.Insert(ctx, record))
got, err := store.Get(ctx, "v1.2.3")
require.NoError(t, err)
assert.JSONEq(t, `{}`, string(got.Options))
}
func TestInsertConflict(t *testing.T) {
ctx := context.Background()
store := newStore(t)
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
record := validVersion("v1.2.3", now, engineversion.StatusActive)
require.NoError(t, store.Insert(ctx, record))
err := store.Insert(ctx, record)
require.Error(t, err)
require.True(t, errors.Is(err, engineversion.ErrConflict), "want ErrConflict, got %v", err)
}
func TestGetNotFound(t *testing.T) {
ctx := context.Background()
store := newStore(t)
_, err := store.Get(ctx, "v9.9.9")
require.Error(t, err)
require.True(t, errors.Is(err, engineversion.ErrNotFound))
}
func TestListNoFilter(t *testing.T) {
ctx := context.Background()
store := newStore(t)
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
require.NoError(t, store.Insert(ctx, validVersion("v1.2.0", now, engineversion.StatusDeprecated)))
require.NoError(t, store.Insert(ctx, validVersion("v1.2.3", now, engineversion.StatusActive)))
require.NoError(t, store.Insert(ctx, validVersion("v1.3.0", now, engineversion.StatusActive)))
all, err := store.List(ctx, nil)
require.NoError(t, err)
require.Len(t, all, 3)
assert.Equal(t, "v1.2.0", all[0].Version)
assert.Equal(t, "v1.2.3", all[1].Version)
assert.Equal(t, "v1.3.0", all[2].Version)
}
func TestListByStatusFilter(t *testing.T) {
ctx := context.Background()
store := newStore(t)
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
require.NoError(t, store.Insert(ctx, validVersion("v1.2.0", now, engineversion.StatusDeprecated)))
require.NoError(t, store.Insert(ctx, validVersion("v1.2.3", now, engineversion.StatusActive)))
require.NoError(t, store.Insert(ctx, validVersion("v1.3.0", now, engineversion.StatusActive)))
active := engineversion.StatusActive
got, err := store.List(ctx, &active)
require.NoError(t, err)
require.Len(t, got, 2)
assert.Equal(t, "v1.2.3", got[0].Version)
assert.Equal(t, "v1.3.0", got[1].Version)
deprecated := engineversion.StatusDeprecated
got, err = store.List(ctx, &deprecated)
require.NoError(t, err)
require.Len(t, got, 1)
assert.Equal(t, "v1.2.0", got[0].Version)
}
func TestListUnknownStatusRejected(t *testing.T) {
ctx := context.Background()
store := newStore(t)
exotic := engineversion.Status("exotic")
_, err := store.List(ctx, &exotic)
require.Error(t, err)
}
func TestUpdateImageRefOnly(t *testing.T) {
ctx := context.Background()
store := newStore(t)
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
require.NoError(t, store.Insert(ctx, validVersion("v1.2.3", now, engineversion.StatusActive)))
newRef := "ghcr.io/galaxy/game:v1.2.4"
updateAt := now.Add(time.Minute)
require.NoError(t, store.Update(ctx, ports.UpdateEngineVersionInput{
Version: "v1.2.3",
ImageRef: &newRef,
Now: updateAt,
}))
got, err := store.Get(ctx, "v1.2.3")
require.NoError(t, err)
assert.Equal(t, newRef, got.ImageRef)
assert.Equal(t, engineversion.StatusActive, got.Status)
assert.True(t, got.UpdatedAt.Equal(updateAt))
}
func TestUpdateAllFields(t *testing.T) {
ctx := context.Background()
store := newStore(t)
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
require.NoError(t, store.Insert(ctx, validVersion("v1.2.3", now, engineversion.StatusActive)))
newRef := "ghcr.io/galaxy/game:v1.2.4"
newOptions := []byte(`{"max_planets":240,"hot_seat":true}`)
deprecated := engineversion.StatusDeprecated
updateAt := now.Add(time.Minute)
require.NoError(t, store.Update(ctx, ports.UpdateEngineVersionInput{
Version: "v1.2.3",
ImageRef: &newRef,
Options: &newOptions,
Status: &deprecated,
Now: updateAt,
}))
got, err := store.Get(ctx, "v1.2.3")
require.NoError(t, err)
assert.Equal(t, newRef, got.ImageRef)
assert.JSONEq(t, string(newOptions), string(got.Options))
assert.Equal(t, engineversion.StatusDeprecated, got.Status)
assert.True(t, got.UpdatedAt.Equal(updateAt))
}
func TestUpdateNotFound(t *testing.T) {
ctx := context.Background()
store := newStore(t)
newRef := "ghcr.io/galaxy/game:v1.2.4"
err := store.Update(ctx, ports.UpdateEngineVersionInput{
Version: "v9.9.9",
ImageRef: &newRef,
Now: time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC),
})
require.Error(t, err)
require.True(t, errors.Is(err, engineversion.ErrNotFound))
}
func TestDeprecateHappy(t *testing.T) {
ctx := context.Background()
store := newStore(t)
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
require.NoError(t, store.Insert(ctx, validVersion("v1.2.3", now, engineversion.StatusActive)))
deprecateAt := now.Add(time.Hour)
require.NoError(t, store.Deprecate(ctx, "v1.2.3", deprecateAt))
got, err := store.Get(ctx, "v1.2.3")
require.NoError(t, err)
assert.Equal(t, engineversion.StatusDeprecated, got.Status)
assert.True(t, got.UpdatedAt.Equal(deprecateAt))
}
func TestDeprecateIdempotent(t *testing.T) {
ctx := context.Background()
store := newStore(t)
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
require.NoError(t, store.Insert(ctx, validVersion("v1.2.3", now, engineversion.StatusDeprecated)))
require.NoError(t, store.Deprecate(ctx, "v1.2.3", now.Add(time.Hour)))
got, err := store.Get(ctx, "v1.2.3")
require.NoError(t, err)
assert.Equal(t, engineversion.StatusDeprecated, got.Status)
// updated_at must remain at the original insert value because the
// idempotent path performs no UPDATE.
assert.True(t, got.UpdatedAt.Equal(now))
}
func TestDeprecateNotFound(t *testing.T) {
ctx := context.Background()
store := newStore(t)
err := store.Deprecate(ctx, "v9.9.9", time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC))
require.Error(t, err)
require.True(t, errors.Is(err, engineversion.ErrNotFound))
}
func TestDeprecateRejectsZeroNow(t *testing.T) {
ctx := context.Background()
store := newStore(t)
err := store.Deprecate(ctx, "v1.2.3", time.Time{})
require.Error(t, err)
}
func TestDeleteHappy(t *testing.T) {
ctx := context.Background()
store := newStore(t)
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
require.NoError(t, store.Insert(ctx, validVersion("v1.2.3", now, engineversion.StatusActive)))
require.NoError(t, store.Delete(ctx, "v1.2.3"))
_, err := store.Get(ctx, "v1.2.3")
require.Error(t, err)
require.True(t, errors.Is(err, engineversion.ErrNotFound))
}
func TestDeleteNotFound(t *testing.T) {
ctx := context.Background()
store := newStore(t)
err := store.Delete(ctx, "v9.9.9")
require.Error(t, err)
require.True(t, errors.Is(err, engineversion.ErrNotFound))
}
func TestDeleteRejectsEmptyVersion(t *testing.T) {
ctx := context.Background()
store := newStore(t)
err := store.Delete(ctx, "")
require.Error(t, err)
}
// TestIsReferencedByActiveRuntime exercises the join between
// engine_versions and runtime_records. The runtime rows are seeded by
// inserting directly through the shared pool, since the
// runtimerecordstore adapter lives in a sibling package.
func TestIsReferencedByActiveRuntime(t *testing.T) {
ctx := context.Background()
pool := poolOnly(t)
store, err := engineversionstore.New(engineversionstore.Config{
DB: pool,
OperationTimeout: pgtest.OperationTimeout,
})
require.NoError(t, err)
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
require.NoError(t, store.Insert(ctx, validVersion("v1.2.3", now, engineversion.StatusActive)))
require.NoError(t, store.Insert(ctx, validVersion("v1.2.4", now, engineversion.StatusActive)))
insertRuntime(t, pool, "game-running", runtime.StatusRunning, "v1.2.3", now)
insertRuntime(t, pool, "game-finished", runtime.StatusFinished, "v1.2.3", now)
insertRuntime(t, pool, "game-stopped", runtime.StatusStopped, "v1.2.3", now)
used, err := store.IsReferencedByActiveRuntime(ctx, "v1.2.3")
require.NoError(t, err)
assert.True(t, used, "v1.2.3 must be reported referenced (game-running uses it)")
unused, err := store.IsReferencedByActiveRuntime(ctx, "v1.2.4")
require.NoError(t, err)
assert.False(t, unused, "v1.2.4 has no active runtime reference")
missing, err := store.IsReferencedByActiveRuntime(ctx, "v9.9.9")
require.NoError(t, err)
assert.False(t, missing)
}
// insertRuntime seeds one runtime_records row directly via raw SQL. The
// adapter under test is engineversionstore; using the runtimerecordstore
// here would couple two adapter test suites unnecessarily.
func insertRuntime(t *testing.T, pool *sql.DB, gameID string, status runtime.Status, engineVersion string, createdAt time.Time) {
t.Helper()
at := createdAt.UTC()
var stoppedAt, finishedAt any
switch status {
case runtime.StatusStopped:
stoppedAt = at
case runtime.StatusFinished:
finishedAt = at
}
const stmt = `
INSERT INTO runtime_records (
game_id, status, engine_endpoint, current_image_ref,
current_engine_version, turn_schedule, current_turn,
next_generation_at, skip_next_tick, engine_health,
created_at, updated_at, started_at, stopped_at, finished_at
) VALUES (
$1, $2, 'http://galaxy-game-' || $1 || ':8080', 'ghcr.io/galaxy/game:' || $3,
$3, '0 18 * * *', 0,
NULL, false, '',
$4, $5, $6, $7, $8
)`
_, err := pool.ExecContext(context.Background(), stmt,
gameID, string(status), engineVersion,
at, at, at, stoppedAt, finishedAt,
)
require.NoError(t, err)
}
func TestIsReferencedRejectsEmptyVersion(t *testing.T) {
ctx := context.Background()
store := newStore(t)
_, err := store.IsReferencedByActiveRuntime(ctx, "")
require.Error(t, err)
}
func TestGetRejectsEmpty(t *testing.T) {
ctx := context.Background()
store := newStore(t)
_, err := store.Get(ctx, "")
require.Error(t, err)
}
func TestUpdateRejectsInvalidInput(t *testing.T) {
ctx := context.Background()
store := newStore(t)
err := store.Update(ctx, ports.UpdateEngineVersionInput{Version: "v1.2.3"})
require.Error(t, err)
}
@@ -0,0 +1,211 @@
// Package pgtest exposes the testcontainers-backed PostgreSQL bootstrap
// shared by every Game Master PG adapter test. The package is regular
// Go code — not a `_test.go` file — so it can be imported by the
// `_test.go` files in the four sibling store packages
// (`runtimerecordstore`, `engineversionstore`, `playermappingstore`,
// `operationlog`).
//
// No production code in `cmd/gamemaster` or in the runtime imports this
// package. The testcontainers-go dependency therefore stays out of the
// production binary's import graph.
package pgtest
import (
"context"
"database/sql"
"net/url"
"os"
"sync"
"testing"
"time"
"galaxy/postgres"
"galaxy/gamemaster/internal/adapters/postgres/migrations"
testcontainers "github.com/testcontainers/testcontainers-go"
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
)
const (
postgresImage = "postgres:16-alpine"
superUser = "galaxy"
superPassword = "galaxy"
superDatabase = "galaxy_gamemaster"
serviceRole = "gamemasterservice"
servicePassword = "gamemasterservice"
serviceSchema = "gamemaster"
containerStartup = 90 * time.Second
// OperationTimeout is the per-statement timeout used by every store
// constructed via the per-package newStore helpers. Tests may pass a
// smaller value if they need to assert deadline behaviour explicitly.
OperationTimeout = 10 * time.Second
)
// Env holds the per-process container plus the *sql.DB pool already
// provisioned with the gamemaster schema, role, and migrations applied.
type Env struct {
container *tcpostgres.PostgresContainer
pool *sql.DB
}
// Pool returns the shared pool. Tests truncate per-table state before
// each run via TruncateAll.
func (env *Env) Pool() *sql.DB { return env.pool }
var (
once sync.Once
cur *Env
curEr error
)
// Ensure starts the PostgreSQL container on first invocation and applies
// the embedded goose migrations. Subsequent invocations reuse the same
// container/pool. When Docker is unavailable Ensure calls t.Skip with the
// underlying error so the test suite still passes on machines without
// Docker.
func Ensure(t testing.TB) *Env {
t.Helper()
once.Do(func() {
cur, curEr = start()
})
if curEr != nil {
t.Skipf("postgres container start failed (Docker unavailable?): %v", curEr)
}
return cur
}
// TruncateAll wipes every Game Master table inside the shared pool,
// leaving the schema and indexes intact. Use it from each test that
// needs a clean slate.
func TruncateAll(t testing.TB) {
t.Helper()
env := Ensure(t)
const stmt = `TRUNCATE TABLE runtime_records, engine_versions, player_mappings, operation_log RESTART IDENTITY CASCADE`
if _, err := env.pool.ExecContext(context.Background(), stmt); err != nil {
t.Fatalf("truncate gamemaster tables: %v", err)
}
}
// Shutdown terminates the shared container and closes the pool. It is
// invoked from each test package's TestMain after `m.Run` returns so the
// container is released even if individual tests panic.
func Shutdown() {
if cur == nil {
return
}
if cur.pool != nil {
_ = cur.pool.Close()
}
if cur.container != nil {
_ = testcontainers.TerminateContainer(cur.container)
}
cur = nil
}
// RunMain is a convenience helper for each store package's TestMain: it
// runs the test main, captures the exit code, shuts the container down,
// and exits. Wiring it through one helper keeps every TestMain to two
// lines.
func RunMain(m *testing.M) {
code := m.Run()
Shutdown()
os.Exit(code)
}
func start() (*Env, error) {
ctx := context.Background()
container, err := tcpostgres.Run(ctx, postgresImage,
tcpostgres.WithDatabase(superDatabase),
tcpostgres.WithUsername(superUser),
tcpostgres.WithPassword(superPassword),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(containerStartup),
),
)
if err != nil {
return nil, err
}
baseDSN, err := container.ConnectionString(ctx, "sslmode=disable")
if err != nil {
_ = testcontainers.TerminateContainer(container)
return nil, err
}
if err := provisionRoleAndSchema(ctx, baseDSN); err != nil {
_ = testcontainers.TerminateContainer(container)
return nil, err
}
scopedDSN, err := dsnForServiceRole(baseDSN)
if err != nil {
_ = testcontainers.TerminateContainer(container)
return nil, err
}
cfg := postgres.DefaultConfig()
cfg.PrimaryDSN = scopedDSN
cfg.OperationTimeout = OperationTimeout
pool, err := postgres.OpenPrimary(ctx, cfg)
if err != nil {
_ = testcontainers.TerminateContainer(container)
return nil, err
}
if err := postgres.Ping(ctx, pool, OperationTimeout); err != nil {
_ = pool.Close()
_ = testcontainers.TerminateContainer(container)
return nil, err
}
if err := postgres.RunMigrations(ctx, pool, migrations.FS(), "."); err != nil {
_ = pool.Close()
_ = testcontainers.TerminateContainer(container)
return nil, err
}
return &Env{container: container, pool: pool}, nil
}
func provisionRoleAndSchema(ctx context.Context, baseDSN string) error {
cfg := postgres.DefaultConfig()
cfg.PrimaryDSN = baseDSN
cfg.OperationTimeout = OperationTimeout
db, err := postgres.OpenPrimary(ctx, cfg)
if err != nil {
return err
}
defer func() { _ = db.Close() }()
statements := []string{
`DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'gamemasterservice') THEN
CREATE ROLE gamemasterservice LOGIN PASSWORD 'gamemasterservice';
END IF;
END $$;`,
`CREATE SCHEMA IF NOT EXISTS gamemaster AUTHORIZATION gamemasterservice;`,
`GRANT USAGE ON SCHEMA gamemaster TO gamemasterservice;`,
}
for _, statement := range statements {
if _, err := db.ExecContext(ctx, statement); err != nil {
return err
}
}
return nil
}
func dsnForServiceRole(baseDSN string) (string, error) {
parsed, err := url.Parse(baseDSN)
if err != nil {
return "", err
}
values := url.Values{}
values.Set("search_path", serviceSchema)
values.Set("sslmode", "disable")
scoped := url.URL{
Scheme: parsed.Scheme,
User: url.UserPassword(serviceRole, servicePassword),
Host: parsed.Host,
Path: parsed.Path,
RawQuery: values.Encode(),
}
return scoped.String(), nil
}
@@ -0,0 +1,111 @@
// Package sqlx contains the small set of helpers shared by every Game
// Master PostgreSQL adapter (runtimerecordstore, engineversionstore,
// playermappingstore, operationlog). The helpers centralise the
// boundary translations for nullable timestamps and the pgx SQLSTATE
// codes the adapters interpret as domain conflicts.
package sqlx
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgconn"
)
// PgUniqueViolationCode identifies the SQLSTATE returned by PostgreSQL
// when a UNIQUE constraint is violated by INSERT or UPDATE.
const PgUniqueViolationCode = "23505"
// IsUniqueViolation reports whether err is a PostgreSQL unique-violation,
// regardless of constraint name.
func IsUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) {
return false
}
return pgErr.Code == PgUniqueViolationCode
}
// IsNoRows reports whether err is sql.ErrNoRows.
func IsNoRows(err error) bool {
return errors.Is(err, sql.ErrNoRows)
}
// NullableTime returns t.UTC() when non-zero, otherwise nil so the column
// is bound as SQL NULL.
func NullableTime(t time.Time) any {
if t.IsZero() {
return nil
}
return t.UTC()
}
// NullableTimePtr returns t.UTC() when t is non-nil and non-zero,
// otherwise nil. Companion of NullableTime for domain types that use
// *time.Time to express absent timestamps.
func NullableTimePtr(t *time.Time) any {
if t == nil {
return nil
}
return NullableTime(*t)
}
// NullableString returns value when non-empty, otherwise nil so the
// column is bound as SQL NULL.
func NullableString(value string) any {
if value == "" {
return nil
}
return value
}
// StringFromNullable copies an optional sql.NullString into a domain
// string. NULL becomes the empty string, matching the Game Master
// domain convention that empty == NULL for nullable text columns.
func StringFromNullable(value sql.NullString) string {
if !value.Valid {
return ""
}
return value.String
}
// TimeFromNullable copies an optional sql.NullTime into a domain
// time.Time, applying the global UTC normalisation rule. NULL values
// become the zero time.Time.
func TimeFromNullable(value sql.NullTime) time.Time {
if !value.Valid {
return time.Time{}
}
return value.Time.UTC()
}
// TimePtrFromNullable copies an optional sql.NullTime into a domain
// *time.Time. NULL becomes nil; non-NULL values are wrapped after UTC
// normalisation.
func TimePtrFromNullable(value sql.NullTime) *time.Time {
if !value.Valid {
return nil
}
t := value.Time.UTC()
return &t
}
// WithTimeout derives a child context bounded by timeout and prefixes
// context errors with operation. Callers must always invoke the returned
// cancel.
func WithTimeout(ctx context.Context, operation string, timeout time.Duration) (context.Context, context.CancelFunc, error) {
if ctx == nil {
return nil, nil, fmt.Errorf("%s: nil context", operation)
}
if err := ctx.Err(); err != nil {
return nil, nil, fmt.Errorf("%s: %w", operation, err)
}
if timeout <= 0 {
return nil, nil, fmt.Errorf("%s: operation timeout must be positive", operation)
}
bounded, cancel := context.WithTimeout(ctx, timeout)
return bounded, cancel, nil
}
@@ -0,0 +1,21 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"time"
)
type EngineVersions struct {
Version string `sql:"primary_key"`
ImageRef string
Options string
Status string
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -0,0 +1,19 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"time"
)
type GooseDbVersion struct {
ID int32 `sql:"primary_key"`
VersionID int64
IsApplied bool
Tstamp time.Time
}
@@ -0,0 +1,25 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"time"
)
type OperationLog struct {
ID int64 `sql:"primary_key"`
GameID string
OpKind string
OpSource string
SourceRef string
Outcome string
ErrorCode string
ErrorMessage string
StartedAt time.Time
FinishedAt *time.Time
}
@@ -0,0 +1,20 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"time"
)
type PlayerMappings struct {
GameID string `sql:"primary_key"`
UserID string `sql:"primary_key"`
RaceName string
EnginePlayerUUID string
CreatedAt time.Time
}
@@ -0,0 +1,30 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"time"
)
type RuntimeRecords struct {
GameID string `sql:"primary_key"`
Status string
EngineEndpoint string
CurrentImageRef string
CurrentEngineVersion string
TurnSchedule string
CurrentTurn int32
NextGenerationAt *time.Time
SkipNextTick bool
EngineHealth string
CreatedAt time.Time
UpdatedAt time.Time
StartedAt *time.Time
StoppedAt *time.Time
FinishedAt *time.Time
}
@@ -0,0 +1,93 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var EngineVersions = newEngineVersionsTable("gamemaster", "engine_versions", "")
type engineVersionsTable struct {
postgres.Table
// Columns
Version postgres.ColumnString
ImageRef postgres.ColumnString
Options postgres.ColumnString
Status postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type EngineVersionsTable struct {
engineVersionsTable
EXCLUDED engineVersionsTable
}
// AS creates new EngineVersionsTable with assigned alias
func (a EngineVersionsTable) AS(alias string) *EngineVersionsTable {
return newEngineVersionsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new EngineVersionsTable with assigned schema name
func (a EngineVersionsTable) FromSchema(schemaName string) *EngineVersionsTable {
return newEngineVersionsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new EngineVersionsTable with assigned table prefix
func (a EngineVersionsTable) WithPrefix(prefix string) *EngineVersionsTable {
return newEngineVersionsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new EngineVersionsTable with assigned table suffix
func (a EngineVersionsTable) WithSuffix(suffix string) *EngineVersionsTable {
return newEngineVersionsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newEngineVersionsTable(schemaName, tableName, alias string) *EngineVersionsTable {
return &EngineVersionsTable{
engineVersionsTable: newEngineVersionsTableImpl(schemaName, tableName, alias),
EXCLUDED: newEngineVersionsTableImpl("", "excluded", ""),
}
}
func newEngineVersionsTableImpl(schemaName, tableName, alias string) engineVersionsTable {
var (
VersionColumn = postgres.StringColumn("version")
ImageRefColumn = postgres.StringColumn("image_ref")
OptionsColumn = postgres.StringColumn("options")
StatusColumn = postgres.StringColumn("status")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
allColumns = postgres.ColumnList{VersionColumn, ImageRefColumn, OptionsColumn, StatusColumn, CreatedAtColumn, UpdatedAtColumn}
mutableColumns = postgres.ColumnList{ImageRefColumn, OptionsColumn, StatusColumn, CreatedAtColumn, UpdatedAtColumn}
defaultColumns = postgres.ColumnList{OptionsColumn}
)
return engineVersionsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
Version: VersionColumn,
ImageRef: ImageRefColumn,
Options: OptionsColumn,
Status: StatusColumn,
CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,87 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var GooseDbVersion = newGooseDbVersionTable("gamemaster", "goose_db_version", "")
type gooseDbVersionTable struct {
postgres.Table
// Columns
ID postgres.ColumnInteger
VersionID postgres.ColumnInteger
IsApplied postgres.ColumnBool
Tstamp postgres.ColumnTimestamp
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type GooseDbVersionTable struct {
gooseDbVersionTable
EXCLUDED gooseDbVersionTable
}
// AS creates new GooseDbVersionTable with assigned alias
func (a GooseDbVersionTable) AS(alias string) *GooseDbVersionTable {
return newGooseDbVersionTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new GooseDbVersionTable with assigned schema name
func (a GooseDbVersionTable) FromSchema(schemaName string) *GooseDbVersionTable {
return newGooseDbVersionTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new GooseDbVersionTable with assigned table prefix
func (a GooseDbVersionTable) WithPrefix(prefix string) *GooseDbVersionTable {
return newGooseDbVersionTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new GooseDbVersionTable with assigned table suffix
func (a GooseDbVersionTable) WithSuffix(suffix string) *GooseDbVersionTable {
return newGooseDbVersionTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newGooseDbVersionTable(schemaName, tableName, alias string) *GooseDbVersionTable {
return &GooseDbVersionTable{
gooseDbVersionTable: newGooseDbVersionTableImpl(schemaName, tableName, alias),
EXCLUDED: newGooseDbVersionTableImpl("", "excluded", ""),
}
}
func newGooseDbVersionTableImpl(schemaName, tableName, alias string) gooseDbVersionTable {
var (
IDColumn = postgres.IntegerColumn("id")
VersionIDColumn = postgres.IntegerColumn("version_id")
IsAppliedColumn = postgres.BoolColumn("is_applied")
TstampColumn = postgres.TimestampColumn("tstamp")
allColumns = postgres.ColumnList{IDColumn, VersionIDColumn, IsAppliedColumn, TstampColumn}
mutableColumns = postgres.ColumnList{VersionIDColumn, IsAppliedColumn, TstampColumn}
defaultColumns = postgres.ColumnList{TstampColumn}
)
return gooseDbVersionTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ID: IDColumn,
VersionID: VersionIDColumn,
IsApplied: IsAppliedColumn,
Tstamp: TstampColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,105 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var OperationLog = newOperationLogTable("gamemaster", "operation_log", "")
type operationLogTable struct {
postgres.Table
// Columns
ID postgres.ColumnInteger
GameID postgres.ColumnString
OpKind postgres.ColumnString
OpSource postgres.ColumnString
SourceRef postgres.ColumnString
Outcome postgres.ColumnString
ErrorCode postgres.ColumnString
ErrorMessage postgres.ColumnString
StartedAt postgres.ColumnTimestampz
FinishedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type OperationLogTable struct {
operationLogTable
EXCLUDED operationLogTable
}
// AS creates new OperationLogTable with assigned alias
func (a OperationLogTable) AS(alias string) *OperationLogTable {
return newOperationLogTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new OperationLogTable with assigned schema name
func (a OperationLogTable) FromSchema(schemaName string) *OperationLogTable {
return newOperationLogTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new OperationLogTable with assigned table prefix
func (a OperationLogTable) WithPrefix(prefix string) *OperationLogTable {
return newOperationLogTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new OperationLogTable with assigned table suffix
func (a OperationLogTable) WithSuffix(suffix string) *OperationLogTable {
return newOperationLogTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newOperationLogTable(schemaName, tableName, alias string) *OperationLogTable {
return &OperationLogTable{
operationLogTable: newOperationLogTableImpl(schemaName, tableName, alias),
EXCLUDED: newOperationLogTableImpl("", "excluded", ""),
}
}
func newOperationLogTableImpl(schemaName, tableName, alias string) operationLogTable {
var (
IDColumn = postgres.IntegerColumn("id")
GameIDColumn = postgres.StringColumn("game_id")
OpKindColumn = postgres.StringColumn("op_kind")
OpSourceColumn = postgres.StringColumn("op_source")
SourceRefColumn = postgres.StringColumn("source_ref")
OutcomeColumn = postgres.StringColumn("outcome")
ErrorCodeColumn = postgres.StringColumn("error_code")
ErrorMessageColumn = postgres.StringColumn("error_message")
StartedAtColumn = postgres.TimestampzColumn("started_at")
FinishedAtColumn = postgres.TimestampzColumn("finished_at")
allColumns = postgres.ColumnList{IDColumn, GameIDColumn, OpKindColumn, OpSourceColumn, SourceRefColumn, OutcomeColumn, ErrorCodeColumn, ErrorMessageColumn, StartedAtColumn, FinishedAtColumn}
mutableColumns = postgres.ColumnList{GameIDColumn, OpKindColumn, OpSourceColumn, SourceRefColumn, OutcomeColumn, ErrorCodeColumn, ErrorMessageColumn, StartedAtColumn, FinishedAtColumn}
defaultColumns = postgres.ColumnList{IDColumn, SourceRefColumn, ErrorCodeColumn, ErrorMessageColumn}
)
return operationLogTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ID: IDColumn,
GameID: GameIDColumn,
OpKind: OpKindColumn,
OpSource: OpSourceColumn,
SourceRef: SourceRefColumn,
Outcome: OutcomeColumn,
ErrorCode: ErrorCodeColumn,
ErrorMessage: ErrorMessageColumn,
StartedAt: StartedAtColumn,
FinishedAt: FinishedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,90 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var PlayerMappings = newPlayerMappingsTable("gamemaster", "player_mappings", "")
type playerMappingsTable struct {
postgres.Table
// Columns
GameID postgres.ColumnString
UserID postgres.ColumnString
RaceName postgres.ColumnString
EnginePlayerUUID postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type PlayerMappingsTable struct {
playerMappingsTable
EXCLUDED playerMappingsTable
}
// AS creates new PlayerMappingsTable with assigned alias
func (a PlayerMappingsTable) AS(alias string) *PlayerMappingsTable {
return newPlayerMappingsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new PlayerMappingsTable with assigned schema name
func (a PlayerMappingsTable) FromSchema(schemaName string) *PlayerMappingsTable {
return newPlayerMappingsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new PlayerMappingsTable with assigned table prefix
func (a PlayerMappingsTable) WithPrefix(prefix string) *PlayerMappingsTable {
return newPlayerMappingsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new PlayerMappingsTable with assigned table suffix
func (a PlayerMappingsTable) WithSuffix(suffix string) *PlayerMappingsTable {
return newPlayerMappingsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newPlayerMappingsTable(schemaName, tableName, alias string) *PlayerMappingsTable {
return &PlayerMappingsTable{
playerMappingsTable: newPlayerMappingsTableImpl(schemaName, tableName, alias),
EXCLUDED: newPlayerMappingsTableImpl("", "excluded", ""),
}
}
func newPlayerMappingsTableImpl(schemaName, tableName, alias string) playerMappingsTable {
var (
GameIDColumn = postgres.StringColumn("game_id")
UserIDColumn = postgres.StringColumn("user_id")
RaceNameColumn = postgres.StringColumn("race_name")
EnginePlayerUUIDColumn = postgres.StringColumn("engine_player_uuid")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{GameIDColumn, UserIDColumn, RaceNameColumn, EnginePlayerUUIDColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{RaceNameColumn, EnginePlayerUUIDColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{}
)
return playerMappingsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
GameID: GameIDColumn,
UserID: UserIDColumn,
RaceName: RaceNameColumn,
EnginePlayerUUID: EnginePlayerUUIDColumn,
CreatedAt: CreatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,120 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var RuntimeRecords = newRuntimeRecordsTable("gamemaster", "runtime_records", "")
type runtimeRecordsTable struct {
postgres.Table
// Columns
GameID postgres.ColumnString
Status postgres.ColumnString
EngineEndpoint postgres.ColumnString
CurrentImageRef postgres.ColumnString
CurrentEngineVersion postgres.ColumnString
TurnSchedule postgres.ColumnString
CurrentTurn postgres.ColumnInteger
NextGenerationAt postgres.ColumnTimestampz
SkipNextTick postgres.ColumnBool
EngineHealth postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz
StartedAt postgres.ColumnTimestampz
StoppedAt postgres.ColumnTimestampz
FinishedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type RuntimeRecordsTable struct {
runtimeRecordsTable
EXCLUDED runtimeRecordsTable
}
// AS creates new RuntimeRecordsTable with assigned alias
func (a RuntimeRecordsTable) AS(alias string) *RuntimeRecordsTable {
return newRuntimeRecordsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new RuntimeRecordsTable with assigned schema name
func (a RuntimeRecordsTable) FromSchema(schemaName string) *RuntimeRecordsTable {
return newRuntimeRecordsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new RuntimeRecordsTable with assigned table prefix
func (a RuntimeRecordsTable) WithPrefix(prefix string) *RuntimeRecordsTable {
return newRuntimeRecordsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new RuntimeRecordsTable with assigned table suffix
func (a RuntimeRecordsTable) WithSuffix(suffix string) *RuntimeRecordsTable {
return newRuntimeRecordsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newRuntimeRecordsTable(schemaName, tableName, alias string) *RuntimeRecordsTable {
return &RuntimeRecordsTable{
runtimeRecordsTable: newRuntimeRecordsTableImpl(schemaName, tableName, alias),
EXCLUDED: newRuntimeRecordsTableImpl("", "excluded", ""),
}
}
func newRuntimeRecordsTableImpl(schemaName, tableName, alias string) runtimeRecordsTable {
var (
GameIDColumn = postgres.StringColumn("game_id")
StatusColumn = postgres.StringColumn("status")
EngineEndpointColumn = postgres.StringColumn("engine_endpoint")
CurrentImageRefColumn = postgres.StringColumn("current_image_ref")
CurrentEngineVersionColumn = postgres.StringColumn("current_engine_version")
TurnScheduleColumn = postgres.StringColumn("turn_schedule")
CurrentTurnColumn = postgres.IntegerColumn("current_turn")
NextGenerationAtColumn = postgres.TimestampzColumn("next_generation_at")
SkipNextTickColumn = postgres.BoolColumn("skip_next_tick")
EngineHealthColumn = postgres.StringColumn("engine_health")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
StartedAtColumn = postgres.TimestampzColumn("started_at")
StoppedAtColumn = postgres.TimestampzColumn("stopped_at")
FinishedAtColumn = postgres.TimestampzColumn("finished_at")
allColumns = postgres.ColumnList{GameIDColumn, StatusColumn, EngineEndpointColumn, CurrentImageRefColumn, CurrentEngineVersionColumn, TurnScheduleColumn, CurrentTurnColumn, NextGenerationAtColumn, SkipNextTickColumn, EngineHealthColumn, CreatedAtColumn, UpdatedAtColumn, StartedAtColumn, StoppedAtColumn, FinishedAtColumn}
mutableColumns = postgres.ColumnList{StatusColumn, EngineEndpointColumn, CurrentImageRefColumn, CurrentEngineVersionColumn, TurnScheduleColumn, CurrentTurnColumn, NextGenerationAtColumn, SkipNextTickColumn, EngineHealthColumn, CreatedAtColumn, UpdatedAtColumn, StartedAtColumn, StoppedAtColumn, FinishedAtColumn}
defaultColumns = postgres.ColumnList{CurrentTurnColumn, SkipNextTickColumn, EngineHealthColumn}
)
return runtimeRecordsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
GameID: GameIDColumn,
Status: StatusColumn,
EngineEndpoint: EngineEndpointColumn,
CurrentImageRef: CurrentImageRefColumn,
CurrentEngineVersion: CurrentEngineVersionColumn,
TurnSchedule: TurnScheduleColumn,
CurrentTurn: CurrentTurnColumn,
NextGenerationAt: NextGenerationAtColumn,
SkipNextTick: SkipNextTickColumn,
EngineHealth: EngineHealthColumn,
CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn,
StartedAt: StartedAtColumn,
StoppedAt: StoppedAtColumn,
FinishedAt: FinishedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,18 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
// UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke
// this method only once at the beginning of the program.
func UseSchema(schema string) {
EngineVersions = EngineVersions.FromSchema(schema)
GooseDbVersion = GooseDbVersion.FromSchema(schema)
OperationLog = OperationLog.FromSchema(schema)
PlayerMappings = PlayerMappings.FromSchema(schema)
RuntimeRecords = RuntimeRecords.FromSchema(schema)
}
@@ -0,0 +1,136 @@
-- +goose Up
-- Initial Game Master PostgreSQL schema.
--
-- Four tables cover the durable surface of the service:
-- * runtime_records — one row per game with the latest known runtime
-- status, scheduling state, and engine health summary;
-- * engine_versions — the deployable engine version registry consumed
-- by Lobby's start flow and the GM admin/patch flow;
-- * player_mappings — the (game_id, user_id) → (race_name,
-- engine_player_uuid) projection installed at register-runtime;
-- * operation_log — append-only audit of every register-runtime,
-- turn-generation, force-next-turn, banish, stop, patch, and
-- engine-version mutation GM performed.
--
-- Schema and the matching `gamemasterservice` role are provisioned
-- outside this script (in tests via cmd/jetgen/main.go::provisionRoleAndSchema;
-- in production via an ops init script). This migration runs as the
-- schema owner with `search_path=gamemaster` and only contains DDL for
-- the service-owned tables and indexes. ARCHITECTURE.md §Database topology
-- mandates that the per-service role's grants stay restricted to its own
-- schema; consequently this file deliberately deviates from PLAN.md
-- Stage 09's literal `CREATE SCHEMA IF NOT EXISTS gamemaster;` instruction.
-- runtime_records holds one durable record per game with the latest
-- known runtime status, scheduling state, and engine health summary.
-- The status enum is enforced by a CHECK so domain code can rely on it
-- without reading every callsite. The composite (status,
-- next_generation_at) index drives the scheduler ticker scan that
-- selects `status='running' AND next_generation_at <= now()` once per
-- second. next_generation_at is nullable: a row enters with
-- status='starting' and a null tick, and only acquires a tick when the
-- register-runtime CAS flips it to 'running'.
CREATE TABLE runtime_records (
game_id text PRIMARY KEY,
status text NOT NULL,
engine_endpoint text NOT NULL,
current_image_ref text NOT NULL,
current_engine_version text NOT NULL,
turn_schedule text NOT NULL,
current_turn integer NOT NULL DEFAULT 0,
next_generation_at timestamptz,
skip_next_tick boolean NOT NULL DEFAULT false,
engine_health text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
started_at timestamptz,
stopped_at timestamptz,
finished_at timestamptz,
CONSTRAINT runtime_records_status_chk
CHECK (status IN (
'starting', 'running', 'generation_in_progress',
'generation_failed', 'stopped', 'engine_unreachable',
'finished'
))
);
CREATE INDEX runtime_records_status_next_gen_idx
ON runtime_records (status, next_generation_at);
-- engine_versions is the deployable engine version registry. Each row
-- ties a semver string to a Docker reference and a free-form options
-- document; the status enum gates the start flow (active versions are
-- accepted by Lobby's resolve, deprecated versions are rejected on new
-- starts but remain valid for already-running games). `options` is
-- jsonb: v1 stores it verbatim and never element-filters.
CREATE TABLE engine_versions (
version text PRIMARY KEY,
image_ref text NOT NULL,
options jsonb NOT NULL DEFAULT '{}'::jsonb,
status text NOT NULL,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
CONSTRAINT engine_versions_status_chk
CHECK (status IN ('active', 'deprecated'))
);
-- player_mappings carries the (game_id, user_id) → (race_name,
-- engine_player_uuid) projection installed at register-runtime. The
-- composite primary key both serves the lookups by (game_id, user_id)
-- on every command/order/report request and as a leftmost-prefix index
-- for the per-game roster reads (`WHERE game_id = $1`). The partial
-- UNIQUE index on (game_id, race_name) enforces the one-race-per-game
-- invariant at the storage boundary.
CREATE TABLE player_mappings (
game_id text NOT NULL,
user_id text NOT NULL,
race_name text NOT NULL,
engine_player_uuid text NOT NULL,
created_at timestamptz NOT NULL,
PRIMARY KEY (game_id, user_id)
);
CREATE UNIQUE INDEX player_mappings_game_race_uniq
ON player_mappings (game_id, race_name);
-- operation_log is an append-only audit of every operation Game Master
-- performed against a game's runtime or against the engine version
-- registry. The (game_id, started_at DESC) index drives audit reads
-- from the GM/Admin REST surface. finished_at is nullable for in-flight
-- rows even though the service layer always finalises the row. The
-- op_kind / op_source / outcome enums are enforced by CHECK constraints
-- to keep the audit schema honest without a separate Go validator.
CREATE TABLE operation_log (
id bigserial PRIMARY KEY,
game_id text NOT NULL,
op_kind text NOT NULL,
op_source text NOT NULL,
source_ref text NOT NULL DEFAULT '',
outcome text NOT NULL,
error_code text NOT NULL DEFAULT '',
error_message text NOT NULL DEFAULT '',
started_at timestamptz NOT NULL,
finished_at timestamptz,
CONSTRAINT operation_log_op_kind_chk
CHECK (op_kind IN (
'register_runtime', 'turn_generation', 'force_next_turn',
'banish', 'stop', 'patch',
'engine_version_create', 'engine_version_update',
'engine_version_deprecate', 'engine_version_delete'
)),
CONSTRAINT operation_log_op_source_chk
CHECK (op_source IN (
'gateway_player', 'lobby_internal', 'admin_rest'
)),
CONSTRAINT operation_log_outcome_chk
CHECK (outcome IN ('success', 'failure'))
);
CREATE INDEX operation_log_game_started_idx
ON operation_log (game_id, started_at DESC);
-- +goose Down
DROP TABLE IF EXISTS operation_log;
DROP TABLE IF EXISTS player_mappings;
DROP TABLE IF EXISTS engine_versions;
DROP TABLE IF EXISTS runtime_records;
@@ -0,0 +1,19 @@
// Package migrations exposes the embedded goose migration files used by
// Game Master to provision its `gamemaster` schema in PostgreSQL.
//
// The embedded filesystem is consumed by `pkg/postgres.RunMigrations`
// during gamemaster-service startup and by `cmd/jetgen` when regenerating
// the `internal/adapters/postgres/jet/` code against a transient
// PostgreSQL instance.
package migrations
import "embed"
//go:embed *.sql
var fs embed.FS
// FS returns the embedded filesystem containing every numbered goose
// migration shipped with Game Master.
func FS() embed.FS {
return fs
}
@@ -0,0 +1,221 @@
// Package operationlog implements the PostgreSQL-backed adapter for
// `ports.OperationLogStore`.
//
// The package owns the on-disk shape of the `operation_log` table
// defined in
// `galaxy/gamemaster/internal/adapters/postgres/migrations/00001_init.sql`
// and translates the schema-agnostic `ports.OperationLogStore`
// interface declared in `internal/ports/operationlog.go` into
// concrete go-jet/v2 statements driven by the pgx driver.
//
// Append uses `INSERT ... RETURNING id` to surface the bigserial id
// back to callers; ListByGame is index-driven by
// `operation_log_game_started_idx`.
package operationlog
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"galaxy/gamemaster/internal/adapters/postgres/internal/sqlx"
pgtable "galaxy/gamemaster/internal/adapters/postgres/jet/gamemaster/table"
"galaxy/gamemaster/internal/domain/operation"
"galaxy/gamemaster/internal/ports"
pg "github.com/go-jet/jet/v2/postgres"
)
// Config configures one PostgreSQL-backed operation-log store.
type Config struct {
DB *sql.DB
OperationTimeout time.Duration
}
// Store persists Game Master operation-log entries in PostgreSQL.
type Store struct {
db *sql.DB
operationTimeout time.Duration
}
// New constructs one PostgreSQL-backed operation-log store from cfg.
func New(cfg Config) (*Store, error) {
if cfg.DB == nil {
return nil, errors.New("new postgres operation log store: db must not be nil")
}
if cfg.OperationTimeout <= 0 {
return nil, errors.New("new postgres operation log store: operation timeout must be positive")
}
return &Store{
db: cfg.DB,
operationTimeout: cfg.OperationTimeout,
}, nil
}
// operationLogSelectColumns matches scanRow's column order.
var operationLogSelectColumns = pg.ColumnList{
pgtable.OperationLog.ID,
pgtable.OperationLog.GameID,
pgtable.OperationLog.OpKind,
pgtable.OperationLog.OpSource,
pgtable.OperationLog.SourceRef,
pgtable.OperationLog.Outcome,
pgtable.OperationLog.ErrorCode,
pgtable.OperationLog.ErrorMessage,
pgtable.OperationLog.StartedAt,
pgtable.OperationLog.FinishedAt,
}
// Append inserts entry into the operation log and returns the
// generated bigserial id. entry is validated through
// operation.OperationEntry.Validate before the SQL is issued.
func (store *Store) Append(ctx context.Context, entry operation.OperationEntry) (int64, error) {
if store == nil || store.db == nil {
return 0, errors.New("append operation log entry: nil store")
}
if err := entry.Validate(); err != nil {
return 0, fmt.Errorf("append operation log entry: %w", err)
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "append operation log entry", store.operationTimeout)
if err != nil {
return 0, err
}
defer cancel()
stmt := pgtable.OperationLog.INSERT(
pgtable.OperationLog.GameID,
pgtable.OperationLog.OpKind,
pgtable.OperationLog.OpSource,
pgtable.OperationLog.SourceRef,
pgtable.OperationLog.Outcome,
pgtable.OperationLog.ErrorCode,
pgtable.OperationLog.ErrorMessage,
pgtable.OperationLog.StartedAt,
pgtable.OperationLog.FinishedAt,
).VALUES(
entry.GameID,
string(entry.OpKind),
string(entry.OpSource),
entry.SourceRef,
string(entry.Outcome),
entry.ErrorCode,
entry.ErrorMessage,
entry.StartedAt.UTC(),
sqlx.NullableTimePtr(entry.FinishedAt),
).RETURNING(pgtable.OperationLog.ID)
query, args := stmt.Sql()
row := store.db.QueryRowContext(operationCtx, query, args...)
var id int64
if err := row.Scan(&id); err != nil {
return 0, fmt.Errorf("append operation log entry: %w", err)
}
return id, nil
}
// ListByGame returns the most recent entries for gameID, ordered by
// started_at descending and id descending (a tie-breaker that keeps
// the order stable when two rows share a started_at). The result is
// capped by limit; non-positive limit is rejected.
func (store *Store) ListByGame(ctx context.Context, gameID string, limit int) ([]operation.OperationEntry, error) {
if store == nil || store.db == nil {
return nil, errors.New("list operation log entries by game: nil store")
}
if strings.TrimSpace(gameID) == "" {
return nil, fmt.Errorf("list operation log entries by game: game id must not be empty")
}
if limit <= 0 {
return nil, fmt.Errorf("list operation log entries by game: limit must be positive, got %d", limit)
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "list operation log entries by game", store.operationTimeout)
if err != nil {
return nil, err
}
defer cancel()
stmt := pg.SELECT(operationLogSelectColumns).
FROM(pgtable.OperationLog).
WHERE(pgtable.OperationLog.GameID.EQ(pg.String(gameID))).
ORDER_BY(pgtable.OperationLog.StartedAt.DESC(), pgtable.OperationLog.ID.DESC()).
LIMIT(int64(limit))
query, args := stmt.Sql()
rows, err := store.db.QueryContext(operationCtx, query, args...)
if err != nil {
return nil, fmt.Errorf("list operation log entries by game: %w", err)
}
defer rows.Close()
entries := make([]operation.OperationEntry, 0)
for rows.Next() {
got, err := scanRow(rows)
if err != nil {
return nil, fmt.Errorf("list operation log entries by game: scan: %w", err)
}
entries = append(entries, got)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list operation log entries by game: %w", err)
}
if len(entries) == 0 {
return nil, nil
}
return entries, nil
}
// rowScanner abstracts *sql.Row and *sql.Rows so scanRow can be shared
// across single-row and iterated reads.
type rowScanner interface {
Scan(dest ...any) error
}
// scanRow scans one operation_log row from rs.
func scanRow(rs rowScanner) (operation.OperationEntry, error) {
var (
id int64
gameID string
opKind string
opSource string
sourceRef string
outcome string
errorCode string
errorMessage string
startedAt time.Time
finishedAt sql.NullTime
)
if err := rs.Scan(
&id,
&gameID,
&opKind,
&opSource,
&sourceRef,
&outcome,
&errorCode,
&errorMessage,
&startedAt,
&finishedAt,
); err != nil {
return operation.OperationEntry{}, err
}
return operation.OperationEntry{
ID: id,
GameID: gameID,
OpKind: operation.OpKind(opKind),
OpSource: operation.OpSource(opSource),
SourceRef: sourceRef,
Outcome: operation.Outcome(outcome),
ErrorCode: errorCode,
ErrorMessage: errorMessage,
StartedAt: startedAt.UTC(),
FinishedAt: sqlx.TimePtrFromNullable(finishedAt),
}, nil
}
// Ensure Store satisfies the ports.OperationLogStore interface at
// compile time.
var _ ports.OperationLogStore = (*Store)(nil)
@@ -0,0 +1,190 @@
package operationlog_test
import (
"context"
"testing"
"time"
"galaxy/gamemaster/internal/adapters/postgres/internal/pgtest"
"galaxy/gamemaster/internal/adapters/postgres/operationlog"
"galaxy/gamemaster/internal/domain/operation"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMain(m *testing.M) { pgtest.RunMain(m) }
func newStore(t *testing.T) *operationlog.Store {
t.Helper()
pgtest.TruncateAll(t)
store, err := operationlog.New(operationlog.Config{
DB: pgtest.Ensure(t).Pool(),
OperationTimeout: pgtest.OperationTimeout,
})
require.NoError(t, err)
return store
}
func successEntry(gameID string, kind operation.OpKind, source operation.OpSource, startedAt time.Time) operation.OperationEntry {
finishedAt := startedAt.Add(50 * time.Millisecond)
return operation.OperationEntry{
GameID: gameID,
OpKind: kind,
OpSource: source,
SourceRef: "req-001",
Outcome: operation.OutcomeSuccess,
StartedAt: startedAt,
FinishedAt: &finishedAt,
}
}
func TestNewRejectsInvalidConfig(t *testing.T) {
_, err := operationlog.New(operationlog.Config{})
require.Error(t, err)
store, err := operationlog.New(operationlog.Config{
DB: pgtest.Ensure(t).Pool(),
OperationTimeout: 0,
})
require.Error(t, err)
require.Nil(t, store)
}
func TestAppendSuccessEntry(t *testing.T) {
ctx := context.Background()
store := newStore(t)
at := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
entry := successEntry("game-001", operation.OpKindRegisterRuntime, operation.OpSourceLobbyInternal, at)
id, err := store.Append(ctx, entry)
require.NoError(t, err)
assert.Greater(t, id, int64(0))
entries, err := store.ListByGame(ctx, "game-001", 10)
require.NoError(t, err)
require.Len(t, entries, 1)
got := entries[0]
assert.Equal(t, id, got.ID)
assert.Equal(t, entry.GameID, got.GameID)
assert.Equal(t, entry.OpKind, got.OpKind)
assert.Equal(t, entry.OpSource, got.OpSource)
assert.Equal(t, entry.SourceRef, got.SourceRef)
assert.Equal(t, operation.OutcomeSuccess, got.Outcome)
assert.Empty(t, got.ErrorCode)
assert.Empty(t, got.ErrorMessage)
assert.True(t, got.StartedAt.Equal(at))
require.NotNil(t, got.FinishedAt)
assert.Equal(t, time.UTC, got.StartedAt.Location())
assert.Equal(t, time.UTC, got.FinishedAt.Location())
}
func TestAppendFailureEntry(t *testing.T) {
ctx := context.Background()
store := newStore(t)
at := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
finishedAt := at.Add(time.Second)
entry := operation.OperationEntry{
GameID: "game-001",
OpKind: operation.OpKindTurnGeneration,
OpSource: operation.OpSourceAdminRest,
Outcome: operation.OutcomeFailure,
ErrorCode: "engine_unreachable",
ErrorMessage: "connection refused",
StartedAt: at,
FinishedAt: &finishedAt,
}
_, err := store.Append(ctx, entry)
require.NoError(t, err)
got, err := store.ListByGame(ctx, "game-001", 1)
require.NoError(t, err)
require.Len(t, got, 1)
assert.Equal(t, operation.OutcomeFailure, got[0].Outcome)
assert.Equal(t, "engine_unreachable", got[0].ErrorCode)
assert.Equal(t, "connection refused", got[0].ErrorMessage)
}
func TestAppendIDsAreMonotonic(t *testing.T) {
ctx := context.Background()
store := newStore(t)
at := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
id1, err := store.Append(ctx, successEntry("game-001", operation.OpKindRegisterRuntime, operation.OpSourceLobbyInternal, at))
require.NoError(t, err)
id2, err := store.Append(ctx, successEntry("game-001", operation.OpKindTurnGeneration, operation.OpSourceLobbyInternal, at.Add(time.Second)))
require.NoError(t, err)
assert.Greater(t, id2, id1, "bigserial ids must be monotonic across appends")
}
func TestAppendValidationRejection(t *testing.T) {
ctx := context.Background()
store := newStore(t)
bad := operation.OperationEntry{}
_, err := store.Append(ctx, bad)
require.Error(t, err)
}
func TestListByGameOrderingDesc(t *testing.T) {
ctx := context.Background()
store := newStore(t)
at := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
_, err := store.Append(ctx, successEntry("game-001", operation.OpKindRegisterRuntime, operation.OpSourceLobbyInternal, at))
require.NoError(t, err)
_, err = store.Append(ctx, successEntry("game-001", operation.OpKindTurnGeneration, operation.OpSourceLobbyInternal, at.Add(time.Second)))
require.NoError(t, err)
_, err = store.Append(ctx, successEntry("game-001", operation.OpKindStop, operation.OpSourceAdminRest, at.Add(2*time.Second)))
require.NoError(t, err)
got, err := store.ListByGame(ctx, "game-001", 10)
require.NoError(t, err)
require.Len(t, got, 3)
assert.Equal(t, operation.OpKindStop, got[0].OpKind)
assert.Equal(t, operation.OpKindTurnGeneration, got[1].OpKind)
assert.Equal(t, operation.OpKindRegisterRuntime, got[2].OpKind)
}
func TestListByGameRespectsLimit(t *testing.T) {
ctx := context.Background()
store := newStore(t)
at := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
for index := range 5 {
_, err := store.Append(ctx, successEntry("game-001", operation.OpKindTurnGeneration, operation.OpSourceLobbyInternal, at.Add(time.Duration(index)*time.Second)))
require.NoError(t, err)
}
got, err := store.ListByGame(ctx, "game-001", 2)
require.NoError(t, err)
require.Len(t, got, 2)
}
func TestListByGameUnknownGame(t *testing.T) {
ctx := context.Background()
store := newStore(t)
got, err := store.ListByGame(ctx, "unknown-game", 10)
require.NoError(t, err)
assert.Empty(t, got)
}
func TestListByGameRejectsBadArgs(t *testing.T) {
ctx := context.Background()
store := newStore(t)
_, err := store.ListByGame(ctx, "", 10)
require.Error(t, err)
_, err = store.ListByGame(ctx, "game-001", 0)
require.Error(t, err)
_, err = store.ListByGame(ctx, "game-001", -1)
require.Error(t, err)
}
@@ -0,0 +1,292 @@
// Package playermappingstore implements the PostgreSQL-backed adapter
// for `ports.PlayerMappingStore`.
//
// The package owns the on-disk shape of the `player_mappings` table
// defined in
// `galaxy/gamemaster/internal/adapters/postgres/migrations/00001_init.sql`
// and translates the schema-agnostic `ports.PlayerMappingStore`
// interface declared in `internal/ports/playermappingstore.go` into
// concrete go-jet/v2 statements driven by the pgx driver.
//
// BulkInsert ships every row in a single multi-row INSERT so the
// operation is atomic — any unique-constraint violation rolls back the
// whole batch and is mapped to playermapping.ErrConflict.
package playermappingstore
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"galaxy/gamemaster/internal/adapters/postgres/internal/sqlx"
pgtable "galaxy/gamemaster/internal/adapters/postgres/jet/gamemaster/table"
"galaxy/gamemaster/internal/domain/playermapping"
"galaxy/gamemaster/internal/ports"
pg "github.com/go-jet/jet/v2/postgres"
)
// Config configures one PostgreSQL-backed player-mapping store.
type Config struct {
DB *sql.DB
OperationTimeout time.Duration
}
// Store persists Game Master player mappings in PostgreSQL.
type Store struct {
db *sql.DB
operationTimeout time.Duration
}
// New constructs one PostgreSQL-backed player-mapping store from cfg.
func New(cfg Config) (*Store, error) {
if cfg.DB == nil {
return nil, errors.New("new postgres player mapping store: db must not be nil")
}
if cfg.OperationTimeout <= 0 {
return nil, errors.New("new postgres player mapping store: operation timeout must be positive")
}
return &Store{
db: cfg.DB,
operationTimeout: cfg.OperationTimeout,
}, nil
}
// playerMappingSelectColumns matches scanRow's column order.
var playerMappingSelectColumns = pg.ColumnList{
pgtable.PlayerMappings.GameID,
pgtable.PlayerMappings.UserID,
pgtable.PlayerMappings.RaceName,
pgtable.PlayerMappings.EnginePlayerUUID,
pgtable.PlayerMappings.CreatedAt,
}
// BulkInsert installs every mapping in records using a single
// multi-row INSERT. Either every row is persisted or none of them is.
// Any PostgreSQL unique-violation
// (`(game_id, user_id)` PK or `(game_id, race_name)` UNIQUE) is mapped
// to playermapping.ErrConflict.
func (store *Store) BulkInsert(ctx context.Context, records []playermapping.PlayerMapping) error {
if store == nil || store.db == nil {
return errors.New("bulk insert player mappings: nil store")
}
if len(records) == 0 {
return nil
}
for index, record := range records {
if err := record.Validate(); err != nil {
return fmt.Errorf("bulk insert player mappings: record %d: %w", index, err)
}
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "bulk insert player mappings", store.operationTimeout)
if err != nil {
return err
}
defer cancel()
stmt := pgtable.PlayerMappings.INSERT(
pgtable.PlayerMappings.GameID,
pgtable.PlayerMappings.UserID,
pgtable.PlayerMappings.RaceName,
pgtable.PlayerMappings.EnginePlayerUUID,
pgtable.PlayerMappings.CreatedAt,
)
for _, record := range records {
stmt = stmt.VALUES(
record.GameID,
record.UserID,
record.RaceName,
record.EnginePlayerUUID,
record.CreatedAt.UTC(),
)
}
query, args := stmt.Sql()
if _, err := store.db.ExecContext(operationCtx, query, args...); err != nil {
if sqlx.IsUniqueViolation(err) {
return fmt.Errorf("bulk insert player mappings: %w", playermapping.ErrConflict)
}
return fmt.Errorf("bulk insert player mappings: %w", err)
}
return nil
}
// Get returns the mapping identified by (gameID, userID).
func (store *Store) Get(ctx context.Context, gameID, userID string) (playermapping.PlayerMapping, error) {
if store == nil || store.db == nil {
return playermapping.PlayerMapping{}, errors.New("get player mapping: nil store")
}
if strings.TrimSpace(gameID) == "" {
return playermapping.PlayerMapping{}, fmt.Errorf("get player mapping: game id must not be empty")
}
if strings.TrimSpace(userID) == "" {
return playermapping.PlayerMapping{}, fmt.Errorf("get player mapping: user id must not be empty")
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "get player mapping", store.operationTimeout)
if err != nil {
return playermapping.PlayerMapping{}, err
}
defer cancel()
stmt := pg.SELECT(playerMappingSelectColumns).
FROM(pgtable.PlayerMappings).
WHERE(pg.AND(
pgtable.PlayerMappings.GameID.EQ(pg.String(gameID)),
pgtable.PlayerMappings.UserID.EQ(pg.String(userID)),
))
query, args := stmt.Sql()
row := store.db.QueryRowContext(operationCtx, query, args...)
got, err := scanRow(row)
if sqlx.IsNoRows(err) {
return playermapping.PlayerMapping{}, playermapping.ErrNotFound
}
if err != nil {
return playermapping.PlayerMapping{}, fmt.Errorf("get player mapping: %w", err)
}
return got, nil
}
// GetByRace returns the mapping identified by (gameID, raceName).
func (store *Store) GetByRace(ctx context.Context, gameID, raceName string) (playermapping.PlayerMapping, error) {
if store == nil || store.db == nil {
return playermapping.PlayerMapping{}, errors.New("get player mapping by race: nil store")
}
if strings.TrimSpace(gameID) == "" {
return playermapping.PlayerMapping{}, fmt.Errorf("get player mapping by race: game id must not be empty")
}
if strings.TrimSpace(raceName) == "" {
return playermapping.PlayerMapping{}, fmt.Errorf("get player mapping by race: race name must not be empty")
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "get player mapping by race", store.operationTimeout)
if err != nil {
return playermapping.PlayerMapping{}, err
}
defer cancel()
stmt := pg.SELECT(playerMappingSelectColumns).
FROM(pgtable.PlayerMappings).
WHERE(pg.AND(
pgtable.PlayerMappings.GameID.EQ(pg.String(gameID)),
pgtable.PlayerMappings.RaceName.EQ(pg.String(raceName)),
))
query, args := stmt.Sql()
row := store.db.QueryRowContext(operationCtx, query, args...)
got, err := scanRow(row)
if sqlx.IsNoRows(err) {
return playermapping.PlayerMapping{}, playermapping.ErrNotFound
}
if err != nil {
return playermapping.PlayerMapping{}, fmt.Errorf("get player mapping by race: %w", err)
}
return got, nil
}
// ListByGame returns every mapping owned by gameID, ordered by user_id
// ascending.
func (store *Store) ListByGame(ctx context.Context, gameID string) ([]playermapping.PlayerMapping, error) {
if store == nil || store.db == nil {
return nil, errors.New("list player mappings by game: nil store")
}
if strings.TrimSpace(gameID) == "" {
return nil, fmt.Errorf("list player mappings by game: game id must not be empty")
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "list player mappings by game", store.operationTimeout)
if err != nil {
return nil, err
}
defer cancel()
stmt := pg.SELECT(playerMappingSelectColumns).
FROM(pgtable.PlayerMappings).
WHERE(pgtable.PlayerMappings.GameID.EQ(pg.String(gameID))).
ORDER_BY(pgtable.PlayerMappings.UserID.ASC())
query, args := stmt.Sql()
rows, err := store.db.QueryContext(operationCtx, query, args...)
if err != nil {
return nil, fmt.Errorf("list player mappings by game: %w", err)
}
defer rows.Close()
mappings := make([]playermapping.PlayerMapping, 0)
for rows.Next() {
got, err := scanRow(rows)
if err != nil {
return nil, fmt.Errorf("list player mappings by game: scan: %w", err)
}
mappings = append(mappings, got)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list player mappings by game: %w", err)
}
if len(mappings) == 0 {
return nil, nil
}
return mappings, nil
}
// DeleteByGame removes every mapping owned by gameID. The call is
// idempotent: it returns nil even when no rows were deleted.
func (store *Store) DeleteByGame(ctx context.Context, gameID string) error {
if store == nil || store.db == nil {
return errors.New("delete player mappings by game: nil store")
}
if strings.TrimSpace(gameID) == "" {
return fmt.Errorf("delete player mappings by game: game id must not be empty")
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "delete player mappings by game", store.operationTimeout)
if err != nil {
return err
}
defer cancel()
stmt := pgtable.PlayerMappings.DELETE().
WHERE(pgtable.PlayerMappings.GameID.EQ(pg.String(gameID)))
query, args := stmt.Sql()
if _, err := store.db.ExecContext(operationCtx, query, args...); err != nil {
return fmt.Errorf("delete player mappings by game: %w", err)
}
return nil
}
// rowScanner abstracts *sql.Row and *sql.Rows so scanRow can be shared
// across single-row and iterated reads.
type rowScanner interface {
Scan(dest ...any) error
}
// scanRow scans one player_mappings row from rs.
func scanRow(rs rowScanner) (playermapping.PlayerMapping, error) {
var (
gameID string
userID string
raceName string
enginePlayerUUID string
createdAt time.Time
)
if err := rs.Scan(&gameID, &userID, &raceName, &enginePlayerUUID, &createdAt); err != nil {
return playermapping.PlayerMapping{}, err
}
return playermapping.PlayerMapping{
GameID: gameID,
UserID: userID,
RaceName: raceName,
EnginePlayerUUID: enginePlayerUUID,
CreatedAt: createdAt.UTC(),
}, nil
}
// Ensure Store satisfies the ports.PlayerMappingStore interface at
// compile time.
var _ ports.PlayerMappingStore = (*Store)(nil)
@@ -0,0 +1,264 @@
package playermappingstore_test
import (
"context"
"errors"
"testing"
"time"
"galaxy/gamemaster/internal/adapters/postgres/internal/pgtest"
"galaxy/gamemaster/internal/adapters/postgres/playermappingstore"
"galaxy/gamemaster/internal/domain/playermapping"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMain(m *testing.M) { pgtest.RunMain(m) }
func newStore(t *testing.T) *playermappingstore.Store {
t.Helper()
pgtest.TruncateAll(t)
store, err := playermappingstore.New(playermappingstore.Config{
DB: pgtest.Ensure(t).Pool(),
OperationTimeout: pgtest.OperationTimeout,
})
require.NoError(t, err)
return store
}
func mapping(gameID, userID, raceName, uuid string, createdAt time.Time) playermapping.PlayerMapping {
return playermapping.PlayerMapping{
GameID: gameID,
UserID: userID,
RaceName: raceName,
EnginePlayerUUID: uuid,
CreatedAt: createdAt,
}
}
func TestNewRejectsInvalidConfig(t *testing.T) {
_, err := playermappingstore.New(playermappingstore.Config{})
require.Error(t, err)
store, err := playermappingstore.New(playermappingstore.Config{
DB: pgtest.Ensure(t).Pool(),
OperationTimeout: 0,
})
require.Error(t, err)
require.Nil(t, store)
}
func TestBulkInsertHappy(t *testing.T) {
ctx := context.Background()
store := newStore(t)
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
records := []playermapping.PlayerMapping{
mapping("game-001", "user-1", "Aelinari", "uuid-1", now),
mapping("game-001", "user-2", "Drazi", "uuid-2", now),
mapping("game-001", "user-3", "Voltori", "uuid-3", now),
}
require.NoError(t, store.BulkInsert(ctx, records))
for _, want := range records {
got, err := store.Get(ctx, want.GameID, want.UserID)
require.NoError(t, err)
assert.Equal(t, want.RaceName, got.RaceName)
assert.Equal(t, want.EnginePlayerUUID, got.EnginePlayerUUID)
assert.True(t, got.CreatedAt.Equal(now))
assert.Equal(t, time.UTC, got.CreatedAt.Location())
}
}
func TestBulkInsertEmpty(t *testing.T) {
ctx := context.Background()
store := newStore(t)
require.NoError(t, store.BulkInsert(ctx, nil))
require.NoError(t, store.BulkInsert(ctx, []playermapping.PlayerMapping{}))
got, err := store.ListByGame(ctx, "game-001")
require.NoError(t, err)
assert.Empty(t, got)
}
func TestBulkInsertAtomicConflictRaceName(t *testing.T) {
ctx := context.Background()
store := newStore(t)
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
// user-2 reuses Aelinari (already taken by user-1) inside the same
// game — the unique (game_id, race_name) index must reject the
// whole batch.
records := []playermapping.PlayerMapping{
mapping("game-001", "user-1", "Aelinari", "uuid-1", now),
mapping("game-001", "user-2", "Drazi", "uuid-2", now),
mapping("game-001", "user-3", "Aelinari", "uuid-3", now),
}
err := store.BulkInsert(ctx, records)
require.Error(t, err)
require.True(t, errors.Is(err, playermapping.ErrConflict), "want ErrConflict, got %v", err)
got, err := store.ListByGame(ctx, "game-001")
require.NoError(t, err)
assert.Empty(t, got, "atomic batch must roll back every row when any row fails")
}
func TestBulkInsertAtomicConflictUserID(t *testing.T) {
ctx := context.Background()
store := newStore(t)
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
records := []playermapping.PlayerMapping{
mapping("game-001", "user-1", "Aelinari", "uuid-1", now),
mapping("game-001", "user-1", "Drazi", "uuid-2", now), // user-1 twice
}
err := store.BulkInsert(ctx, records)
require.Error(t, err)
require.True(t, errors.Is(err, playermapping.ErrConflict))
}
func TestBulkInsertConflictAcrossCalls(t *testing.T) {
ctx := context.Background()
store := newStore(t)
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
require.NoError(t, store.BulkInsert(ctx, []playermapping.PlayerMapping{
mapping("game-001", "user-1", "Aelinari", "uuid-1", now),
}))
err := store.BulkInsert(ctx, []playermapping.PlayerMapping{
mapping("game-001", "user-1", "DifferentRace", "uuid-2", now),
})
require.Error(t, err)
require.True(t, errors.Is(err, playermapping.ErrConflict))
}
func TestBulkInsertRejectsInvalid(t *testing.T) {
ctx := context.Background()
store := newStore(t)
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
bad := []playermapping.PlayerMapping{
mapping("game-001", "user-1", "Aelinari", "uuid-1", now),
{GameID: "game-001", UserID: "", RaceName: "Drazi", EnginePlayerUUID: "uuid-2", CreatedAt: now},
}
err := store.BulkInsert(ctx, bad)
require.Error(t, err)
require.False(t, errors.Is(err, playermapping.ErrConflict))
got, err := store.ListByGame(ctx, "game-001")
require.NoError(t, err)
assert.Empty(t, got, "validation rejection must not insert any row")
}
func TestGetMissingReturnsNotFound(t *testing.T) {
ctx := context.Background()
store := newStore(t)
_, err := store.Get(ctx, "game-001", "user-1")
require.Error(t, err)
require.True(t, errors.Is(err, playermapping.ErrNotFound))
}
func TestGetByRace(t *testing.T) {
ctx := context.Background()
store := newStore(t)
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
require.NoError(t, store.BulkInsert(ctx, []playermapping.PlayerMapping{
mapping("game-001", "user-1", "Aelinari", "uuid-1", now),
mapping("game-001", "user-2", "Drazi", "uuid-2", now),
}))
got, err := store.GetByRace(ctx, "game-001", "Aelinari")
require.NoError(t, err)
assert.Equal(t, "user-1", got.UserID)
_, err = store.GetByRace(ctx, "game-001", "Voltori")
require.Error(t, err)
require.True(t, errors.Is(err, playermapping.ErrNotFound))
}
func TestListByGameSortedByUserID(t *testing.T) {
ctx := context.Background()
store := newStore(t)
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
require.NoError(t, store.BulkInsert(ctx, []playermapping.PlayerMapping{
mapping("game-001", "user-c", "Aelinari", "uuid-1", now),
mapping("game-001", "user-a", "Drazi", "uuid-2", now),
mapping("game-001", "user-b", "Voltori", "uuid-3", now),
// other game's mappings must not leak
mapping("game-002", "user-z", "Outsider", "uuid-4", now),
}))
got, err := store.ListByGame(ctx, "game-001")
require.NoError(t, err)
require.Len(t, got, 3)
assert.Equal(t, "user-a", got[0].UserID)
assert.Equal(t, "user-b", got[1].UserID)
assert.Equal(t, "user-c", got[2].UserID)
}
func TestListByGameUnknown(t *testing.T) {
ctx := context.Background()
store := newStore(t)
got, err := store.ListByGame(ctx, "unknown-game")
require.NoError(t, err)
assert.Empty(t, got)
}
func TestDeleteByGameIdempotent(t *testing.T) {
ctx := context.Background()
store := newStore(t)
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
require.NoError(t, store.BulkInsert(ctx, []playermapping.PlayerMapping{
mapping("game-001", "user-1", "Aelinari", "uuid-1", now),
mapping("game-001", "user-2", "Drazi", "uuid-2", now),
}))
require.NoError(t, store.DeleteByGame(ctx, "game-001"))
got, err := store.ListByGame(ctx, "game-001")
require.NoError(t, err)
assert.Empty(t, got)
// Second call must be a no-op.
require.NoError(t, store.DeleteByGame(ctx, "game-001"))
}
func TestGetRejectsEmptyArgs(t *testing.T) {
ctx := context.Background()
store := newStore(t)
_, err := store.Get(ctx, "", "user-1")
require.Error(t, err)
_, err = store.Get(ctx, "game-001", "")
require.Error(t, err)
}
func TestGetByRaceRejectsEmptyArgs(t *testing.T) {
ctx := context.Background()
store := newStore(t)
_, err := store.GetByRace(ctx, "", "Aelinari")
require.Error(t, err)
_, err = store.GetByRace(ctx, "game-001", "")
require.Error(t, err)
}
func TestListByGameRejectsEmpty(t *testing.T) {
ctx := context.Background()
store := newStore(t)
_, err := store.ListByGame(ctx, "")
require.Error(t, err)
}
func TestDeleteByGameRejectsEmpty(t *testing.T) {
ctx := context.Background()
store := newStore(t)
err := store.DeleteByGame(ctx, "")
require.Error(t, err)
}
@@ -0,0 +1,636 @@
// Package runtimerecordstore implements the PostgreSQL-backed adapter
// for `ports.RuntimeRecordStore`.
//
// The package owns the on-disk shape of the `runtime_records` table
// defined in
// `galaxy/gamemaster/internal/adapters/postgres/migrations/00001_init.sql`
// and translates the schema-agnostic `ports.RuntimeRecordStore`
// interface declared in `internal/ports/runtimerecordstore.go` into
// concrete go-jet/v2 statements driven by the pgx driver.
//
// Lifecycle transitions (UpdateStatus) use compare-and-swap on
// `(game_id, status)` rather than holding a SELECT ... FOR UPDATE lock
// across the caller's logic, mirroring the pattern used by
// `rtmanager/internal/adapters/postgres/runtimerecordstore`.
package runtimerecordstore
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"galaxy/gamemaster/internal/adapters/postgres/internal/sqlx"
pgtable "galaxy/gamemaster/internal/adapters/postgres/jet/gamemaster/table"
"galaxy/gamemaster/internal/domain/runtime"
"galaxy/gamemaster/internal/ports"
pg "github.com/go-jet/jet/v2/postgres"
)
// Config configures one PostgreSQL-backed runtime-record store. The
// store does not own the underlying *sql.DB lifecycle; the caller
// (typically the service runtime) opens, instruments, migrates, and
// closes the pool.
type Config struct {
// DB stores the connection pool the store uses for every query.
DB *sql.DB
// OperationTimeout bounds one round trip. The store creates a
// derived context for each operation so callers cannot starve the
// pool with an unbounded ctx.
OperationTimeout time.Duration
}
// Store persists Game Master runtime records in PostgreSQL.
type Store struct {
db *sql.DB
operationTimeout time.Duration
}
// New constructs one PostgreSQL-backed runtime-record store from cfg.
func New(cfg Config) (*Store, error) {
if cfg.DB == nil {
return nil, errors.New("new postgres runtime record store: db must not be nil")
}
if cfg.OperationTimeout <= 0 {
return nil, errors.New("new postgres runtime record store: operation timeout must be positive")
}
return &Store{
db: cfg.DB,
operationTimeout: cfg.OperationTimeout,
}, nil
}
// runtimeSelectColumns is the canonical SELECT list for the
// runtime_records table, matching scanRecord's column order.
var runtimeSelectColumns = pg.ColumnList{
pgtable.RuntimeRecords.GameID,
pgtable.RuntimeRecords.Status,
pgtable.RuntimeRecords.EngineEndpoint,
pgtable.RuntimeRecords.CurrentImageRef,
pgtable.RuntimeRecords.CurrentEngineVersion,
pgtable.RuntimeRecords.TurnSchedule,
pgtable.RuntimeRecords.CurrentTurn,
pgtable.RuntimeRecords.NextGenerationAt,
pgtable.RuntimeRecords.SkipNextTick,
pgtable.RuntimeRecords.EngineHealth,
pgtable.RuntimeRecords.CreatedAt,
pgtable.RuntimeRecords.UpdatedAt,
pgtable.RuntimeRecords.StartedAt,
pgtable.RuntimeRecords.StoppedAt,
pgtable.RuntimeRecords.FinishedAt,
}
// Get returns the record identified by gameID. It returns
// runtime.ErrNotFound when no record exists.
func (store *Store) Get(ctx context.Context, gameID string) (runtime.RuntimeRecord, error) {
if store == nil || store.db == nil {
return runtime.RuntimeRecord{}, errors.New("get runtime record: nil store")
}
if strings.TrimSpace(gameID) == "" {
return runtime.RuntimeRecord{}, fmt.Errorf("get runtime record: game id must not be empty")
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "get runtime record", store.operationTimeout)
if err != nil {
return runtime.RuntimeRecord{}, err
}
defer cancel()
stmt := pg.SELECT(runtimeSelectColumns).
FROM(pgtable.RuntimeRecords).
WHERE(pgtable.RuntimeRecords.GameID.EQ(pg.String(gameID)))
query, args := stmt.Sql()
row := store.db.QueryRowContext(operationCtx, query, args...)
record, err := scanRecord(row)
if sqlx.IsNoRows(err) {
return runtime.RuntimeRecord{}, runtime.ErrNotFound
}
if err != nil {
return runtime.RuntimeRecord{}, fmt.Errorf("get runtime record: %w", err)
}
return record, nil
}
// Insert installs record into the store. Returns runtime.ErrConflict
// when a row already exists for record.GameID.
func (store *Store) Insert(ctx context.Context, record runtime.RuntimeRecord) error {
if store == nil || store.db == nil {
return errors.New("insert runtime record: nil store")
}
if err := record.Validate(); err != nil {
return fmt.Errorf("insert runtime record: %w", err)
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "insert runtime record", store.operationTimeout)
if err != nil {
return err
}
defer cancel()
stmt := pgtable.RuntimeRecords.INSERT(
pgtable.RuntimeRecords.GameID,
pgtable.RuntimeRecords.Status,
pgtable.RuntimeRecords.EngineEndpoint,
pgtable.RuntimeRecords.CurrentImageRef,
pgtable.RuntimeRecords.CurrentEngineVersion,
pgtable.RuntimeRecords.TurnSchedule,
pgtable.RuntimeRecords.CurrentTurn,
pgtable.RuntimeRecords.NextGenerationAt,
pgtable.RuntimeRecords.SkipNextTick,
pgtable.RuntimeRecords.EngineHealth,
pgtable.RuntimeRecords.CreatedAt,
pgtable.RuntimeRecords.UpdatedAt,
pgtable.RuntimeRecords.StartedAt,
pgtable.RuntimeRecords.StoppedAt,
pgtable.RuntimeRecords.FinishedAt,
).VALUES(
record.GameID,
string(record.Status),
record.EngineEndpoint,
record.CurrentImageRef,
record.CurrentEngineVersion,
record.TurnSchedule,
int32(record.CurrentTurn),
sqlx.NullableTimePtr(record.NextGenerationAt),
record.SkipNextTick,
record.EngineHealth,
record.CreatedAt.UTC(),
record.UpdatedAt.UTC(),
sqlx.NullableTimePtr(record.StartedAt),
sqlx.NullableTimePtr(record.StoppedAt),
sqlx.NullableTimePtr(record.FinishedAt),
)
query, args := stmt.Sql()
if _, err := store.db.ExecContext(operationCtx, query, args...); err != nil {
if sqlx.IsUniqueViolation(err) {
return fmt.Errorf("insert runtime record: %w", runtime.ErrConflict)
}
return fmt.Errorf("insert runtime record: %w", err)
}
return nil
}
// UpdateStatus applies one status transition with a compare-and-swap
// guard on (game_id, status). The destination's lifecycle timestamps
// (started_at, stopped_at, finished_at) and the optional fields
// (engine_health, current_image_ref, current_engine_version) are
// written only when applicable.
func (store *Store) UpdateStatus(ctx context.Context, input ports.UpdateStatusInput) error {
if store == nil || store.db == nil {
return errors.New("update runtime status: nil store")
}
if err := input.Validate(); err != nil {
return err
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "update runtime status", store.operationTimeout)
if err != nil {
return err
}
defer cancel()
assignments := buildUpdateStatusAssignments(input, input.Now.UTC())
// The first positional argument to UPDATE is required by jet's
// API but ignored when SET receives ColumnAssigment values
// (jet then serialises SetClauseNew instead of clauseSet).
stmt := pgtable.RuntimeRecords.UPDATE(pgtable.RuntimeRecords.Status).
SET(assignments[0], assignments[1:]...).
WHERE(pg.AND(
pgtable.RuntimeRecords.GameID.EQ(pg.String(input.GameID)),
pgtable.RuntimeRecords.Status.EQ(pg.String(string(input.ExpectedFrom))),
))
query, args := stmt.Sql()
result, err := store.db.ExecContext(operationCtx, query, args...)
if err != nil {
return fmt.Errorf("update runtime status: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("update runtime status: rows affected: %w", err)
}
if affected == 0 {
return store.classifyMissingUpdate(operationCtx, input.GameID)
}
return nil
}
// buildUpdateStatusAssignments returns the slice of column assignments
// produced by one UpdateStatus call. Mandatory assignments (status,
// updated_at) are always present; lifecycle timestamps and optional
// fields appear only when relevant to the destination status or when
// the corresponding pointer is non-nil.
//
// The slice element type is `any` so the result can be spread into
// `UpdateStatement.SET(value any, values ...any)` without manual
// boxing at the call site.
func buildUpdateStatusAssignments(input ports.UpdateStatusInput, now time.Time) []any {
nowExpr := pg.TimestampzT(now)
assignments := []any{
pgtable.RuntimeRecords.Status.SET(pg.String(string(input.To))),
pgtable.RuntimeRecords.UpdatedAt.SET(nowExpr),
}
if input.To == runtime.StatusRunning && input.ExpectedFrom == runtime.StatusStarting {
assignments = append(assignments, pgtable.RuntimeRecords.StartedAt.SET(nowExpr))
}
if input.To == runtime.StatusStopped {
assignments = append(assignments, pgtable.RuntimeRecords.StoppedAt.SET(nowExpr))
}
if input.To == runtime.StatusFinished {
assignments = append(assignments, pgtable.RuntimeRecords.FinishedAt.SET(nowExpr))
}
if input.EngineHealthSummary != nil {
assignments = append(assignments, pgtable.RuntimeRecords.EngineHealth.SET(pg.String(*input.EngineHealthSummary)))
}
if input.CurrentImageRef != nil {
assignments = append(assignments, pgtable.RuntimeRecords.CurrentImageRef.SET(pg.String(*input.CurrentImageRef)))
}
if input.CurrentEngineVersion != nil {
assignments = append(assignments, pgtable.RuntimeRecords.CurrentEngineVersion.SET(pg.String(*input.CurrentEngineVersion)))
}
return assignments
}
// classifyMissingUpdate distinguishes ErrNotFound from ErrConflict
// after an UPDATE that affected zero rows. A row that is absent yields
// ErrNotFound; a row whose status does not match the CAS predicate
// yields ErrConflict.
func (store *Store) classifyMissingUpdate(ctx context.Context, gameID string) error {
probe := pg.SELECT(pgtable.RuntimeRecords.Status).
FROM(pgtable.RuntimeRecords).
WHERE(pgtable.RuntimeRecords.GameID.EQ(pg.String(gameID)))
probeQuery, probeArgs := probe.Sql()
var current string
row := store.db.QueryRowContext(ctx, probeQuery, probeArgs...)
if err := row.Scan(&current); err != nil {
if sqlx.IsNoRows(err) {
return runtime.ErrNotFound
}
return fmt.Errorf("update runtime status: probe: %w", err)
}
return runtime.ErrConflict
}
// UpdateImage rotates the `current_image_ref` and
// `current_engine_version` columns of one runtime row under a
// compare-and-swap guard on `(game_id, status)`. The destination
// status is preserved; only `updated_at` and the two image columns
// change. Returns runtime.ErrNotFound when no row matches and
// runtime.ErrConflict when the stored status differs from
// input.ExpectedStatus. Used by the admin patch flow (Stage 17) where
// Runtime Manager recreates the engine container with a new image
// while the runtime stays `running`.
func (store *Store) UpdateImage(ctx context.Context, input ports.UpdateImageInput) error {
if store == nil || store.db == nil {
return errors.New("update runtime image: nil store")
}
if err := input.Validate(); err != nil {
return err
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "update runtime image", store.operationTimeout)
if err != nil {
return err
}
defer cancel()
now := input.Now.UTC()
stmt := pgtable.RuntimeRecords.UPDATE(
pgtable.RuntimeRecords.CurrentImageRef,
pgtable.RuntimeRecords.CurrentEngineVersion,
pgtable.RuntimeRecords.UpdatedAt,
).SET(
pg.String(input.CurrentImageRef),
pg.String(input.CurrentEngineVersion),
pg.TimestampzT(now),
).WHERE(pg.AND(
pgtable.RuntimeRecords.GameID.EQ(pg.String(input.GameID)),
pgtable.RuntimeRecords.Status.EQ(pg.String(string(input.ExpectedStatus))),
))
query, args := stmt.Sql()
result, err := store.db.ExecContext(operationCtx, query, args...)
if err != nil {
return fmt.Errorf("update runtime image: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("update runtime image: rows affected: %w", err)
}
if affected == 0 {
return store.classifyMissingUpdate(operationCtx, input.GameID)
}
return nil
}
// UpdateEngineHealth rotates the `engine_health` column of one runtime
// row plus `updated_at`. The destination status is preserved and no
// CAS guard is applied so late-arriving runtime:health_events still
// refresh the summary regardless of the current runtime status. Used
// by the Stage 18 health-events consumer. Returns runtime.ErrNotFound
// when no row exists for input.GameID.
func (store *Store) UpdateEngineHealth(ctx context.Context, input ports.UpdateEngineHealthInput) error {
if store == nil || store.db == nil {
return errors.New("update runtime engine health: nil store")
}
if err := input.Validate(); err != nil {
return err
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "update runtime engine health", store.operationTimeout)
if err != nil {
return err
}
defer cancel()
stmt := pgtable.RuntimeRecords.UPDATE(
pgtable.RuntimeRecords.EngineHealth,
pgtable.RuntimeRecords.UpdatedAt,
).SET(
pg.String(input.EngineHealthSummary),
pg.TimestampzT(input.Now.UTC()),
).WHERE(pgtable.RuntimeRecords.GameID.EQ(pg.String(input.GameID)))
query, args := stmt.Sql()
result, err := store.db.ExecContext(operationCtx, query, args...)
if err != nil {
return fmt.Errorf("update runtime engine health: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("update runtime engine health: rows affected: %w", err)
}
if affected == 0 {
return runtime.ErrNotFound
}
return nil
}
// UpdateScheduling mutates the scheduling columns of one runtime row
// (`next_generation_at`, `skip_next_tick`, `current_turn`) plus
// `updated_at`. Returns runtime.ErrNotFound when no row exists.
func (store *Store) UpdateScheduling(ctx context.Context, input ports.UpdateSchedulingInput) error {
if store == nil || store.db == nil {
return errors.New("update runtime scheduling: nil store")
}
if err := input.Validate(); err != nil {
return err
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "update runtime scheduling", store.operationTimeout)
if err != nil {
return err
}
defer cancel()
var nextGenExpr pg.Expression
if input.NextGenerationAt != nil {
nextGenExpr = pg.TimestampzT(input.NextGenerationAt.UTC())
} else {
nextGenExpr = pg.NULL
}
stmt := pgtable.RuntimeRecords.UPDATE(
pgtable.RuntimeRecords.NextGenerationAt,
pgtable.RuntimeRecords.SkipNextTick,
pgtable.RuntimeRecords.CurrentTurn,
pgtable.RuntimeRecords.UpdatedAt,
).SET(
nextGenExpr,
pg.Bool(input.SkipNextTick),
pg.Int32(int32(input.CurrentTurn)),
pg.TimestampzT(input.Now.UTC()),
).WHERE(pgtable.RuntimeRecords.GameID.EQ(pg.String(input.GameID)))
query, args := stmt.Sql()
result, err := store.db.ExecContext(operationCtx, query, args...)
if err != nil {
return fmt.Errorf("update runtime scheduling: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("update runtime scheduling: rows affected: %w", err)
}
if affected == 0 {
return runtime.ErrNotFound
}
return nil
}
// Delete removes the record identified by gameID. The call is
// idempotent: it returns nil even when no row matches (mirrors
// PlayerMappingStore.DeleteByGame). Used by the register-runtime
// rollback path (Stage 13) when engine /admin/init or any later setup
// step fails after the row has been installed with status=starting.
func (store *Store) Delete(ctx context.Context, gameID string) error {
if store == nil || store.db == nil {
return errors.New("delete runtime record: nil store")
}
if strings.TrimSpace(gameID) == "" {
return fmt.Errorf("delete runtime record: game id must not be empty")
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "delete runtime record", store.operationTimeout)
if err != nil {
return err
}
defer cancel()
stmt := pgtable.RuntimeRecords.DELETE().
WHERE(pgtable.RuntimeRecords.GameID.EQ(pg.String(gameID)))
query, args := stmt.Sql()
if _, err := store.db.ExecContext(operationCtx, query, args...); err != nil {
return fmt.Errorf("delete runtime record: %w", err)
}
return nil
}
// ListDueRunning returns every record whose status is `running` and
// whose `next_generation_at <= now`. The order is
// (next_generation_at ASC, game_id ASC), matching the
// `runtime_records_status_next_gen_idx` direction.
func (store *Store) ListDueRunning(ctx context.Context, now time.Time) ([]runtime.RuntimeRecord, error) {
if store == nil || store.db == nil {
return nil, errors.New("list due runtime records: nil store")
}
if now.IsZero() {
return nil, fmt.Errorf("list due runtime records: now must not be zero")
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "list due runtime records", store.operationTimeout)
if err != nil {
return nil, err
}
defer cancel()
cutoff := pg.TimestampzT(now.UTC())
stmt := pg.SELECT(runtimeSelectColumns).
FROM(pgtable.RuntimeRecords).
WHERE(pg.AND(
pgtable.RuntimeRecords.Status.EQ(pg.String(string(runtime.StatusRunning))),
pgtable.RuntimeRecords.NextGenerationAt.LT_EQ(cutoff),
)).
ORDER_BY(
pgtable.RuntimeRecords.NextGenerationAt.ASC(),
pgtable.RuntimeRecords.GameID.ASC(),
)
return store.queryRecords(operationCtx, stmt, "list due runtime records")
}
// List returns every record in the store, ordered by `created_at`
// descending and by `game_id` ascending as a tie-breaker. Used by the
// `internalListRuntimes` REST handler when no status filter is
// supplied.
func (store *Store) List(ctx context.Context) ([]runtime.RuntimeRecord, error) {
if store == nil || store.db == nil {
return nil, errors.New("list runtime records: nil store")
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "list runtime records", store.operationTimeout)
if err != nil {
return nil, err
}
defer cancel()
stmt := pg.SELECT(runtimeSelectColumns).
FROM(pgtable.RuntimeRecords).
ORDER_BY(
pgtable.RuntimeRecords.CreatedAt.DESC(),
pgtable.RuntimeRecords.GameID.ASC(),
)
return store.queryRecords(operationCtx, stmt, "list runtime records")
}
// ListByStatus returns every record currently indexed under status,
// ordered by game_id ASC.
func (store *Store) ListByStatus(ctx context.Context, status runtime.Status) ([]runtime.RuntimeRecord, error) {
if store == nil || store.db == nil {
return nil, errors.New("list runtime records by status: nil store")
}
if !status.IsKnown() {
return nil, fmt.Errorf("list runtime records by status: status %q is unsupported", status)
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "list runtime records by status", store.operationTimeout)
if err != nil {
return nil, err
}
defer cancel()
stmt := pg.SELECT(runtimeSelectColumns).
FROM(pgtable.RuntimeRecords).
WHERE(pgtable.RuntimeRecords.Status.EQ(pg.String(string(status)))).
ORDER_BY(pgtable.RuntimeRecords.GameID.ASC())
return store.queryRecords(operationCtx, stmt, "list runtime records by status")
}
// queryRecords runs a SELECT statement and scans every returned row
// into a runtime.RuntimeRecord slice. opName is used only to prefix
// error messages.
func (store *Store) queryRecords(ctx context.Context, stmt pg.SelectStatement, opName string) ([]runtime.RuntimeRecord, error) {
query, args := stmt.Sql()
rows, err := store.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("%s: %w", opName, err)
}
defer rows.Close()
records := make([]runtime.RuntimeRecord, 0)
for rows.Next() {
record, err := scanRecord(rows)
if err != nil {
return nil, fmt.Errorf("%s: scan: %w", opName, err)
}
records = append(records, record)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("%s: %w", opName, err)
}
if len(records) == 0 {
return nil, nil
}
return records, nil
}
// rowScanner abstracts *sql.Row and *sql.Rows so scanRecord can be
// shared across both single-row and iterated reads.
type rowScanner interface {
Scan(dest ...any) error
}
// scanRecord scans one runtime_records row from rs. Returns
// sql.ErrNoRows verbatim so callers can distinguish "no row" from a
// hard error.
func scanRecord(rs rowScanner) (runtime.RuntimeRecord, error) {
var (
gameID string
status string
engineEndpoint string
currentImageRef string
currentEngineVersion string
turnSchedule string
currentTurn int32
nextGenerationAt sql.NullTime
skipNextTick bool
engineHealth string
createdAt time.Time
updatedAt time.Time
startedAt sql.NullTime
stoppedAt sql.NullTime
finishedAt sql.NullTime
)
if err := rs.Scan(
&gameID,
&status,
&engineEndpoint,
&currentImageRef,
&currentEngineVersion,
&turnSchedule,
&currentTurn,
&nextGenerationAt,
&skipNextTick,
&engineHealth,
&createdAt,
&updatedAt,
&startedAt,
&stoppedAt,
&finishedAt,
); err != nil {
return runtime.RuntimeRecord{}, err
}
return runtime.RuntimeRecord{
GameID: gameID,
Status: runtime.Status(status),
EngineEndpoint: engineEndpoint,
CurrentImageRef: currentImageRef,
CurrentEngineVersion: currentEngineVersion,
TurnSchedule: turnSchedule,
CurrentTurn: int(currentTurn),
NextGenerationAt: sqlx.TimePtrFromNullable(nextGenerationAt),
SkipNextTick: skipNextTick,
EngineHealth: engineHealth,
CreatedAt: createdAt.UTC(),
UpdatedAt: updatedAt.UTC(),
StartedAt: sqlx.TimePtrFromNullable(startedAt),
StoppedAt: sqlx.TimePtrFromNullable(stoppedAt),
FinishedAt: sqlx.TimePtrFromNullable(finishedAt),
}, nil
}
// Ensure Store satisfies the ports.RuntimeRecordStore interface at
// compile time.
var _ ports.RuntimeRecordStore = (*Store)(nil)
@@ -0,0 +1,718 @@
package runtimerecordstore_test
import (
"context"
"errors"
"sync"
"testing"
"time"
"galaxy/gamemaster/internal/adapters/postgres/internal/pgtest"
"galaxy/gamemaster/internal/adapters/postgres/runtimerecordstore"
"galaxy/gamemaster/internal/domain/runtime"
"galaxy/gamemaster/internal/ports"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMain(m *testing.M) { pgtest.RunMain(m) }
func newStore(t *testing.T) *runtimerecordstore.Store {
t.Helper()
pgtest.TruncateAll(t)
store, err := runtimerecordstore.New(runtimerecordstore.Config{
DB: pgtest.Ensure(t).Pool(),
OperationTimeout: pgtest.OperationTimeout,
})
require.NoError(t, err)
return store
}
func startingRecord(gameID string, createdAt time.Time) runtime.RuntimeRecord {
return runtime.RuntimeRecord{
GameID: gameID,
Status: runtime.StatusStarting,
EngineEndpoint: "http://galaxy-game-" + gameID + ":8080",
CurrentImageRef: "ghcr.io/galaxy/game:v1.2.3",
CurrentEngineVersion: "v1.2.3",
TurnSchedule: "0 18 * * *",
CurrentTurn: 0,
EngineHealth: "",
CreatedAt: createdAt,
UpdatedAt: createdAt,
}
}
func runningRecord(gameID string, createdAt time.Time, nextGen time.Time) runtime.RuntimeRecord {
startedAt := createdAt.Add(time.Second)
return runtime.RuntimeRecord{
GameID: gameID,
Status: runtime.StatusRunning,
EngineEndpoint: "http://galaxy-game-" + gameID + ":8080",
CurrentImageRef: "ghcr.io/galaxy/game:v1.2.3",
CurrentEngineVersion: "v1.2.3",
TurnSchedule: "0 18 * * *",
CurrentTurn: 1,
NextGenerationAt: &nextGen,
EngineHealth: "healthy",
CreatedAt: createdAt,
UpdatedAt: startedAt,
StartedAt: &startedAt,
}
}
func TestNewRejectsInvalidConfig(t *testing.T) {
_, err := runtimerecordstore.New(runtimerecordstore.Config{})
require.Error(t, err)
store, err := runtimerecordstore.New(runtimerecordstore.Config{
DB: pgtest.Ensure(t).Pool(),
OperationTimeout: 0,
})
require.Error(t, err)
require.Nil(t, store)
}
func TestInsertGetRoundTrip(t *testing.T) {
ctx := context.Background()
store := newStore(t)
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
record := startingRecord("game-001", now)
require.NoError(t, store.Insert(ctx, record))
got, err := store.Get(ctx, record.GameID)
require.NoError(t, err)
assert.Equal(t, record.GameID, got.GameID)
assert.Equal(t, runtime.StatusStarting, got.Status)
assert.Equal(t, record.EngineEndpoint, got.EngineEndpoint)
assert.Equal(t, record.CurrentImageRef, got.CurrentImageRef)
assert.Equal(t, record.CurrentEngineVersion, got.CurrentEngineVersion)
assert.Equal(t, record.TurnSchedule, got.TurnSchedule)
assert.Equal(t, 0, got.CurrentTurn)
assert.Nil(t, got.NextGenerationAt)
assert.False(t, got.SkipNextTick)
assert.Equal(t, "", got.EngineHealth)
assert.True(t, got.CreatedAt.Equal(now), "created_at: want %v, got %v", now, got.CreatedAt)
assert.Equal(t, time.UTC, got.CreatedAt.Location())
assert.True(t, got.UpdatedAt.Equal(now))
assert.Equal(t, time.UTC, got.UpdatedAt.Location())
assert.Nil(t, got.StartedAt)
assert.Nil(t, got.StoppedAt)
assert.Nil(t, got.FinishedAt)
}
func TestInsertRejectsDuplicate(t *testing.T) {
ctx := context.Background()
store := newStore(t)
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
record := startingRecord("game-001", now)
require.NoError(t, store.Insert(ctx, record))
err := store.Insert(ctx, record)
require.Error(t, err)
require.True(t, errors.Is(err, runtime.ErrConflict), "want ErrConflict, got %v", err)
}
func TestInsertRejectsInvalidRecord(t *testing.T) {
ctx := context.Background()
store := newStore(t)
bad := runtime.RuntimeRecord{} // empty
err := store.Insert(ctx, bad)
require.Error(t, err)
require.False(t, errors.Is(err, runtime.ErrConflict))
}
func TestGetReturnsErrNotFound(t *testing.T) {
ctx := context.Background()
store := newStore(t)
_, err := store.Get(ctx, "missing")
require.Error(t, err)
require.True(t, errors.Is(err, runtime.ErrNotFound))
}
func TestUpdateStatusStartingToRunningSetsStartedAt(t *testing.T) {
ctx := context.Background()
store := newStore(t)
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
require.NoError(t, store.Insert(ctx, startingRecord("game-001", created)))
now := created.Add(2 * time.Second)
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
GameID: "game-001",
ExpectedFrom: runtime.StatusStarting,
To: runtime.StatusRunning,
Now: now,
}))
got, err := store.Get(ctx, "game-001")
require.NoError(t, err)
assert.Equal(t, runtime.StatusRunning, got.Status)
require.NotNil(t, got.StartedAt)
assert.True(t, got.StartedAt.Equal(now))
assert.True(t, got.UpdatedAt.Equal(now))
assert.Nil(t, got.StoppedAt)
assert.Nil(t, got.FinishedAt)
}
func TestUpdateStatusToFinishedSetsFinishedAt(t *testing.T) {
ctx := context.Background()
store := newStore(t)
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
nextGen := created.Add(time.Hour)
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
GameID: "game-001",
ExpectedFrom: runtime.StatusRunning,
To: runtime.StatusGenerationInProgress,
Now: created.Add(2 * time.Second),
}))
finishAt := created.Add(time.Hour)
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
GameID: "game-001",
ExpectedFrom: runtime.StatusGenerationInProgress,
To: runtime.StatusFinished,
Now: finishAt,
}))
got, err := store.Get(ctx, "game-001")
require.NoError(t, err)
assert.Equal(t, runtime.StatusFinished, got.Status)
require.NotNil(t, got.FinishedAt)
assert.True(t, got.FinishedAt.Equal(finishAt))
assert.True(t, got.UpdatedAt.Equal(finishAt))
}
func TestUpdateStatusToStoppedSetsStoppedAt(t *testing.T) {
ctx := context.Background()
store := newStore(t)
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
nextGen := created.Add(time.Hour)
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
stopAt := created.Add(2 * time.Hour)
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
GameID: "game-001",
ExpectedFrom: runtime.StatusRunning,
To: runtime.StatusStopped,
Now: stopAt,
}))
got, err := store.Get(ctx, "game-001")
require.NoError(t, err)
assert.Equal(t, runtime.StatusStopped, got.Status)
require.NotNil(t, got.StoppedAt)
assert.True(t, got.StoppedAt.Equal(stopAt))
require.NotNil(t, got.StartedAt, "started_at must remain set after stop")
assert.Nil(t, got.FinishedAt)
}
func TestUpdateStatusEngineUnreachableRecoveryKeepsStartedAt(t *testing.T) {
ctx := context.Background()
store := newStore(t)
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
nextGen := created.Add(time.Hour)
original := runningRecord("game-001", created, nextGen)
require.NoError(t, store.Insert(ctx, original))
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
GameID: "game-001",
ExpectedFrom: runtime.StatusRunning,
To: runtime.StatusEngineUnreachable,
Now: created.Add(time.Minute),
}))
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
GameID: "game-001",
ExpectedFrom: runtime.StatusEngineUnreachable,
To: runtime.StatusRunning,
Now: created.Add(2 * time.Minute),
}))
got, err := store.Get(ctx, "game-001")
require.NoError(t, err)
assert.Equal(t, runtime.StatusRunning, got.Status)
require.NotNil(t, got.StartedAt)
assert.True(t, got.StartedAt.Equal(*original.StartedAt),
"recovery transition must not overwrite started_at")
}
func TestUpdateStatusOptionalFields(t *testing.T) {
ctx := context.Background()
store := newStore(t)
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
nextGen := created.Add(time.Hour)
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
healthy := "engine_unreachable_summary"
imageRef := "ghcr.io/galaxy/game:v1.2.4"
engineVersion := "v1.2.4"
now := created.Add(time.Minute)
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
GameID: "game-001",
ExpectedFrom: runtime.StatusRunning,
To: runtime.StatusGenerationInProgress,
Now: now,
EngineHealthSummary: &healthy,
CurrentImageRef: &imageRef,
CurrentEngineVersion: &engineVersion,
}))
got, err := store.Get(ctx, "game-001")
require.NoError(t, err)
assert.Equal(t, runtime.StatusGenerationInProgress, got.Status)
assert.Equal(t, healthy, got.EngineHealth)
assert.Equal(t, imageRef, got.CurrentImageRef)
assert.Equal(t, engineVersion, got.CurrentEngineVersion)
}
func TestUpdateStatusOnMissingReturnsNotFound(t *testing.T) {
ctx := context.Background()
store := newStore(t)
err := store.UpdateStatus(ctx, ports.UpdateStatusInput{
GameID: "ghost",
ExpectedFrom: runtime.StatusRunning,
To: runtime.StatusStopped,
Now: time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC),
})
require.Error(t, err)
require.True(t, errors.Is(err, runtime.ErrNotFound), "want ErrNotFound, got %v", err)
}
func TestUpdateStatusStaleCASReturnsConflict(t *testing.T) {
ctx := context.Background()
store := newStore(t)
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
require.NoError(t, store.Insert(ctx, startingRecord("game-001", created)))
err := store.UpdateStatus(ctx, ports.UpdateStatusInput{
GameID: "game-001",
ExpectedFrom: runtime.StatusRunning,
To: runtime.StatusStopped,
Now: created.Add(time.Second),
})
require.Error(t, err)
require.True(t, errors.Is(err, runtime.ErrConflict), "want ErrConflict, got %v", err)
}
func TestUpdateStatusConcurrentCAS(t *testing.T) {
ctx := context.Background()
store := newStore(t)
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
nextGen := created.Add(time.Hour)
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
const concurrency = 8
results := make([]error, concurrency)
var wg sync.WaitGroup
wg.Add(concurrency)
for index := range concurrency {
go func() {
defer wg.Done()
results[index] = store.UpdateStatus(ctx, ports.UpdateStatusInput{
GameID: "game-001",
ExpectedFrom: runtime.StatusRunning,
To: runtime.StatusStopped,
Now: created.Add(time.Duration(index+1) * time.Second),
})
}()
}
wg.Wait()
wins, conflicts := 0, 0
for _, err := range results {
switch {
case err == nil:
wins++
case errors.Is(err, runtime.ErrConflict):
conflicts++
default:
t.Errorf("unexpected error: %v", err)
}
}
assert.Equal(t, 1, wins, "exactly one caller must win the CAS race")
assert.Equal(t, concurrency-1, conflicts, "the rest must observe runtime.ErrConflict")
}
func TestUpdateImageHappy(t *testing.T) {
ctx := context.Background()
store := newStore(t)
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
nextGen := created.Add(time.Hour)
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
now := nextGen.Add(time.Second)
require.NoError(t, store.UpdateImage(ctx, ports.UpdateImageInput{
GameID: "game-001",
ExpectedStatus: runtime.StatusRunning,
CurrentImageRef: "ghcr.io/galaxy/game:v1.2.4",
CurrentEngineVersion: "v1.2.4",
Now: now,
}))
got, err := store.Get(ctx, "game-001")
require.NoError(t, err)
assert.Equal(t, runtime.StatusRunning, got.Status, "patch must not change status")
assert.Equal(t, "ghcr.io/galaxy/game:v1.2.4", got.CurrentImageRef)
assert.Equal(t, "v1.2.4", got.CurrentEngineVersion)
assert.True(t, got.UpdatedAt.Equal(now))
require.NotNil(t, got.NextGenerationAt, "next_generation_at must remain untouched")
assert.True(t, got.NextGenerationAt.Equal(nextGen))
assert.Equal(t, 1, got.CurrentTurn, "current_turn must remain untouched")
}
func TestUpdateImageStaleStatusReturnsConflict(t *testing.T) {
ctx := context.Background()
store := newStore(t)
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
require.NoError(t, store.Insert(ctx, startingRecord("game-001", created)))
err := store.UpdateImage(ctx, ports.UpdateImageInput{
GameID: "game-001",
ExpectedStatus: runtime.StatusRunning,
CurrentImageRef: "ghcr.io/galaxy/game:v1.2.4",
CurrentEngineVersion: "v1.2.4",
Now: created.Add(time.Second),
})
require.Error(t, err)
require.True(t, errors.Is(err, runtime.ErrConflict), "want ErrConflict, got %v", err)
}
func TestUpdateImageOnMissingReturnsNotFound(t *testing.T) {
ctx := context.Background()
store := newStore(t)
err := store.UpdateImage(ctx, ports.UpdateImageInput{
GameID: "ghost",
ExpectedStatus: runtime.StatusRunning,
CurrentImageRef: "ghcr.io/galaxy/game:v1.2.4",
CurrentEngineVersion: "v1.2.4",
Now: time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC),
})
require.Error(t, err)
require.True(t, errors.Is(err, runtime.ErrNotFound), "want ErrNotFound, got %v", err)
}
func TestUpdateImageRejectsInvalidInput(t *testing.T) {
ctx := context.Background()
store := newStore(t)
err := store.UpdateImage(ctx, ports.UpdateImageInput{
GameID: "",
ExpectedStatus: runtime.StatusRunning,
CurrentImageRef: "ghcr.io/galaxy/game:v1.2.4",
CurrentEngineVersion: "v1.2.4",
Now: time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC),
})
require.Error(t, err)
require.False(t, errors.Is(err, runtime.ErrConflict))
require.False(t, errors.Is(err, runtime.ErrNotFound))
}
func TestUpdateEngineHealthHappy(t *testing.T) {
ctx := context.Background()
store := newStore(t)
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
nextGen := created.Add(time.Hour)
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
now := nextGen.Add(2 * time.Second)
require.NoError(t, store.UpdateEngineHealth(ctx, ports.UpdateEngineHealthInput{
GameID: "game-001",
EngineHealthSummary: "probe_failed",
Now: now,
}))
got, err := store.Get(ctx, "game-001")
require.NoError(t, err)
assert.Equal(t, runtime.StatusRunning, got.Status, "engine health update must not change status")
assert.Equal(t, "probe_failed", got.EngineHealth)
assert.True(t, got.UpdatedAt.Equal(now))
require.NotNil(t, got.NextGenerationAt, "next_generation_at must remain untouched")
assert.True(t, got.NextGenerationAt.Equal(nextGen))
assert.Equal(t, 1, got.CurrentTurn, "current_turn must remain untouched")
}
func TestUpdateEngineHealthAcceptsEmptySummary(t *testing.T) {
ctx := context.Background()
store := newStore(t)
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
nextGen := created.Add(time.Hour)
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
now := nextGen.Add(time.Second)
require.NoError(t, store.UpdateEngineHealth(ctx, ports.UpdateEngineHealthInput{
GameID: "game-001",
EngineHealthSummary: "",
Now: now,
}))
got, err := store.Get(ctx, "game-001")
require.NoError(t, err)
assert.Equal(t, "", got.EngineHealth)
}
func TestUpdateEngineHealthOnMissingReturnsNotFound(t *testing.T) {
ctx := context.Background()
store := newStore(t)
err := store.UpdateEngineHealth(ctx, ports.UpdateEngineHealthInput{
GameID: "ghost",
EngineHealthSummary: "exited",
Now: time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC),
})
require.Error(t, err)
require.True(t, errors.Is(err, runtime.ErrNotFound), "want ErrNotFound, got %v", err)
}
func TestUpdateEngineHealthRejectsInvalidInput(t *testing.T) {
ctx := context.Background()
store := newStore(t)
err := store.UpdateEngineHealth(ctx, ports.UpdateEngineHealthInput{
GameID: "",
EngineHealthSummary: "healthy",
Now: time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC),
})
require.Error(t, err)
require.False(t, errors.Is(err, runtime.ErrConflict))
require.False(t, errors.Is(err, runtime.ErrNotFound))
}
func TestUpdateEngineHealthAppliesFromAnyStatus(t *testing.T) {
ctx := context.Background()
store := newStore(t)
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
require.NoError(t, store.Insert(ctx, startingRecord("game-001", created)))
now := created.Add(time.Second)
require.NoError(t, store.UpdateEngineHealth(ctx, ports.UpdateEngineHealthInput{
GameID: "game-001",
EngineHealthSummary: "exited",
Now: now,
}))
got, err := store.Get(ctx, "game-001")
require.NoError(t, err)
assert.Equal(t, runtime.StatusStarting, got.Status, "no status mutation expected")
assert.Equal(t, "exited", got.EngineHealth)
}
func TestUpdateSchedulingHappy(t *testing.T) {
ctx := context.Background()
store := newStore(t)
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
nextGen := created.Add(time.Hour)
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
updated := nextGen.Add(time.Hour)
now := nextGen.Add(time.Second)
require.NoError(t, store.UpdateScheduling(ctx, ports.UpdateSchedulingInput{
GameID: "game-001",
NextGenerationAt: &updated,
SkipNextTick: true,
CurrentTurn: 5,
Now: now,
}))
got, err := store.Get(ctx, "game-001")
require.NoError(t, err)
require.NotNil(t, got.NextGenerationAt)
assert.True(t, got.NextGenerationAt.Equal(updated))
assert.True(t, got.SkipNextTick)
assert.Equal(t, 5, got.CurrentTurn)
assert.True(t, got.UpdatedAt.Equal(now))
}
func TestUpdateSchedulingClearsNextGen(t *testing.T) {
ctx := context.Background()
store := newStore(t)
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
nextGen := created.Add(time.Hour)
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
now := nextGen.Add(time.Second)
require.NoError(t, store.UpdateScheduling(ctx, ports.UpdateSchedulingInput{
GameID: "game-001",
NextGenerationAt: nil,
SkipNextTick: false,
CurrentTurn: 0,
Now: now,
}))
got, err := store.Get(ctx, "game-001")
require.NoError(t, err)
assert.Nil(t, got.NextGenerationAt)
assert.False(t, got.SkipNextTick)
}
func TestUpdateSchedulingOnMissingReturnsNotFound(t *testing.T) {
ctx := context.Background()
store := newStore(t)
err := store.UpdateScheduling(ctx, ports.UpdateSchedulingInput{
GameID: "ghost",
CurrentTurn: 0,
Now: time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC),
})
require.Error(t, err)
require.True(t, errors.Is(err, runtime.ErrNotFound))
}
func TestListDueRunning(t *testing.T) {
ctx := context.Background()
store := newStore(t)
createdEarlier := time.Date(2026, time.April, 27, 10, 0, 0, 0, time.UTC)
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
due := created.Add(-time.Minute) // due before now
future := created.Add(time.Hour) // not due yet
dueRecord := runningRecord("game-due", created, due)
require.NoError(t, store.Insert(ctx, dueRecord))
futureRecord := runningRecord("game-future", created, future)
require.NoError(t, store.Insert(ctx, futureRecord))
// A stopped record whose next_generation_at is in the past must
// still be excluded by the running-status filter.
stoppedRecord := startingRecord("game-stopped", createdEarlier)
stoppedRecord.Status = runtime.StatusStopped
startedAt := createdEarlier.Add(time.Second)
stoppedAt := createdEarlier.Add(time.Minute)
stoppedRecord.StartedAt = &startedAt
stoppedRecord.StoppedAt = &stoppedAt
stoppedRecord.UpdatedAt = stoppedAt
stalePast := created.Add(-30 * time.Minute)
stoppedRecord.NextGenerationAt = &stalePast
require.NoError(t, store.Insert(ctx, stoppedRecord))
results, err := store.ListDueRunning(ctx, created)
require.NoError(t, err)
require.Len(t, results, 1)
assert.Equal(t, "game-due", results[0].GameID)
}
func TestListByStatus(t *testing.T) {
ctx := context.Background()
store := newStore(t)
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
require.NoError(t, store.Insert(ctx, runningRecord("game-r1", created, created.Add(time.Hour))))
require.NoError(t, store.Insert(ctx, runningRecord("game-r2", created, created.Add(time.Hour))))
require.NoError(t, store.Insert(ctx, startingRecord("game-s1", created)))
running, err := store.ListByStatus(ctx, runtime.StatusRunning)
require.NoError(t, err)
require.Len(t, running, 2)
assert.Equal(t, "game-r1", running[0].GameID)
assert.Equal(t, "game-r2", running[1].GameID)
starting, err := store.ListByStatus(ctx, runtime.StatusStarting)
require.NoError(t, err)
require.Len(t, starting, 1)
assert.Equal(t, "game-s1", starting[0].GameID)
finished, err := store.ListByStatus(ctx, runtime.StatusFinished)
require.NoError(t, err)
assert.Empty(t, finished)
}
func TestListReturnsEveryRecordOrderedByCreatedAtDesc(t *testing.T) {
ctx := context.Background()
store := newStore(t)
earliest := time.Date(2026, time.April, 27, 10, 0, 0, 0, time.UTC)
middle := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
latest := time.Date(2026, time.April, 27, 14, 0, 0, 0, time.UTC)
require.NoError(t, store.Insert(ctx, startingRecord("game-earliest", earliest)))
require.NoError(t, store.Insert(ctx, runningRecord("game-middle", middle, middle.Add(time.Hour))))
require.NoError(t, store.Insert(ctx, runningRecord("game-latest", latest, latest.Add(time.Hour))))
records, err := store.List(ctx)
require.NoError(t, err)
require.Len(t, records, 3)
assert.Equal(t, "game-latest", records[0].GameID)
assert.Equal(t, "game-middle", records[1].GameID)
assert.Equal(t, "game-earliest", records[2].GameID)
}
func TestListReturnsEmptySliceWhenStoreIsEmpty(t *testing.T) {
ctx := context.Background()
store := newStore(t)
records, err := store.List(ctx)
require.NoError(t, err)
assert.Empty(t, records)
}
func TestListByStatusUnknownRejected(t *testing.T) {
ctx := context.Background()
store := newStore(t)
_, err := store.ListByStatus(ctx, runtime.Status("exotic"))
require.Error(t, err)
}
func TestListDueRunningRejectsZeroNow(t *testing.T) {
ctx := context.Background()
store := newStore(t)
_, err := store.ListDueRunning(ctx, time.Time{})
require.Error(t, err)
}
func TestGetRejectsEmptyGameID(t *testing.T) {
ctx := context.Background()
store := newStore(t)
_, err := store.Get(ctx, "")
require.Error(t, err)
}
func TestDeleteIdempotent(t *testing.T) {
ctx := context.Background()
store := newStore(t)
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
require.NoError(t, store.Insert(ctx, startingRecord("game-001", now)))
require.NoError(t, store.Delete(ctx, "game-001"))
_, err := store.Get(ctx, "game-001")
require.ErrorIs(t, err, runtime.ErrNotFound)
// Second call must be a no-op.
require.NoError(t, store.Delete(ctx, "game-001"))
}
func TestDeleteRejectsEmptyGameID(t *testing.T) {
ctx := context.Background()
store := newStore(t)
require.Error(t, store.Delete(ctx, ""))
}
@@ -0,0 +1,38 @@
// Package redisstate hosts the Game Master Redis adapters that share a
// single keyspace. The sole sibling subpackage in v1 is
// `streamoffsets` (the per-consumer offset for the
// runtime:health_events stream); membership cache lives in process and
// does not touch Redis.
//
// The package itself only declares the keyspace; concrete stores live
// in nested packages so dependencies (miniredis, testcontainers) stay
// out of consumer build graphs that do not need them.
package redisstate
import "encoding/base64"
// defaultPrefix is the mandatory `gamemaster:` namespace prefix shared
// by every Game Master Redis key.
const defaultPrefix = "gamemaster:"
// Keyspace builds the Game Master Redis keys. The namespace covers
// stream consumer offsets in v1.
//
// Dynamic key segments are encoded with base64url so raw key structure
// does not depend on caller-provided characters; this matches the
// encoding chosen by `lobby/internal/adapters/redisstate.Keyspace` and
// `rtmanager/internal/adapters/redisstate.Keyspace`.
type Keyspace struct{}
// StreamOffset returns the Redis key that stores the last successfully
// processed entry id for one Redis Stream consumer. The streamLabel 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.
func (Keyspace) StreamOffset(streamLabel string) string {
return defaultPrefix + "stream_offsets:" + encodeKeyComponent(streamLabel)
}
func encodeKeyComponent(value string) string {
return base64.RawURLEncoding.EncodeToString([]byte(value))
}
@@ -0,0 +1,94 @@
// Package streamoffsets implements the Redis-backed adapter for
// `ports.StreamOffsetStore`.
//
// In v1 the only consumer that calls Load/Save is the
// runtime:health_events worker (PLAN stage 18). Keys are produced by
// `redisstate.Keyspace.StreamOffset`, mirroring the lobby and rtmanager
// patterns.
package streamoffsets
import (
"context"
"errors"
"fmt"
"strings"
"galaxy/gamemaster/internal/adapters/redisstate"
"galaxy/gamemaster/internal/ports"
"github.com/redis/go-redis/v9"
)
// Config configures one Redis-backed stream-offset store. The store
// does not own the redis client lifecycle; the caller (typically the
// service runtime) opens and closes it.
type Config struct {
Client *redis.Client
}
// Store persists Game Master stream consumer offsets in Redis.
type Store struct {
client *redis.Client
keys redisstate.Keyspace
}
// New constructs one Redis-backed stream-offset store from cfg.
func New(cfg Config) (*Store, error) {
if cfg.Client == nil {
return nil, errors.New("new gamemaster stream offset store: nil redis client")
}
return &Store{
client: cfg.Client,
keys: redisstate.Keyspace{},
}, nil
}
// Load returns the last processed entry id for streamLabel when one
// is stored. A missing key returns ("", false, nil).
func (store *Store) Load(ctx context.Context, streamLabel string) (string, bool, error) {
if store == nil || store.client == nil {
return "", false, errors.New("load gamemaster stream offset: nil store")
}
if ctx == nil {
return "", false, errors.New("load gamemaster stream offset: nil context")
}
if strings.TrimSpace(streamLabel) == "" {
return "", false, errors.New("load gamemaster stream offset: stream label must not be empty")
}
value, err := store.client.Get(ctx, store.keys.StreamOffset(streamLabel)).Result()
switch {
case errors.Is(err, redis.Nil):
return "", false, nil
case err != nil:
return "", false, fmt.Errorf("load gamemaster stream offset: %w", err)
}
return value, true, nil
}
// Save stores entryID as the new offset for streamLabel. The key has
// no TTL — offsets are durable and only overwritten by subsequent
// Saves.
func (store *Store) Save(ctx context.Context, streamLabel, entryID string) error {
if store == nil || store.client == nil {
return errors.New("save gamemaster stream offset: nil store")
}
if ctx == nil {
return errors.New("save gamemaster stream offset: nil context")
}
if strings.TrimSpace(streamLabel) == "" {
return errors.New("save gamemaster stream offset: stream label must not be empty")
}
if strings.TrimSpace(entryID) == "" {
return errors.New("save gamemaster stream offset: entry id must not be empty")
}
if err := store.client.Set(ctx, store.keys.StreamOffset(streamLabel), entryID, 0).Err(); err != nil {
return fmt.Errorf("save gamemaster stream offset: %w", err)
}
return nil
}
// Ensure Store satisfies the ports.StreamOffsetStore interface at
// compile time.
var _ ports.StreamOffsetStore = (*Store)(nil)
@@ -0,0 +1,93 @@
package streamoffsets_test
import (
"context"
"testing"
"galaxy/gamemaster/internal/adapters/redisstate"
"galaxy/gamemaster/internal/adapters/redisstate/streamoffsets"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newOffsetStore(t *testing.T) (*streamoffsets.Store, *miniredis.Miniredis) {
t.Helper()
server := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() { _ = client.Close() })
store, err := streamoffsets.New(streamoffsets.Config{Client: client})
require.NoError(t, err)
return store, server
}
func TestNewRejectsNilClient(t *testing.T) {
_, err := streamoffsets.New(streamoffsets.Config{})
require.Error(t, err)
}
func TestLoadMissingReturnsNotFound(t *testing.T) {
store, _ := newOffsetStore(t)
id, found, err := store.Load(context.Background(), "health_events")
require.NoError(t, err)
assert.False(t, found)
assert.Empty(t, id)
}
func TestSaveLoadRoundTrip(t *testing.T) {
store, server := newOffsetStore(t)
const entryID = "1700000000000-0"
require.NoError(t, store.Save(context.Background(), "health_events", entryID))
id, found, err := store.Load(context.Background(), "health_events")
require.NoError(t, err)
assert.True(t, found)
assert.Equal(t, entryID, id)
// Verify the namespace prefix lands as expected.
expectedKey := redisstate.Keyspace{}.StreamOffset("health_events")
assert.True(t, server.Exists(expectedKey),
"key %q must exist after Save", expectedKey)
}
func TestSaveOverwritesPreviousValue(t *testing.T) {
store, _ := newOffsetStore(t)
require.NoError(t, store.Save(context.Background(), "health_events", "1-0"))
require.NoError(t, store.Save(context.Background(), "health_events", "2-0"))
id, found, err := store.Load(context.Background(), "health_events")
require.NoError(t, err)
assert.True(t, found)
assert.Equal(t, "2-0", id)
}
func TestSaveRejectsBadInputs(t *testing.T) {
store, _ := newOffsetStore(t)
require.Error(t, store.Save(context.Background(), "", "1-0"))
require.Error(t, store.Save(context.Background(), "health_events", ""))
//nolint:staticcheck // intentional nil ctx test
require.Error(t, store.Save(nil, "health_events", "1-0"))
}
func TestLoadRejectsBadInputs(t *testing.T) {
store, _ := newOffsetStore(t)
_, _, err := store.Load(context.Background(), "")
require.Error(t, err)
//nolint:staticcheck // intentional nil ctx test
_, _, err = store.Load(nil, "health_events")
require.Error(t, err)
}
func TestNilStoreOperationsRejected(t *testing.T) {
var store *streamoffsets.Store
_, _, err := store.Load(context.Background(), "health_events")
require.Error(t, err)
require.Error(t, store.Save(context.Background(), "health_events", "1-0"))
}
@@ -0,0 +1,225 @@
// Package rtmclient provides the trusted-internal Runtime Manager
// REST client Game Master uses for synchronous lifecycle operations
// against an already-running container. Two routes are mounted:
//
// - POST /api/v1/internal/runtimes/{game_id}/stop
// - POST /api/v1/internal/runtimes/{game_id}/patch
//
// `Restart` is reserved per `gamemaster/PLAN.md` Stage 10 and is not
// part of the v1 surface.
package rtmclient
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"galaxy/gamemaster/internal/ports"
)
const (
stopPathTemplate = "/api/v1/internal/runtimes/%s/stop"
patchPathTemplate = "/api/v1/internal/runtimes/%s/patch"
)
// Config configures one HTTP-backed Runtime Manager internal client.
type Config struct {
// BaseURL stores the absolute base URL of the Runtime Manager
// internal HTTP listener (e.g. `http://rtmanager:8096`).
BaseURL string
// RequestTimeout bounds one outbound stop/patch request.
RequestTimeout time.Duration
}
// Client speaks REST/JSON to the Runtime Manager internal API.
type Client struct {
baseURL string
requestTimeout time.Duration
httpClient *http.Client
closeIdleConnections func()
}
type stopRequestEnvelope struct {
Reason string `json:"reason"`
}
type patchRequestEnvelope struct {
ImageRef string `json:"image_ref"`
}
type errorEnvelope struct {
Error *errorBody `json:"error"`
}
type errorBody struct {
Code string `json:"code"`
Message string `json:"message"`
}
// NewClient constructs an RTM internal client with otelhttp-wrapped
// transport cloned from `http.DefaultTransport`. Call `Close` to
// release idle connections at shutdown.
func NewClient(cfg Config) (*Client, error) {
transport, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return nil, errors.New("new rtm client: default transport is not *http.Transport")
}
cloned := transport.Clone()
return newClient(cfg, &http.Client{Transport: otelhttp.NewTransport(cloned)}, cloned.CloseIdleConnections)
}
func newClient(cfg Config, httpClient *http.Client, closeIdleConnections func()) (*Client, error) {
switch {
case strings.TrimSpace(cfg.BaseURL) == "":
return nil, errors.New("new rtm client: base url must not be empty")
case cfg.RequestTimeout <= 0:
return nil, errors.New("new rtm client: request timeout must be positive")
case httpClient == nil:
return nil, errors.New("new rtm client: http client must not be nil")
}
parsed, err := url.Parse(strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/"))
if err != nil {
return nil, fmt.Errorf("new rtm client: parse base url: %w", err)
}
if parsed.Scheme == "" || parsed.Host == "" {
return nil, errors.New("new rtm client: base url must be absolute")
}
return &Client{
baseURL: parsed.String(),
requestTimeout: cfg.RequestTimeout,
httpClient: httpClient,
closeIdleConnections: closeIdleConnections,
}, nil
}
// Close releases idle HTTP connections owned by the underlying
// transport. Safe to call multiple times.
func (client *Client) Close() error {
if client == nil || client.closeIdleConnections == nil {
return nil
}
client.closeIdleConnections()
return nil
}
// Stop calls POST /api/v1/internal/runtimes/{game_id}/stop with body
// `{reason}`. Any non-success outcome is wrapped with
// `ports.ErrRTMUnavailable`.
func (client *Client) Stop(ctx context.Context, gameID, reason string) error {
if err := client.validate(ctx, gameID); err != nil {
return err
}
if strings.TrimSpace(reason) == "" {
return errors.New("rtm stop: reason must not be empty")
}
body, err := json.Marshal(stopRequestEnvelope{Reason: reason})
if err != nil {
return fmt.Errorf("rtm stop: encode request: %w", err)
}
return client.callMutation(ctx, fmt.Sprintf(stopPathTemplate, url.PathEscape(gameID)), body, "rtm stop")
}
// Patch calls POST /api/v1/internal/runtimes/{game_id}/patch with body
// `{image_ref}`. A `409 conflict` from RTM (semver violation) is also
// wrapped with `ports.ErrRTMUnavailable`; the underlying `error_code`
// is preserved in the wrapped error message so callers can branch on
// the substring if needed.
func (client *Client) Patch(ctx context.Context, gameID, imageRef string) error {
if err := client.validate(ctx, gameID); err != nil {
return err
}
if strings.TrimSpace(imageRef) == "" {
return errors.New("rtm patch: image ref must not be empty")
}
body, err := json.Marshal(patchRequestEnvelope{ImageRef: imageRef})
if err != nil {
return fmt.Errorf("rtm patch: encode request: %w", err)
}
return client.callMutation(ctx, fmt.Sprintf(patchPathTemplate, url.PathEscape(gameID)), body, "rtm patch")
}
func (client *Client) validate(ctx context.Context, gameID string) error {
if client == nil || client.httpClient == nil {
return errors.New("rtm client: nil client")
}
if ctx == nil {
return errors.New("rtm client: nil context")
}
if err := ctx.Err(); err != nil {
return err
}
if strings.TrimSpace(gameID) == "" {
return errors.New("rtm client: game id must not be empty")
}
return nil
}
func (client *Client) callMutation(ctx context.Context, requestPath string, body []byte, opLabel string) error {
payload, statusCode, err := client.doRequest(ctx, http.MethodPost, requestPath, body)
if err != nil {
return fmt.Errorf("%w: %s: %w", ports.ErrRTMUnavailable, opLabel, err)
}
if statusCode >= 200 && statusCode < 300 {
return nil
}
errorCode := decodeErrorCode(payload)
if errorCode != "" {
return fmt.Errorf("%w: %s: unexpected status %d (error_code=%s)", ports.ErrRTMUnavailable, opLabel, statusCode, errorCode)
}
return fmt.Errorf("%w: %s: unexpected status %d", ports.ErrRTMUnavailable, opLabel, statusCode)
}
func (client *Client) doRequest(ctx context.Context, method, requestPath string, body []byte) ([]byte, int, error) {
attemptCtx, cancel := context.WithTimeout(ctx, client.requestTimeout)
defer cancel()
var reader io.Reader
if len(body) > 0 {
reader = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(attemptCtx, method, client.baseURL+requestPath, reader)
if err != nil {
return nil, 0, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Accept", "application/json")
if len(body) > 0 {
req.Header.Set("Content-Type", "application/json")
}
resp, err := client.httpClient.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, fmt.Errorf("read response body: %w", err)
}
return respBody, resp.StatusCode, nil
}
func decodeErrorCode(payload []byte) string {
if len(payload) == 0 {
return ""
}
var envelope errorEnvelope
if err := json.Unmarshal(payload, &envelope); err != nil {
return ""
}
if envelope.Error == nil {
return ""
}
return envelope.Error.Code
}
// Compile-time assertion: Client implements ports.RTMClient.
var _ ports.RTMClient = (*Client)(nil)
@@ -0,0 +1,156 @@
package rtmclient
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"galaxy/gamemaster/internal/ports"
)
func newTestClient(t *testing.T, baseURL string, timeout time.Duration) *Client {
t.Helper()
client, err := NewClient(Config{BaseURL: baseURL, RequestTimeout: timeout})
require.NoError(t, err)
t.Cleanup(func() { _ = client.Close() })
return client
}
func TestNewClientValidatesConfig(t *testing.T) {
cases := map[string]Config{
"empty base url": {BaseURL: "", RequestTimeout: time.Second},
"non-absolute": {BaseURL: "rtm:8096", RequestTimeout: time.Second},
"zero timeout": {BaseURL: "http://rtm:8096", RequestTimeout: 0},
"negative timeout": {BaseURL: "http://rtm:8096", RequestTimeout: -time.Second},
}
for name, cfg := range cases {
t.Run(name, func(t *testing.T) {
_, err := NewClient(cfg)
require.Error(t, err)
})
}
}
func TestStopHappyPath(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/api/v1/internal/runtimes/game-1/stop", r.URL.Path)
require.Equal(t, "application/json", r.Header.Get("Content-Type"))
body, err := io.ReadAll(r.Body)
require.NoError(t, err)
var got stopRequestEnvelope
require.NoError(t, json.Unmarshal(body, &got))
assert.Equal(t, "admin_request", got.Reason)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"game_id":"game-1","status":"stopped"}`))
}))
defer server.Close()
client := newTestClient(t, server.URL, time.Second)
require.NoError(t, client.Stop(context.Background(), "game-1", "admin_request"))
}
func TestStopRejectsBadInput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Fatal("must not contact rtm on bad input")
}))
defer server.Close()
client := newTestClient(t, server.URL, time.Second)
require.Error(t, client.Stop(context.Background(), " ", "admin_request"))
require.Error(t, client.Stop(context.Background(), "g", " "))
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := client.Stop(ctx, "g", "admin_request")
require.Error(t, err)
assert.True(t, errors.Is(err, context.Canceled))
}
func TestStopInternalError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":{"code":"internal_error","message":"boom"}}`))
}))
defer server.Close()
client := newTestClient(t, server.URL, time.Second)
err := client.Stop(context.Background(), "g", "admin_request")
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrRTMUnavailable))
assert.Contains(t, err.Error(), "internal_error")
}
func TestStopTimeoutMapsToUnavailable(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(120 * time.Millisecond)
_, _ = w.Write([]byte(`{}`))
}))
defer server.Close()
client := newTestClient(t, server.URL, 30*time.Millisecond)
err := client.Stop(context.Background(), "g", "admin_request")
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrRTMUnavailable))
}
func TestPatchHappyPath(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/api/v1/internal/runtimes/g/patch", r.URL.Path)
body, err := io.ReadAll(r.Body)
require.NoError(t, err)
var got patchRequestEnvelope
require.NoError(t, json.Unmarshal(body, &got))
assert.Equal(t, "galaxy/game:1.2.4", got.ImageRef)
_, _ = w.Write([]byte(`{"game_id":"g","status":"running"}`))
}))
defer server.Close()
client := newTestClient(t, server.URL, time.Second)
require.NoError(t, client.Patch(context.Background(), "g", "galaxy/game:1.2.4"))
}
func TestPatchSemverConflictMapsToUnavailable(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusConflict)
_, _ = w.Write([]byte(`{"error":{"code":"semver_patch_only","message":"cross-major patch not allowed"}}`))
}))
defer server.Close()
client := newTestClient(t, server.URL, time.Second)
err := client.Patch(context.Background(), "g", "galaxy/game:2.0.0")
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrRTMUnavailable))
assert.Contains(t, err.Error(), "semver_patch_only")
}
func TestPatchRejectsBadInput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Fatal("must not contact rtm on bad input")
}))
defer server.Close()
client := newTestClient(t, server.URL, time.Second)
require.Error(t, client.Patch(context.Background(), " ", "galaxy/game:1.0.0"))
require.Error(t, client.Patch(context.Background(), "g", " "))
}
func TestCloseIsIdempotent(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{}`))
}))
defer server.Close()
client := newTestClient(t, server.URL, time.Second)
require.NoError(t, client.Stop(context.Background(), "g", "admin_request"))
require.NoError(t, client.Close())
require.NoError(t, client.Close())
}
@@ -0,0 +1,611 @@
package internalhttp
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
"galaxy/gamemaster/internal/api/internalhttp/handlers"
"galaxy/gamemaster/internal/domain/engineversion"
"galaxy/gamemaster/internal/domain/operation"
domainruntime "galaxy/gamemaster/internal/domain/runtime"
"galaxy/gamemaster/internal/service/adminbanish"
"galaxy/gamemaster/internal/service/adminforce"
"galaxy/gamemaster/internal/service/adminpatch"
"galaxy/gamemaster/internal/service/adminstop"
"galaxy/gamemaster/internal/service/commandexecute"
engineversionsvc "galaxy/gamemaster/internal/service/engineversion"
"galaxy/gamemaster/internal/service/livenessreply"
"galaxy/gamemaster/internal/service/orderput"
"galaxy/gamemaster/internal/service/registerruntime"
"galaxy/gamemaster/internal/service/reportget"
"galaxy/gamemaster/internal/service/turngeneration"
"github.com/getkin/kin-openapi/openapi3"
"github.com/getkin/kin-openapi/openapi3filter"
"github.com/getkin/kin-openapi/routers"
"github.com/getkin/kin-openapi/routers/legacy"
"github.com/stretchr/testify/require"
)
// TestInternalRESTConformance loads the OpenAPI specification, drives
// every internal REST operation against the live listener backed by
// stub services, and validates each request and response body
// against the spec via `openapi3filter.ValidateRequest` and
// `openapi3filter.ValidateResponse`. Failure-path response shapes
// are intentionally out of scope here; per-handler tests under
// `handlers/<op>_test.go` cover the failure branches.
func TestInternalRESTConformance(t *testing.T) {
t.Parallel()
doc := loadConformanceSpec(t)
router, err := legacy.NewRouter(doc)
require.NoError(t, err)
deps := newConformanceDeps()
server, err := NewServer(newConformanceConfig(), Dependencies{
Logger: nil,
Telemetry: nil,
Readiness: nil,
RuntimeRecords: deps.runtimeRecords,
RegisterRuntime: deps.registerRuntime,
ForceNextTurn: deps.forceNextTurn,
StopRuntime: deps.stopRuntime,
PatchRuntime: deps.patchRuntime,
BanishRace: deps.banishRace,
InvalidateMemberships: deps.membership,
GameLiveness: deps.liveness,
EngineVersions: deps.engineVersions,
CommandExecute: deps.commandExecute,
PutOrders: deps.putOrders,
GetReport: deps.getReport,
})
require.NoError(t, err)
cases := []conformanceCase{
{name: "internalHealthz", method: http.MethodGet, path: "/healthz"},
{name: "internalReadyz", method: http.MethodGet, path: "/readyz"},
{
name: "internalRegisterRuntime",
method: http.MethodPost,
path: "/api/v1/internal/games/" + conformanceGameID + "/register-runtime",
contentType: "application/json",
body: `{
"engine_endpoint": "http://galaxy-game-` + conformanceGameID + `:8080",
"members": [{"user_id": "user-1", "race_name": "Aelinari"}],
"target_engine_version": "1.2.3",
"turn_schedule": "0 18 * * *"
}`,
},
{
name: "internalBanishRace",
method: http.MethodPost,
path: "/api/v1/internal/games/" + conformanceGameID + "/race/Aelinari/banish",
expectedStatus: http.StatusNoContent,
},
{
name: "internalInvalidateMemberships",
method: http.MethodPost,
path: "/api/v1/internal/games/" + conformanceGameID + "/memberships/invalidate",
expectedStatus: http.StatusNoContent,
},
{
name: "internalGameLiveness",
method: http.MethodGet,
path: "/api/v1/internal/games/" + conformanceGameID + "/liveness",
},
{name: "internalListRuntimes", method: http.MethodGet, path: "/api/v1/internal/runtimes"},
{
name: "internalGetRuntime",
method: http.MethodGet,
path: "/api/v1/internal/runtimes/" + conformanceGameID,
},
{
name: "internalForceNextTurn",
method: http.MethodPost,
path: "/api/v1/internal/runtimes/" + conformanceGameID + "/force-next-turn",
},
{
name: "internalStopRuntime",
method: http.MethodPost,
path: "/api/v1/internal/runtimes/" + conformanceGameID + "/stop",
contentType: "application/json",
body: `{"reason":"admin_request"}`,
},
{
name: "internalPatchRuntime",
method: http.MethodPost,
path: "/api/v1/internal/runtimes/" + conformanceGameID + "/patch",
contentType: "application/json",
body: `{"version":"1.2.4"}`,
},
{name: "internalListEngineVersions", method: http.MethodGet, path: "/api/v1/internal/engine-versions"},
{
name: "internalCreateEngineVersion",
method: http.MethodPost,
path: "/api/v1/internal/engine-versions",
contentType: "application/json",
body: `{"version":"1.2.5","image_ref":"galaxy/game:1.2.5"}`,
expectedStatus: http.StatusCreated,
},
{
name: "internalGetEngineVersion",
method: http.MethodGet,
path: "/api/v1/internal/engine-versions/1.2.3",
},
{
name: "internalUpdateEngineVersion",
method: http.MethodPatch,
path: "/api/v1/internal/engine-versions/1.2.3",
contentType: "application/json",
body: `{"image_ref":"galaxy/game:1.2.3-patch"}`,
},
{
name: "internalDeprecateEngineVersion",
method: http.MethodDelete,
path: "/api/v1/internal/engine-versions/1.2.3",
expectedStatus: http.StatusNoContent,
},
{
name: "internalResolveEngineVersionImageRef",
method: http.MethodGet,
path: "/api/v1/internal/engine-versions/1.2.3/image-ref",
},
{
name: "internalExecuteCommands",
method: http.MethodPost,
path: "/api/v1/internal/games/" + conformanceGameID + "/commands",
contentType: "application/json",
body: `{"commands":[{"name":"build","args":{}}]}`,
extraHeaders: map[string]string{userIDHeader: conformanceUserID},
},
{
name: "internalPutOrders",
method: http.MethodPost,
path: "/api/v1/internal/games/" + conformanceGameID + "/orders",
contentType: "application/json",
body: `{"commands":[{"name":"move","args":{}}]}`,
extraHeaders: map[string]string{userIDHeader: conformanceUserID},
},
{
name: "internalGetReport",
method: http.MethodGet,
path: "/api/v1/internal/games/" + conformanceGameID + "/reports/0",
extraHeaders: map[string]string{userIDHeader: conformanceUserID},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
runConformanceCase(t, server.handler, router, tc)
})
}
}
const (
conformanceGameID = "game-conformance"
conformanceUserID = "user-conformance"
conformanceServerURL = "http://localhost:8097"
userIDHeader = "X-User-ID"
)
type conformanceCase struct {
name string
method string
path string
contentType string
body string
expectedStatus int
extraHeaders map[string]string
}
func runConformanceCase(t *testing.T, handler http.Handler, router routers.Router, tc conformanceCase) {
t.Helper()
expectedStatus := tc.expectedStatus
if expectedStatus == 0 {
expectedStatus = http.StatusOK
}
var bodyReader io.Reader
if tc.body != "" {
bodyReader = strings.NewReader(tc.body)
}
request := httptest.NewRequest(tc.method, tc.path, bodyReader)
if tc.contentType != "" {
request.Header.Set("Content-Type", tc.contentType)
}
request.Header.Set("X-Galaxy-Caller", "admin")
for key, value := range tc.extraHeaders {
request.Header.Set(key, value)
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
require.Equalf(t, expectedStatus, recorder.Code,
"operation %s returned %d: %s", tc.name, recorder.Code, recorder.Body.String())
validationURL := conformanceServerURL + tc.path
validationRequest := httptest.NewRequest(tc.method, validationURL, bodyReaderFor(tc.body))
if tc.contentType != "" {
validationRequest.Header.Set("Content-Type", tc.contentType)
}
validationRequest.Header.Set("X-Galaxy-Caller", "admin")
for key, value := range tc.extraHeaders {
validationRequest.Header.Set(key, value)
}
route, pathParams, err := router.FindRoute(validationRequest)
require.NoError(t, err)
requestInput := &openapi3filter.RequestValidationInput{
Request: validationRequest,
PathParams: pathParams,
Route: route,
Options: &openapi3filter.Options{
IncludeResponseStatus: true,
},
}
require.NoError(t, openapi3filter.ValidateRequest(context.Background(), requestInput))
responseInput := &openapi3filter.ResponseValidationInput{
RequestValidationInput: requestInput,
Status: recorder.Code,
Header: recorder.Header(),
Options: &openapi3filter.Options{
IncludeResponseStatus: true,
},
}
responseInput.SetBodyBytes(recorder.Body.Bytes())
require.NoError(t, openapi3filter.ValidateResponse(context.Background(), responseInput))
}
func loadConformanceSpec(t *testing.T) *openapi3.T {
t.Helper()
_, thisFile, _, ok := runtime.Caller(0)
require.True(t, ok)
specPath := filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "api", "internal-openapi.yaml")
loader := openapi3.NewLoader()
doc, err := loader.LoadFromFile(specPath)
require.NoError(t, err)
require.NoError(t, doc.Validate(context.Background()))
return doc
}
func bodyReaderFor(raw string) io.Reader {
if raw == "" {
return http.NoBody
}
return bytes.NewBufferString(raw)
}
func newConformanceConfig() Config {
return Config{
Addr: ":0",
ReadHeaderTimeout: time.Second,
ReadTimeout: time.Second,
WriteTimeout: time.Second,
IdleTimeout: time.Second,
}
}
// conformanceDeps groups the stub collaborators handed to the listener.
type conformanceDeps struct {
runtimeRecords *conformanceRuntimeRecords
registerRuntime *conformanceRegister
forceNextTurn *conformanceForce
stopRuntime *conformanceStop
patchRuntime *conformancePatch
banishRace *conformanceBanish
membership *conformanceMembership
liveness *conformanceLiveness
engineVersions *conformanceEngineVersions
commandExecute *conformanceCommands
putOrders *conformanceOrders
getReport *conformanceReport
}
func newConformanceDeps() *conformanceDeps {
return &conformanceDeps{
runtimeRecords: newConformanceRuntimeRecords(),
registerRuntime: &conformanceRegister{},
forceNextTurn: &conformanceForce{},
stopRuntime: &conformanceStop{},
patchRuntime: &conformancePatch{},
banishRace: &conformanceBanish{},
membership: &conformanceMembership{},
liveness: &conformanceLiveness{},
engineVersions: newConformanceEngineVersions(),
commandExecute: &conformanceCommands{},
putOrders: &conformanceOrders{},
getReport: &conformanceReport{},
}
}
// conformanceRecord builds a canonical running runtime record used
// by every stub service.
func conformanceRuntimeRecord() domainruntime.RuntimeRecord {
moment := time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC)
next := moment.Add(time.Minute)
started := moment
return domainruntime.RuntimeRecord{
GameID: conformanceGameID,
Status: domainruntime.StatusRunning,
EngineEndpoint: "http://galaxy-game-" + conformanceGameID + ":8080",
CurrentImageRef: "galaxy/game:1.2.3",
CurrentEngineVersion: "1.2.3",
TurnSchedule: "0 18 * * *",
CurrentTurn: 0,
NextGenerationAt: &next,
SkipNextTick: false,
EngineHealth: "healthy",
CreatedAt: moment,
UpdatedAt: moment,
StartedAt: &started,
}
}
func conformanceEngineVersionRecord(version string) engineversion.EngineVersion {
moment := time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC)
return engineversion.EngineVersion{
Version: version,
ImageRef: "galaxy/game:" + version,
Options: nil,
Status: engineversion.StatusActive,
CreatedAt: moment,
UpdatedAt: moment,
}
}
// conformanceRuntimeRecords is an in-memory store seeded with the
// canonical record so the get/list endpoints have something to return.
type conformanceRuntimeRecords struct {
mu sync.Mutex
stored map[string]domainruntime.RuntimeRecord
}
func newConformanceRuntimeRecords() *conformanceRuntimeRecords {
return &conformanceRuntimeRecords{
stored: map[string]domainruntime.RuntimeRecord{
conformanceGameID: conformanceRuntimeRecord(),
},
}
}
func (s *conformanceRuntimeRecords) Get(_ context.Context, gameID string) (domainruntime.RuntimeRecord, error) {
s.mu.Lock()
defer s.mu.Unlock()
record, ok := s.stored[gameID]
if !ok {
return domainruntime.RuntimeRecord{}, domainruntime.ErrNotFound
}
return record, nil
}
func (s *conformanceRuntimeRecords) List(_ context.Context) ([]domainruntime.RuntimeRecord, error) {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]domainruntime.RuntimeRecord, 0, len(s.stored))
for _, record := range s.stored {
out = append(out, record)
}
return out, nil
}
func (s *conformanceRuntimeRecords) ListByStatus(_ context.Context, status domainruntime.Status) ([]domainruntime.RuntimeRecord, error) {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]domainruntime.RuntimeRecord, 0, len(s.stored))
for _, record := range s.stored {
if record.Status == status {
out = append(out, record)
}
}
return out, nil
}
type conformanceRegister struct{}
func (s *conformanceRegister) Handle(_ context.Context, _ registerruntime.Input) (registerruntime.Result, error) {
return registerruntime.Result{
Record: conformanceRuntimeRecord(),
Outcome: operation.OutcomeSuccess,
}, nil
}
type conformanceForce struct{}
func (s *conformanceForce) Handle(_ context.Context, _ adminforce.Input) (adminforce.Result, error) {
return adminforce.Result{
TurnGeneration: turngeneration.Result{Record: conformanceRuntimeRecord()},
SkipScheduled: true,
Outcome: operation.OutcomeSuccess,
}, nil
}
type conformanceStop struct{}
func (s *conformanceStop) Handle(_ context.Context, _ adminstop.Input) (adminstop.Result, error) {
rec := conformanceRuntimeRecord()
rec.Status = domainruntime.StatusStopped
stopped := rec.UpdatedAt.Add(time.Second)
rec.StoppedAt = &stopped
rec.UpdatedAt = stopped
return adminstop.Result{Record: rec, Outcome: operation.OutcomeSuccess}, nil
}
type conformancePatch struct{}
func (s *conformancePatch) Handle(_ context.Context, in adminpatch.Input) (adminpatch.Result, error) {
rec := conformanceRuntimeRecord()
if in.Version != "" {
rec.CurrentImageRef = "galaxy/game:" + in.Version
rec.CurrentEngineVersion = in.Version
}
return adminpatch.Result{Record: rec, Outcome: operation.OutcomeSuccess}, nil
}
type conformanceBanish struct{}
func (s *conformanceBanish) Handle(_ context.Context, _ adminbanish.Input) (adminbanish.Result, error) {
return adminbanish.Result{Outcome: operation.OutcomeSuccess}, nil
}
type conformanceMembership struct{}
func (m *conformanceMembership) Invalidate(string) {}
type conformanceLiveness struct{}
func (s *conformanceLiveness) Handle(_ context.Context, _ livenessreply.Input) (livenessreply.Result, error) {
return livenessreply.Result{
Ready: true,
Status: domainruntime.StatusRunning,
}, nil
}
type conformanceEngineVersions struct {
mu sync.Mutex
versions map[string]engineversion.EngineVersion
}
func newConformanceEngineVersions() *conformanceEngineVersions {
return &conformanceEngineVersions{
versions: map[string]engineversion.EngineVersion{
"1.2.3": conformanceEngineVersionRecord("1.2.3"),
},
}
}
func (s *conformanceEngineVersions) List(_ context.Context, _ *engineversion.Status) ([]engineversion.EngineVersion, error) {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]engineversion.EngineVersion, 0, len(s.versions))
for _, version := range s.versions {
out = append(out, version)
}
return out, nil
}
func (s *conformanceEngineVersions) Get(_ context.Context, version string) (engineversion.EngineVersion, error) {
s.mu.Lock()
defer s.mu.Unlock()
v, ok := s.versions[version]
if !ok {
return engineversion.EngineVersion{}, engineversionsvc.ErrNotFound
}
return v, nil
}
func (s *conformanceEngineVersions) ResolveImageRef(_ context.Context, version string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
v, ok := s.versions[version]
if !ok {
return "", engineversionsvc.ErrNotFound
}
return v.ImageRef, nil
}
func (s *conformanceEngineVersions) Create(_ context.Context, in engineversionsvc.CreateInput) (engineversion.EngineVersion, error) {
rec := engineversion.EngineVersion{
Version: in.Version,
ImageRef: in.ImageRef,
Options: in.Options,
Status: engineversion.StatusActive,
CreatedAt: time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC),
}
s.mu.Lock()
s.versions[in.Version] = rec
s.mu.Unlock()
return rec, nil
}
func (s *conformanceEngineVersions) Update(_ context.Context, in engineversionsvc.UpdateInput) (engineversion.EngineVersion, error) {
s.mu.Lock()
defer s.mu.Unlock()
rec, ok := s.versions[in.Version]
if !ok {
return engineversion.EngineVersion{}, engineversionsvc.ErrNotFound
}
if in.ImageRef != nil {
rec.ImageRef = *in.ImageRef
}
if in.Status != nil {
rec.Status = *in.Status
}
rec.UpdatedAt = time.Date(2026, 4, 30, 13, 0, 0, 0, time.UTC)
s.versions[in.Version] = rec
return rec, nil
}
func (s *conformanceEngineVersions) Deprecate(_ context.Context, in engineversionsvc.DeprecateInput) error {
s.mu.Lock()
defer s.mu.Unlock()
rec, ok := s.versions[in.Version]
if !ok {
return engineversionsvc.ErrNotFound
}
rec.Status = engineversion.StatusDeprecated
rec.UpdatedAt = time.Date(2026, 4, 30, 14, 0, 0, 0, time.UTC)
s.versions[in.Version] = rec
return nil
}
type conformanceCommands struct{}
func (s *conformanceCommands) Handle(_ context.Context, _ commandexecute.Input) (commandexecute.Result, error) {
return commandexecute.Result{
Outcome: operation.OutcomeSuccess,
RawResponse: json.RawMessage(`{"results":[]}`),
}, nil
}
type conformanceOrders struct{}
func (s *conformanceOrders) Handle(_ context.Context, _ orderput.Input) (orderput.Result, error) {
return orderput.Result{
Outcome: operation.OutcomeSuccess,
RawResponse: json.RawMessage(`{"results":[]}`),
}, nil
}
type conformanceReport struct{}
func (s *conformanceReport) Handle(_ context.Context, _ reportget.Input) (reportget.Result, error) {
return reportget.Result{
Outcome: operation.OutcomeSuccess,
RawResponse: json.RawMessage(`{"player":"Aelinari","turn":0}`),
}, nil
}
// Compile-time guards that the stubs satisfy the handler-level
// service interfaces accepted by the listener.
var (
_ handlers.RegisterRuntimeService = (*conformanceRegister)(nil)
_ handlers.ForceNextTurnService = (*conformanceForce)(nil)
_ handlers.StopRuntimeService = (*conformanceStop)(nil)
_ handlers.PatchRuntimeService = (*conformancePatch)(nil)
_ handlers.BanishRaceService = (*conformanceBanish)(nil)
_ handlers.MembershipInvalidator = (*conformanceMembership)(nil)
_ handlers.LivenessService = (*conformanceLiveness)(nil)
_ handlers.EngineVersionService = (*conformanceEngineVersions)(nil)
_ handlers.CommandExecuteService = (*conformanceCommands)(nil)
_ handlers.OrderPutService = (*conformanceOrders)(nil)
_ handlers.ReportGetService = (*conformanceReport)(nil)
_ handlers.RuntimeRecordsReader = (*conformanceRuntimeRecords)(nil)
)
@@ -0,0 +1,54 @@
package handlers
import (
"net/http"
"galaxy/gamemaster/internal/domain/operation"
"galaxy/gamemaster/internal/service/adminbanish"
)
// newBanishRaceHandler returns the handler for
// `POST /api/v1/internal/games/{game_id}/race/{race_name}/banish`. The
// request has no body; both identifiers come from the URL path.
// Success returns `204 No Content`.
func newBanishRaceHandler(deps Dependencies) http.HandlerFunc {
logger := loggerFor(deps.Logger, "internal_rest.banish_race")
return func(writer http.ResponseWriter, request *http.Request) {
if deps.BanishRace == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "banish race service is not wired")
return
}
gameID, ok := extractGameID(writer, request)
if !ok {
return
}
raceName, ok := extractRaceName(writer, request)
if !ok {
return
}
result, err := deps.BanishRace.Handle(request.Context(), adminbanish.Input{
GameID: gameID,
RaceName: raceName,
OpSource: resolveOpSource(request),
SourceRef: requestSourceRef(request),
})
if err != nil {
logger.ErrorContext(request.Context(), "banish race service errored",
"game_id", gameID,
"race_name", raceName,
"err", err.Error(),
)
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "banish race service failed")
return
}
if result.Outcome == operation.OutcomeFailure {
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
return
}
writeNoContent(writer)
}
}
@@ -0,0 +1,422 @@
package handlers
import (
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strings"
"galaxy/gamemaster/internal/domain/engineversion"
"galaxy/gamemaster/internal/domain/operation"
"galaxy/gamemaster/internal/domain/runtime"
engineversionsvc "galaxy/gamemaster/internal/service/engineversion"
)
// jsonContentType is the Content-Type used by every internal REST
// response body except the engine pass-through bodies which retain
// the engine's chosen Content-Type.
const jsonContentType = "application/json; charset=utf-8"
// callerHeader is the optional caller-classification header used to
// attribute each request to a specific entry point. Documented in
// `gamemaster/README.md` §«Internal REST API». Missing or unknown
// values map to OpSourceAdminRest.
const callerHeader = "X-Galaxy-Caller"
// userIDHeader carries the verified player identity propagated by
// Edge Gateway on hot-path operations. Required for
// `internalExecuteCommands`, `internalPutOrders`, and
// `internalGetReport`.
const userIDHeader = "X-User-ID"
// requestIDHeader is read into `operation_log.source_ref` when present
// so REST callers can correlate audit rows with their requests.
const requestIDHeader = "X-Request-ID"
// gameIDPathParam, raceNamePathParam, versionPathParam, turnPathParam
// mirror the parameter names declared in
// `gamemaster/api/internal-openapi.yaml`.
const (
gameIDPathParam = "game_id"
raceNamePathParam = "race_name"
versionPathParam = "version"
turnPathParam = "turn"
)
// Stable error codes used by the handler layer when no service result
// is available (e.g., the service is not wired or the request shape
// failed pre-decode validation). The values match the vocabulary
// frozen by `gamemaster/README.md §Error Model` and
// `gamemaster/api/internal-openapi.yaml`.
const (
errorCodeInvalidRequest = "invalid_request"
errorCodeForbidden = "forbidden"
errorCodeRuntimeNotFound = "runtime_not_found"
errorCodeEngineVersionNotFound = "engine_version_not_found"
errorCodeEngineVersionInUse = "engine_version_in_use"
errorCodeConflict = "conflict"
errorCodeRuntimeNotRunning = "runtime_not_running"
errorCodeSemverPatchOnly = "semver_patch_only"
errorCodeEngineUnreachable = "engine_unreachable"
errorCodeEngineValidationError = "engine_validation_error"
errorCodeEngineProtocolError = "engine_protocol_violation"
errorCodeServiceUnavailable = "service_unavailable"
errorCodeInternal = "internal_error"
)
// errorBody mirrors the `error` element of the OpenAPI ErrorResponse
// schema.
type errorBody struct {
Code string `json:"code"`
Message string `json:"message"`
}
// errorResponse mirrors the OpenAPI ErrorResponse envelope.
type errorResponse struct {
Error errorBody `json:"error"`
}
// runtimeRecordResponse mirrors the OpenAPI RuntimeRecord schema.
// Required timestamps are always present and encode as int64 UTC
// milliseconds; optional ones use `*int64` so an absent value is
// omitted from the JSON form (rather than encoded as `null`).
type runtimeRecordResponse struct {
GameID string `json:"game_id"`
RuntimeStatus string `json:"runtime_status"`
EngineEndpoint string `json:"engine_endpoint"`
CurrentImageRef string `json:"current_image_ref"`
CurrentEngineVersion string `json:"current_engine_version"`
TurnSchedule string `json:"turn_schedule"`
CurrentTurn int `json:"current_turn"`
NextGenerationAt int64 `json:"next_generation_at"`
SkipNextTick bool `json:"skip_next_tick"`
EngineHealthSummary string `json:"engine_health_summary"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
StartedAt *int64 `json:"started_at,omitempty"`
StoppedAt *int64 `json:"stopped_at,omitempty"`
FinishedAt *int64 `json:"finished_at,omitempty"`
}
// runtimeListResponse mirrors the OpenAPI RuntimeListResponse schema.
// Runtimes is always non-nil so an empty result encodes as
// `{"runtimes":[]}` rather than `{"runtimes":null}`.
type runtimeListResponse struct {
Runtimes []runtimeRecordResponse `json:"runtimes"`
}
// engineVersionResponse mirrors the OpenAPI EngineVersion schema.
// Options is a `json.RawMessage` so the engine-side document passes
// through verbatim.
type engineVersionResponse struct {
Version string `json:"version"`
ImageRef string `json:"image_ref"`
Options json.RawMessage `json:"options"`
Status string `json:"status"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
// engineVersionListResponse mirrors the OpenAPI
// EngineVersionListResponse schema.
type engineVersionListResponse struct {
Versions []engineVersionResponse `json:"versions"`
}
// imageRefResponse mirrors the OpenAPI ImageRefResponse schema.
type imageRefResponse struct {
ImageRef string `json:"image_ref"`
}
// livenessResponse mirrors the OpenAPI LivenessResponse schema.
type livenessResponse struct {
Ready bool `json:"ready"`
Status string `json:"status"`
}
// encodeRuntimeRecord turns a domain RuntimeRecord into its wire shape.
// Required `next_generation_at` encodes as `0` when the record carries
// no scheduled tick (e.g., status=starting before the first
// scheduling write); optional lifecycle timestamps are omitted when
// nil.
func encodeRuntimeRecord(record runtime.RuntimeRecord) runtimeRecordResponse {
resp := runtimeRecordResponse{
GameID: record.GameID,
RuntimeStatus: string(record.Status),
EngineEndpoint: record.EngineEndpoint,
CurrentImageRef: record.CurrentImageRef,
CurrentEngineVersion: record.CurrentEngineVersion,
TurnSchedule: record.TurnSchedule,
CurrentTurn: record.CurrentTurn,
SkipNextTick: record.SkipNextTick,
EngineHealthSummary: record.EngineHealth,
CreatedAt: record.CreatedAt.UTC().UnixMilli(),
UpdatedAt: record.UpdatedAt.UTC().UnixMilli(),
}
if record.NextGenerationAt != nil {
resp.NextGenerationAt = record.NextGenerationAt.UTC().UnixMilli()
}
if record.StartedAt != nil {
v := record.StartedAt.UTC().UnixMilli()
resp.StartedAt = &v
}
if record.StoppedAt != nil {
v := record.StoppedAt.UTC().UnixMilli()
resp.StoppedAt = &v
}
if record.FinishedAt != nil {
v := record.FinishedAt.UTC().UnixMilli()
resp.FinishedAt = &v
}
return resp
}
// encodeRuntimeList turns a domain RuntimeRecord slice into a wire
// list response. records may be nil (empty store); the result still
// carries an empty Runtimes slice so the JSON form is `{"runtimes":[]}`.
func encodeRuntimeList(records []runtime.RuntimeRecord) runtimeListResponse {
resp := runtimeListResponse{
Runtimes: make([]runtimeRecordResponse, 0, len(records)),
}
for _, record := range records {
resp.Runtimes = append(resp.Runtimes, encodeRuntimeRecord(record))
}
return resp
}
// encodeEngineVersion turns a domain EngineVersion into its wire shape.
// Empty Options bytes encode as the JSON object literal `{}` to
// satisfy the schema (`type: object`).
func encodeEngineVersion(version engineversion.EngineVersion) engineVersionResponse {
options := json.RawMessage(version.Options)
if len(options) == 0 {
options = json.RawMessage("{}")
}
return engineVersionResponse{
Version: version.Version,
ImageRef: version.ImageRef,
Options: options,
Status: string(version.Status),
CreatedAt: version.CreatedAt.UTC().UnixMilli(),
UpdatedAt: version.UpdatedAt.UTC().UnixMilli(),
}
}
// encodeEngineVersionList turns a slice of domain EngineVersions into
// a wire list response. The Versions slice is always non-nil.
func encodeEngineVersionList(versions []engineversion.EngineVersion) engineVersionListResponse {
resp := engineVersionListResponse{
Versions: make([]engineVersionResponse, 0, len(versions)),
}
for _, version := range versions {
resp.Versions = append(resp.Versions, encodeEngineVersion(version))
}
return resp
}
// writeJSON writes payload as a JSON response with the given status
// code.
func writeJSON(writer http.ResponseWriter, statusCode int, payload any) {
writer.Header().Set("Content-Type", jsonContentType)
writer.WriteHeader(statusCode)
_ = json.NewEncoder(writer).Encode(payload)
}
// writeNoContent writes `204 No Content` with no body. The
// Content-Type header is intentionally omitted so kin-openapi's
// response validator does not look for a body.
func writeNoContent(writer http.ResponseWriter) {
writer.WriteHeader(http.StatusNoContent)
}
// writeRawJSON writes raw, already-encoded JSON bytes as the response
// body with the given status code. Used by the hot-path handlers
// where the engine's response body is forwarded verbatim.
func writeRawJSON(writer http.ResponseWriter, statusCode int, body []byte) {
writer.Header().Set("Content-Type", jsonContentType)
writer.WriteHeader(statusCode)
_, _ = writer.Write(body)
}
// writeError writes the canonical error envelope at statusCode.
func writeError(writer http.ResponseWriter, statusCode int, code, message string) {
writeJSON(writer, statusCode, errorResponse{
Error: errorBody{Code: code, Message: message},
})
}
// writeFailure writes the canonical error envelope using the HTTP
// status mapped from code via mapErrorCodeToStatus. Used by every
// service-backed handler when its service returns
// `Outcome=failure`.
func writeFailure(writer http.ResponseWriter, code, message string) {
writeError(writer, mapErrorCodeToStatus(code), code, message)
}
// mapErrorCodeToStatus maps a stable error code to the HTTP status
// declared by `gamemaster/api/internal-openapi.yaml`. Unknown codes
// degrade to 500 so a future error code that ships ahead of its
// handler-layer mapping still produces a structurally valid response.
func mapErrorCodeToStatus(code string) int {
switch code {
case errorCodeInvalidRequest:
return http.StatusBadRequest
case errorCodeForbidden:
return http.StatusForbidden
case errorCodeRuntimeNotFound, errorCodeEngineVersionNotFound:
return http.StatusNotFound
case errorCodeConflict,
errorCodeRuntimeNotRunning,
errorCodeSemverPatchOnly,
errorCodeEngineVersionInUse:
return http.StatusConflict
case errorCodeEngineUnreachable,
errorCodeEngineValidationError,
errorCodeEngineProtocolError:
return http.StatusBadGateway
case errorCodeServiceUnavailable:
return http.StatusServiceUnavailable
default:
return http.StatusInternalServerError
}
}
// mapServiceError translates one of the `engineversionsvc` sentinel
// errors into the corresponding HTTP status, error code, and message.
// Unknown errors degrade to `500 internal_error`.
func mapServiceError(err error) (int, string, string) {
switch {
case errors.Is(err, engineversionsvc.ErrInvalidRequest):
return http.StatusBadRequest, errorCodeInvalidRequest, err.Error()
case errors.Is(err, engineversionsvc.ErrNotFound):
return http.StatusNotFound, errorCodeEngineVersionNotFound, err.Error()
case errors.Is(err, engineversionsvc.ErrConflict):
return http.StatusConflict, errorCodeConflict, err.Error()
case errors.Is(err, engineversionsvc.ErrInUse):
return http.StatusConflict, errorCodeEngineVersionInUse, err.Error()
case errors.Is(err, engineversionsvc.ErrServiceUnavailable):
return http.StatusServiceUnavailable, errorCodeServiceUnavailable, err.Error()
default:
return http.StatusInternalServerError, errorCodeInternal, "internal server error"
}
}
// decodeStrictJSON decodes one request body into target with strict
// JSON semantics: unknown fields are rejected and trailing content is
// rejected. Mirrors the helper used by lobby and rtmanager.
func decodeStrictJSON(body io.Reader, target any) error {
decoder := json.NewDecoder(body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
if decoder.More() {
return errors.New("unexpected trailing content after JSON body")
}
return nil
}
// readRawJSONBody returns the raw request body provided it parses as
// a JSON value. The hot-path handlers use this helper because the
// envelope is engine-owned (`additionalProperties: true` on
// ExecuteCommandsRequest / PutOrdersRequest); strict decoding would
// reject legitimate extra fields.
func readRawJSONBody(reader io.Reader) ([]byte, error) {
if reader == nil {
return nil, errors.New("request body is required")
}
body, err := io.ReadAll(reader)
if err != nil {
return nil, err
}
if len(body) == 0 {
return nil, errors.New("request body is required")
}
if !json.Valid(body) {
return nil, errors.New("request body is not valid JSON")
}
return body, nil
}
// extractGameID pulls the {game_id} path variable from request. An
// empty or whitespace-only value writes a `400 invalid_request` and
// returns ok=false so callers can short-circuit.
func extractGameID(writer http.ResponseWriter, request *http.Request) (string, bool) {
raw := request.PathValue(gameIDPathParam)
if strings.TrimSpace(raw) == "" {
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, "game id is required")
return "", false
}
return raw, true
}
// extractRaceName pulls the {race_name} path variable.
func extractRaceName(writer http.ResponseWriter, request *http.Request) (string, bool) {
raw := request.PathValue(raceNamePathParam)
if strings.TrimSpace(raw) == "" {
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, "race name is required")
return "", false
}
return raw, true
}
// extractVersion pulls the {version} path variable.
func extractVersion(writer http.ResponseWriter, request *http.Request) (string, bool) {
raw := request.PathValue(versionPathParam)
if strings.TrimSpace(raw) == "" {
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, "version is required")
return "", false
}
return raw, true
}
// extractUserID pulls the verified player identity from the
// X-User-ID header. The hot-path operations require this header per
// the OpenAPI spec; absent or whitespace-only values short-circuit
// with `400 invalid_request`.
func extractUserID(writer http.ResponseWriter, request *http.Request) (string, bool) {
raw := strings.TrimSpace(request.Header.Get(userIDHeader))
if raw == "" {
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, "X-User-ID header is required")
return "", false
}
return raw, true
}
// resolveOpSource maps the X-Galaxy-Caller header value to an
// `operation.OpSource`. Missing or unknown values default to
// OpSourceAdminRest, matching the documented contract in
// `gamemaster/README.md` §«Internal REST API».
func resolveOpSource(request *http.Request) operation.OpSource {
switch strings.ToLower(strings.TrimSpace(request.Header.Get(callerHeader))) {
case "gateway":
return operation.OpSourceGatewayPlayer
case "lobby":
return operation.OpSourceLobbyInternal
case "admin":
return operation.OpSourceAdminRest
default:
return operation.OpSourceAdminRest
}
}
// requestSourceRef returns an opaque per-request reference recorded
// in `operation_log.source_ref`. v1 reads the X-Request-ID header
// when present so callers may correlate REST requests with audit
// rows.
func requestSourceRef(request *http.Request) string {
return strings.TrimSpace(request.Header.Get(requestIDHeader))
}
// loggerFor returns a logger annotated with the operation tag. Each
// handler scopes its logs by op so operators filtering on
// `op=internal_rest.<operation>` see exactly the lifecycle they care
// about.
func loggerFor(parent *slog.Logger, op string) *slog.Logger {
if parent == nil {
parent = slog.Default()
}
return parent.With("component", "internal_http.handlers", "op", op)
}
@@ -0,0 +1,205 @@
package handlers
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"galaxy/gamemaster/internal/domain/engineversion"
"galaxy/gamemaster/internal/domain/operation"
"galaxy/gamemaster/internal/domain/runtime"
engineversionsvc "galaxy/gamemaster/internal/service/engineversion"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMapErrorCodeToStatusCoversEveryDocumentedCode(t *testing.T) {
t.Parallel()
cases := map[string]int{
errorCodeInvalidRequest: http.StatusBadRequest,
errorCodeForbidden: http.StatusForbidden,
errorCodeRuntimeNotFound: http.StatusNotFound,
errorCodeEngineVersionNotFound: http.StatusNotFound,
errorCodeConflict: http.StatusConflict,
errorCodeRuntimeNotRunning: http.StatusConflict,
errorCodeSemverPatchOnly: http.StatusConflict,
errorCodeEngineVersionInUse: http.StatusConflict,
errorCodeEngineUnreachable: http.StatusBadGateway,
errorCodeEngineValidationError: http.StatusBadGateway,
errorCodeEngineProtocolError: http.StatusBadGateway,
errorCodeServiceUnavailable: http.StatusServiceUnavailable,
errorCodeInternal: http.StatusInternalServerError,
"unknown_code": http.StatusInternalServerError,
}
for code, expected := range cases {
assert.Equalf(t, expected, mapErrorCodeToStatus(code), "code %q", code)
}
}
func TestMapServiceErrorMapsEverySentinel(t *testing.T) {
t.Parallel()
cases := []struct {
err error
status int
code string
}{
{engineversionsvc.ErrInvalidRequest, http.StatusBadRequest, errorCodeInvalidRequest},
{engineversionsvc.ErrNotFound, http.StatusNotFound, errorCodeEngineVersionNotFound},
{engineversionsvc.ErrConflict, http.StatusConflict, errorCodeConflict},
{engineversionsvc.ErrInUse, http.StatusConflict, errorCodeEngineVersionInUse},
{engineversionsvc.ErrServiceUnavailable, http.StatusServiceUnavailable, errorCodeServiceUnavailable},
{errors.New("plain go error"), http.StatusInternalServerError, errorCodeInternal},
}
for _, tc := range cases {
status, code, _ := mapServiceError(tc.err)
assert.Equalf(t, tc.status, status, "status for %v", tc.err)
assert.Equalf(t, tc.code, code, "code for %v", tc.err)
}
}
func TestResolveOpSourceMapsCallerHeader(t *testing.T) {
t.Parallel()
cases := map[string]operation.OpSource{
"": operation.OpSourceAdminRest,
"unknown": operation.OpSourceAdminRest,
"GATEWAY": operation.OpSourceGatewayPlayer,
" lobby ": operation.OpSourceLobbyInternal,
"admin": operation.OpSourceAdminRest,
}
for value, expected := range cases {
request := httptest.NewRequest(http.MethodGet, "/", nil)
if value != "" {
request.Header.Set(callerHeader, value)
}
assert.Equalf(t, expected, resolveOpSource(request), "header %q", value)
}
}
func TestRequestSourceRefReadsXRequestID(t *testing.T) {
t.Parallel()
request := httptest.NewRequest(http.MethodGet, "/", nil)
assert.Empty(t, requestSourceRef(request))
request.Header.Set(requestIDHeader, " trace-123 ")
assert.Equal(t, "trace-123", requestSourceRef(request))
}
func TestDecodeStrictJSONRejectsUnknownFieldsAndTrailingContent(t *testing.T) {
t.Parallel()
type input struct {
Field string `json:"field"`
}
var ok input
require.NoError(t, decodeStrictJSON(strings.NewReader(`{"field":"value"}`), &ok))
assert.Equal(t, "value", ok.Field)
var rejected input
err := decodeStrictJSON(strings.NewReader(`{"field":"v","extra":1}`), &rejected)
require.Error(t, err)
var trailing input
err = decodeStrictJSON(strings.NewReader(`{"field":"v"}{"another":true}`), &trailing)
require.Error(t, err)
}
func TestReadRawJSONBodyValidatesPayload(t *testing.T) {
t.Parallel()
body, err := readRawJSONBody(strings.NewReader(`{"commands":[]}`))
require.NoError(t, err)
assert.JSONEq(t, `{"commands":[]}`, string(body))
_, err = readRawJSONBody(strings.NewReader(""))
require.Error(t, err)
_, err = readRawJSONBody(strings.NewReader("not json"))
require.Error(t, err)
}
func TestEncodeRuntimeRecordIncludesEveryRequiredField(t *testing.T) {
t.Parallel()
moment := time.Date(2026, 5, 1, 9, 30, 0, 0, time.UTC)
next := moment.Add(time.Minute)
record := runtime.RuntimeRecord{
GameID: "game-1",
Status: runtime.StatusRunning,
EngineEndpoint: "http://example:8080",
CurrentImageRef: "galaxy/game:1.2.3",
CurrentEngineVersion: "1.2.3",
TurnSchedule: "0 18 * * *",
CurrentTurn: 7,
NextGenerationAt: &next,
SkipNextTick: true,
EngineHealth: "healthy",
CreatedAt: moment,
UpdatedAt: moment,
StartedAt: &moment,
}
encoded := encodeRuntimeRecord(record)
assert.Equal(t, "game-1", encoded.GameID)
assert.Equal(t, "running", encoded.RuntimeStatus)
assert.Equal(t, moment.UnixMilli(), encoded.CreatedAt)
assert.Equal(t, next.UnixMilli(), encoded.NextGenerationAt)
require.NotNil(t, encoded.StartedAt)
assert.Equal(t, moment.UnixMilli(), *encoded.StartedAt)
assert.Nil(t, encoded.StoppedAt)
assert.Nil(t, encoded.FinishedAt)
}
func TestEncodeRuntimeRecordZerosNextGenerationWhenNil(t *testing.T) {
t.Parallel()
moment := time.Date(2026, 5, 1, 9, 30, 0, 0, time.UTC)
record := runtime.RuntimeRecord{
GameID: "game-1",
Status: runtime.StatusStarting,
EngineEndpoint: "http://example:8080",
CurrentImageRef: "galaxy/game:1.2.3",
CurrentEngineVersion: "1.2.3",
TurnSchedule: "0 18 * * *",
CreatedAt: moment,
UpdatedAt: moment,
}
encoded := encodeRuntimeRecord(record)
assert.Equal(t, int64(0), encoded.NextGenerationAt)
assert.Nil(t, encoded.StartedAt)
}
func TestEncodeEngineVersionDefaultsEmptyOptionsToObject(t *testing.T) {
t.Parallel()
moment := time.Date(2026, 5, 1, 9, 30, 0, 0, time.UTC)
encoded := encodeEngineVersion(engineversion.EngineVersion{
Version: "1.2.3",
ImageRef: "galaxy/game:1.2.3",
Status: engineversion.StatusActive,
CreatedAt: moment,
UpdatedAt: moment,
})
assert.Equal(t, "{}", string(encoded.Options))
assert.Equal(t, "active", encoded.Status)
}
func TestEncodeRuntimeListAlwaysReturnsNonNilSlice(t *testing.T) {
t.Parallel()
resp := encodeRuntimeList(nil)
require.NotNil(t, resp.Runtimes)
assert.Empty(t, resp.Runtimes)
}
@@ -0,0 +1,50 @@
package handlers
import (
"encoding/json"
"net/http"
engineversionsvc "galaxy/gamemaster/internal/service/engineversion"
)
// createEngineVersionRequestBody mirrors the OpenAPI
// CreateEngineVersionRequest schema.
type createEngineVersionRequestBody struct {
Version string `json:"version"`
ImageRef string `json:"image_ref"`
Options json.RawMessage `json:"options,omitempty"`
}
// newCreateEngineVersionHandler returns the handler for
// `POST /api/v1/internal/engine-versions`.
func newCreateEngineVersionHandler(deps Dependencies) http.HandlerFunc {
logger := loggerFor(deps.Logger, "internal_rest.create_engine_version")
return func(writer http.ResponseWriter, request *http.Request) {
if deps.EngineVersions == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "engine version service is not wired")
return
}
var body createEngineVersionRequestBody
if err := decodeStrictJSON(request.Body, &body); err != nil {
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, err.Error())
return
}
record, err := deps.EngineVersions.Create(request.Context(), engineversionsvc.CreateInput{
Version: body.Version,
ImageRef: body.ImageRef,
Options: []byte(body.Options),
OpSource: resolveOpSource(request),
SourceRef: requestSourceRef(request),
})
if err != nil {
logger.ErrorContext(request.Context(), "create engine version failed", "err", err.Error())
status, code, message := mapServiceError(err)
writeError(writer, status, code, message)
return
}
writeJSON(writer, http.StatusCreated, encodeEngineVersion(record))
}
}
@@ -0,0 +1,44 @@
package handlers
import (
"net/http"
engineversionsvc "galaxy/gamemaster/internal/service/engineversion"
)
// newDeprecateEngineVersionHandler returns the handler for
// `DELETE /api/v1/internal/engine-versions/{version}`. The endpoint
// flips the row's status to `deprecated` (decision D2 in
// `gamemaster/docs/stage19-internal-rest-handlers.md`); hard removal
// is reserved for future Admin Service operations and not exposed
// here.
func newDeprecateEngineVersionHandler(deps Dependencies) http.HandlerFunc {
logger := loggerFor(deps.Logger, "internal_rest.deprecate_engine_version")
return func(writer http.ResponseWriter, request *http.Request) {
if deps.EngineVersions == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "engine version service is not wired")
return
}
version, ok := extractVersion(writer, request)
if !ok {
return
}
if err := deps.EngineVersions.Deprecate(request.Context(), engineversionsvc.DeprecateInput{
Version: version,
OpSource: resolveOpSource(request),
SourceRef: requestSourceRef(request),
}); err != nil {
logger.ErrorContext(request.Context(), "deprecate engine version failed",
"version", version,
"err", err.Error(),
)
status, code, message := mapServiceError(err)
writeError(writer, status, code, message)
return
}
writeNoContent(writer)
}
}
@@ -0,0 +1,60 @@
package handlers
import (
"net/http"
"galaxy/gamemaster/internal/domain/operation"
"galaxy/gamemaster/internal/service/commandexecute"
)
// newExecuteCommandsHandler returns the handler for
// `POST /api/v1/internal/games/{game_id}/commands`. The request body
// is engine-owned (`additionalProperties: true`) and is forwarded to
// the service as a `json.RawMessage`. The response on success is the
// engine's payload byte-for-byte; failure outcomes use the canonical
// error envelope per the OpenAPI contract.
func newExecuteCommandsHandler(deps Dependencies) http.HandlerFunc {
logger := loggerFor(deps.Logger, "internal_rest.execute_commands")
return func(writer http.ResponseWriter, request *http.Request) {
if deps.CommandExecute == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "command execute service is not wired")
return
}
gameID, ok := extractGameID(writer, request)
if !ok {
return
}
userID, ok := extractUserID(writer, request)
if !ok {
return
}
body, err := readRawJSONBody(request.Body)
if err != nil {
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, err.Error())
return
}
result, err := deps.CommandExecute.Handle(request.Context(), commandexecute.Input{
GameID: gameID,
UserID: userID,
Payload: body,
})
if err != nil {
logger.ErrorContext(request.Context(), "command execute service errored",
"game_id", gameID,
"user_id", userID,
"err", err.Error(),
)
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "command execute service failed")
return
}
if result.Outcome == operation.OutcomeFailure {
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
return
}
writeRawJSON(writer, http.StatusOK, []byte(result.RawResponse))
}
}
@@ -0,0 +1,49 @@
package handlers
import (
"net/http"
"galaxy/gamemaster/internal/domain/operation"
"galaxy/gamemaster/internal/service/adminforce"
)
// newForceNextTurnHandler returns the handler for
// `POST /api/v1/internal/runtimes/{game_id}/force-next-turn`. The
// request has no body; the handler delegates to
// `adminforce.Service.Handle` and encodes the resulting runtime
// record on success.
func newForceNextTurnHandler(deps Dependencies) http.HandlerFunc {
logger := loggerFor(deps.Logger, "internal_rest.force_next_turn")
return func(writer http.ResponseWriter, request *http.Request) {
if deps.ForceNextTurn == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "force next turn service is not wired")
return
}
gameID, ok := extractGameID(writer, request)
if !ok {
return
}
result, err := deps.ForceNextTurn.Handle(request.Context(), adminforce.Input{
GameID: gameID,
OpSource: resolveOpSource(request),
SourceRef: requestSourceRef(request),
})
if err != nil {
logger.ErrorContext(request.Context(), "force next turn service errored",
"game_id", gameID,
"err", err.Error(),
)
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "force next turn service failed")
return
}
if result.Outcome == operation.OutcomeFailure {
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
return
}
writeJSON(writer, http.StatusOK, encodeRuntimeRecord(result.TurnGeneration.Record))
}
}
@@ -0,0 +1,50 @@
package handlers
import (
"net/http"
"strings"
"galaxy/gamemaster/internal/service/livenessreply"
)
// newGameLivenessHandler returns the handler for
// `GET /api/v1/internal/games/{game_id}/liveness`. The endpoint
// always responds with 200 + LivenessResponse; Go-level errors
// returned by the service map to 500 / 503 according to their
// embedded error code prefix.
func newGameLivenessHandler(deps Dependencies) http.HandlerFunc {
logger := loggerFor(deps.Logger, "internal_rest.game_liveness")
return func(writer http.ResponseWriter, request *http.Request) {
if deps.GameLiveness == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "game liveness service is not wired")
return
}
gameID, ok := extractGameID(writer, request)
if !ok {
return
}
result, err := deps.GameLiveness.Handle(request.Context(), livenessreply.Input{GameID: gameID})
if err != nil {
logger.ErrorContext(request.Context(), "game liveness service errored",
"game_id", gameID,
"err", err.Error(),
)
switch {
case strings.HasPrefix(err.Error(), livenessreply.ErrorCodeInvalidRequest+":"):
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, err.Error())
case strings.HasPrefix(err.Error(), livenessreply.ErrorCodeServiceUnavailable+":"):
writeError(writer, http.StatusServiceUnavailable, errorCodeServiceUnavailable, "service unavailable")
default:
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "game liveness service failed")
}
return
}
writeJSON(writer, http.StatusOK, livenessResponse{
Ready: result.Ready,
Status: string(result.Status),
})
}
}
@@ -0,0 +1,33 @@
package handlers
import "net/http"
// newGetEngineVersionHandler returns the handler for
// `GET /api/v1/internal/engine-versions/{version}`.
func newGetEngineVersionHandler(deps Dependencies) http.HandlerFunc {
logger := loggerFor(deps.Logger, "internal_rest.get_engine_version")
return func(writer http.ResponseWriter, request *http.Request) {
if deps.EngineVersions == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "engine version service is not wired")
return
}
version, ok := extractVersion(writer, request)
if !ok {
return
}
record, err := deps.EngineVersions.Get(request.Context(), version)
if err != nil {
logger.ErrorContext(request.Context(), "get engine version failed",
"version", version,
"err", err.Error(),
)
status, code, message := mapServiceError(err)
writeError(writer, status, code, message)
return
}
writeJSON(writer, http.StatusOK, encodeEngineVersion(record))
}
}
@@ -0,0 +1,67 @@
package handlers
import (
"net/http"
"strconv"
"strings"
"galaxy/gamemaster/internal/domain/operation"
"galaxy/gamemaster/internal/service/reportget"
)
// newGetReportHandler returns the handler for
// `GET /api/v1/internal/games/{game_id}/reports/{turn}`. Path
// validation rejects non-numeric or negative turn values with
// `400 invalid_request` before the service is touched.
func newGetReportHandler(deps Dependencies) http.HandlerFunc {
logger := loggerFor(deps.Logger, "internal_rest.get_report")
return func(writer http.ResponseWriter, request *http.Request) {
if deps.GetReport == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "get report service is not wired")
return
}
gameID, ok := extractGameID(writer, request)
if !ok {
return
}
userID, ok := extractUserID(writer, request)
if !ok {
return
}
raw := strings.TrimSpace(request.PathValue(turnPathParam))
if raw == "" {
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, "turn is required")
return
}
turn, err := strconv.Atoi(raw)
if err != nil || turn < 0 {
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, "turn must be a non-negative integer")
return
}
result, err := deps.GetReport.Handle(request.Context(), reportget.Input{
GameID: gameID,
UserID: userID,
Turn: turn,
})
if err != nil {
logger.ErrorContext(request.Context(), "get report service errored",
"game_id", gameID,
"user_id", userID,
"turn", turn,
"err", err.Error(),
)
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "get report service failed")
return
}
if result.Outcome == operation.OutcomeFailure {
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
return
}
writeRawJSON(writer, http.StatusOK, []byte(result.RawResponse))
}
}
@@ -0,0 +1,43 @@
package handlers
import (
"errors"
"net/http"
"galaxy/gamemaster/internal/domain/runtime"
)
// newGetRuntimeHandler returns the handler for
// `GET /api/v1/internal/runtimes/{game_id}`. Reads from
// `RuntimeRecordsReader.Get` and translates `runtime.ErrNotFound` to
// `404 runtime_not_found`.
func newGetRuntimeHandler(deps Dependencies) http.HandlerFunc {
logger := loggerFor(deps.Logger, "internal_rest.get_runtime")
return func(writer http.ResponseWriter, request *http.Request) {
if deps.RuntimeRecords == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "runtime records store is not wired")
return
}
gameID, ok := extractGameID(writer, request)
if !ok {
return
}
record, err := deps.RuntimeRecords.Get(request.Context(), gameID)
if err != nil {
if errors.Is(err, runtime.ErrNotFound) {
writeError(writer, http.StatusNotFound, errorCodeRuntimeNotFound, "runtime not found")
return
}
logger.ErrorContext(request.Context(), "get runtime record failed",
"game_id", gameID,
"err", err.Error(),
)
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "failed to read runtime record")
return
}
writeJSON(writer, http.StatusOK, encodeRuntimeRecord(record))
}
}
@@ -0,0 +1,119 @@
// Package handlers serves the trusted internal REST surface of Game
// Master frozen by `gamemaster/api/internal-openapi.yaml`. The package
// owns one HandlerFunc per OpenAPI operation; route registration goes
// through Register so the listener (`internal/api/internalhttp`) keeps
// its lifecycle code separate from the per-operation logic. Handlers
// delegate every business decision to the `internal/service/*`
// packages and never decode engine-owned hot-path payloads.
//
// The pattern mirrors `rtmanager/internal/api/internalhttp/handlers`
// so a reader familiar with one service can find their way around the
// other.
package handlers
import (
"log/slog"
"net/http"
)
// Route paths frozen by `gamemaster/api/internal-openapi.yaml`. The
// values match the operation IDs asserted in
// `gamemaster/contract_openapi_test.go`; renaming any of them is a
// contract change.
const (
registerRuntimePath = "/api/v1/internal/games/{game_id}/register-runtime"
banishRacePath = "/api/v1/internal/games/{game_id}/race/{race_name}/banish"
invalidateMembershipsPath = "/api/v1/internal/games/{game_id}/memberships/invalidate"
gameLivenessPath = "/api/v1/internal/games/{game_id}/liveness"
listRuntimesPath = "/api/v1/internal/runtimes"
getRuntimePath = "/api/v1/internal/runtimes/{game_id}"
forceNextTurnPath = "/api/v1/internal/runtimes/{game_id}/force-next-turn"
stopRuntimePath = "/api/v1/internal/runtimes/{game_id}/stop"
patchRuntimePath = "/api/v1/internal/runtimes/{game_id}/patch"
listEngineVersionsPath = "/api/v1/internal/engine-versions"
createEngineVersionPath = "/api/v1/internal/engine-versions"
engineVersionItemPath = "/api/v1/internal/engine-versions/{version}"
resolveEngineVersionImageRefPath = "/api/v1/internal/engine-versions/{version}/image-ref"
executeCommandsPath = "/api/v1/internal/games/{game_id}/commands"
putOrdersPath = "/api/v1/internal/games/{game_id}/orders"
getReportPath = "/api/v1/internal/games/{game_id}/reports/{turn}"
)
// Dependencies bundles the collaborators required to serve the
// gateway-, Lobby-, and Admin-facing internal REST surface. Any port
// may be nil; in that case the routes that depend on it return
// `500 internal_error` with the message «service is not wired». This
// mirrors the rtmanager handlers' guard so partially-wired listener
// tests do not crash on routes they do not exercise.
type Dependencies struct {
// Logger receives structured per-handler logs. nil falls back to
// slog.Default.
Logger *slog.Logger
// RuntimeRecords backs the read-only list/get runtime endpoints.
// Reads do not produce operation_log rows, mirroring
// `rtmanager/docs/services.md` §18.
RuntimeRecords RuntimeRecordsReader
// RegisterRuntime is the orchestrator for the
// `internalRegisterRuntime` operation.
RegisterRuntime RegisterRuntimeService
// ForceNextTurn drives the synchronous force-next-turn flow.
ForceNextTurn ForceNextTurnService
// StopRuntime drives the admin stop flow.
StopRuntime StopRuntimeService
// PatchRuntime drives the admin patch flow.
PatchRuntime PatchRuntimeService
// BanishRace drives the engine race-banish flow.
BanishRace BanishRaceService
// InvalidateMemberships purges the in-process membership cache for a
// game id; backed by `service/membership.Cache.Invalidate`.
InvalidateMemberships MembershipInvalidator
// GameLiveness returns the current runtime status without
// contacting the engine.
GameLiveness LivenessService
// EngineVersions exposes the multi-method engine-version registry
// service (List/Get/ResolveImageRef/Create/Update/Deprecate).
EngineVersions EngineVersionService
// CommandExecute forwards a player command batch to the engine.
CommandExecute CommandExecuteService
// PutOrders forwards a player order batch to the engine.
PutOrders OrderPutService
// GetReport reads a per-player turn report from the engine.
GetReport ReportGetService
}
// Register attaches every internal REST route to mux. The function is
// idempotent against the listener-level probes (`/healthz`,
// `/readyz`); the probe routes are owned by the listener and remain
// disjoint from the paths registered here.
func Register(mux *http.ServeMux, deps Dependencies) {
mux.HandleFunc(http.MethodPost+" "+registerRuntimePath, newRegisterRuntimeHandler(deps))
mux.HandleFunc(http.MethodGet+" "+getRuntimePath, newGetRuntimeHandler(deps))
mux.HandleFunc(http.MethodGet+" "+listRuntimesPath, newListRuntimesHandler(deps))
mux.HandleFunc(http.MethodPost+" "+forceNextTurnPath, newForceNextTurnHandler(deps))
mux.HandleFunc(http.MethodPost+" "+stopRuntimePath, newStopRuntimeHandler(deps))
mux.HandleFunc(http.MethodPost+" "+patchRuntimePath, newPatchRuntimeHandler(deps))
mux.HandleFunc(http.MethodPost+" "+banishRacePath, newBanishRaceHandler(deps))
mux.HandleFunc(http.MethodPost+" "+invalidateMembershipsPath, newInvalidateMembershipsHandler(deps))
mux.HandleFunc(http.MethodGet+" "+gameLivenessPath, newGameLivenessHandler(deps))
mux.HandleFunc(http.MethodGet+" "+listEngineVersionsPath, newListEngineVersionsHandler(deps))
mux.HandleFunc(http.MethodPost+" "+createEngineVersionPath, newCreateEngineVersionHandler(deps))
mux.HandleFunc(http.MethodGet+" "+engineVersionItemPath, newGetEngineVersionHandler(deps))
mux.HandleFunc(http.MethodPatch+" "+engineVersionItemPath, newUpdateEngineVersionHandler(deps))
mux.HandleFunc(http.MethodDelete+" "+engineVersionItemPath, newDeprecateEngineVersionHandler(deps))
mux.HandleFunc(http.MethodGet+" "+resolveEngineVersionImageRefPath, newResolveEngineVersionImageRefHandler(deps))
mux.HandleFunc(http.MethodPost+" "+executeCommandsPath, newExecuteCommandsHandler(deps))
mux.HandleFunc(http.MethodPost+" "+putOrdersPath, newPutOrdersHandler(deps))
mux.HandleFunc(http.MethodGet+" "+getReportPath, newGetReportHandler(deps))
}
@@ -0,0 +1,422 @@
package handlers_test
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"galaxy/gamemaster/internal/api/internalhttp/handlers"
"galaxy/gamemaster/internal/api/internalhttp/handlers/mocks"
"galaxy/gamemaster/internal/domain/engineversion"
"galaxy/gamemaster/internal/domain/operation"
"galaxy/gamemaster/internal/domain/runtime"
"galaxy/gamemaster/internal/service/adminstop"
"galaxy/gamemaster/internal/service/commandexecute"
engineversionsvc "galaxy/gamemaster/internal/service/engineversion"
"galaxy/gamemaster/internal/service/livenessreply"
"galaxy/gamemaster/internal/service/registerruntime"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
)
// driveHandler builds a fresh ServeMux + handler set bound to deps,
// fires one request, and returns the recorder.
func driveHandler(t *testing.T, deps handlers.Dependencies, method, path string, body io.Reader, headers map[string]string) *httptest.ResponseRecorder {
t.Helper()
mux := http.NewServeMux()
handlers.Register(mux, deps)
request := httptest.NewRequest(method, path, body)
for key, value := range headers {
request.Header.Set(key, value)
}
if body != nil {
request.Header.Set("Content-Type", "application/json")
}
recorder := httptest.NewRecorder()
mux.ServeHTTP(recorder, request)
return recorder
}
func decodeErrorBody(t *testing.T, recorder *httptest.ResponseRecorder) (string, string) {
t.Helper()
var body struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body))
return body.Error.Code, body.Error.Message
}
func TestRegisterRuntimeHandlerHappyPath(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
moment := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
record := runtime.RuntimeRecord{
GameID: "game-1",
Status: runtime.StatusRunning,
EngineEndpoint: "http://engine:8080",
CurrentImageRef: "galaxy/game:1.2.3",
CurrentEngineVersion: "1.2.3",
TurnSchedule: "0 18 * * *",
CreatedAt: moment,
UpdatedAt: moment,
}
registerSvc := mocks.NewMockRegisterRuntimeService(ctrl)
registerSvc.EXPECT().
Handle(gomock.Any(), gomock.AssignableToTypeOf(registerruntime.Input{})).
DoAndReturn(func(_ context.Context, in registerruntime.Input) (registerruntime.Result, error) {
assert.Equal(t, "game-1", in.GameID)
assert.Equal(t, "http://engine:8080", in.EngineEndpoint)
assert.Equal(t, operation.OpSourceLobbyInternal, in.OpSource)
require.Len(t, in.Members, 1)
return registerruntime.Result{Record: record, Outcome: operation.OutcomeSuccess}, nil
})
body := strings.NewReader(`{
"engine_endpoint": "http://engine:8080",
"members": [{"user_id":"u1","race_name":"Aelinari"}],
"target_engine_version": "1.2.3",
"turn_schedule": "0 18 * * *"
}`)
recorder := driveHandler(t,
handlers.Dependencies{RegisterRuntime: registerSvc},
http.MethodPost,
"/api/v1/internal/games/game-1/register-runtime",
body,
map[string]string{"X-Galaxy-Caller": "lobby"},
)
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
assert.Contains(t, recorder.Body.String(), `"game_id":"game-1"`)
}
func TestRegisterRuntimeHandlerRejectsUnknownFields(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
registerSvc := mocks.NewMockRegisterRuntimeService(ctrl)
// no expectations — handler must short-circuit before calling.
body := strings.NewReader(`{"engine_endpoint":"http://e","extra":1}`)
recorder := driveHandler(t,
handlers.Dependencies{RegisterRuntime: registerSvc},
http.MethodPost,
"/api/v1/internal/games/game-1/register-runtime",
body,
nil,
)
require.Equal(t, http.StatusBadRequest, recorder.Code)
code, _ := decodeErrorBody(t, recorder)
assert.Equal(t, "invalid_request", code)
}
func TestRegisterRuntimeHandlerWiresFailureCodes(t *testing.T) {
t.Parallel()
cases := []struct {
name string
errCode string
wantStatus int
}{
{"invalid_request", registerruntime.ErrorCodeInvalidRequest, http.StatusBadRequest},
{"conflict", registerruntime.ErrorCodeConflict, http.StatusConflict},
{"engine_version_not_found", registerruntime.ErrorCodeEngineVersionNotFound, http.StatusNotFound},
{"engine_unreachable", registerruntime.ErrorCodeEngineUnreachable, http.StatusBadGateway},
{"service_unavailable", registerruntime.ErrorCodeServiceUnavailable, http.StatusServiceUnavailable},
{"internal_error", registerruntime.ErrorCodeInternal, http.StatusInternalServerError},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
svc := mocks.NewMockRegisterRuntimeService(ctrl)
svc.EXPECT().
Handle(gomock.Any(), gomock.Any()).
Return(registerruntime.Result{
Outcome: operation.OutcomeFailure,
ErrorCode: tc.errCode,
ErrorMessage: tc.errCode + " details",
}, nil)
body := strings.NewReader(`{
"engine_endpoint": "http://e",
"members":[{"user_id":"u1","race_name":"r"}],
"target_engine_version":"1.0.0",
"turn_schedule":"* * * * *"
}`)
recorder := driveHandler(t,
handlers.Dependencies{RegisterRuntime: svc},
http.MethodPost,
"/api/v1/internal/games/game-1/register-runtime",
body,
nil,
)
assert.Equal(t, tc.wantStatus, recorder.Code)
code, _ := decodeErrorBody(t, recorder)
assert.Equal(t, tc.errCode, code)
})
}
}
func TestRegisterRuntimeHandlerNilServiceReturns500(t *testing.T) {
t.Parallel()
body := strings.NewReader(`{"engine_endpoint":"http://e"}`)
recorder := driveHandler(t,
handlers.Dependencies{},
http.MethodPost,
"/api/v1/internal/games/game-1/register-runtime",
body,
nil,
)
require.Equal(t, http.StatusInternalServerError, recorder.Code)
code, _ := decodeErrorBody(t, recorder)
assert.Equal(t, "internal_error", code)
}
func TestStopRuntimeHandlerForwardsReason(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
moment := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
record := runtime.RuntimeRecord{
GameID: "game-1",
Status: runtime.StatusStopped,
EngineEndpoint: "http://engine:8080",
CurrentImageRef: "galaxy/game:1.2.3",
CurrentEngineVersion: "1.2.3",
TurnSchedule: "0 18 * * *",
CreatedAt: moment,
UpdatedAt: moment,
}
stopSvc := mocks.NewMockStopRuntimeService(ctrl)
stopSvc.EXPECT().
Handle(gomock.Any(), gomock.AssignableToTypeOf(adminstop.Input{})).
DoAndReturn(func(_ context.Context, in adminstop.Input) (adminstop.Result, error) {
assert.Equal(t, "admin_request", in.Reason)
return adminstop.Result{Record: record, Outcome: operation.OutcomeSuccess}, nil
})
body := strings.NewReader(`{"reason":"admin_request"}`)
recorder := driveHandler(t,
handlers.Dependencies{StopRuntime: stopSvc},
http.MethodPost,
"/api/v1/internal/runtimes/game-1/stop",
body,
nil,
)
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
}
func TestGetEngineVersionHandlerMapsNotFound(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
svc := mocks.NewMockEngineVersionService(ctrl)
svc.EXPECT().
Get(gomock.Any(), "9.9.9").
Return(engineversion.EngineVersion{}, engineversionsvc.ErrNotFound)
recorder := driveHandler(t,
handlers.Dependencies{EngineVersions: svc},
http.MethodGet,
"/api/v1/internal/engine-versions/9.9.9",
nil,
nil,
)
assert.Equal(t, http.StatusNotFound, recorder.Code)
code, _ := decodeErrorBody(t, recorder)
assert.Equal(t, "engine_version_not_found", code)
}
func TestListEngineVersionsHandlerRejectsUnknownStatus(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
svc := mocks.NewMockEngineVersionService(ctrl)
// no expectations — short-circuits.
recorder := driveHandler(t,
handlers.Dependencies{EngineVersions: svc},
http.MethodGet,
"/api/v1/internal/engine-versions?status=mystery",
nil,
nil,
)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
code, _ := decodeErrorBody(t, recorder)
assert.Equal(t, "invalid_request", code)
}
func TestDeprecateEngineVersionReturns204(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
svc := mocks.NewMockEngineVersionService(ctrl)
svc.EXPECT().
Deprecate(gomock.Any(), gomock.AssignableToTypeOf(engineversionsvc.DeprecateInput{})).
Return(nil)
recorder := driveHandler(t,
handlers.Dependencies{EngineVersions: svc},
http.MethodDelete,
"/api/v1/internal/engine-versions/1.0.0",
nil,
nil,
)
assert.Equal(t, http.StatusNoContent, recorder.Code)
assert.Empty(t, recorder.Body.String())
}
func TestDeprecateEngineVersionDoesNotReportInUse(t *testing.T) {
t.Parallel()
// D2: the DELETE endpoint flips status; the handler does not call
// Service.Delete and therefore can never produce
// `engine_version_in_use`. Deprecate's own error vocabulary is
// limited to invalid_request / not_found / service_unavailable.
ctrl := gomock.NewController(t)
svc := mocks.NewMockEngineVersionService(ctrl)
svc.EXPECT().
Deprecate(gomock.Any(), gomock.Any()).
Return(engineversionsvc.ErrNotFound)
recorder := driveHandler(t,
handlers.Dependencies{EngineVersions: svc},
http.MethodDelete,
"/api/v1/internal/engine-versions/9.9.9",
nil,
nil,
)
assert.Equal(t, http.StatusNotFound, recorder.Code)
}
func TestExecuteCommandsRequiresUserIDHeader(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
svc := mocks.NewMockCommandExecuteService(ctrl)
// short-circuit before service is touched.
recorder := driveHandler(t,
handlers.Dependencies{CommandExecute: svc},
http.MethodPost,
"/api/v1/internal/games/game-1/commands",
strings.NewReader(`{"commands":[]}`),
nil,
)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
code, msg := decodeErrorBody(t, recorder)
assert.Equal(t, "invalid_request", code)
assert.Contains(t, msg, "X-User-ID")
}
func TestExecuteCommandsRejectsInvalidJSONBody(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
svc := mocks.NewMockCommandExecuteService(ctrl)
recorder := driveHandler(t,
handlers.Dependencies{CommandExecute: svc},
http.MethodPost,
"/api/v1/internal/games/game-1/commands",
strings.NewReader("not json"),
map[string]string{"X-User-ID": "u1"},
)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
code, _ := decodeErrorBody(t, recorder)
assert.Equal(t, "invalid_request", code)
}
func TestExecuteCommandsForwardsRawResponseOnSuccess(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
svc := mocks.NewMockCommandExecuteService(ctrl)
svc.EXPECT().
Handle(gomock.Any(), gomock.AssignableToTypeOf(commandexecute.Input{})).
DoAndReturn(func(_ context.Context, in commandexecute.Input) (commandexecute.Result, error) {
assert.Equal(t, "game-1", in.GameID)
assert.Equal(t, "u1", in.UserID)
assert.JSONEq(t, `{"commands":[{"name":"build"}]}`, string(in.Payload))
return commandexecute.Result{
Outcome: operation.OutcomeSuccess,
RawResponse: []byte(`{"results":[{"ok":true}]}`),
}, nil
})
recorder := driveHandler(t,
handlers.Dependencies{CommandExecute: svc},
http.MethodPost,
"/api/v1/internal/games/game-1/commands",
strings.NewReader(`{"commands":[{"name":"build"}]}`),
map[string]string{"X-User-ID": "u1"},
)
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
assert.JSONEq(t, `{"results":[{"ok":true}]}`, recorder.Body.String())
}
func TestInvalidateMembershipsAlwaysReturns204(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
cache := mocks.NewMockMembershipInvalidator(ctrl)
cache.EXPECT().Invalidate("game-7").Times(1)
recorder := driveHandler(t,
handlers.Dependencies{InvalidateMemberships: cache},
http.MethodPost,
"/api/v1/internal/games/game-7/memberships/invalidate",
nil,
nil,
)
assert.Equal(t, http.StatusNoContent, recorder.Code)
}
func TestGameLivenessHandlerMapsServiceUnavailable(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
svc := mocks.NewMockLivenessService(ctrl)
svc.EXPECT().
Handle(gomock.Any(), livenessreply.Input{GameID: "game-1"}).
Return(livenessreply.Result{}, errors.New(livenessreply.ErrorCodeServiceUnavailable+": store ping"))
recorder := driveHandler(t,
handlers.Dependencies{GameLiveness: svc},
http.MethodGet,
"/api/v1/internal/games/game-1/liveness",
nil,
nil,
)
assert.Equal(t, http.StatusServiceUnavailable, recorder.Code)
code, _ := decodeErrorBody(t, recorder)
assert.Equal(t, "service_unavailable", code)
}
func TestGetReportRejectsNegativeTurn(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
svc := mocks.NewMockReportGetService(ctrl)
// short-circuits.
recorder := driveHandler(t,
handlers.Dependencies{GetReport: svc},
http.MethodGet,
"/api/v1/internal/games/game-1/reports/-3",
nil,
map[string]string{"X-User-ID": "u1"},
)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
code, _ := decodeErrorBody(t, recorder)
assert.Equal(t, "invalid_request", code)
}
@@ -0,0 +1,25 @@
package handlers
import "net/http"
// newInvalidateMembershipsHandler returns the handler for
// `POST /api/v1/internal/games/{game_id}/memberships/invalidate`. The
// underlying cache invalidation is a fire-and-forget local operation,
// so the handler always responds with `204 No Content` once the path
// parameter validates.
func newInvalidateMembershipsHandler(deps Dependencies) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if deps.InvalidateMemberships == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "membership cache invalidator is not wired")
return
}
gameID, ok := extractGameID(writer, request)
if !ok {
return
}
deps.InvalidateMemberships.Invalidate(gameID)
writeNoContent(writer)
}
}
@@ -0,0 +1,42 @@
package handlers
import (
"net/http"
"strings"
"galaxy/gamemaster/internal/domain/engineversion"
)
// newListEngineVersionsHandler returns the handler for
// `GET /api/v1/internal/engine-versions`. The optional `status`
// query parameter narrows the result.
func newListEngineVersionsHandler(deps Dependencies) http.HandlerFunc {
logger := loggerFor(deps.Logger, "internal_rest.list_engine_versions")
return func(writer http.ResponseWriter, request *http.Request) {
if deps.EngineVersions == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "engine version service is not wired")
return
}
var statusFilter *engineversion.Status
raw := strings.TrimSpace(request.URL.Query().Get("status"))
if raw != "" {
candidate := engineversion.Status(raw)
if !candidate.IsKnown() {
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, "status query parameter is unsupported")
return
}
statusFilter = &candidate
}
versions, err := deps.EngineVersions.List(request.Context(), statusFilter)
if err != nil {
logger.ErrorContext(request.Context(), "list engine versions failed", "err", err.Error())
status, code, message := mapServiceError(err)
writeError(writer, status, code, message)
return
}
writeJSON(writer, http.StatusOK, encodeEngineVersionList(versions))
}
}
@@ -0,0 +1,54 @@
package handlers
import (
"net/http"
"strings"
"galaxy/gamemaster/internal/domain/runtime"
)
// newListRuntimesHandler returns the handler for
// `GET /api/v1/internal/runtimes`. The optional `status` query
// parameter narrows the result; an unknown value short-circuits with
// `400 invalid_request`. Records are returned ordered by
// `created_at DESC` (the underlying store guarantees the ordering).
func newListRuntimesHandler(deps Dependencies) http.HandlerFunc {
logger := loggerFor(deps.Logger, "internal_rest.list_runtimes")
return func(writer http.ResponseWriter, request *http.Request) {
if deps.RuntimeRecords == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "runtime records store is not wired")
return
}
ctx := request.Context()
raw := strings.TrimSpace(request.URL.Query().Get("status"))
if raw == "" {
records, err := deps.RuntimeRecords.List(ctx)
if err != nil {
logger.ErrorContext(ctx, "list runtime records failed", "err", err.Error())
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "failed to list runtime records")
return
}
writeJSON(writer, http.StatusOK, encodeRuntimeList(records))
return
}
status := runtime.Status(raw)
if !status.IsKnown() {
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, "status query parameter is unsupported")
return
}
records, err := deps.RuntimeRecords.ListByStatus(ctx, status)
if err != nil {
logger.ErrorContext(ctx, "list runtime records by status failed",
"status", string(status),
"err", err.Error(),
)
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "failed to list runtime records")
return
}
writeJSON(writer, http.StatusOK, encodeRuntimeList(records))
}
}
@@ -0,0 +1,598 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: galaxy/gamemaster/internal/api/internalhttp/handlers (interfaces: RegisterRuntimeService,ForceNextTurnService,StopRuntimeService,PatchRuntimeService,BanishRaceService,LivenessService,CommandExecuteService,OrderPutService,ReportGetService,MembershipInvalidator,EngineVersionService,RuntimeRecordsReader)
//
// Generated by this command:
//
// mockgen -destination=./mocks/mock_services.go -package=mocks galaxy/gamemaster/internal/api/internalhttp/handlers RegisterRuntimeService,ForceNextTurnService,StopRuntimeService,PatchRuntimeService,BanishRaceService,LivenessService,CommandExecuteService,OrderPutService,ReportGetService,MembershipInvalidator,EngineVersionService,RuntimeRecordsReader
//
// Package mocks is a generated GoMock package.
package mocks
import (
context "context"
engineversion "galaxy/gamemaster/internal/domain/engineversion"
runtime "galaxy/gamemaster/internal/domain/runtime"
adminbanish "galaxy/gamemaster/internal/service/adminbanish"
adminforce "galaxy/gamemaster/internal/service/adminforce"
adminpatch "galaxy/gamemaster/internal/service/adminpatch"
adminstop "galaxy/gamemaster/internal/service/adminstop"
commandexecute "galaxy/gamemaster/internal/service/commandexecute"
engineversion0 "galaxy/gamemaster/internal/service/engineversion"
livenessreply "galaxy/gamemaster/internal/service/livenessreply"
orderput "galaxy/gamemaster/internal/service/orderput"
registerruntime "galaxy/gamemaster/internal/service/registerruntime"
reportget "galaxy/gamemaster/internal/service/reportget"
reflect "reflect"
gomock "go.uber.org/mock/gomock"
)
// MockRegisterRuntimeService is a mock of RegisterRuntimeService interface.
type MockRegisterRuntimeService struct {
ctrl *gomock.Controller
recorder *MockRegisterRuntimeServiceMockRecorder
isgomock struct{}
}
// MockRegisterRuntimeServiceMockRecorder is the mock recorder for MockRegisterRuntimeService.
type MockRegisterRuntimeServiceMockRecorder struct {
mock *MockRegisterRuntimeService
}
// NewMockRegisterRuntimeService creates a new mock instance.
func NewMockRegisterRuntimeService(ctrl *gomock.Controller) *MockRegisterRuntimeService {
mock := &MockRegisterRuntimeService{ctrl: ctrl}
mock.recorder = &MockRegisterRuntimeServiceMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockRegisterRuntimeService) EXPECT() *MockRegisterRuntimeServiceMockRecorder {
return m.recorder
}
// Handle mocks base method.
func (m *MockRegisterRuntimeService) Handle(ctx context.Context, in registerruntime.Input) (registerruntime.Result, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Handle", ctx, in)
ret0, _ := ret[0].(registerruntime.Result)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Handle indicates an expected call of Handle.
func (mr *MockRegisterRuntimeServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockRegisterRuntimeService)(nil).Handle), ctx, in)
}
// MockForceNextTurnService is a mock of ForceNextTurnService interface.
type MockForceNextTurnService struct {
ctrl *gomock.Controller
recorder *MockForceNextTurnServiceMockRecorder
isgomock struct{}
}
// MockForceNextTurnServiceMockRecorder is the mock recorder for MockForceNextTurnService.
type MockForceNextTurnServiceMockRecorder struct {
mock *MockForceNextTurnService
}
// NewMockForceNextTurnService creates a new mock instance.
func NewMockForceNextTurnService(ctrl *gomock.Controller) *MockForceNextTurnService {
mock := &MockForceNextTurnService{ctrl: ctrl}
mock.recorder = &MockForceNextTurnServiceMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockForceNextTurnService) EXPECT() *MockForceNextTurnServiceMockRecorder {
return m.recorder
}
// Handle mocks base method.
func (m *MockForceNextTurnService) Handle(ctx context.Context, in adminforce.Input) (adminforce.Result, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Handle", ctx, in)
ret0, _ := ret[0].(adminforce.Result)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Handle indicates an expected call of Handle.
func (mr *MockForceNextTurnServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockForceNextTurnService)(nil).Handle), ctx, in)
}
// MockStopRuntimeService is a mock of StopRuntimeService interface.
type MockStopRuntimeService struct {
ctrl *gomock.Controller
recorder *MockStopRuntimeServiceMockRecorder
isgomock struct{}
}
// MockStopRuntimeServiceMockRecorder is the mock recorder for MockStopRuntimeService.
type MockStopRuntimeServiceMockRecorder struct {
mock *MockStopRuntimeService
}
// NewMockStopRuntimeService creates a new mock instance.
func NewMockStopRuntimeService(ctrl *gomock.Controller) *MockStopRuntimeService {
mock := &MockStopRuntimeService{ctrl: ctrl}
mock.recorder = &MockStopRuntimeServiceMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockStopRuntimeService) EXPECT() *MockStopRuntimeServiceMockRecorder {
return m.recorder
}
// Handle mocks base method.
func (m *MockStopRuntimeService) Handle(ctx context.Context, in adminstop.Input) (adminstop.Result, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Handle", ctx, in)
ret0, _ := ret[0].(adminstop.Result)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Handle indicates an expected call of Handle.
func (mr *MockStopRuntimeServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockStopRuntimeService)(nil).Handle), ctx, in)
}
// MockPatchRuntimeService is a mock of PatchRuntimeService interface.
type MockPatchRuntimeService struct {
ctrl *gomock.Controller
recorder *MockPatchRuntimeServiceMockRecorder
isgomock struct{}
}
// MockPatchRuntimeServiceMockRecorder is the mock recorder for MockPatchRuntimeService.
type MockPatchRuntimeServiceMockRecorder struct {
mock *MockPatchRuntimeService
}
// NewMockPatchRuntimeService creates a new mock instance.
func NewMockPatchRuntimeService(ctrl *gomock.Controller) *MockPatchRuntimeService {
mock := &MockPatchRuntimeService{ctrl: ctrl}
mock.recorder = &MockPatchRuntimeServiceMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockPatchRuntimeService) EXPECT() *MockPatchRuntimeServiceMockRecorder {
return m.recorder
}
// Handle mocks base method.
func (m *MockPatchRuntimeService) Handle(ctx context.Context, in adminpatch.Input) (adminpatch.Result, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Handle", ctx, in)
ret0, _ := ret[0].(adminpatch.Result)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Handle indicates an expected call of Handle.
func (mr *MockPatchRuntimeServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockPatchRuntimeService)(nil).Handle), ctx, in)
}
// MockBanishRaceService is a mock of BanishRaceService interface.
type MockBanishRaceService struct {
ctrl *gomock.Controller
recorder *MockBanishRaceServiceMockRecorder
isgomock struct{}
}
// MockBanishRaceServiceMockRecorder is the mock recorder for MockBanishRaceService.
type MockBanishRaceServiceMockRecorder struct {
mock *MockBanishRaceService
}
// NewMockBanishRaceService creates a new mock instance.
func NewMockBanishRaceService(ctrl *gomock.Controller) *MockBanishRaceService {
mock := &MockBanishRaceService{ctrl: ctrl}
mock.recorder = &MockBanishRaceServiceMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockBanishRaceService) EXPECT() *MockBanishRaceServiceMockRecorder {
return m.recorder
}
// Handle mocks base method.
func (m *MockBanishRaceService) Handle(ctx context.Context, in adminbanish.Input) (adminbanish.Result, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Handle", ctx, in)
ret0, _ := ret[0].(adminbanish.Result)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Handle indicates an expected call of Handle.
func (mr *MockBanishRaceServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockBanishRaceService)(nil).Handle), ctx, in)
}
// MockLivenessService is a mock of LivenessService interface.
type MockLivenessService struct {
ctrl *gomock.Controller
recorder *MockLivenessServiceMockRecorder
isgomock struct{}
}
// MockLivenessServiceMockRecorder is the mock recorder for MockLivenessService.
type MockLivenessServiceMockRecorder struct {
mock *MockLivenessService
}
// NewMockLivenessService creates a new mock instance.
func NewMockLivenessService(ctrl *gomock.Controller) *MockLivenessService {
mock := &MockLivenessService{ctrl: ctrl}
mock.recorder = &MockLivenessServiceMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockLivenessService) EXPECT() *MockLivenessServiceMockRecorder {
return m.recorder
}
// Handle mocks base method.
func (m *MockLivenessService) Handle(ctx context.Context, in livenessreply.Input) (livenessreply.Result, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Handle", ctx, in)
ret0, _ := ret[0].(livenessreply.Result)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Handle indicates an expected call of Handle.
func (mr *MockLivenessServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockLivenessService)(nil).Handle), ctx, in)
}
// MockCommandExecuteService is a mock of CommandExecuteService interface.
type MockCommandExecuteService struct {
ctrl *gomock.Controller
recorder *MockCommandExecuteServiceMockRecorder
isgomock struct{}
}
// MockCommandExecuteServiceMockRecorder is the mock recorder for MockCommandExecuteService.
type MockCommandExecuteServiceMockRecorder struct {
mock *MockCommandExecuteService
}
// NewMockCommandExecuteService creates a new mock instance.
func NewMockCommandExecuteService(ctrl *gomock.Controller) *MockCommandExecuteService {
mock := &MockCommandExecuteService{ctrl: ctrl}
mock.recorder = &MockCommandExecuteServiceMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockCommandExecuteService) EXPECT() *MockCommandExecuteServiceMockRecorder {
return m.recorder
}
// Handle mocks base method.
func (m *MockCommandExecuteService) Handle(ctx context.Context, in commandexecute.Input) (commandexecute.Result, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Handle", ctx, in)
ret0, _ := ret[0].(commandexecute.Result)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Handle indicates an expected call of Handle.
func (mr *MockCommandExecuteServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockCommandExecuteService)(nil).Handle), ctx, in)
}
// MockOrderPutService is a mock of OrderPutService interface.
type MockOrderPutService struct {
ctrl *gomock.Controller
recorder *MockOrderPutServiceMockRecorder
isgomock struct{}
}
// MockOrderPutServiceMockRecorder is the mock recorder for MockOrderPutService.
type MockOrderPutServiceMockRecorder struct {
mock *MockOrderPutService
}
// NewMockOrderPutService creates a new mock instance.
func NewMockOrderPutService(ctrl *gomock.Controller) *MockOrderPutService {
mock := &MockOrderPutService{ctrl: ctrl}
mock.recorder = &MockOrderPutServiceMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockOrderPutService) EXPECT() *MockOrderPutServiceMockRecorder {
return m.recorder
}
// Handle mocks base method.
func (m *MockOrderPutService) Handle(ctx context.Context, in orderput.Input) (orderput.Result, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Handle", ctx, in)
ret0, _ := ret[0].(orderput.Result)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Handle indicates an expected call of Handle.
func (mr *MockOrderPutServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockOrderPutService)(nil).Handle), ctx, in)
}
// MockReportGetService is a mock of ReportGetService interface.
type MockReportGetService struct {
ctrl *gomock.Controller
recorder *MockReportGetServiceMockRecorder
isgomock struct{}
}
// MockReportGetServiceMockRecorder is the mock recorder for MockReportGetService.
type MockReportGetServiceMockRecorder struct {
mock *MockReportGetService
}
// NewMockReportGetService creates a new mock instance.
func NewMockReportGetService(ctrl *gomock.Controller) *MockReportGetService {
mock := &MockReportGetService{ctrl: ctrl}
mock.recorder = &MockReportGetServiceMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockReportGetService) EXPECT() *MockReportGetServiceMockRecorder {
return m.recorder
}
// Handle mocks base method.
func (m *MockReportGetService) Handle(ctx context.Context, in reportget.Input) (reportget.Result, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Handle", ctx, in)
ret0, _ := ret[0].(reportget.Result)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Handle indicates an expected call of Handle.
func (mr *MockReportGetServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockReportGetService)(nil).Handle), ctx, in)
}
// MockMembershipInvalidator is a mock of MembershipInvalidator interface.
type MockMembershipInvalidator struct {
ctrl *gomock.Controller
recorder *MockMembershipInvalidatorMockRecorder
isgomock struct{}
}
// MockMembershipInvalidatorMockRecorder is the mock recorder for MockMembershipInvalidator.
type MockMembershipInvalidatorMockRecorder struct {
mock *MockMembershipInvalidator
}
// NewMockMembershipInvalidator creates a new mock instance.
func NewMockMembershipInvalidator(ctrl *gomock.Controller) *MockMembershipInvalidator {
mock := &MockMembershipInvalidator{ctrl: ctrl}
mock.recorder = &MockMembershipInvalidatorMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockMembershipInvalidator) EXPECT() *MockMembershipInvalidatorMockRecorder {
return m.recorder
}
// Invalidate mocks base method.
func (m *MockMembershipInvalidator) Invalidate(gameID string) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "Invalidate", gameID)
}
// Invalidate indicates an expected call of Invalidate.
func (mr *MockMembershipInvalidatorMockRecorder) Invalidate(gameID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Invalidate", reflect.TypeOf((*MockMembershipInvalidator)(nil).Invalidate), gameID)
}
// MockEngineVersionService is a mock of EngineVersionService interface.
type MockEngineVersionService struct {
ctrl *gomock.Controller
recorder *MockEngineVersionServiceMockRecorder
isgomock struct{}
}
// MockEngineVersionServiceMockRecorder is the mock recorder for MockEngineVersionService.
type MockEngineVersionServiceMockRecorder struct {
mock *MockEngineVersionService
}
// NewMockEngineVersionService creates a new mock instance.
func NewMockEngineVersionService(ctrl *gomock.Controller) *MockEngineVersionService {
mock := &MockEngineVersionService{ctrl: ctrl}
mock.recorder = &MockEngineVersionServiceMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockEngineVersionService) EXPECT() *MockEngineVersionServiceMockRecorder {
return m.recorder
}
// Create mocks base method.
func (m *MockEngineVersionService) Create(ctx context.Context, in engineversion0.CreateInput) (engineversion.EngineVersion, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Create", ctx, in)
ret0, _ := ret[0].(engineversion.EngineVersion)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Create indicates an expected call of Create.
func (mr *MockEngineVersionServiceMockRecorder) Create(ctx, in any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockEngineVersionService)(nil).Create), ctx, in)
}
// Deprecate mocks base method.
func (m *MockEngineVersionService) Deprecate(ctx context.Context, in engineversion0.DeprecateInput) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Deprecate", ctx, in)
ret0, _ := ret[0].(error)
return ret0
}
// Deprecate indicates an expected call of Deprecate.
func (mr *MockEngineVersionServiceMockRecorder) Deprecate(ctx, in any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Deprecate", reflect.TypeOf((*MockEngineVersionService)(nil).Deprecate), ctx, in)
}
// Get mocks base method.
func (m *MockEngineVersionService) Get(ctx context.Context, version string) (engineversion.EngineVersion, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Get", ctx, version)
ret0, _ := ret[0].(engineversion.EngineVersion)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Get indicates an expected call of Get.
func (mr *MockEngineVersionServiceMockRecorder) Get(ctx, version any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockEngineVersionService)(nil).Get), ctx, version)
}
// List mocks base method.
func (m *MockEngineVersionService) List(ctx context.Context, statusFilter *engineversion.Status) ([]engineversion.EngineVersion, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "List", ctx, statusFilter)
ret0, _ := ret[0].([]engineversion.EngineVersion)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// List indicates an expected call of List.
func (mr *MockEngineVersionServiceMockRecorder) List(ctx, statusFilter any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockEngineVersionService)(nil).List), ctx, statusFilter)
}
// ResolveImageRef mocks base method.
func (m *MockEngineVersionService) ResolveImageRef(ctx context.Context, version string) (string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ResolveImageRef", ctx, version)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ResolveImageRef indicates an expected call of ResolveImageRef.
func (mr *MockEngineVersionServiceMockRecorder) ResolveImageRef(ctx, version any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveImageRef", reflect.TypeOf((*MockEngineVersionService)(nil).ResolveImageRef), ctx, version)
}
// Update mocks base method.
func (m *MockEngineVersionService) Update(ctx context.Context, in engineversion0.UpdateInput) (engineversion.EngineVersion, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Update", ctx, in)
ret0, _ := ret[0].(engineversion.EngineVersion)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Update indicates an expected call of Update.
func (mr *MockEngineVersionServiceMockRecorder) Update(ctx, in any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockEngineVersionService)(nil).Update), ctx, in)
}
// MockRuntimeRecordsReader is a mock of RuntimeRecordsReader interface.
type MockRuntimeRecordsReader struct {
ctrl *gomock.Controller
recorder *MockRuntimeRecordsReaderMockRecorder
isgomock struct{}
}
// MockRuntimeRecordsReaderMockRecorder is the mock recorder for MockRuntimeRecordsReader.
type MockRuntimeRecordsReaderMockRecorder struct {
mock *MockRuntimeRecordsReader
}
// NewMockRuntimeRecordsReader creates a new mock instance.
func NewMockRuntimeRecordsReader(ctrl *gomock.Controller) *MockRuntimeRecordsReader {
mock := &MockRuntimeRecordsReader{ctrl: ctrl}
mock.recorder = &MockRuntimeRecordsReaderMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockRuntimeRecordsReader) EXPECT() *MockRuntimeRecordsReaderMockRecorder {
return m.recorder
}
// Get mocks base method.
func (m *MockRuntimeRecordsReader) Get(ctx context.Context, gameID string) (runtime.RuntimeRecord, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Get", ctx, gameID)
ret0, _ := ret[0].(runtime.RuntimeRecord)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Get indicates an expected call of Get.
func (mr *MockRuntimeRecordsReaderMockRecorder) Get(ctx, gameID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockRuntimeRecordsReader)(nil).Get), ctx, gameID)
}
// List mocks base method.
func (m *MockRuntimeRecordsReader) List(ctx context.Context) ([]runtime.RuntimeRecord, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "List", ctx)
ret0, _ := ret[0].([]runtime.RuntimeRecord)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// List indicates an expected call of List.
func (mr *MockRuntimeRecordsReaderMockRecorder) List(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockRuntimeRecordsReader)(nil).List), ctx)
}
// ListByStatus mocks base method.
func (m *MockRuntimeRecordsReader) ListByStatus(ctx context.Context, status runtime.Status) ([]runtime.RuntimeRecord, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListByStatus", ctx, status)
ret0, _ := ret[0].([]runtime.RuntimeRecord)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ListByStatus indicates an expected call of ListByStatus.
func (mr *MockRuntimeRecordsReaderMockRecorder) ListByStatus(ctx, status any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByStatus", reflect.TypeOf((*MockRuntimeRecordsReader)(nil).ListByStatus), ctx, status)
}
@@ -0,0 +1,59 @@
package handlers
import (
"net/http"
"galaxy/gamemaster/internal/domain/operation"
"galaxy/gamemaster/internal/service/adminpatch"
)
// patchRuntimeRequestBody mirrors the OpenAPI PatchRuntimeRequest
// schema.
type patchRuntimeRequestBody struct {
Version string `json:"version"`
}
// newPatchRuntimeHandler returns the handler for
// `POST /api/v1/internal/runtimes/{game_id}/patch`.
func newPatchRuntimeHandler(deps Dependencies) http.HandlerFunc {
logger := loggerFor(deps.Logger, "internal_rest.patch_runtime")
return func(writer http.ResponseWriter, request *http.Request) {
if deps.PatchRuntime == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "patch runtime service is not wired")
return
}
gameID, ok := extractGameID(writer, request)
if !ok {
return
}
var body patchRuntimeRequestBody
if err := decodeStrictJSON(request.Body, &body); err != nil {
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, err.Error())
return
}
result, err := deps.PatchRuntime.Handle(request.Context(), adminpatch.Input{
GameID: gameID,
Version: body.Version,
OpSource: resolveOpSource(request),
SourceRef: requestSourceRef(request),
})
if err != nil {
logger.ErrorContext(request.Context(), "patch runtime service errored",
"game_id", gameID,
"err", err.Error(),
)
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "patch runtime service failed")
return
}
if result.Outcome == operation.OutcomeFailure {
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
return
}
writeJSON(writer, http.StatusOK, encodeRuntimeRecord(result.Record))
}
}
@@ -0,0 +1,58 @@
package handlers
import (
"net/http"
"galaxy/gamemaster/internal/domain/operation"
"galaxy/gamemaster/internal/service/orderput"
)
// newPutOrdersHandler returns the handler for
// `POST /api/v1/internal/games/{game_id}/orders`. The shape and
// semantics mirror executeCommands: engine-owned body, raw JSON
// pass-through on success, error envelope on failure.
func newPutOrdersHandler(deps Dependencies) http.HandlerFunc {
logger := loggerFor(deps.Logger, "internal_rest.put_orders")
return func(writer http.ResponseWriter, request *http.Request) {
if deps.PutOrders == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "put orders service is not wired")
return
}
gameID, ok := extractGameID(writer, request)
if !ok {
return
}
userID, ok := extractUserID(writer, request)
if !ok {
return
}
body, err := readRawJSONBody(request.Body)
if err != nil {
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, err.Error())
return
}
result, err := deps.PutOrders.Handle(request.Context(), orderput.Input{
GameID: gameID,
UserID: userID,
Payload: body,
})
if err != nil {
logger.ErrorContext(request.Context(), "put orders service errored",
"game_id", gameID,
"user_id", userID,
"err", err.Error(),
)
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "put orders service failed")
return
}
if result.Outcome == operation.OutcomeFailure {
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
return
}
writeRawJSON(writer, http.StatusOK, []byte(result.RawResponse))
}
}
@@ -0,0 +1,81 @@
package handlers
import (
"net/http"
"galaxy/gamemaster/internal/domain/operation"
"galaxy/gamemaster/internal/service/registerruntime"
)
// registerRuntimeRequestBody mirrors the OpenAPI
// RegisterRuntimeRequest schema. Strict decoding rejects unknown
// fields.
type registerRuntimeRequestBody struct {
EngineEndpoint string `json:"engine_endpoint"`
Members []registerRuntimeMemberBody `json:"members"`
TargetEngineVersion string `json:"target_engine_version"`
TurnSchedule string `json:"turn_schedule"`
}
// registerRuntimeMemberBody mirrors the OpenAPI
// RegisterRuntimeMember schema.
type registerRuntimeMemberBody struct {
UserID string `json:"user_id"`
RaceName string `json:"race_name"`
}
// newRegisterRuntimeHandler returns the handler for
// `POST /api/v1/internal/games/{game_id}/register-runtime`.
func newRegisterRuntimeHandler(deps Dependencies) http.HandlerFunc {
logger := loggerFor(deps.Logger, "internal_rest.register_runtime")
return func(writer http.ResponseWriter, request *http.Request) {
if deps.RegisterRuntime == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "register runtime service is not wired")
return
}
gameID, ok := extractGameID(writer, request)
if !ok {
return
}
var body registerRuntimeRequestBody
if err := decodeStrictJSON(request.Body, &body); err != nil {
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, err.Error())
return
}
members := make([]registerruntime.Member, 0, len(body.Members))
for _, member := range body.Members {
members = append(members, registerruntime.Member{
UserID: member.UserID,
RaceName: member.RaceName,
})
}
result, err := deps.RegisterRuntime.Handle(request.Context(), registerruntime.Input{
GameID: gameID,
EngineEndpoint: body.EngineEndpoint,
Members: members,
TargetEngineVersion: body.TargetEngineVersion,
TurnSchedule: body.TurnSchedule,
OpSource: resolveOpSource(request),
SourceRef: requestSourceRef(request),
})
if err != nil {
logger.ErrorContext(request.Context(), "register runtime service errored",
"game_id", gameID,
"err", err.Error(),
)
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "register runtime service failed")
return
}
if result.Outcome == operation.OutcomeFailure {
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
return
}
writeJSON(writer, http.StatusOK, encodeRuntimeRecord(result.Record))
}
}
@@ -0,0 +1,35 @@
package handlers
import "net/http"
// newResolveEngineVersionImageRefHandler returns the handler for
// `GET /api/v1/internal/engine-versions/{version}/image-ref`. It is
// the hot-path Lobby calls before publishing a `runtime:start_jobs`
// envelope; the response carries only the image reference.
func newResolveEngineVersionImageRefHandler(deps Dependencies) http.HandlerFunc {
logger := loggerFor(deps.Logger, "internal_rest.resolve_image_ref")
return func(writer http.ResponseWriter, request *http.Request) {
if deps.EngineVersions == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "engine version service is not wired")
return
}
version, ok := extractVersion(writer, request)
if !ok {
return
}
imageRef, err := deps.EngineVersions.ResolveImageRef(request.Context(), version)
if err != nil {
logger.ErrorContext(request.Context(), "resolve image ref failed",
"version", version,
"err", err.Error(),
)
status, code, message := mapServiceError(err)
writeError(writer, status, code, message)
return
}
writeJSON(writer, http.StatusOK, imageRefResponse{ImageRef: imageRef})
}
}
@@ -0,0 +1,98 @@
package handlers
import (
"context"
"galaxy/gamemaster/internal/domain/engineversion"
"galaxy/gamemaster/internal/domain/runtime"
"galaxy/gamemaster/internal/service/adminbanish"
"galaxy/gamemaster/internal/service/adminforce"
"galaxy/gamemaster/internal/service/adminpatch"
"galaxy/gamemaster/internal/service/adminstop"
"galaxy/gamemaster/internal/service/commandexecute"
engineversionsvc "galaxy/gamemaster/internal/service/engineversion"
"galaxy/gamemaster/internal/service/livenessreply"
"galaxy/gamemaster/internal/service/orderput"
"galaxy/gamemaster/internal/service/registerruntime"
"galaxy/gamemaster/internal/service/reportget"
)
//go:generate go run go.uber.org/mock/mockgen -destination=./mocks/mock_services.go -package=mocks galaxy/gamemaster/internal/api/internalhttp/handlers RegisterRuntimeService,ForceNextTurnService,StopRuntimeService,PatchRuntimeService,BanishRaceService,LivenessService,CommandExecuteService,OrderPutService,ReportGetService,MembershipInvalidator,EngineVersionService,RuntimeRecordsReader
// RegisterRuntimeService wires the `internalRegisterRuntime` handler
// to the underlying register-runtime orchestrator.
type RegisterRuntimeService interface {
Handle(ctx context.Context, in registerruntime.Input) (registerruntime.Result, error)
}
// ForceNextTurnService wires the `internalForceNextTurn` handler.
type ForceNextTurnService interface {
Handle(ctx context.Context, in adminforce.Input) (adminforce.Result, error)
}
// StopRuntimeService wires the `internalStopRuntime` handler.
type StopRuntimeService interface {
Handle(ctx context.Context, in adminstop.Input) (adminstop.Result, error)
}
// PatchRuntimeService wires the `internalPatchRuntime` handler.
type PatchRuntimeService interface {
Handle(ctx context.Context, in adminpatch.Input) (adminpatch.Result, error)
}
// BanishRaceService wires the `internalBanishRace` handler.
type BanishRaceService interface {
Handle(ctx context.Context, in adminbanish.Input) (adminbanish.Result, error)
}
// LivenessService wires the `internalGameLiveness` handler.
type LivenessService interface {
Handle(ctx context.Context, in livenessreply.Input) (livenessreply.Result, error)
}
// CommandExecuteService wires the `internalExecuteCommands` handler.
type CommandExecuteService interface {
Handle(ctx context.Context, in commandexecute.Input) (commandexecute.Result, error)
}
// OrderPutService wires the `internalPutOrders` handler.
type OrderPutService interface {
Handle(ctx context.Context, in orderput.Input) (orderput.Result, error)
}
// ReportGetService wires the `internalGetReport` handler.
type ReportGetService interface {
Handle(ctx context.Context, in reportget.Input) (reportget.Result, error)
}
// MembershipInvalidator wires the `internalInvalidateMemberships`
// handler. Backed by `service/membership.Cache.Invalidate`.
type MembershipInvalidator interface {
// Invalidate purges the in-process membership cache entry for
// gameID. The call is fire-and-forget and never returns an error;
// missing entries are a no-op.
Invalidate(gameID string)
}
// EngineVersionService wires every engine-version registry handler. The
// service exposes one Go-error-returning method per OpenAPI operation;
// the handler layer translates the wrapped sentinel errors into
// `engine_version_*` codes via `mapServiceError`.
type EngineVersionService interface {
List(ctx context.Context, statusFilter *engineversion.Status) ([]engineversion.EngineVersion, error)
Get(ctx context.Context, version string) (engineversion.EngineVersion, error)
ResolveImageRef(ctx context.Context, version string) (string, error)
Create(ctx context.Context, in engineversionsvc.CreateInput) (engineversion.EngineVersion, error)
Update(ctx context.Context, in engineversionsvc.UpdateInput) (engineversion.EngineVersion, error)
Deprecate(ctx context.Context, in engineversionsvc.DeprecateInput) error
}
// RuntimeRecordsReader exposes the read-only subset of
// `ports.RuntimeRecordStore` required by the get/list runtime
// handlers. The narrower surface keeps the handler layer from
// inadvertently mutating runtime state.
type RuntimeRecordsReader interface {
Get(ctx context.Context, gameID string) (runtime.RuntimeRecord, error)
List(ctx context.Context) ([]runtime.RuntimeRecord, error)
ListByStatus(ctx context.Context, status runtime.Status) ([]runtime.RuntimeRecord, error)
}
@@ -0,0 +1,59 @@
package handlers
import (
"net/http"
"galaxy/gamemaster/internal/domain/operation"
"galaxy/gamemaster/internal/service/adminstop"
)
// stopRuntimeRequestBody mirrors the OpenAPI StopRuntimeRequest
// schema.
type stopRuntimeRequestBody struct {
Reason string `json:"reason"`
}
// newStopRuntimeHandler returns the handler for
// `POST /api/v1/internal/runtimes/{game_id}/stop`.
func newStopRuntimeHandler(deps Dependencies) http.HandlerFunc {
logger := loggerFor(deps.Logger, "internal_rest.stop_runtime")
return func(writer http.ResponseWriter, request *http.Request) {
if deps.StopRuntime == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "stop runtime service is not wired")
return
}
gameID, ok := extractGameID(writer, request)
if !ok {
return
}
var body stopRuntimeRequestBody
if err := decodeStrictJSON(request.Body, &body); err != nil {
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, err.Error())
return
}
result, err := deps.StopRuntime.Handle(request.Context(), adminstop.Input{
GameID: gameID,
Reason: body.Reason,
OpSource: resolveOpSource(request),
SourceRef: requestSourceRef(request),
})
if err != nil {
logger.ErrorContext(request.Context(), "stop runtime service errored",
"game_id", gameID,
"err", err.Error(),
)
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "stop runtime service failed")
return
}
if result.Outcome == operation.OutcomeFailure {
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
return
}
writeJSON(writer, http.StatusOK, encodeRuntimeRecord(result.Record))
}
}
@@ -0,0 +1,69 @@
package handlers
import (
"encoding/json"
"net/http"
"galaxy/gamemaster/internal/domain/engineversion"
engineversionsvc "galaxy/gamemaster/internal/service/engineversion"
)
// updateEngineVersionRequestBody mirrors the OpenAPI
// UpdateEngineVersionRequest schema. Every field is optional; the
// service rejects calls with no fields set as `invalid_request`.
type updateEngineVersionRequestBody struct {
ImageRef *string `json:"image_ref,omitempty"`
Options *json.RawMessage `json:"options,omitempty"`
Status *string `json:"status,omitempty"`
}
// newUpdateEngineVersionHandler returns the handler for
// `PATCH /api/v1/internal/engine-versions/{version}`.
func newUpdateEngineVersionHandler(deps Dependencies) http.HandlerFunc {
logger := loggerFor(deps.Logger, "internal_rest.update_engine_version")
return func(writer http.ResponseWriter, request *http.Request) {
if deps.EngineVersions == nil {
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "engine version service is not wired")
return
}
version, ok := extractVersion(writer, request)
if !ok {
return
}
var body updateEngineVersionRequestBody
if err := decodeStrictJSON(request.Body, &body); err != nil {
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, err.Error())
return
}
input := engineversionsvc.UpdateInput{
Version: version,
ImageRef: body.ImageRef,
OpSource: resolveOpSource(request),
SourceRef: requestSourceRef(request),
}
if body.Options != nil {
optionBytes := []byte(*body.Options)
input.Options = &optionBytes
}
if body.Status != nil {
candidate := engineversion.Status(*body.Status)
input.Status = &candidate
}
record, err := deps.EngineVersions.Update(request.Context(), input)
if err != nil {
logger.ErrorContext(request.Context(), "update engine version failed",
"version", version,
"err", err.Error(),
)
status, code, message := mapServiceError(err)
writeError(writer, status, code, message)
return
}
writeJSON(writer, http.StatusOK, encodeEngineVersion(record))
}
}
@@ -0,0 +1,392 @@
// Package internalhttp provides the trusted internal HTTP listener
// used by the runnable Game Master process. It exposes the `/healthz`
// and `/readyz` probes plus every internal REST operation declared in
// `gamemaster/api/internal-openapi.yaml`. Per-operation handlers live
// in the nested `handlers` package; this file owns the listener
// lifecycle and the probe routes only.
package internalhttp
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"strconv"
"sync"
"time"
"galaxy/gamemaster/internal/api/internalhttp/handlers"
"galaxy/gamemaster/internal/telemetry"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/otel/attribute"
)
const jsonContentType = "application/json; charset=utf-8"
// errorCodeServiceUnavailable mirrors the stable error code declared in
// `gamemaster/api/internal-openapi.yaml` §Error Model.
const errorCodeServiceUnavailable = "service_unavailable"
// HealthzPath and ReadyzPath are the internal probe routes documented in
// `gamemaster/api/internal-openapi.yaml`.
const (
HealthzPath = "/healthz"
ReadyzPath = "/readyz"
)
// ReadinessProbe reports whether the dependencies the listener guards
// (PostgreSQL, Redis) are reachable. A non-nil error is reported to the
// caller as `503 service_unavailable` with the wrapped message.
type ReadinessProbe interface {
Check(ctx context.Context) error
}
// Config describes the trusted internal HTTP listener owned by Game
// Master.
type Config struct {
// Addr is the TCP listen address used by the internal HTTP server.
Addr string
// ReadHeaderTimeout bounds how long the listener may spend reading
// request headers before the server rejects the connection.
ReadHeaderTimeout time.Duration
// ReadTimeout bounds how long the listener may spend reading one
// request.
ReadTimeout time.Duration
// WriteTimeout bounds how long the listener may spend writing one
// response.
WriteTimeout time.Duration
// IdleTimeout bounds how long the listener keeps an idle keep-alive
// connection open.
IdleTimeout time.Duration
}
// Validate reports whether cfg contains a usable internal HTTP listener
// configuration.
func (cfg Config) Validate() error {
switch {
case cfg.Addr == "":
return errors.New("internal HTTP addr must not be empty")
case cfg.ReadHeaderTimeout <= 0:
return errors.New("internal HTTP read header timeout must be positive")
case cfg.ReadTimeout <= 0:
return errors.New("internal HTTP read timeout must be positive")
case cfg.WriteTimeout <= 0:
return errors.New("internal HTTP write timeout must be positive")
case cfg.IdleTimeout <= 0:
return errors.New("internal HTTP idle timeout must be positive")
default:
return nil
}
}
// Dependencies describes the collaborators used by the internal HTTP
// transport layer. The probe-only fields (Logger, Telemetry,
// Readiness) drive `/healthz` and `/readyz`; the remaining fields
// pass through to the per-operation handlers registered by
// `handlers.Register`.
type Dependencies struct {
// Logger writes structured listener lifecycle logs. When nil,
// slog.Default is used.
Logger *slog.Logger
// Telemetry records low-cardinality probe metrics and lifecycle
// events.
Telemetry *telemetry.Runtime
// Readiness reports whether PG / Redis are reachable. A nil
// readiness probe makes `/readyz` always answer `200`; the runtime
// always supplies a real probe in production wiring.
Readiness ReadinessProbe
// RuntimeRecords backs the read-only list/get runtime endpoints.
RuntimeRecords handlers.RuntimeRecordsReader
// RegisterRuntime is the orchestrator for `internalRegisterRuntime`.
RegisterRuntime handlers.RegisterRuntimeService
// ForceNextTurn drives the synchronous force-next-turn flow.
ForceNextTurn handlers.ForceNextTurnService
// StopRuntime drives the admin stop flow.
StopRuntime handlers.StopRuntimeService
// PatchRuntime drives the admin patch flow.
PatchRuntime handlers.PatchRuntimeService
// BanishRace drives the engine race-banish flow.
BanishRace handlers.BanishRaceService
// InvalidateMemberships purges the in-process membership cache.
InvalidateMemberships handlers.MembershipInvalidator
// GameLiveness returns the current runtime status without
// contacting the engine.
GameLiveness handlers.LivenessService
// EngineVersions exposes the multi-method engine-version registry
// service.
EngineVersions handlers.EngineVersionService
// CommandExecute forwards a player command batch to the engine.
CommandExecute handlers.CommandExecuteService
// PutOrders forwards a player order batch to the engine.
PutOrders handlers.OrderPutService
// GetReport reads a per-player turn report from the engine.
GetReport handlers.ReportGetService
}
// Server owns the trusted internal HTTP listener exposed by Game Master.
type Server struct {
cfg Config
handler http.Handler
logger *slog.Logger
metrics *telemetry.Runtime
stateMu sync.RWMutex
server *http.Server
listener net.Listener
}
// NewServer constructs one trusted internal HTTP server for cfg and deps.
func NewServer(cfg Config, deps Dependencies) (*Server, error) {
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("new internal HTTP server: %w", err)
}
logger := deps.Logger
if logger == nil {
logger = slog.Default()
}
return &Server{
cfg: cfg,
handler: newHandler(deps, logger),
logger: logger.With("component", "internal_http"),
metrics: deps.Telemetry,
}, nil
}
// Addr returns the currently bound listener address after Run is called.
// It returns an empty string if the server has not yet bound a listener.
func (server *Server) Addr() string {
server.stateMu.RLock()
defer server.stateMu.RUnlock()
if server.listener == nil {
return ""
}
return server.listener.Addr().String()
}
// Run binds the configured listener and serves the internal HTTP surface
// until Shutdown closes the server.
func (server *Server) Run(ctx context.Context) error {
if ctx == nil {
return errors.New("run internal HTTP server: nil context")
}
if err := ctx.Err(); err != nil {
return err
}
listener, err := net.Listen("tcp", server.cfg.Addr)
if err != nil {
return fmt.Errorf("run internal HTTP server: listen on %q: %w", server.cfg.Addr, err)
}
httpServer := &http.Server{
Handler: server.handler,
ReadHeaderTimeout: server.cfg.ReadHeaderTimeout,
ReadTimeout: server.cfg.ReadTimeout,
WriteTimeout: server.cfg.WriteTimeout,
IdleTimeout: server.cfg.IdleTimeout,
}
server.stateMu.Lock()
server.server = httpServer
server.listener = listener
server.stateMu.Unlock()
server.logger.Info("gamemaster internal HTTP server started", "addr", listener.Addr().String())
defer func() {
server.stateMu.Lock()
server.server = nil
server.listener = nil
server.stateMu.Unlock()
}()
err = httpServer.Serve(listener)
switch {
case err == nil:
return nil
case errors.Is(err, http.ErrServerClosed):
server.logger.Info("gamemaster internal HTTP server stopped")
return nil
default:
return fmt.Errorf("run internal HTTP server: serve on %q: %w", server.cfg.Addr, err)
}
}
// Shutdown gracefully stops the internal HTTP server within ctx.
func (server *Server) Shutdown(ctx context.Context) error {
if ctx == nil {
return errors.New("shutdown internal HTTP server: nil context")
}
server.stateMu.RLock()
httpServer := server.server
server.stateMu.RUnlock()
if httpServer == nil {
return nil
}
if err := httpServer.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("shutdown internal HTTP server: %w", err)
}
return nil
}
func newHandler(deps Dependencies, logger *slog.Logger) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET "+HealthzPath, handleHealthz)
mux.HandleFunc("GET "+ReadyzPath, handleReadyz(deps.Readiness, logger))
handlers.Register(mux, handlers.Dependencies{
Logger: logger,
RuntimeRecords: deps.RuntimeRecords,
RegisterRuntime: deps.RegisterRuntime,
ForceNextTurn: deps.ForceNextTurn,
StopRuntime: deps.StopRuntime,
PatchRuntime: deps.PatchRuntime,
BanishRace: deps.BanishRace,
InvalidateMemberships: deps.InvalidateMemberships,
GameLiveness: deps.GameLiveness,
EngineVersions: deps.EngineVersions,
CommandExecute: deps.CommandExecute,
PutOrders: deps.PutOrders,
GetReport: deps.GetReport,
})
metrics := deps.Telemetry
options := []otelhttp.Option{}
if metrics != nil {
options = append(options,
otelhttp.WithTracerProvider(metrics.TracerProvider()),
otelhttp.WithMeterProvider(metrics.MeterProvider()),
)
}
return otelhttp.NewHandler(withObservability(mux, metrics), "gamemaster.internal_http", options...)
}
func withObservability(next http.Handler, metrics *telemetry.Runtime) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
startedAt := time.Now()
recorder := &statusRecorder{
ResponseWriter: writer,
statusCode: http.StatusOK,
}
next.ServeHTTP(recorder, request)
route := request.Pattern
switch recorder.statusCode {
case http.StatusMethodNotAllowed:
route = "method_not_allowed"
case http.StatusNotFound:
route = "not_found"
case 0:
route = "unmatched"
}
if route == "" {
route = "unmatched"
}
if metrics != nil {
metrics.RecordInternalHTTPRequest(
request.Context(),
[]attribute.KeyValue{
attribute.String("route", route),
attribute.String("method", request.Method),
attribute.String("status_code", strconv.Itoa(recorder.statusCode)),
},
time.Since(startedAt),
)
}
})
}
func handleHealthz(writer http.ResponseWriter, _ *http.Request) {
writeStatusResponse(writer, http.StatusOK, "ok")
}
func handleReadyz(probe ReadinessProbe, logger *slog.Logger) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
if probe == nil {
writeStatusResponse(writer, http.StatusOK, "ready")
return
}
if err := probe.Check(request.Context()); err != nil {
logger.WarnContext(request.Context(), "gamemaster readiness probe failed",
"err", err.Error(),
)
writeServiceUnavailable(writer, err.Error())
return
}
writeStatusResponse(writer, http.StatusOK, "ready")
}
}
func writeStatusResponse(writer http.ResponseWriter, statusCode int, status string) {
writer.Header().Set("Content-Type", jsonContentType)
writer.WriteHeader(statusCode)
_ = json.NewEncoder(writer).Encode(statusResponse{Status: status})
}
func writeServiceUnavailable(writer http.ResponseWriter, message string) {
writer.Header().Set("Content-Type", jsonContentType)
writer.WriteHeader(http.StatusServiceUnavailable)
_ = json.NewEncoder(writer).Encode(errorResponse{
Error: errorBody{
Code: errorCodeServiceUnavailable,
Message: message,
},
})
}
type statusResponse struct {
Status string `json:"status"`
}
type errorBody struct {
Code string `json:"code"`
Message string `json:"message"`
}
type errorResponse struct {
Error errorBody `json:"error"`
}
type statusRecorder struct {
http.ResponseWriter
statusCode int
}
func (recorder *statusRecorder) WriteHeader(statusCode int) {
recorder.statusCode = statusCode
recorder.ResponseWriter.WriteHeader(statusCode)
}
@@ -0,0 +1,142 @@
package internalhttp
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func newTestConfig() Config {
return Config{
Addr: ":0",
ReadHeaderTimeout: time.Second,
ReadTimeout: time.Second,
WriteTimeout: time.Second,
IdleTimeout: time.Second,
}
}
type stubReadiness struct {
err error
}
func (probe stubReadiness) Check(_ context.Context) error {
return probe.err
}
func newTestServer(t *testing.T, deps Dependencies) http.Handler {
t.Helper()
server, err := NewServer(newTestConfig(), deps)
require.NoError(t, err)
return server.handler
}
func TestHealthzReturnsOK(t *testing.T) {
t.Parallel()
handler := newTestServer(t, Dependencies{})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, HealthzPath, nil)
handler.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, jsonContentType, rec.Header().Get("Content-Type"))
var body statusResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
require.Equal(t, "ok", body.Status)
}
func TestReadyzReturnsReadyWhenProbeIsNil(t *testing.T) {
t.Parallel()
handler := newTestServer(t, Dependencies{})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, ReadyzPath, nil)
handler.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var body statusResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
require.Equal(t, "ready", body.Status)
}
func TestReadyzReturnsReadyWhenProbeSucceeds(t *testing.T) {
t.Parallel()
handler := newTestServer(t, Dependencies{Readiness: stubReadiness{}})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, ReadyzPath, nil)
handler.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var body statusResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
require.Equal(t, "ready", body.Status)
}
func TestReadyzReturnsServiceUnavailableWhenProbeFails(t *testing.T) {
t.Parallel()
handler := newTestServer(t, Dependencies{
Readiness: stubReadiness{err: errors.New("postgres ping: connection refused")},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, ReadyzPath, nil)
handler.ServeHTTP(rec, req)
require.Equal(t, http.StatusServiceUnavailable, rec.Code)
require.Equal(t, jsonContentType, rec.Header().Get("Content-Type"))
var body errorResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
require.Equal(t, errorCodeServiceUnavailable, body.Error.Code)
require.True(t, strings.Contains(body.Error.Message, "postgres"))
}
func TestNewServerRejectsInvalidConfig(t *testing.T) {
t.Parallel()
_, err := NewServer(Config{}, Dependencies{})
require.Error(t, err)
}
func TestRunBindsListenerAndShutsDown(t *testing.T) {
t.Parallel()
server, err := NewServer(newTestConfig(), Dependencies{})
require.NoError(t, err)
runErr := make(chan error, 1)
go func() {
runErr <- server.Run(t.Context())
}()
require.Eventually(t, func() bool {
return server.Addr() != ""
}, time.Second, 10*time.Millisecond, "listener should bind quickly")
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), time.Second)
defer shutdownCancel()
require.NoError(t, server.Shutdown(shutdownCtx))
select {
case err := <-runErr:
require.NoError(t, err)
case <-time.After(time.Second):
t.Fatal("server did not return after shutdown")
}
}
+170
View File
@@ -0,0 +1,170 @@
// Package app wires the Game Master process lifecycle and coordinates
// component startup and graceful shutdown.
package app
import (
"context"
"errors"
"fmt"
"sync"
"galaxy/gamemaster/internal/config"
)
// Component is a long-lived Game Master subsystem that participates in
// coordinated startup and graceful shutdown.
type Component interface {
// Run starts the component and blocks until it stops.
Run(context.Context) error
// Shutdown stops the component within the provided timeout-bounded
// context.
Shutdown(context.Context) error
}
// App owns the process-level lifecycle of Game Master and its registered
// components.
type App struct {
cfg config.Config
components []Component
}
// New constructs App with a defensive copy of the supplied components.
func New(cfg config.Config, components ...Component) *App {
clonedComponents := append([]Component(nil), components...)
return &App{
cfg: cfg,
components: clonedComponents,
}
}
// Run starts all configured components, waits for cancellation or the
// first component failure, and then executes best-effort graceful
// shutdown.
func (app *App) Run(ctx context.Context) error {
if ctx == nil {
return errors.New("run gamemaster app: nil context")
}
if err := app.validate(); err != nil {
return err
}
if len(app.components) == 0 {
<-ctx.Done()
return nil
}
runCtx, cancel := context.WithCancel(ctx)
defer cancel()
results := make(chan componentResult, len(app.components))
var runWaitGroup sync.WaitGroup
for index, component := range app.components {
runWaitGroup.Add(1)
go func(componentIndex int, component Component) {
defer runWaitGroup.Done()
results <- componentResult{
index: componentIndex,
err: component.Run(runCtx),
}
}(index, component)
}
var runErr error
select {
case <-ctx.Done():
case result := <-results:
runErr = classifyComponentResult(ctx, result)
}
cancel()
shutdownErr := app.shutdownComponents()
waitErr := app.waitForComponents(&runWaitGroup)
return errors.Join(runErr, shutdownErr, waitErr)
}
type componentResult struct {
index int
err error
}
func (app *App) validate() error {
if app.cfg.ShutdownTimeout <= 0 {
return fmt.Errorf("run gamemaster app: shutdown timeout must be positive, got %s", app.cfg.ShutdownTimeout)
}
for index, component := range app.components {
if component == nil {
return fmt.Errorf("run gamemaster app: component %d is nil", index)
}
}
return nil
}
func classifyComponentResult(parentCtx context.Context, result componentResult) error {
switch {
case result.err == nil:
if parentCtx.Err() != nil {
return nil
}
return fmt.Errorf("run gamemaster app: component %d exited without error before shutdown", result.index)
case errors.Is(result.err, context.Canceled) && parentCtx.Err() != nil:
return nil
default:
return fmt.Errorf("run gamemaster app: component %d: %w", result.index, result.err)
}
}
func (app *App) shutdownComponents() error {
var shutdownWaitGroup sync.WaitGroup
errs := make(chan error, len(app.components))
for index, component := range app.components {
shutdownWaitGroup.Add(1)
go func(componentIndex int, component Component) {
defer shutdownWaitGroup.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), app.cfg.ShutdownTimeout)
defer cancel()
if err := component.Shutdown(shutdownCtx); err != nil {
errs <- fmt.Errorf("shutdown gamemaster component %d: %w", componentIndex, err)
}
}(index, component)
}
shutdownWaitGroup.Wait()
close(errs)
var joined error
for err := range errs {
joined = errors.Join(joined, err)
}
return joined
}
func (app *App) waitForComponents(runWaitGroup *sync.WaitGroup) error {
done := make(chan struct{})
go func() {
runWaitGroup.Wait()
close(done)
}()
waitCtx, cancel := context.WithTimeout(context.Background(), app.cfg.ShutdownTimeout)
defer cancel()
select {
case <-done:
return nil
case <-waitCtx.Done():
return fmt.Errorf("wait for gamemaster components: %w", waitCtx.Err())
}
}
+125
View File
@@ -0,0 +1,125 @@
package app
import (
"context"
"errors"
"strings"
"sync/atomic"
"testing"
"time"
"galaxy/gamemaster/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type fakeComponent struct {
runErr error
shutdownErr error
runHook func(context.Context) error
shutdownHook func(context.Context) error
runCount atomic.Int32
downCount atomic.Int32
blockForCtx bool
}
func (component *fakeComponent) Run(ctx context.Context) error {
component.runCount.Add(1)
if component.runHook != nil {
return component.runHook(ctx)
}
if component.blockForCtx {
<-ctx.Done()
return ctx.Err()
}
return component.runErr
}
func (component *fakeComponent) Shutdown(ctx context.Context) error {
component.downCount.Add(1)
if component.shutdownHook != nil {
return component.shutdownHook(ctx)
}
return component.shutdownErr
}
func newCfg() config.Config {
return config.Config{ShutdownTimeout: time.Second}
}
func TestAppRunWithoutComponentsBlocksUntilContextDone(t *testing.T) {
t.Parallel()
app := New(newCfg())
ctx, cancel := context.WithCancel(context.Background())
cancel()
require.NoError(t, app.Run(ctx))
}
func TestAppRunReturnsOnContextCancel(t *testing.T) {
t.Parallel()
component := &fakeComponent{blockForCtx: true}
app := New(newCfg(), component)
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(10 * time.Millisecond)
cancel()
}()
require.NoError(t, app.Run(ctx))
assert.EqualValues(t, 1, component.runCount.Load())
assert.EqualValues(t, 1, component.downCount.Load())
}
func TestAppRunPropagatesComponentFailure(t *testing.T) {
t.Parallel()
failure := errors.New("boom")
component := &fakeComponent{runErr: failure}
app := New(newCfg(), component)
err := app.Run(context.Background())
require.Error(t, err)
require.ErrorIs(t, err, failure)
assert.EqualValues(t, 1, component.downCount.Load())
}
func TestAppRunFailsOnNilContext(t *testing.T) {
t.Parallel()
app := New(newCfg())
var ctx context.Context
require.Error(t, app.Run(ctx))
}
func TestAppRunFailsOnNonPositiveShutdownTimeout(t *testing.T) {
t.Parallel()
app := New(config.Config{}, &fakeComponent{})
require.Error(t, app.Run(context.Background()))
}
func TestAppRunFailsOnNilComponent(t *testing.T) {
t.Parallel()
app := New(newCfg(), nil)
require.Error(t, app.Run(context.Background()))
}
func TestAppRunFlagsCleanExitBeforeShutdown(t *testing.T) {
t.Parallel()
component := &fakeComponent{}
app := New(newCfg(), component)
err := app.Run(context.Background())
require.Error(t, err)
require.True(t, strings.Contains(err.Error(), "exited without error"))
}
+45
View File
@@ -0,0 +1,45 @@
package app
import (
"context"
"errors"
"galaxy/redisconn"
"galaxy/gamemaster/internal/config"
"galaxy/gamemaster/internal/telemetry"
"github.com/redis/go-redis/v9"
)
// newRedisClient builds the master Redis client from cfg via the shared
// `pkg/redisconn` helper. Replica clients are not opened in this iteration
// per ARCHITECTURE.md §Persistence Backends; they will be wired when read
// routing is introduced.
func newRedisClient(cfg config.RedisConfig) *redis.Client {
return redisconn.NewMasterClient(cfg.Conn)
}
// instrumentRedisClient attaches the OpenTelemetry tracing and metrics
// instrumentation to client when telemetryRuntime is available. The
// actual instrumentation lives in `pkg/redisconn` so every Galaxy service
// shares one surface.
func instrumentRedisClient(redisClient *redis.Client, telemetryRuntime *telemetry.Runtime) error {
if redisClient == nil {
return errors.New("instrument redis client: nil client")
}
if telemetryRuntime == nil {
return nil
}
return redisconn.Instrument(redisClient,
redisconn.WithTracerProvider(telemetryRuntime.TracerProvider()),
redisconn.WithMeterProvider(telemetryRuntime.MeterProvider()),
)
}
// pingRedis performs a single Redis PING bounded by
// cfg.Conn.OperationTimeout to confirm that the configured Redis endpoint
// is reachable at startup.
func pingRedis(ctx context.Context, cfg config.RedisConfig, redisClient *redis.Client) error {
return redisconn.Ping(ctx, redisClient, cfg.Conn.OperationTimeout)
}
+238
View File
@@ -0,0 +1,238 @@
package app
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"time"
"galaxy/postgres"
"galaxy/redisconn"
"galaxy/gamemaster/internal/adapters/postgres/migrations"
"galaxy/gamemaster/internal/api/internalhttp"
"galaxy/gamemaster/internal/config"
"galaxy/gamemaster/internal/telemetry"
"github.com/redis/go-redis/v9"
)
// Runtime owns the runnable Game Master process plus the cleanup
// functions that release runtime resources after shutdown.
type Runtime struct {
cfg config.Config
app *App
wiring *wiring
internalServer *internalhttp.Server
cleanupFns []func() error
}
// NewRuntime constructs the runnable Game Master process from cfg.
//
// The runtime opens one shared `*redis.Client`, one `*sql.DB`, and one
// OpenTelemetry runtime; all are released in reverse construction order
// on shutdown. Embedded goose migrations apply synchronously after the
// PostgreSQL pool is opened and pinged, before any listener is constructed.
func NewRuntime(ctx context.Context, cfg config.Config, logger *slog.Logger) (*Runtime, error) {
if ctx == nil {
return nil, errors.New("new gamemaster runtime: nil context")
}
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("new gamemaster runtime: %w", err)
}
if logger == nil {
logger = slog.Default()
}
runtime := &Runtime{
cfg: cfg,
}
cleanupOnError := func(err error) (*Runtime, error) {
if cleanupErr := runtime.Close(); cleanupErr != nil {
return nil, fmt.Errorf("%w; cleanup: %w", err, cleanupErr)
}
return nil, err
}
telemetryRuntime, err := telemetry.NewProcess(ctx, telemetry.ProcessConfig{
ServiceName: cfg.Telemetry.ServiceName,
TracesExporter: cfg.Telemetry.TracesExporter,
MetricsExporter: cfg.Telemetry.MetricsExporter,
TracesProtocol: cfg.Telemetry.TracesProtocol,
MetricsProtocol: cfg.Telemetry.MetricsProtocol,
StdoutTracesEnabled: cfg.Telemetry.StdoutTracesEnabled,
StdoutMetricsEnabled: cfg.Telemetry.StdoutMetricsEnabled,
}, logger)
if err != nil {
return cleanupOnError(fmt.Errorf("new gamemaster runtime: telemetry: %w", err))
}
runtime.cleanupFns = append(runtime.cleanupFns, func() error {
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
defer cancel()
return telemetryRuntime.Shutdown(shutdownCtx)
})
redisClient := newRedisClient(cfg.Redis)
if err := instrumentRedisClient(redisClient, telemetryRuntime); err != nil {
return cleanupOnError(fmt.Errorf("new gamemaster runtime: %w", err))
}
runtime.cleanupFns = append(runtime.cleanupFns, func() error {
err := redisClient.Close()
if errors.Is(err, redis.ErrClosed) {
return nil
}
return err
})
if err := pingRedis(ctx, cfg.Redis, redisClient); err != nil {
return cleanupOnError(fmt.Errorf("new gamemaster runtime: %w", err))
}
pgPool, err := postgres.OpenPrimary(ctx, cfg.Postgres.Conn,
postgres.WithTracerProvider(telemetryRuntime.TracerProvider()),
postgres.WithMeterProvider(telemetryRuntime.MeterProvider()),
)
if err != nil {
return cleanupOnError(fmt.Errorf("new gamemaster runtime: open postgres: %w", err))
}
runtime.cleanupFns = append(runtime.cleanupFns, pgPool.Close)
unregisterPGStats, err := postgres.InstrumentDBStats(pgPool,
postgres.WithMeterProvider(telemetryRuntime.MeterProvider()),
)
if err != nil {
return cleanupOnError(fmt.Errorf("new gamemaster runtime: instrument postgres: %w", err))
}
runtime.cleanupFns = append(runtime.cleanupFns, func() error {
return unregisterPGStats()
})
if err := postgres.Ping(ctx, pgPool, cfg.Postgres.Conn.OperationTimeout); err != nil {
return cleanupOnError(fmt.Errorf("new gamemaster runtime: ping postgres: %w", err))
}
if err := postgres.RunMigrations(ctx, pgPool, migrations.FS(), "."); err != nil {
return cleanupOnError(fmt.Errorf("new gamemaster runtime: run postgres migrations: %w", err))
}
wiring, err := newWiring(cfg, redisClient, pgPool, time.Now, logger, telemetryRuntime)
if err != nil {
return cleanupOnError(fmt.Errorf("new gamemaster runtime: wiring: %w", err))
}
runtime.wiring = wiring
runtime.cleanupFns = append(runtime.cleanupFns, wiring.close)
probe := newReadinessProbe(pgPool, redisClient, cfg)
internalServer, err := internalhttp.NewServer(internalhttp.Config{
Addr: cfg.InternalHTTP.Addr,
ReadHeaderTimeout: cfg.InternalHTTP.ReadHeaderTimeout,
ReadTimeout: cfg.InternalHTTP.ReadTimeout,
WriteTimeout: cfg.InternalHTTP.WriteTimeout,
IdleTimeout: cfg.InternalHTTP.IdleTimeout,
}, internalhttp.Dependencies{
Logger: logger,
Telemetry: telemetryRuntime,
Readiness: probe,
RuntimeRecords: wiring.runtimeRecords,
RegisterRuntime: wiring.registerRuntimeSvc,
ForceNextTurn: wiring.forceNextTurnSvc,
StopRuntime: wiring.stopRuntimeSvc,
PatchRuntime: wiring.patchRuntimeSvc,
BanishRace: wiring.banishRaceSvc,
InvalidateMemberships: wiring.membershipCache,
GameLiveness: wiring.livenessSvc,
EngineVersions: wiring.engineVersionSvc,
CommandExecute: wiring.commandExecuteSvc,
PutOrders: wiring.orderPutSvc,
GetReport: wiring.reportGetSvc,
})
if err != nil {
return cleanupOnError(fmt.Errorf("new gamemaster runtime: internal HTTP server: %w", err))
}
runtime.internalServer = internalServer
runtime.app = New(cfg,
internalServer,
wiring.schedulerTicker,
wiring.healthEventsConsumer,
)
return runtime, nil
}
// InternalServer returns the internal HTTP server owned by runtime. It is
// primarily exposed for tests; production code should not depend on it.
func (runtime *Runtime) InternalServer() *internalhttp.Server {
if runtime == nil {
return nil
}
return runtime.internalServer
}
// Run serves the internal HTTP listener until ctx is canceled or one
// component fails.
func (runtime *Runtime) Run(ctx context.Context) error {
if ctx == nil {
return errors.New("run gamemaster runtime: nil context")
}
if runtime == nil {
return errors.New("run gamemaster runtime: nil runtime")
}
if runtime.app == nil {
return errors.New("run gamemaster runtime: nil app")
}
return runtime.app.Run(ctx)
}
// Close releases every runtime dependency in reverse construction order.
// Close is safe to call multiple times.
func (runtime *Runtime) Close() error {
if runtime == nil {
return nil
}
var joined error
for index := len(runtime.cleanupFns) - 1; index >= 0; index-- {
if err := runtime.cleanupFns[index](); err != nil {
joined = errors.Join(joined, err)
}
}
runtime.cleanupFns = nil
return joined
}
// readinessProbe pings every steady-state dependency the listener
// guards: PostgreSQL primary and Redis master.
type readinessProbe struct {
pgPool *sql.DB
redisClient *redis.Client
postgresTimeout time.Duration
redisTimeout time.Duration
}
func newReadinessProbe(pgPool *sql.DB, redisClient *redis.Client, cfg config.Config) *readinessProbe {
return &readinessProbe{
pgPool: pgPool,
redisClient: redisClient,
postgresTimeout: cfg.Postgres.Conn.OperationTimeout,
redisTimeout: cfg.Redis.Conn.OperationTimeout,
}
}
// Check pings PostgreSQL and Redis. The first failing dependency aborts
// the check so callers see a single, actionable error.
func (probe *readinessProbe) Check(ctx context.Context) error {
if err := postgres.Ping(ctx, probe.pgPool, probe.postgresTimeout); err != nil {
return err
}
return redisconn.Ping(ctx, probe.redisClient, probe.redisTimeout)
}
+479
View File
@@ -0,0 +1,479 @@
package app
import (
"database/sql"
"errors"
"fmt"
"log/slog"
"time"
"galaxy/gamemaster/internal/adapters/engineclient"
"galaxy/gamemaster/internal/adapters/lobbyclient"
"galaxy/gamemaster/internal/adapters/lobbyeventspublisher"
"galaxy/gamemaster/internal/adapters/notificationpublisher"
"galaxy/gamemaster/internal/adapters/postgres/engineversionstore"
"galaxy/gamemaster/internal/adapters/postgres/operationlog"
"galaxy/gamemaster/internal/adapters/postgres/playermappingstore"
"galaxy/gamemaster/internal/adapters/postgres/runtimerecordstore"
"galaxy/gamemaster/internal/adapters/redisstate/streamoffsets"
"galaxy/gamemaster/internal/adapters/rtmclient"
"galaxy/gamemaster/internal/config"
"galaxy/gamemaster/internal/service/adminbanish"
"galaxy/gamemaster/internal/service/adminforce"
"galaxy/gamemaster/internal/service/adminpatch"
"galaxy/gamemaster/internal/service/adminstop"
"galaxy/gamemaster/internal/service/commandexecute"
engineversionsvc "galaxy/gamemaster/internal/service/engineversion"
"galaxy/gamemaster/internal/service/livenessreply"
"galaxy/gamemaster/internal/service/membership"
"galaxy/gamemaster/internal/service/orderput"
"galaxy/gamemaster/internal/service/registerruntime"
"galaxy/gamemaster/internal/service/reportget"
"galaxy/gamemaster/internal/service/scheduler"
"galaxy/gamemaster/internal/service/turngeneration"
"galaxy/gamemaster/internal/telemetry"
"galaxy/gamemaster/internal/worker/healtheventsconsumer"
"galaxy/gamemaster/internal/worker/schedulerticker"
"github.com/redis/go-redis/v9"
)
// wiring owns the process-level singletons constructed once during
// `NewRuntime` and consumed by every worker and HTTP handler. Stage
// 19 grew the struct to hold every store, adapter, service and
// worker required by the listener and the long-lived components.
type wiring struct {
cfg config.Config
redisClient *redis.Client
pgPool *sql.DB
clock func() time.Time
logger *slog.Logger
telemetry *telemetry.Runtime
// Stores.
runtimeRecords *runtimerecordstore.Store
engineVersions *engineversionstore.Store
playerMappings *playermappingstore.Store
operationLogs *operationlog.Store
streamOffsets *streamoffsets.Store
// External adapters.
engineClient *engineclient.Client
lobbyClient *lobbyclient.Client
rtmClient *rtmclient.Client
notificationPublisher *notificationpublisher.Publisher
lobbyEventsPublisher *lobbyeventspublisher.Publisher
// Services.
membershipCache *membership.Cache
registerRuntimeSvc *registerruntime.Service
engineVersionSvc *engineversionsvc.Service
stopRuntimeSvc *adminstop.Service
forceNextTurnSvc *adminforce.Service
patchRuntimeSvc *adminpatch.Service
banishRaceSvc *adminbanish.Service
livenessSvc *livenessreply.Service
commandExecuteSvc *commandexecute.Service
orderPutSvc *orderput.Service
reportGetSvc *reportget.Service
schedulerSvc *scheduler.Service
turnGenerationSvc *turngeneration.Service
// Workers.
schedulerTicker *schedulerticker.Worker
healthEventsConsumer *healtheventsconsumer.Worker
// closers releases adapter-level resources at runtime shutdown.
closers []func() error
}
// newWiring constructs the process-level dependency set. It validates
// every required collaborator so callers can rely on them being
// non-nil. Construction proceeds in four phases: persistence stores,
// external adapters, services, workers. Each phase is in its own
// helper to keep the function readable.
func newWiring(
cfg config.Config,
redisClient *redis.Client,
pgPool *sql.DB,
clock func() time.Time,
logger *slog.Logger,
telemetryRuntime *telemetry.Runtime,
) (*wiring, error) {
if redisClient == nil {
return nil, errors.New("new gamemaster wiring: nil redis client")
}
if pgPool == nil {
return nil, errors.New("new gamemaster wiring: nil postgres pool")
}
if clock == nil {
clock = time.Now
}
if logger == nil {
logger = slog.Default()
}
if telemetryRuntime == nil {
return nil, fmt.Errorf("new gamemaster wiring: nil telemetry runtime")
}
w := &wiring{
cfg: cfg,
redisClient: redisClient,
pgPool: pgPool,
clock: clock,
logger: logger,
telemetry: telemetryRuntime,
}
if err := w.buildPersistence(); err != nil {
return nil, fmt.Errorf("new gamemaster wiring: persistence: %w", err)
}
if err := w.buildAdapters(); err != nil {
return nil, fmt.Errorf("new gamemaster wiring: adapters: %w", err)
}
if err := w.buildServices(); err != nil {
return nil, fmt.Errorf("new gamemaster wiring: services: %w", err)
}
if err := w.buildWorkers(); err != nil {
return nil, fmt.Errorf("new gamemaster wiring: workers: %w", err)
}
return w, nil
}
// buildPersistence constructs the four PostgreSQL stores plus the
// Redis-backed stream-offset store. The stores share the connection
// pools opened by the runtime; their lifecycles are owned by the
// runtime, not the wiring.
func (w *wiring) buildPersistence() error {
timeout := w.cfg.Postgres.Conn.OperationTimeout
runtimeRecords, err := runtimerecordstore.New(runtimerecordstore.Config{
DB: w.pgPool,
OperationTimeout: timeout,
})
if err != nil {
return fmt.Errorf("runtime record store: %w", err)
}
w.runtimeRecords = runtimeRecords
engineVersions, err := engineversionstore.New(engineversionstore.Config{
DB: w.pgPool,
OperationTimeout: timeout,
})
if err != nil {
return fmt.Errorf("engine version store: %w", err)
}
w.engineVersions = engineVersions
playerMappings, err := playermappingstore.New(playermappingstore.Config{
DB: w.pgPool,
OperationTimeout: timeout,
})
if err != nil {
return fmt.Errorf("player mapping store: %w", err)
}
w.playerMappings = playerMappings
operationLogs, err := operationlog.New(operationlog.Config{
DB: w.pgPool,
OperationTimeout: timeout,
})
if err != nil {
return fmt.Errorf("operation log store: %w", err)
}
w.operationLogs = operationLogs
streamOffsets, err := streamoffsets.New(streamoffsets.Config{Client: w.redisClient})
if err != nil {
return fmt.Errorf("stream offset store: %w", err)
}
w.streamOffsets = streamOffsets
return nil
}
// buildAdapters constructs the HTTP clients (engine, Lobby, Runtime
// Manager) and the two Redis Stream publishers. Their `Close` hooks
// are appended to w.closers so idle TCP connections are released on
// shutdown.
func (w *wiring) buildAdapters() error {
engine, err := engineclient.NewClient(engineclient.Config{
CallTimeout: w.cfg.EngineClient.CallTimeout,
ProbeTimeout: w.cfg.EngineClient.ProbeTimeout,
})
if err != nil {
return fmt.Errorf("engine client: %w", err)
}
w.engineClient = engine
w.closers = append(w.closers, engine.Close)
lobby, err := lobbyclient.NewClient(lobbyclient.Config{
BaseURL: w.cfg.Lobby.BaseURL,
RequestTimeout: w.cfg.Lobby.Timeout,
})
if err != nil {
return fmt.Errorf("lobby client: %w", err)
}
w.lobbyClient = lobby
w.closers = append(w.closers, lobby.Close)
rtm, err := rtmclient.NewClient(rtmclient.Config{
BaseURL: w.cfg.RTM.BaseURL,
RequestTimeout: w.cfg.RTM.Timeout,
})
if err != nil {
return fmt.Errorf("rtm client: %w", err)
}
w.rtmClient = rtm
w.closers = append(w.closers, rtm.Close)
notification, err := notificationpublisher.NewPublisher(notificationpublisher.Config{
Client: w.redisClient,
Stream: w.cfg.Streams.NotificationIntents,
})
if err != nil {
return fmt.Errorf("notification publisher: %w", err)
}
w.notificationPublisher = notification
lobbyEvents, err := lobbyeventspublisher.NewPublisher(lobbyeventspublisher.Config{
Client: w.redisClient,
Stream: w.cfg.Streams.LobbyEvents,
})
if err != nil {
return fmt.Errorf("lobby events publisher: %w", err)
}
w.lobbyEventsPublisher = lobbyEvents
return nil
}
// buildServices constructs every service-layer collaborator consumed
// by the REST listener and the workers. Construction order matters
// only between turngeneration → adminforce (the latter wraps the
// former) and between membership cache → command/order/report
// services.
func (w *wiring) buildServices() error {
cache, err := membership.NewCache(membership.Dependencies{
Lobby: w.lobbyClient,
Telemetry: w.telemetry,
Logger: w.logger,
Clock: w.clock,
TTL: w.cfg.MembershipCache.TTL,
MaxGames: w.cfg.MembershipCache.MaxGames,
})
if err != nil {
return fmt.Errorf("membership cache: %w", err)
}
w.membershipCache = cache
w.schedulerSvc = scheduler.New()
registerSvc, err := registerruntime.NewService(registerruntime.Dependencies{
RuntimeRecords: w.runtimeRecords,
EngineVersions: w.engineVersions,
PlayerMappings: w.playerMappings,
OperationLogs: w.operationLogs,
Engine: w.engineClient,
LobbyEvents: w.lobbyEventsPublisher,
Telemetry: w.telemetry,
Logger: w.logger,
Clock: w.clock,
})
if err != nil {
return fmt.Errorf("register runtime service: %w", err)
}
w.registerRuntimeSvc = registerSvc
engineVersionSvc, err := engineversionsvc.NewService(engineversionsvc.Dependencies{
EngineVersions: w.engineVersions,
OperationLogs: w.operationLogs,
Logger: w.logger,
Clock: w.clock,
})
if err != nil {
return fmt.Errorf("engine version service: %w", err)
}
w.engineVersionSvc = engineVersionSvc
turnGen, err := turngeneration.NewService(turngeneration.Dependencies{
RuntimeRecords: w.runtimeRecords,
PlayerMappings: w.playerMappings,
OperationLogs: w.operationLogs,
Engine: w.engineClient,
LobbyEvents: w.lobbyEventsPublisher,
Notifications: w.notificationPublisher,
Lobby: w.lobbyClient,
Scheduler: w.schedulerSvc,
Telemetry: w.telemetry,
Logger: w.logger,
Clock: w.clock,
})
if err != nil {
return fmt.Errorf("turn generation service: %w", err)
}
w.turnGenerationSvc = turnGen
stopSvc, err := adminstop.NewService(adminstop.Dependencies{
RuntimeRecords: w.runtimeRecords,
OperationLogs: w.operationLogs,
RTM: w.rtmClient,
LobbyEvents: w.lobbyEventsPublisher,
Telemetry: w.telemetry,
Logger: w.logger,
Clock: w.clock,
})
if err != nil {
return fmt.Errorf("admin stop service: %w", err)
}
w.stopRuntimeSvc = stopSvc
forceSvc, err := adminforce.NewService(adminforce.Dependencies{
RuntimeRecords: w.runtimeRecords,
OperationLogs: w.operationLogs,
TurnGeneration: turnGen,
Telemetry: w.telemetry,
Logger: w.logger,
Clock: w.clock,
})
if err != nil {
return fmt.Errorf("admin force service: %w", err)
}
w.forceNextTurnSvc = forceSvc
patchSvc, err := adminpatch.NewService(adminpatch.Dependencies{
RuntimeRecords: w.runtimeRecords,
EngineVersions: w.engineVersions,
OperationLogs: w.operationLogs,
RTM: w.rtmClient,
Telemetry: w.telemetry,
Logger: w.logger,
Clock: w.clock,
})
if err != nil {
return fmt.Errorf("admin patch service: %w", err)
}
w.patchRuntimeSvc = patchSvc
banishSvc, err := adminbanish.NewService(adminbanish.Dependencies{
RuntimeRecords: w.runtimeRecords,
PlayerMappings: w.playerMappings,
OperationLogs: w.operationLogs,
Engine: w.engineClient,
Telemetry: w.telemetry,
Logger: w.logger,
Clock: w.clock,
})
if err != nil {
return fmt.Errorf("admin banish service: %w", err)
}
w.banishRaceSvc = banishSvc
livenessSvc, err := livenessreply.NewService(livenessreply.Dependencies{
RuntimeRecords: w.runtimeRecords,
Logger: w.logger,
})
if err != nil {
return fmt.Errorf("liveness reply service: %w", err)
}
w.livenessSvc = livenessSvc
commandSvc, err := commandexecute.NewService(commandexecute.Dependencies{
RuntimeRecords: w.runtimeRecords,
PlayerMappings: w.playerMappings,
Membership: cache,
Engine: w.engineClient,
Telemetry: w.telemetry,
Logger: w.logger,
Clock: w.clock,
})
if err != nil {
return fmt.Errorf("command execute service: %w", err)
}
w.commandExecuteSvc = commandSvc
orderSvc, err := orderput.NewService(orderput.Dependencies{
RuntimeRecords: w.runtimeRecords,
PlayerMappings: w.playerMappings,
Membership: cache,
Engine: w.engineClient,
Telemetry: w.telemetry,
Logger: w.logger,
Clock: w.clock,
})
if err != nil {
return fmt.Errorf("put orders service: %w", err)
}
w.orderPutSvc = orderSvc
reportSvc, err := reportget.NewService(reportget.Dependencies{
RuntimeRecords: w.runtimeRecords,
PlayerMappings: w.playerMappings,
Membership: cache,
Engine: w.engineClient,
Telemetry: w.telemetry,
Logger: w.logger,
Clock: w.clock,
})
if err != nil {
return fmt.Errorf("get report service: %w", err)
}
w.reportGetSvc = reportSvc
return nil
}
// buildWorkers constructs the long-lived components started by
// `App.Run` alongside the listener: the per-second scheduler ticker
// and the runtime:health_events consumer.
func (w *wiring) buildWorkers() error {
ticker, err := schedulerticker.NewWorker(schedulerticker.Dependencies{
RuntimeRecords: w.runtimeRecords,
TurnGeneration: w.turnGenerationSvc,
Telemetry: w.telemetry,
Interval: w.cfg.Scheduler.TickInterval,
Clock: w.clock,
Logger: w.logger,
})
if err != nil {
return fmt.Errorf("scheduler ticker: %w", err)
}
w.schedulerTicker = ticker
healthConsumer, err := healtheventsconsumer.NewWorker(healtheventsconsumer.Dependencies{
Client: w.redisClient,
Stream: w.cfg.Streams.HealthEvents,
BlockTimeout: w.cfg.Streams.BlockTimeout,
OffsetStore: w.streamOffsets,
RuntimeRecords: w.runtimeRecords,
LobbyEvents: w.lobbyEventsPublisher,
Telemetry: w.telemetry,
Clock: w.clock,
Logger: w.logger,
})
if err != nil {
return fmt.Errorf("health events consumer: %w", err)
}
w.healthEventsConsumer = healthConsumer
return nil
}
// close releases adapter-level resources owned by the wiring layer.
// Returns the joined error of every closer; the caller is expected
// to invoke this once during process shutdown. Closers run in LIFO
// order so the resource opened last is released first.
func (w *wiring) close() error {
var joined error
for index := len(w.closers) - 1; index >= 0; index-- {
if err := w.closers[index](); err != nil {
joined = errors.Join(joined, err)
}
}
w.closers = nil
return joined
}
+448
View File
@@ -0,0 +1,448 @@
// Package config loads the Game Master process configuration from
// environment variables.
package config
import (
"fmt"
"strings"
"time"
"galaxy/postgres"
"galaxy/redisconn"
"galaxy/gamemaster/internal/telemetry"
)
const (
envPrefix = "GAMEMASTER"
shutdownTimeoutEnvVar = "GAMEMASTER_SHUTDOWN_TIMEOUT"
logLevelEnvVar = "GAMEMASTER_LOG_LEVEL"
internalHTTPAddrEnvVar = "GAMEMASTER_INTERNAL_HTTP_ADDR"
internalHTTPReadHeaderTimeoutEnvVar = "GAMEMASTER_INTERNAL_HTTP_READ_HEADER_TIMEOUT"
internalHTTPReadTimeoutEnvVar = "GAMEMASTER_INTERNAL_HTTP_READ_TIMEOUT"
internalHTTPWriteTimeoutEnvVar = "GAMEMASTER_INTERNAL_HTTP_WRITE_TIMEOUT"
internalHTTPIdleTimeoutEnvVar = "GAMEMASTER_INTERNAL_HTTP_IDLE_TIMEOUT"
lobbyEventsStreamEnvVar = "GAMEMASTER_REDIS_LOBBY_EVENTS_STREAM"
healthEventsStreamEnvVar = "GAMEMASTER_REDIS_HEALTH_EVENTS_STREAM"
notificationIntentsStreamEnvVar = "GAMEMASTER_REDIS_NOTIFICATION_INTENTS_STREAM"
streamBlockTimeoutEnvVar = "GAMEMASTER_STREAM_BLOCK_TIMEOUT"
engineCallTimeoutEnvVar = "GAMEMASTER_ENGINE_CALL_TIMEOUT"
engineProbeTimeoutEnvVar = "GAMEMASTER_ENGINE_PROBE_TIMEOUT"
lobbyInternalBaseURLEnvVar = "GAMEMASTER_LOBBY_INTERNAL_BASE_URL"
lobbyInternalTimeoutEnvVar = "GAMEMASTER_LOBBY_INTERNAL_TIMEOUT"
rtmInternalBaseURLEnvVar = "GAMEMASTER_RTM_INTERNAL_BASE_URL"
rtmInternalTimeoutEnvVar = "GAMEMASTER_RTM_INTERNAL_TIMEOUT"
schedulerTickIntervalEnvVar = "GAMEMASTER_SCHEDULER_TICK_INTERVAL"
turnGenerationTimeoutEnvVar = "GAMEMASTER_TURN_GENERATION_TIMEOUT"
membershipCacheTTLEnvVar = "GAMEMASTER_MEMBERSHIP_CACHE_TTL"
membershipCacheMaxGamesEnvVar = "GAMEMASTER_MEMBERSHIP_CACHE_MAX_GAMES"
otelServiceNameEnvVar = "OTEL_SERVICE_NAME"
otelTracesExporterEnvVar = "OTEL_TRACES_EXPORTER"
otelMetricsExporterEnvVar = "OTEL_METRICS_EXPORTER"
otelExporterOTLPProtocolEnvVar = "OTEL_EXPORTER_OTLP_PROTOCOL"
otelExporterOTLPTracesProtocolEnvVar = "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL"
otelExporterOTLPMetricsProtocolEnvVar = "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL"
otelStdoutTracesEnabledEnvVar = "GAMEMASTER_OTEL_STDOUT_TRACES_ENABLED"
otelStdoutMetricsEnabledEnvVar = "GAMEMASTER_OTEL_STDOUT_METRICS_ENABLED"
defaultShutdownTimeout = 30 * time.Second
defaultLogLevel = "info"
defaultInternalHTTPAddr = ":8097"
defaultReadHeaderTimeout = 2 * time.Second
defaultReadTimeout = 5 * time.Second
defaultWriteTimeout = 30 * time.Second
defaultIdleTimeout = 60 * time.Second
defaultLobbyEventsStream = "gm:lobby_events"
defaultHealthEventsStream = "runtime:health_events"
defaultNotificationIntentsStream = "notification:intents"
defaultStreamBlockTimeout = 5 * time.Second
defaultEngineCallTimeout = 30 * time.Second
defaultEngineProbeTimeout = 5 * time.Second
defaultLobbyInternalTimeout = 2 * time.Second
defaultRTMInternalTimeout = 5 * time.Second
defaultSchedulerTickInterval = time.Second
defaultTurnGenerationTimeout = 60 * time.Second
defaultMembershipCacheTTL = 30 * time.Second
defaultMembershipCacheMaxGames = 4096
defaultOTelServiceName = "galaxy-gamemaster"
)
// Config stores the full Game Master process configuration.
type Config struct {
// ShutdownTimeout bounds graceful shutdown of every long-lived
// component.
ShutdownTimeout time.Duration
// Logging configures the process-wide structured logger.
Logging LoggingConfig
// InternalHTTP configures the trusted internal HTTP listener.
InternalHTTP InternalHTTPConfig
// Postgres configures the PostgreSQL-backed durable store consumed
// via `pkg/postgres`.
Postgres PostgresConfig
// Redis configures the shared Redis connection topology consumed via
// `pkg/redisconn`.
Redis RedisConfig
// Streams stores the stable Redis Stream names GM reads from and
// writes to.
Streams StreamsConfig
// EngineClient configures per-call timeouts of the engine HTTP
// client.
EngineClient EngineClientConfig
// Lobby configures the synchronous Lobby internal REST client.
Lobby LobbyClientConfig
// RTM configures the synchronous Runtime Manager internal REST
// client.
RTM RTMClientConfig
// Scheduler configures the scheduler ticker worker and the per-turn
// generation deadline.
Scheduler SchedulerConfig
// MembershipCache configures the in-process membership cache.
MembershipCache MembershipCacheConfig
// Telemetry configures the process-wide OpenTelemetry runtime.
Telemetry TelemetryConfig
}
// LoggingConfig configures the process-wide structured logger.
type LoggingConfig struct {
// Level stores the process log level accepted by log/slog.
Level string
}
// InternalHTTPConfig configures the trusted internal HTTP listener.
type InternalHTTPConfig struct {
// Addr stores the TCP listen address.
Addr string
// ReadHeaderTimeout bounds request-header reading.
ReadHeaderTimeout time.Duration
// ReadTimeout bounds reading one request.
ReadTimeout time.Duration
// WriteTimeout bounds writing one response.
WriteTimeout time.Duration
// IdleTimeout bounds how long keep-alive connections stay open.
IdleTimeout time.Duration
}
// Validate reports whether cfg stores a usable internal HTTP listener
// configuration.
func (cfg InternalHTTPConfig) Validate() error {
switch {
case strings.TrimSpace(cfg.Addr) == "":
return fmt.Errorf("internal HTTP addr must not be empty")
case !isTCPAddr(cfg.Addr):
return fmt.Errorf("internal HTTP addr %q must use host:port form", cfg.Addr)
case cfg.ReadHeaderTimeout <= 0:
return fmt.Errorf("internal HTTP read header timeout must be positive")
case cfg.ReadTimeout <= 0:
return fmt.Errorf("internal HTTP read timeout must be positive")
case cfg.WriteTimeout <= 0:
return fmt.Errorf("internal HTTP write timeout must be positive")
case cfg.IdleTimeout <= 0:
return fmt.Errorf("internal HTTP idle timeout must be positive")
default:
return nil
}
}
// PostgresConfig configures the PostgreSQL-backed durable store consumed
// via `pkg/postgres`.
type PostgresConfig struct {
// Conn carries the primary plus replica DSN topology and pool tuning.
Conn postgres.Config
}
// Validate reports whether cfg stores a usable PostgreSQL configuration.
func (cfg PostgresConfig) Validate() error {
return cfg.Conn.Validate()
}
// RedisConfig configures the Game Master Redis connection topology.
type RedisConfig struct {
// Conn carries the connection topology (master, replicas, password,
// db, per-call timeout).
Conn redisconn.Config
}
// Validate reports whether cfg stores a usable Redis configuration.
func (cfg RedisConfig) Validate() error {
return cfg.Conn.Validate()
}
// StreamsConfig stores the stable Redis Stream names used by Game Master.
type StreamsConfig struct {
// LobbyEvents stores the Redis Streams key GM publishes runtime
// snapshot updates and game-finished events to.
LobbyEvents string
// HealthEvents stores the Redis Streams key GM consumes runtime
// health events from.
HealthEvents string
// NotificationIntents stores the Redis Streams key GM publishes
// notification intents to.
NotificationIntents string
// BlockTimeout bounds the maximum blocking read window for stream
// consumers.
BlockTimeout time.Duration
}
// Validate reports whether cfg stores usable stream names.
func (cfg StreamsConfig) Validate() error {
switch {
case strings.TrimSpace(cfg.LobbyEvents) == "":
return fmt.Errorf("redis lobby events stream must not be empty")
case strings.TrimSpace(cfg.HealthEvents) == "":
return fmt.Errorf("redis health events stream must not be empty")
case strings.TrimSpace(cfg.NotificationIntents) == "":
return fmt.Errorf("redis notification intents stream must not be empty")
case cfg.BlockTimeout <= 0:
return fmt.Errorf("redis stream block timeout must be positive")
default:
return nil
}
}
// EngineClientConfig configures per-call timeouts of the engine HTTP
// client.
type EngineClientConfig struct {
// CallTimeout bounds one full engine call (including turn generation
// for large games).
CallTimeout time.Duration
// ProbeTimeout bounds inspect-style reads against the engine.
ProbeTimeout time.Duration
}
// Validate reports whether cfg stores usable engine client timeouts.
func (cfg EngineClientConfig) Validate() error {
switch {
case cfg.CallTimeout <= 0:
return fmt.Errorf("engine call timeout must be positive")
case cfg.ProbeTimeout <= 0:
return fmt.Errorf("engine probe timeout must be positive")
default:
return nil
}
}
// LobbyClientConfig configures the synchronous Lobby internal REST
// client.
type LobbyClientConfig struct {
// BaseURL stores the trusted Lobby internal listener base URL.
BaseURL string
// Timeout bounds one Lobby internal request.
Timeout time.Duration
}
// Validate reports whether cfg stores a usable Lobby client
// configuration.
func (cfg LobbyClientConfig) Validate() error {
switch {
case strings.TrimSpace(cfg.BaseURL) == "":
return fmt.Errorf("lobby internal base url must not be empty")
case !isHTTPURL(cfg.BaseURL):
return fmt.Errorf("lobby internal base url %q must be an absolute http(s) URL", cfg.BaseURL)
case cfg.Timeout <= 0:
return fmt.Errorf("lobby internal timeout must be positive")
default:
return nil
}
}
// RTMClientConfig configures the synchronous Runtime Manager internal
// REST client.
type RTMClientConfig struct {
// BaseURL stores the trusted Runtime Manager internal listener base
// URL.
BaseURL string
// Timeout bounds one Runtime Manager internal request.
Timeout time.Duration
}
// Validate reports whether cfg stores a usable Runtime Manager client
// configuration.
func (cfg RTMClientConfig) Validate() error {
switch {
case strings.TrimSpace(cfg.BaseURL) == "":
return fmt.Errorf("rtm internal base url must not be empty")
case !isHTTPURL(cfg.BaseURL):
return fmt.Errorf("rtm internal base url %q must be an absolute http(s) URL", cfg.BaseURL)
case cfg.Timeout <= 0:
return fmt.Errorf("rtm internal timeout must be positive")
default:
return nil
}
}
// SchedulerConfig configures the scheduler ticker worker and the
// per-turn generation deadline.
type SchedulerConfig struct {
// TickInterval is the period between two scheduler scans for due
// runtime records.
TickInterval time.Duration
// TurnGenerationTimeout bounds one engine `/admin/turn` call from
// the scheduler's perspective.
TurnGenerationTimeout time.Duration
}
// Validate reports whether cfg stores usable scheduler timings.
func (cfg SchedulerConfig) Validate() error {
switch {
case cfg.TickInterval <= 0:
return fmt.Errorf("scheduler tick interval must be positive")
case cfg.TurnGenerationTimeout <= 0:
return fmt.Errorf("turn generation timeout must be positive")
default:
return nil
}
}
// MembershipCacheConfig configures the in-process membership cache.
type MembershipCacheConfig struct {
// TTL bounds how long an unobserved membership entry stays cached
// before a forced reload from Lobby.
TTL time.Duration
// MaxGames bounds how many games can populate the cache before
// LRU eviction kicks in.
MaxGames int
}
// Validate reports whether cfg stores usable membership cache settings.
func (cfg MembershipCacheConfig) Validate() error {
switch {
case cfg.TTL <= 0:
return fmt.Errorf("membership cache ttl must be positive")
case cfg.MaxGames <= 0:
return fmt.Errorf("membership cache max games must be positive")
default:
return nil
}
}
// TelemetryConfig configures the Game Master OpenTelemetry runtime.
type TelemetryConfig struct {
// ServiceName overrides the default OpenTelemetry service name.
ServiceName string
// TracesExporter selects the external traces exporter. Supported
// values are `none` and `otlp`.
TracesExporter string
// MetricsExporter selects the external metrics exporter. Supported
// values are `none` and `otlp`.
MetricsExporter string
// TracesProtocol selects the OTLP traces protocol when
// TracesExporter is `otlp`.
TracesProtocol string
// MetricsProtocol selects the OTLP metrics protocol when
// MetricsExporter is `otlp`.
MetricsProtocol string
// StdoutTracesEnabled enables the additional stdout trace exporter
// used for local development and debugging.
StdoutTracesEnabled bool
// StdoutMetricsEnabled enables the additional stdout metric
// exporter used for local development and debugging.
StdoutMetricsEnabled bool
}
// Validate reports whether cfg contains a supported OpenTelemetry
// configuration.
func (cfg TelemetryConfig) Validate() error {
return telemetry.ProcessConfig{
ServiceName: cfg.ServiceName,
TracesExporter: cfg.TracesExporter,
MetricsExporter: cfg.MetricsExporter,
TracesProtocol: cfg.TracesProtocol,
MetricsProtocol: cfg.MetricsProtocol,
StdoutTracesEnabled: cfg.StdoutTracesEnabled,
StdoutMetricsEnabled: cfg.StdoutMetricsEnabled,
}.Validate()
}
// DefaultConfig returns the default Game Master process configuration.
func DefaultConfig() Config {
return Config{
ShutdownTimeout: defaultShutdownTimeout,
Logging: LoggingConfig{
Level: defaultLogLevel,
},
InternalHTTP: InternalHTTPConfig{
Addr: defaultInternalHTTPAddr,
ReadHeaderTimeout: defaultReadHeaderTimeout,
ReadTimeout: defaultReadTimeout,
WriteTimeout: defaultWriteTimeout,
IdleTimeout: defaultIdleTimeout,
},
Postgres: PostgresConfig{
Conn: postgres.DefaultConfig(),
},
Redis: RedisConfig{
Conn: redisconn.DefaultConfig(),
},
Streams: StreamsConfig{
LobbyEvents: defaultLobbyEventsStream,
HealthEvents: defaultHealthEventsStream,
NotificationIntents: defaultNotificationIntentsStream,
BlockTimeout: defaultStreamBlockTimeout,
},
EngineClient: EngineClientConfig{
CallTimeout: defaultEngineCallTimeout,
ProbeTimeout: defaultEngineProbeTimeout,
},
Lobby: LobbyClientConfig{
Timeout: defaultLobbyInternalTimeout,
},
RTM: RTMClientConfig{
Timeout: defaultRTMInternalTimeout,
},
Scheduler: SchedulerConfig{
TickInterval: defaultSchedulerTickInterval,
TurnGenerationTimeout: defaultTurnGenerationTimeout,
},
MembershipCache: MembershipCacheConfig{
TTL: defaultMembershipCacheTTL,
MaxGames: defaultMembershipCacheMaxGames,
},
Telemetry: TelemetryConfig{
ServiceName: defaultOTelServiceName,
TracesExporter: "none",
MetricsExporter: "none",
},
}
}
+169
View File
@@ -0,0 +1,169 @@
package config
import (
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func validEnv(t *testing.T) {
t.Helper()
t.Setenv("GAMEMASTER_INTERNAL_HTTP_ADDR", ":8097")
t.Setenv("GAMEMASTER_POSTGRES_PRIMARY_DSN", "postgres://gm:secret@localhost:5432/galaxy?search_path=gamemaster&sslmode=disable")
t.Setenv("GAMEMASTER_REDIS_MASTER_ADDR", "localhost:6379")
t.Setenv("GAMEMASTER_REDIS_PASSWORD", "secret")
t.Setenv("GAMEMASTER_LOBBY_INTERNAL_BASE_URL", "http://lobby:8095")
t.Setenv("GAMEMASTER_RTM_INTERNAL_BASE_URL", "http://rtmanager:8096")
}
func TestLoadFromEnvAcceptsDefaults(t *testing.T) {
validEnv(t)
cfg, err := LoadFromEnv()
require.NoError(t, err)
require.Equal(t, ":8097", cfg.InternalHTTP.Addr)
require.Equal(t, 30*time.Second, cfg.ShutdownTimeout)
require.Equal(t, "info", cfg.Logging.Level)
require.Equal(t, "gm:lobby_events", cfg.Streams.LobbyEvents)
require.Equal(t, "runtime:health_events", cfg.Streams.HealthEvents)
require.Equal(t, "notification:intents", cfg.Streams.NotificationIntents)
require.Equal(t, 5*time.Second, cfg.Streams.BlockTimeout)
require.Equal(t, 30*time.Second, cfg.EngineClient.CallTimeout)
require.Equal(t, 5*time.Second, cfg.EngineClient.ProbeTimeout)
require.Equal(t, "http://lobby:8095", cfg.Lobby.BaseURL)
require.Equal(t, 2*time.Second, cfg.Lobby.Timeout)
require.Equal(t, "http://rtmanager:8096", cfg.RTM.BaseURL)
require.Equal(t, 5*time.Second, cfg.RTM.Timeout)
require.Equal(t, time.Second, cfg.Scheduler.TickInterval)
require.Equal(t, 60*time.Second, cfg.Scheduler.TurnGenerationTimeout)
require.Equal(t, 30*time.Second, cfg.MembershipCache.TTL)
require.Equal(t, 4096, cfg.MembershipCache.MaxGames)
require.Equal(t, "galaxy-gamemaster", cfg.Telemetry.ServiceName)
}
func TestLoadFromEnvHonoursOverrides(t *testing.T) {
validEnv(t)
t.Setenv("GAMEMASTER_INTERNAL_HTTP_ADDR", ":9097")
t.Setenv("GAMEMASTER_REDIS_LOBBY_EVENTS_STREAM", "custom:lobby_events")
t.Setenv("GAMEMASTER_ENGINE_CALL_TIMEOUT", "45s")
t.Setenv("GAMEMASTER_SCHEDULER_TICK_INTERVAL", "500ms")
t.Setenv("GAMEMASTER_MEMBERSHIP_CACHE_TTL", "60s")
t.Setenv("GAMEMASTER_MEMBERSHIP_CACHE_MAX_GAMES", "1024")
cfg, err := LoadFromEnv()
require.NoError(t, err)
require.Equal(t, ":9097", cfg.InternalHTTP.Addr)
require.Equal(t, "custom:lobby_events", cfg.Streams.LobbyEvents)
require.Equal(t, 45*time.Second, cfg.EngineClient.CallTimeout)
require.Equal(t, 500*time.Millisecond, cfg.Scheduler.TickInterval)
require.Equal(t, 60*time.Second, cfg.MembershipCache.TTL)
require.Equal(t, 1024, cfg.MembershipCache.MaxGames)
}
func TestLoadFromEnvRequiresInternalHTTPAddr(t *testing.T) {
t.Setenv("GAMEMASTER_POSTGRES_PRIMARY_DSN", "postgres://gm:secret@localhost:5432/galaxy")
t.Setenv("GAMEMASTER_REDIS_MASTER_ADDR", "localhost:6379")
t.Setenv("GAMEMASTER_REDIS_PASSWORD", "secret")
t.Setenv("GAMEMASTER_LOBBY_INTERNAL_BASE_URL", "http://lobby:8095")
t.Setenv("GAMEMASTER_RTM_INTERNAL_BASE_URL", "http://rtmanager:8096")
_, err := LoadFromEnv()
require.Error(t, err)
require.Contains(t, err.Error(), "GAMEMASTER_INTERNAL_HTTP_ADDR")
}
func TestLoadFromEnvRequiresLobbyBaseURL(t *testing.T) {
t.Setenv("GAMEMASTER_INTERNAL_HTTP_ADDR", ":8097")
t.Setenv("GAMEMASTER_POSTGRES_PRIMARY_DSN", "postgres://gm:secret@localhost:5432/galaxy")
t.Setenv("GAMEMASTER_REDIS_MASTER_ADDR", "localhost:6379")
t.Setenv("GAMEMASTER_REDIS_PASSWORD", "secret")
t.Setenv("GAMEMASTER_RTM_INTERNAL_BASE_URL", "http://rtmanager:8096")
_, err := LoadFromEnv()
require.Error(t, err)
require.Contains(t, err.Error(), "GAMEMASTER_LOBBY_INTERNAL_BASE_URL")
}
func TestLoadFromEnvRequiresRTMBaseURL(t *testing.T) {
t.Setenv("GAMEMASTER_INTERNAL_HTTP_ADDR", ":8097")
t.Setenv("GAMEMASTER_POSTGRES_PRIMARY_DSN", "postgres://gm:secret@localhost:5432/galaxy")
t.Setenv("GAMEMASTER_REDIS_MASTER_ADDR", "localhost:6379")
t.Setenv("GAMEMASTER_REDIS_PASSWORD", "secret")
t.Setenv("GAMEMASTER_LOBBY_INTERNAL_BASE_URL", "http://lobby:8095")
_, err := LoadFromEnv()
require.Error(t, err)
require.Contains(t, err.Error(), "GAMEMASTER_RTM_INTERNAL_BASE_URL")
}
func TestLoadFromEnvRejectsBadLogLevel(t *testing.T) {
validEnv(t)
t.Setenv("GAMEMASTER_LOG_LEVEL", "verbose")
_, err := LoadFromEnv()
require.Error(t, err)
require.Contains(t, err.Error(), "GAMEMASTER_LOG_LEVEL")
}
func TestLoadFromEnvRejectsBadDuration(t *testing.T) {
validEnv(t)
t.Setenv("GAMEMASTER_ENGINE_CALL_TIMEOUT", "thirty seconds")
_, err := LoadFromEnv()
require.Error(t, err)
require.Contains(t, err.Error(), "GAMEMASTER_ENGINE_CALL_TIMEOUT")
}
func TestInternalHTTPValidateRejectsBadAddr(t *testing.T) {
cfg := DefaultConfig().InternalHTTP
cfg.Addr = "not-an-addr"
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "host:port")
}
func TestStreamsValidateRequiresAllNames(t *testing.T) {
cfg := DefaultConfig().Streams
cfg.LobbyEvents = " "
err := cfg.Validate()
require.Error(t, err)
require.True(t, strings.Contains(err.Error(), "lobby events"))
}
func TestLobbyClientValidateRejectsBadURL(t *testing.T) {
cfg := LobbyClientConfig{BaseURL: "ftp://lobby", Timeout: time.Second}
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "http(s)")
}
func TestRTMClientValidateRejectsEmptyURL(t *testing.T) {
cfg := RTMClientConfig{BaseURL: " ", Timeout: time.Second}
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "rtm internal base url")
}
func TestSchedulerValidateRejectsZeroInterval(t *testing.T) {
cfg := SchedulerConfig{TickInterval: 0, TurnGenerationTimeout: time.Second}
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "scheduler tick interval")
}
func TestMembershipCacheValidateRejectsZero(t *testing.T) {
cfg := MembershipCacheConfig{TTL: 0, MaxGames: 1}
err := cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "ttl")
cfg = MembershipCacheConfig{TTL: time.Second, MaxGames: 0}
err = cfg.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "max games")
}
+219
View File
@@ -0,0 +1,219 @@
package config
import (
"fmt"
"os"
"strconv"
"strings"
"time"
"galaxy/postgres"
"galaxy/redisconn"
)
// LoadFromEnv builds Config from environment variables and validates the
// resulting configuration.
func LoadFromEnv() (Config, error) {
cfg := DefaultConfig()
var err error
cfg.ShutdownTimeout, err = durationEnv(shutdownTimeoutEnvVar, cfg.ShutdownTimeout)
if err != nil {
return Config{}, err
}
cfg.Logging.Level = stringEnv(logLevelEnvVar, cfg.Logging.Level)
addr, ok := os.LookupEnv(internalHTTPAddrEnvVar)
if !ok || strings.TrimSpace(addr) == "" {
return Config{}, fmt.Errorf("%s must be set", internalHTTPAddrEnvVar)
}
cfg.InternalHTTP.Addr = strings.TrimSpace(addr)
cfg.InternalHTTP.ReadHeaderTimeout, err = durationEnv(internalHTTPReadHeaderTimeoutEnvVar, cfg.InternalHTTP.ReadHeaderTimeout)
if err != nil {
return Config{}, err
}
cfg.InternalHTTP.ReadTimeout, err = durationEnv(internalHTTPReadTimeoutEnvVar, cfg.InternalHTTP.ReadTimeout)
if err != nil {
return Config{}, err
}
cfg.InternalHTTP.WriteTimeout, err = durationEnv(internalHTTPWriteTimeoutEnvVar, cfg.InternalHTTP.WriteTimeout)
if err != nil {
return Config{}, err
}
cfg.InternalHTTP.IdleTimeout, err = durationEnv(internalHTTPIdleTimeoutEnvVar, cfg.InternalHTTP.IdleTimeout)
if err != nil {
return Config{}, err
}
pgConn, err := postgres.LoadFromEnv(envPrefix)
if err != nil {
return Config{}, err
}
cfg.Postgres.Conn = pgConn
redisConn, err := redisconn.LoadFromEnv(envPrefix)
if err != nil {
return Config{}, err
}
cfg.Redis.Conn = redisConn
cfg.Streams.LobbyEvents = stringEnv(lobbyEventsStreamEnvVar, cfg.Streams.LobbyEvents)
cfg.Streams.HealthEvents = stringEnv(healthEventsStreamEnvVar, cfg.Streams.HealthEvents)
cfg.Streams.NotificationIntents = stringEnv(notificationIntentsStreamEnvVar, cfg.Streams.NotificationIntents)
cfg.Streams.BlockTimeout, err = durationEnv(streamBlockTimeoutEnvVar, cfg.Streams.BlockTimeout)
if err != nil {
return Config{}, err
}
cfg.EngineClient.CallTimeout, err = durationEnv(engineCallTimeoutEnvVar, cfg.EngineClient.CallTimeout)
if err != nil {
return Config{}, err
}
cfg.EngineClient.ProbeTimeout, err = durationEnv(engineProbeTimeoutEnvVar, cfg.EngineClient.ProbeTimeout)
if err != nil {
return Config{}, err
}
lobbyURL, ok := os.LookupEnv(lobbyInternalBaseURLEnvVar)
if !ok || strings.TrimSpace(lobbyURL) == "" {
return Config{}, fmt.Errorf("%s must be set", lobbyInternalBaseURLEnvVar)
}
cfg.Lobby.BaseURL = strings.TrimSpace(lobbyURL)
cfg.Lobby.Timeout, err = durationEnv(lobbyInternalTimeoutEnvVar, cfg.Lobby.Timeout)
if err != nil {
return Config{}, err
}
rtmURL, ok := os.LookupEnv(rtmInternalBaseURLEnvVar)
if !ok || strings.TrimSpace(rtmURL) == "" {
return Config{}, fmt.Errorf("%s must be set", rtmInternalBaseURLEnvVar)
}
cfg.RTM.BaseURL = strings.TrimSpace(rtmURL)
cfg.RTM.Timeout, err = durationEnv(rtmInternalTimeoutEnvVar, cfg.RTM.Timeout)
if err != nil {
return Config{}, err
}
cfg.Scheduler.TickInterval, err = durationEnv(schedulerTickIntervalEnvVar, cfg.Scheduler.TickInterval)
if err != nil {
return Config{}, err
}
cfg.Scheduler.TurnGenerationTimeout, err = durationEnv(turnGenerationTimeoutEnvVar, cfg.Scheduler.TurnGenerationTimeout)
if err != nil {
return Config{}, err
}
cfg.MembershipCache.TTL, err = durationEnv(membershipCacheTTLEnvVar, cfg.MembershipCache.TTL)
if err != nil {
return Config{}, err
}
cfg.MembershipCache.MaxGames, err = intEnv(membershipCacheMaxGamesEnvVar, cfg.MembershipCache.MaxGames)
if err != nil {
return Config{}, err
}
cfg.Telemetry.ServiceName = stringEnv(otelServiceNameEnvVar, cfg.Telemetry.ServiceName)
cfg.Telemetry.TracesExporter = normalizeExporterValue(stringEnv(otelTracesExporterEnvVar, cfg.Telemetry.TracesExporter))
cfg.Telemetry.MetricsExporter = normalizeExporterValue(stringEnv(otelMetricsExporterEnvVar, cfg.Telemetry.MetricsExporter))
cfg.Telemetry.TracesProtocol = normalizeProtocolValue(
os.Getenv(otelExporterOTLPTracesProtocolEnvVar),
os.Getenv(otelExporterOTLPProtocolEnvVar),
cfg.Telemetry.TracesProtocol,
)
cfg.Telemetry.MetricsProtocol = normalizeProtocolValue(
os.Getenv(otelExporterOTLPMetricsProtocolEnvVar),
os.Getenv(otelExporterOTLPProtocolEnvVar),
cfg.Telemetry.MetricsProtocol,
)
cfg.Telemetry.StdoutTracesEnabled, err = boolEnv(otelStdoutTracesEnabledEnvVar, cfg.Telemetry.StdoutTracesEnabled)
if err != nil {
return Config{}, err
}
cfg.Telemetry.StdoutMetricsEnabled, err = boolEnv(otelStdoutMetricsEnabledEnvVar, cfg.Telemetry.StdoutMetricsEnabled)
if err != nil {
return Config{}, err
}
if err := cfg.Validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
func stringEnv(name string, fallback string) string {
value, ok := os.LookupEnv(name)
if !ok {
return fallback
}
return strings.TrimSpace(value)
}
func durationEnv(name string, fallback time.Duration) (time.Duration, error) {
value, ok := os.LookupEnv(name)
if !ok {
return fallback, nil
}
parsed, err := time.ParseDuration(strings.TrimSpace(value))
if err != nil {
return 0, fmt.Errorf("%s: parse duration: %w", name, err)
}
return parsed, nil
}
func intEnv(name string, fallback int) (int, error) {
value, ok := os.LookupEnv(name)
if !ok {
return fallback, nil
}
parsed, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil {
return 0, fmt.Errorf("%s: parse int: %w", name, err)
}
return parsed, nil
}
func boolEnv(name string, fallback bool) (bool, error) {
value, ok := os.LookupEnv(name)
if !ok {
return fallback, nil
}
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
if err != nil {
return false, fmt.Errorf("%s: parse bool: %w", name, err)
}
return parsed, nil
}
func normalizeExporterValue(value string) string {
trimmed := strings.TrimSpace(value)
switch trimmed {
case "", "none":
return "none"
default:
return trimmed
}
}
func normalizeProtocolValue(primary string, fallback string, defaultValue string) string {
primary = strings.TrimSpace(primary)
if primary != "" {
return primary
}
fallback = strings.TrimSpace(fallback)
if fallback != "" {
return fallback
}
return strings.TrimSpace(defaultValue)
}
+90
View File
@@ -0,0 +1,90 @@
package config
import (
"fmt"
"log/slog"
"net"
"net/url"
"strings"
)
// Validate reports whether cfg stores a usable Game Master process
// configuration.
func (cfg Config) Validate() error {
if cfg.ShutdownTimeout <= 0 {
return fmt.Errorf("%s must be positive", shutdownTimeoutEnvVar)
}
if err := validateSlogLevel(cfg.Logging.Level); err != nil {
return fmt.Errorf("%s: %w", logLevelEnvVar, err)
}
if err := cfg.InternalHTTP.Validate(); err != nil {
return err
}
if err := cfg.Postgres.Validate(); err != nil {
return err
}
if err := cfg.Redis.Validate(); err != nil {
return err
}
if err := cfg.Streams.Validate(); err != nil {
return err
}
if err := cfg.EngineClient.Validate(); err != nil {
return err
}
if err := cfg.Lobby.Validate(); err != nil {
return err
}
if err := cfg.RTM.Validate(); err != nil {
return err
}
if err := cfg.Scheduler.Validate(); err != nil {
return err
}
if err := cfg.MembershipCache.Validate(); err != nil {
return err
}
if err := cfg.Telemetry.Validate(); err != nil {
return err
}
return nil
}
func validateSlogLevel(level string) error {
var slogLevel slog.Level
if err := slogLevel.UnmarshalText([]byte(strings.TrimSpace(level))); err != nil {
return fmt.Errorf("invalid slog level %q: %w", level, err)
}
return nil
}
func isTCPAddr(value string) bool {
host, port, err := net.SplitHostPort(strings.TrimSpace(value))
if err != nil {
return false
}
if port == "" {
return false
}
if host == "" {
return true
}
return !strings.Contains(host, " ")
}
func isHTTPURL(value string) bool {
parsed, err := url.Parse(strings.TrimSpace(value))
if err != nil {
return false
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return false
}
return parsed.Host != ""
}
@@ -0,0 +1,121 @@
// Package engineversion defines the engine version registry domain
// model owned by Game Master.
//
// The registry mirrors the durable shape of the `engine_versions`
// PostgreSQL table (see
// `galaxy/gamemaster/internal/adapters/postgres/migrations/00001_init.sql`)
// and the user-visible status enum frozen in
// `galaxy/gamemaster/api/internal-openapi.yaml`.
//
// `Options` is intentionally kept opaque ([]byte holding raw JSON) so
// the v1 service does not impose a Go-side schema on the engine-owned
// document. Schema-aware handling lands when an engine version actually
// requires it; until then the registry is a pass-through store.
package engineversion
import (
"errors"
"fmt"
"strings"
"time"
)
// Status identifies one engine-version registry state.
type Status string
const (
// StatusActive marks a version as deployable. Lobby's start flow
// resolves image refs only against active versions.
StatusActive Status = "active"
// StatusDeprecated marks a version as no longer offered for new
// starts. Already-running games on a deprecated version are
// unaffected; the runtime stays bound to the version it started on.
StatusDeprecated Status = "deprecated"
)
// IsKnown reports whether status belongs to the frozen engine-version
// status vocabulary.
func (status Status) IsKnown() bool {
switch status {
case StatusActive, StatusDeprecated:
return true
default:
return false
}
}
// AllStatuses returns the frozen list of every engine-version status
// value. The slice order is stable across calls.
func AllStatuses() []Status {
return []Status{StatusActive, StatusDeprecated}
}
// EngineVersion stores one row of the `engine_versions` registry table.
// Options carries the raw `jsonb` document verbatim so the registry
// stays decoupled from any engine-side schema.
type EngineVersion struct {
// Version stores the canonical semver string (primary key).
Version string
// ImageRef stores the Docker reference of the engine image.
ImageRef string
// Options stores the engine-side options document as raw JSON. Empty
// is treated as `{}` by adapters that hydrate the column.
Options []byte
// Status reports whether the version is deployable (`active`) or
// no longer offered for new starts (`deprecated`).
Status Status
// CreatedAt stores the wall-clock at which the row was created.
CreatedAt time.Time
// UpdatedAt stores the wall-clock of the most recent mutation.
UpdatedAt time.Time
}
// Validate reports whether record satisfies the engine-version
// invariants implied by `engine_versions_status_chk` and the README
// §Engine Version Registry surface.
func (record EngineVersion) Validate() error {
if strings.TrimSpace(record.Version) == "" {
return fmt.Errorf("version must not be empty")
}
if strings.TrimSpace(record.ImageRef) == "" {
return fmt.Errorf("image ref must not be empty")
}
if !record.Status.IsKnown() {
return fmt.Errorf("status %q is unsupported", record.Status)
}
if record.CreatedAt.IsZero() {
return fmt.Errorf("created at must not be zero")
}
if record.UpdatedAt.IsZero() {
return fmt.Errorf("updated at must not be zero")
}
if record.UpdatedAt.Before(record.CreatedAt) {
return fmt.Errorf("updated at must not be before created at")
}
return nil
}
// ErrNotFound reports that an engine-version lookup failed because no
// matching row exists.
var ErrNotFound = errors.New("engine version not found")
// ErrInUse reports that a hard-delete or deprecate operation was
// rejected because the version is still referenced by a non-finished
// runtime record.
var ErrInUse = errors.New("engine version in use")
// ErrConflict reports that an engine-version mutation could not be
// applied because a row with the same primary key already exists.
// Adapters surface a PostgreSQL unique-violation through this sentinel
// so the service layer maps it to a `conflict` REST envelope.
var ErrConflict = errors.New("engine version already exists")
// ErrInvalidSemver reports that a semver string did not parse against
// `golang.org/x/mod/semver`'s grammar.
var ErrInvalidSemver = errors.New("invalid semver")
@@ -0,0 +1,63 @@
package engineversion
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func validVersion() EngineVersion {
created := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
return EngineVersion{
Version: "v1.2.3",
ImageRef: "ghcr.io/galaxy/game:v1.2.3",
Options: []byte(`{"max_planets":120}`),
Status: StatusActive,
CreatedAt: created,
UpdatedAt: created,
}
}
func TestStatusIsKnown(t *testing.T) {
for _, status := range AllStatuses() {
assert.True(t, status.IsKnown(), "want known: %q", status)
}
assert.False(t, Status("retired").IsKnown())
assert.False(t, Status("").IsKnown())
}
func TestEngineVersionValidateHappy(t *testing.T) {
require.NoError(t, validVersion().Validate())
}
func TestEngineVersionValidateAcceptsEmptyOptions(t *testing.T) {
record := validVersion()
record.Options = nil
assert.NoError(t, record.Validate())
}
func TestEngineVersionValidateRejects(t *testing.T) {
tests := []struct {
name string
mutate func(*EngineVersion)
}{
{"empty version", func(v *EngineVersion) { v.Version = "" }},
{"empty image ref", func(v *EngineVersion) { v.ImageRef = "" }},
{"unknown status", func(v *EngineVersion) { v.Status = "exotic" }},
{"zero created at", func(v *EngineVersion) { v.CreatedAt = time.Time{} }},
{"zero updated at", func(v *EngineVersion) { v.UpdatedAt = time.Time{} }},
{"updated before created", func(v *EngineVersion) {
v.UpdatedAt = v.CreatedAt.Add(-time.Minute)
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
record := validVersion()
tt.mutate(&record)
assert.Error(t, record.Validate())
})
}
}
@@ -0,0 +1,60 @@
package engineversion
import (
"fmt"
"strings"
"golang.org/x/mod/semver"
)
// ParseSemver normalises version into the canonical "vMAJOR.MINOR.PATCH"
// form expected by `golang.org/x/mod/semver` and reports a wrapped
// ErrInvalidSemver when the resulting string is not a valid full semver.
//
// Whitespace is trimmed; a missing leading "v" is added before the
// validity check so callers may pass either "1.2.3" or "v1.2.3". The
// stripped base must carry exactly three dot-separated numeric
// components — `golang.org/x/mod/semver` accepts shortened forms such
// as "v1" or "v1.2", but the engine-version registry requires the full
// triple, so this function rejects anything narrower.
func ParseSemver(version string) (string, error) {
candidate := strings.TrimSpace(version)
if candidate == "" {
return "", fmt.Errorf("%w: empty", ErrInvalidSemver)
}
if !strings.HasPrefix(candidate, "v") {
candidate = "v" + candidate
}
if !semver.IsValid(candidate) {
return "", fmt.Errorf("%w: %q", ErrInvalidSemver, version)
}
base := candidate
if i := strings.IndexAny(base, "-+"); i >= 0 {
base = base[:i]
}
if strings.Count(base, ".") != 2 {
return "", fmt.Errorf(
"%w: %q (need vMAJOR.MINOR.PATCH)",
ErrInvalidSemver, version,
)
}
return candidate, nil
}
// IsPatchUpgrade reports whether next is a same-major.minor upgrade of
// current. Both inputs are parsed through ParseSemver so callers may
// pass either bare or `v`-prefixed forms. A wrapped ErrInvalidSemver is
// returned when either argument fails to parse; the boolean result is
// undefined in that case.
func IsPatchUpgrade(current, next string) (bool, error) {
curr, err := ParseSemver(current)
if err != nil {
return false, fmt.Errorf("current: %w", err)
}
nxt, err := ParseSemver(next)
if err != nil {
return false, fmt.Errorf("next: %w", err)
}
return semver.MajorMinor(curr) == semver.MajorMinor(nxt), nil
}
@@ -0,0 +1,85 @@
package engineversion
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseSemverNormalises(t *testing.T) {
tests := []struct {
input string
want string
}{
{"1.2.3", "v1.2.3"},
{"v1.2.3", "v1.2.3"},
{" v0.4.0 ", "v0.4.0"},
{"v2.0.0-rc.1", "v2.0.0-rc.1"},
{"v2.0.0+build.7", "v2.0.0+build.7"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got, err := ParseSemver(tt.input)
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
func TestParseSemverRejects(t *testing.T) {
tests := []string{
"",
" ",
"latest",
"1",
"1.2",
"v1.2",
"1.2.3.4",
"v1.2.x",
}
for _, input := range tests {
t.Run(input, func(t *testing.T) {
_, err := ParseSemver(input)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrInvalidSemver))
})
}
}
func TestIsPatchUpgrade(t *testing.T) {
tests := []struct {
name string
current string
next string
want bool
}{
{"same patch", "v1.2.3", "v1.2.3", true},
{"patch bump", "v1.2.3", "v1.2.4", true},
{"patch downgrade", "1.2.4", "1.2.0", true},
{"prerelease patch", "v1.2.3", "v1.2.3-rc.1", true},
{"minor bump", "v1.2.3", "v1.3.0", false},
{"minor downgrade", "v1.2.3", "v1.1.9", false},
{"major bump", "v1.2.3", "v2.0.0", false},
{"major downgrade", "v2.0.0", "v1.9.9", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := IsPatchUpgrade(tt.current, tt.next)
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
func TestIsPatchUpgradeRejectsBadInputs(t *testing.T) {
_, err := IsPatchUpgrade("garbage", "v1.2.3")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrInvalidSemver))
_, err = IsPatchUpgrade("v1.2.3", "")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrInvalidSemver))
}
+244
View File
@@ -0,0 +1,244 @@
// Package operation defines the runtime-operation audit-log domain
// types owned by Game Master.
//
// One OperationEntry maps to one row of the `operation_log` PostgreSQL
// table (see
// `galaxy/gamemaster/internal/adapters/postgres/migrations/00001_init.sql`).
// The OpKind / OpSource / Outcome enums match the SQL CHECK constraints
// verbatim and feed the telemetry counters declared in
// `galaxy/gamemaster/README.md §Observability`.
package operation
import (
"fmt"
"strings"
"time"
)
// OpKind identifies the kind of operation Game Master performed.
type OpKind string
const (
// OpKindRegisterRuntime records a register-runtime operation
// (engine init plus first transition to running).
OpKindRegisterRuntime OpKind = "register_runtime"
// OpKindTurnGeneration records a turn-generation operation
// (scheduler ticker or admin force).
OpKindTurnGeneration OpKind = "turn_generation"
// OpKindForceNextTurn records the admin force-next-turn driver
// (separate from the turn-generation entry it produces, so audit
// callers can tell scheduler ticks from manual ones).
OpKindForceNextTurn OpKind = "force_next_turn"
// OpKindBanish records a /admin/race/banish call against the
// engine container.
OpKindBanish OpKind = "banish"
// OpKindStop records the admin stop driver (the underlying RTM
// stop call is recorded in Runtime Manager's own operation log).
OpKindStop OpKind = "stop"
// OpKindPatch records the admin patch driver.
OpKindPatch OpKind = "patch"
// OpKindEngineVersionCreate records a registry CREATE.
OpKindEngineVersionCreate OpKind = "engine_version_create"
// OpKindEngineVersionUpdate records a registry PATCH.
OpKindEngineVersionUpdate OpKind = "engine_version_update"
// OpKindEngineVersionDeprecate records a registry DELETE / soft
// deprecate.
OpKindEngineVersionDeprecate OpKind = "engine_version_deprecate"
// OpKindEngineVersionDelete records a registry hard delete: the
// row is removed from `engine_versions` after the service layer
// confirms no non-finished runtime still references it.
OpKindEngineVersionDelete OpKind = "engine_version_delete"
)
// IsKnown reports whether kind belongs to the frozen op-kind vocabulary.
func (kind OpKind) IsKnown() bool {
switch kind {
case OpKindRegisterRuntime,
OpKindTurnGeneration,
OpKindForceNextTurn,
OpKindBanish,
OpKindStop,
OpKindPatch,
OpKindEngineVersionCreate,
OpKindEngineVersionUpdate,
OpKindEngineVersionDeprecate,
OpKindEngineVersionDelete:
return true
default:
return false
}
}
// AllOpKinds returns the frozen list of every op-kind value. The slice
// order is stable across calls.
func AllOpKinds() []OpKind {
return []OpKind{
OpKindRegisterRuntime,
OpKindTurnGeneration,
OpKindForceNextTurn,
OpKindBanish,
OpKindStop,
OpKindPatch,
OpKindEngineVersionCreate,
OpKindEngineVersionUpdate,
OpKindEngineVersionDeprecate,
OpKindEngineVersionDelete,
}
}
// OpSource identifies where one operation entered Game Master.
type OpSource string
const (
// OpSourceGatewayPlayer identifies entries triggered by a verified
// player command, order, or report read forwarded through Edge
// Gateway.
OpSourceGatewayPlayer OpSource = "gateway_player"
// OpSourceLobbyInternal identifies entries triggered by Game Lobby
// over the trusted internal REST surface (register-runtime,
// memberships invalidate, banish, liveness).
OpSourceLobbyInternal OpSource = "lobby_internal"
// OpSourceAdminRest identifies entries triggered by Admin Service
// (or system administrators today). The default when the
// `X-Galaxy-Caller` header is missing or unrecognised.
OpSourceAdminRest OpSource = "admin_rest"
)
// IsKnown reports whether source belongs to the frozen op-source
// vocabulary.
func (source OpSource) IsKnown() bool {
switch source {
case OpSourceGatewayPlayer,
OpSourceLobbyInternal,
OpSourceAdminRest:
return true
default:
return false
}
}
// AllOpSources returns the frozen list of every op-source value. The
// slice order is stable across calls.
func AllOpSources() []OpSource {
return []OpSource{
OpSourceGatewayPlayer,
OpSourceLobbyInternal,
OpSourceAdminRest,
}
}
// Outcome reports the high-level outcome of one operation.
type Outcome string
const (
// OutcomeSuccess reports that the operation completed without
// surfacing an error.
OutcomeSuccess Outcome = "success"
// OutcomeFailure reports that the operation surfaced a stable
// error code recorded in OperationEntry.ErrorCode.
OutcomeFailure Outcome = "failure"
)
// IsKnown reports whether outcome belongs to the frozen outcome
// vocabulary.
func (outcome Outcome) IsKnown() bool {
switch outcome {
case OutcomeSuccess, OutcomeFailure:
return true
default:
return false
}
}
// AllOutcomes returns the frozen list of every outcome value. The slice
// order is stable across calls.
func AllOutcomes() []Outcome {
return []Outcome{OutcomeSuccess, OutcomeFailure}
}
// OperationEntry stores one append-only audit row of the `operation_log`
// table. ID is zero on records that have not been persisted yet; the
// store assigns it from the table's bigserial column. FinishedAt is a
// pointer because the column is nullable for in-flight rows even though
// the service layer finalises the row in the same transaction.
type OperationEntry struct {
// ID identifies the persisted row. Zero before persistence.
ID int64
// GameID identifies the platform game this operation acted on.
GameID string
// OpKind classifies what the operation did.
OpKind OpKind
// OpSource classifies how the operation entered Game Master.
OpSource OpSource
// SourceRef stores an opaque per-source reference such as a request
// id, a Redis Stream entry id, or an admin user id. Empty when the
// source does not provide one.
SourceRef string
// Outcome reports whether the operation succeeded or failed.
Outcome Outcome
// ErrorCode stores the stable error code on failure. Empty on
// success.
ErrorCode string
// ErrorMessage stores the operator-readable detail on failure.
// Empty on success.
ErrorMessage string
// StartedAt stores the wall-clock at which the operation began.
StartedAt time.Time
// FinishedAt stores the wall-clock at which the operation
// finalised. Nil for in-flight rows.
FinishedAt *time.Time
}
// Validate reports whether entry satisfies the operation-log invariants
// implied by the SQL CHECK constraints and the README §Persistence
// Layout listing.
func (entry OperationEntry) Validate() error {
if strings.TrimSpace(entry.GameID) == "" {
return fmt.Errorf("game id must not be empty")
}
if !entry.OpKind.IsKnown() {
return fmt.Errorf("op kind %q is unsupported", entry.OpKind)
}
if !entry.OpSource.IsKnown() {
return fmt.Errorf("op source %q is unsupported", entry.OpSource)
}
if !entry.Outcome.IsKnown() {
return fmt.Errorf("outcome %q is unsupported", entry.Outcome)
}
if entry.StartedAt.IsZero() {
return fmt.Errorf("started at must not be zero")
}
if entry.FinishedAt != nil {
if entry.FinishedAt.IsZero() {
return fmt.Errorf("finished at must not be zero when present")
}
if entry.FinishedAt.Before(entry.StartedAt) {
return fmt.Errorf("finished at must not be before started at")
}
}
if entry.Outcome == OutcomeFailure && strings.TrimSpace(entry.ErrorCode) == "" {
return fmt.Errorf("error code must not be empty for failure entries")
}
return nil
}
@@ -0,0 +1,100 @@
package operation
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func validSuccessEntry() OperationEntry {
started := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
finished := started.Add(time.Second)
return OperationEntry{
GameID: "game-1",
OpKind: OpKindRegisterRuntime,
OpSource: OpSourceLobbyInternal,
Outcome: OutcomeSuccess,
StartedAt: started,
FinishedAt: &finished,
}
}
func validFailureEntry() OperationEntry {
entry := validSuccessEntry()
entry.Outcome = OutcomeFailure
entry.ErrorCode = "engine_unreachable"
entry.ErrorMessage = "engine returned 502"
return entry
}
func TestOpKindIsKnown(t *testing.T) {
for _, kind := range AllOpKinds() {
assert.True(t, kind.IsKnown(), "want known: %q", kind)
}
assert.False(t, OpKind("exotic").IsKnown())
assert.Len(t, AllOpKinds(), 10)
}
func TestOpSourceIsKnown(t *testing.T) {
for _, src := range AllOpSources() {
assert.True(t, src.IsKnown(), "want known: %q", src)
}
assert.False(t, OpSource("exotic").IsKnown())
assert.Len(t, AllOpSources(), 3)
}
func TestOutcomeIsKnown(t *testing.T) {
for _, outcome := range AllOutcomes() {
assert.True(t, outcome.IsKnown(), "want known: %q", outcome)
}
assert.False(t, Outcome("exotic").IsKnown())
assert.Len(t, AllOutcomes(), 2)
}
func TestOperationEntryValidateHappy(t *testing.T) {
require.NoError(t, validSuccessEntry().Validate())
require.NoError(t, validFailureEntry().Validate())
}
func TestOperationEntryValidateAcceptsInFlight(t *testing.T) {
entry := validSuccessEntry()
entry.FinishedAt = nil
assert.NoError(t, entry.Validate())
}
func TestOperationEntryValidateRejects(t *testing.T) {
tests := []struct {
name string
mutate func(*OperationEntry)
}{
{"empty game id", func(e *OperationEntry) { e.GameID = "" }},
{"unknown op kind", func(e *OperationEntry) { e.OpKind = "exotic" }},
{"unknown op source", func(e *OperationEntry) { e.OpSource = "exotic" }},
{"unknown outcome", func(e *OperationEntry) { e.Outcome = "exotic" }},
{"zero started at", func(e *OperationEntry) { e.StartedAt = time.Time{} }},
{"zero finished at when present", func(e *OperationEntry) {
zero := time.Time{}
e.FinishedAt = &zero
}},
{"finished before started", func(e *OperationEntry) {
before := e.StartedAt.Add(-time.Second)
e.FinishedAt = &before
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
entry := validSuccessEntry()
tt.mutate(&entry)
assert.Error(t, entry.Validate())
})
}
}
func TestOperationEntryValidateRejectsFailureWithoutCode(t *testing.T) {
entry := validFailureEntry()
entry.ErrorCode = ""
assert.Error(t, entry.Validate())
}
@@ -0,0 +1,71 @@
// Package playermapping defines the durable mapping between platform
// users and engine player handles owned by Game Master.
//
// One PlayerMapping mirrors one row of the `player_mappings` PostgreSQL
// table (see
// `galaxy/gamemaster/internal/adapters/postgres/migrations/00001_init.sql`).
// The composite primary key `(game_id, user_id)` and the unique
// `(game_id, race_name)` index live in the SQL schema; the domain model
// captures the per-row invariants enforced from the application side.
package playermapping
import (
"errors"
"fmt"
"strings"
"time"
)
// PlayerMapping stores one (game_id, user_id) → (race_name,
// engine_player_uuid) projection installed at register-runtime.
type PlayerMapping struct {
// GameID identifies the game owning this mapping.
GameID string
// UserID identifies the platform user this mapping refers to.
UserID string
// RaceName stores the in-game race name reserved for the user in
// the original casing presented by the engine.
RaceName string
// EnginePlayerUUID stores the engine-side player handle returned by
// the engine /admin/init response.
EnginePlayerUUID string
// CreatedAt stores the wall-clock at which the row was inserted.
CreatedAt time.Time
}
// Validate reports whether mapping satisfies the player-mapping
// invariants implied by the README §Persistence Layout / player_mappings
// columns and the SQL primary-key + unique-index constraints.
func (mapping PlayerMapping) Validate() error {
if strings.TrimSpace(mapping.GameID) == "" {
return fmt.Errorf("game id must not be empty")
}
if strings.TrimSpace(mapping.UserID) == "" {
return fmt.Errorf("user id must not be empty")
}
if strings.TrimSpace(mapping.RaceName) == "" {
return fmt.Errorf("race name must not be empty")
}
if strings.TrimSpace(mapping.EnginePlayerUUID) == "" {
return fmt.Errorf("engine player uuid must not be empty")
}
if mapping.CreatedAt.IsZero() {
return fmt.Errorf("created at must not be zero")
}
return nil
}
// ErrNotFound reports that a player-mapping lookup failed because no
// matching row exists.
var ErrNotFound = errors.New("player mapping not found")
// ErrConflict reports that a player-mapping insert could not be applied
// because a row with the same `(game_id, user_id)` primary key or with
// the same `(game_id, race_name)` unique pair already exists. Adapters
// surface PostgreSQL unique-violations through this sentinel so the
// service layer maps it to a `conflict` REST envelope.
var ErrConflict = errors.New("player mapping already exists")
@@ -0,0 +1,44 @@
package playermapping
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func validMapping() PlayerMapping {
return PlayerMapping{
GameID: "game-1",
UserID: "user-1",
RaceName: "Aelinari",
EnginePlayerUUID: "00000000-0000-0000-0000-000000000001",
CreatedAt: time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC),
}
}
func TestPlayerMappingValidateHappy(t *testing.T) {
require.NoError(t, validMapping().Validate())
}
func TestPlayerMappingValidateRejects(t *testing.T) {
tests := []struct {
name string
mutate func(*PlayerMapping)
}{
{"empty game id", func(m *PlayerMapping) { m.GameID = "" }},
{"empty user id", func(m *PlayerMapping) { m.UserID = "" }},
{"empty race name", func(m *PlayerMapping) { m.RaceName = "" }},
{"empty engine uuid", func(m *PlayerMapping) { m.EnginePlayerUUID = "" }},
{"zero created at", func(m *PlayerMapping) { m.CreatedAt = time.Time{} }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mapping := validMapping()
tt.mutate(&mapping)
assert.Error(t, mapping.Validate())
})
}
}
@@ -0,0 +1,43 @@
package runtime
import (
"errors"
"fmt"
)
// ErrNotFound reports that a runtime record was requested but does not
// exist in the store.
var ErrNotFound = errors.New("runtime record not found")
// ErrConflict reports that a runtime mutation could not be applied
// because the record changed concurrently or failed a compare-and-swap
// guard.
var ErrConflict = errors.New("runtime record conflict")
// ErrInvalidTransition is the sentinel returned when Transition rejects
// a `(from, to)` pair.
var ErrInvalidTransition = errors.New("invalid runtime status transition")
// InvalidTransitionError stores the rejected `(from, to)` pair and wraps
// ErrInvalidTransition so callers can match it with errors.Is.
type InvalidTransitionError struct {
// From stores the source status that was attempted to leave.
From Status
// To stores the destination status that was attempted to enter.
To Status
}
// Error reports a human-readable summary of the rejected pair.
func (err *InvalidTransitionError) Error() string {
return fmt.Sprintf(
"invalid runtime status transition from %q to %q",
err.From, err.To,
)
}
// Unwrap returns ErrInvalidTransition so errors.Is recognizes the
// sentinel.
func (err *InvalidTransitionError) Unwrap() error {
return ErrInvalidTransition
}
+254
View File
@@ -0,0 +1,254 @@
// Package runtime defines the runtime-record domain model, status
// machine, and sentinel errors owned by Game Master.
//
// The package mirrors the durable shape of the `runtime_records`
// PostgreSQL table (see
// `galaxy/gamemaster/internal/adapters/postgres/migrations/00001_init.sql`).
// Every status / transition / required-field rule already documented in
// `galaxy/gamemaster/README.md` lives here as code so adapter and service
// layers do not re-derive it.
package runtime
import (
"fmt"
"strings"
"time"
)
// Status identifies one runtime-record lifecycle state.
type Status string
const (
// StatusStarting reports that register-runtime has persisted the row
// but the engine /admin/init call has not yet succeeded.
StatusStarting Status = "starting"
// StatusRunning reports that the runtime is healthy and accepting
// player commands and turn generation.
StatusRunning Status = "running"
// StatusGenerationInProgress reports that the scheduler or admin
// force-next-turn flow has CAS'd the row to drive turn generation.
StatusGenerationInProgress Status = "generation_in_progress"
// StatusGenerationFailed reports that turn generation surfaced an
// engine error and the runtime is awaiting manual recovery.
StatusGenerationFailed Status = "generation_failed"
// StatusStopped reports that an admin stop has completed; the row
// stays in PostgreSQL for audit.
StatusStopped Status = "stopped"
// StatusEngineUnreachable reports that runtime:health_events observed
// an engine container failure (exited, OOM, disappeared, or repeated
// probe failures).
StatusEngineUnreachable Status = "engine_unreachable"
// StatusFinished reports that the engine returned `finished:true` on
// a turn-generation response. The state is terminal: the row stays
// here indefinitely; operator cleanup is the only path out.
StatusFinished Status = "finished"
)
// IsKnown reports whether status belongs to the frozen runtime status
// vocabulary.
func (status Status) IsKnown() bool {
switch status {
case StatusStarting,
StatusRunning,
StatusGenerationInProgress,
StatusGenerationFailed,
StatusStopped,
StatusEngineUnreachable,
StatusFinished:
return true
default:
return false
}
}
// IsTerminal reports whether status can no longer accept lifecycle
// transitions. Per `gamemaster/README.md §Game Master status model`, only
// `finished` is terminal; `stopped` may still be observed but is treated
// as a non-terminal end-state for admin replay purposes (no transitions
// out of it are wired in v1, but the state machine does not forbid them
// architecturally).
func (status Status) IsTerminal() bool {
return status == StatusFinished
}
// AllStatuses returns the frozen list of every runtime status value. The
// slice order is stable across calls and matches the README §Persistence
// Layout listing.
func AllStatuses() []Status {
return []Status{
StatusStarting,
StatusRunning,
StatusGenerationInProgress,
StatusGenerationFailed,
StatusStopped,
StatusEngineUnreachable,
StatusFinished,
}
}
// RuntimeRecord stores one durable runtime record owned by Game Master.
// It mirrors one row of the `runtime_records` table.
//
// NextGenerationAt is *time.Time so a missing tick (e.g., a row that has
// just entered with status=starting) is unambiguous. StartedAt, StoppedAt,
// and FinishedAt are *time.Time for the same reason and align with the
// jet-generated model.
type RuntimeRecord struct {
// GameID identifies the platform game owning this runtime record.
GameID string
// Status stores the current lifecycle state.
Status Status
// EngineEndpoint stores the stable URL Game Master uses to reach the
// engine container, in `http://galaxy-game-{game_id}:8080` form.
EngineEndpoint string
// CurrentImageRef stores the Docker reference of the running engine
// image (or the most recent one for stopped/finished records).
CurrentImageRef string
// CurrentEngineVersion stores the semver of the currently-bound
// engine version (registered in `engine_versions`).
CurrentEngineVersion string
// TurnSchedule stores the five-field cron expression governing turn
// generation, copied from the platform game record at
// register-runtime time.
TurnSchedule string
// CurrentTurn stores the last completed turn number; zero until the
// first turn generates.
CurrentTurn int
// NextGenerationAt stores the next due tick. Nil when no tick is
// scheduled (e.g., status=starting, finished, stopped).
NextGenerationAt *time.Time
// SkipNextTick is true when force-next-turn has set the skip flag
// for the next regular tick. Cleared by the scheduler after the
// first scheduled step is skipped.
SkipNextTick bool
// EngineHealth stores the short text summary derived from
// runtime:health_events; empty until the first health observation.
EngineHealth string
// CreatedAt stores the wall-clock at which the record was created.
CreatedAt time.Time
// UpdatedAt stores the wall-clock of the most recent mutation.
UpdatedAt time.Time
// StartedAt stores the wall-clock at which the runtime first
// transitioned to running. Non-nil once the status leaves starting.
StartedAt *time.Time
// StoppedAt stores the wall-clock at which the runtime was stopped.
// Non-nil when status is stopped.
StoppedAt *time.Time
// FinishedAt stores the wall-clock at which the engine reported
// finish. Non-nil when status is finished.
FinishedAt *time.Time
}
// Validate reports whether record satisfies the runtime-record invariants
// implied by README §Lifecycles and the SQL CHECK on `runtime_records`.
func (record RuntimeRecord) Validate() error {
if strings.TrimSpace(record.GameID) == "" {
return fmt.Errorf("game id must not be empty")
}
if !record.Status.IsKnown() {
return fmt.Errorf("status %q is unsupported", record.Status)
}
if strings.TrimSpace(record.EngineEndpoint) == "" {
return fmt.Errorf("engine endpoint must not be empty")
}
if strings.TrimSpace(record.CurrentImageRef) == "" {
return fmt.Errorf("current image ref must not be empty")
}
if strings.TrimSpace(record.CurrentEngineVersion) == "" {
return fmt.Errorf("current engine version must not be empty")
}
if strings.TrimSpace(record.TurnSchedule) == "" {
return fmt.Errorf("turn schedule must not be empty")
}
if record.CurrentTurn < 0 {
return fmt.Errorf("current turn must not be negative")
}
if record.CreatedAt.IsZero() {
return fmt.Errorf("created at must not be zero")
}
if record.UpdatedAt.IsZero() {
return fmt.Errorf("updated at must not be zero")
}
if record.UpdatedAt.Before(record.CreatedAt) {
return fmt.Errorf("updated at must not be before created at")
}
if record.NextGenerationAt != nil && record.NextGenerationAt.IsZero() {
return fmt.Errorf("next generation at must not be zero when present")
}
switch record.Status {
case StatusStarting:
if record.StartedAt != nil {
return fmt.Errorf("started at must be nil for starting records")
}
case StatusRunning,
StatusGenerationInProgress,
StatusGenerationFailed,
StatusEngineUnreachable:
if record.StartedAt == nil {
return fmt.Errorf(
"started at must not be nil for %s records",
record.Status,
)
}
if record.StartedAt.IsZero() {
return fmt.Errorf("started at must not be zero when present")
}
case StatusStopped:
if record.StartedAt == nil {
return fmt.Errorf("started at must not be nil for stopped records")
}
if record.StoppedAt == nil {
return fmt.Errorf("stopped at must not be nil for stopped records")
}
if record.StoppedAt.IsZero() {
return fmt.Errorf("stopped at must not be zero when present")
}
if record.StoppedAt.Before(*record.StartedAt) {
return fmt.Errorf("stopped at must not be before started at")
}
case StatusFinished:
if record.StartedAt == nil {
return fmt.Errorf("started at must not be nil for finished records")
}
if record.FinishedAt == nil {
return fmt.Errorf("finished at must not be nil for finished records")
}
if record.FinishedAt.IsZero() {
return fmt.Errorf("finished at must not be zero when present")
}
if record.FinishedAt.Before(*record.StartedAt) {
return fmt.Errorf("finished at must not be before started at")
}
}
if record.StartedAt != nil && record.StartedAt.Before(record.CreatedAt) {
return fmt.Errorf("started at must not be before created at")
}
return nil
}
@@ -0,0 +1,130 @@
package runtime
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func validRunningRecord() RuntimeRecord {
created := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
started := created.Add(time.Minute)
updated := started.Add(time.Minute)
next := updated.Add(time.Hour)
return RuntimeRecord{
GameID: "game-1",
Status: StatusRunning,
EngineEndpoint: "http://galaxy-game-1:8080",
CurrentImageRef: "ghcr.io/galaxy/game:v1.2.3",
CurrentEngineVersion: "v1.2.3",
TurnSchedule: "0 18 * * *",
CurrentTurn: 0,
NextGenerationAt: &next,
CreatedAt: created,
UpdatedAt: updated,
StartedAt: &started,
}
}
func TestStatusIsKnown(t *testing.T) {
for _, status := range AllStatuses() {
assert.True(t, status.IsKnown(), "want known: %q", status)
}
assert.False(t, Status("exotic").IsKnown())
assert.False(t, Status("").IsKnown())
}
func TestStatusIsTerminal(t *testing.T) {
assert.True(t, StatusFinished.IsTerminal())
for _, status := range AllStatuses() {
if status == StatusFinished {
continue
}
assert.False(t, status.IsTerminal(), "%q must not be terminal", status)
}
}
func TestAllStatusesStable(t *testing.T) {
first := AllStatuses()
second := AllStatuses()
assert.Equal(t, first, second)
assert.Len(t, first, 7)
}
func TestRuntimeRecordValidateHappy(t *testing.T) {
require.NoError(t, validRunningRecord().Validate())
}
func TestRuntimeRecordValidateAcceptsStarting(t *testing.T) {
record := validRunningRecord()
record.Status = StatusStarting
record.StartedAt = nil
record.NextGenerationAt = nil
assert.NoError(t, record.Validate())
}
func TestRuntimeRecordValidateRequiresFinishedAt(t *testing.T) {
record := validRunningRecord()
record.Status = StatusFinished
record.FinishedAt = nil
assert.Error(t, record.Validate())
finished := record.UpdatedAt.Add(time.Minute)
record.FinishedAt = &finished
assert.NoError(t, record.Validate())
}
func TestRuntimeRecordValidateRequiresStoppedAtForStopped(t *testing.T) {
record := validRunningRecord()
record.Status = StatusStopped
assert.Error(t, record.Validate())
stopped := record.UpdatedAt.Add(time.Minute)
record.StoppedAt = &stopped
assert.NoError(t, record.Validate())
}
func TestRuntimeRecordValidateRejects(t *testing.T) {
tests := []struct {
name string
mutate func(*RuntimeRecord)
}{
{"empty game id", func(r *RuntimeRecord) { r.GameID = "" }},
{"unknown status", func(r *RuntimeRecord) { r.Status = "exotic" }},
{"empty engine endpoint", func(r *RuntimeRecord) { r.EngineEndpoint = "" }},
{"empty image ref", func(r *RuntimeRecord) { r.CurrentImageRef = "" }},
{"empty engine version", func(r *RuntimeRecord) { r.CurrentEngineVersion = "" }},
{"empty turn schedule", func(r *RuntimeRecord) { r.TurnSchedule = "" }},
{"negative turn", func(r *RuntimeRecord) { r.CurrentTurn = -1 }},
{"zero created at", func(r *RuntimeRecord) { r.CreatedAt = time.Time{} }},
{"zero updated at", func(r *RuntimeRecord) { r.UpdatedAt = time.Time{} }},
{"updated before created", func(r *RuntimeRecord) {
r.UpdatedAt = r.CreatedAt.Add(-time.Minute)
}},
{"started before created", func(r *RuntimeRecord) {
before := r.CreatedAt.Add(-time.Minute)
r.StartedAt = &before
}},
{"running missing started at", func(r *RuntimeRecord) { r.StartedAt = nil }},
{"starting with started at", func(r *RuntimeRecord) {
r.Status = StatusStarting
// keep StartedAt set
}},
{"zero next generation at", func(r *RuntimeRecord) {
zero := time.Time{}
r.NextGenerationAt = &zero
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
record := validRunningRecord()
tt.mutate(&record)
assert.Error(t, record.Validate())
})
}
}
@@ -0,0 +1,77 @@
package runtime
// transitionKey stores one `(from, to)` pair in the allowed-transitions
// table.
type transitionKey struct {
from Status
to Status
}
// allowedTransitions enumerates the runtime-status transitions Game
// Master is allowed to apply. The set mirrors the lifecycle flows frozen
// in `galaxy/gamemaster/README.md §Lifecycles`:
//
// - starting → running: register-runtime CAS after a successful
// engine /admin/init.
// - running → generation_in_progress: scheduler ticker or admin
// force-next-turn enters turn generation.
// - generation_in_progress → running: turn generation succeeded with
// `finished=false`.
// - generation_in_progress → generation_failed: engine timeout or
// 5xx during turn generation.
// - generation_in_progress → finished: engine returned
// `finished=true`; the state is terminal.
// - generation_failed → generation_in_progress: admin force-next-turn
// after manual recovery.
// - running → engine_unreachable: runtime:health_events observed an
// engine container failure (Stage 18 consumer).
// - engine_unreachable → running: runtime:health_events observed a
// recovery; reserved for the Stage 18 consumer; declared here so
// Stage 18 needs no transitions edit.
// - running → stopped, generation_in_progress → stopped,
// generation_failed → stopped, engine_unreachable → stopped: admin
// stop is allowed from every non-terminal status (README §Stop:
// «CAS `runtime_records.status: * → stopped`»).
var allowedTransitions = map[transitionKey]struct{}{
{StatusStarting, StatusRunning}: {},
{StatusRunning, StatusGenerationInProgress}: {},
{StatusGenerationInProgress, StatusRunning}: {},
{StatusGenerationInProgress, StatusGenerationFailed}: {},
{StatusGenerationInProgress, StatusFinished}: {},
{StatusGenerationFailed, StatusGenerationInProgress}: {},
{StatusRunning, StatusEngineUnreachable}: {},
{StatusEngineUnreachable, StatusRunning}: {},
{StatusRunning, StatusStopped}: {},
{StatusGenerationInProgress, StatusStopped}: {},
{StatusGenerationFailed, StatusStopped}: {},
{StatusEngineUnreachable, StatusStopped}: {},
}
// AllowedTransitions returns a copy of the `(from, to)` allowed
// transitions table used by Transition. The returned map is safe to
// mutate; callers should not rely on iteration order.
func AllowedTransitions() map[Status][]Status {
result := make(map[Status][]Status)
for key := range allowedTransitions {
result[key.from] = append(result[key.from], key.to)
}
return result
}
// Transition reports whether from may transition to next. The function
// returns nil when the pair is permitted, and an *InvalidTransitionError
// wrapping ErrInvalidTransition otherwise. It does not touch any store
// and is safe to call from any layer.
func Transition(from Status, next Status) error {
if !from.IsKnown() || !next.IsKnown() {
return &InvalidTransitionError{From: from, To: next}
}
if _, ok := allowedTransitions[transitionKey{from: from, to: next}]; !ok {
return &InvalidTransitionError{From: from, To: next}
}
return nil
}
@@ -0,0 +1,90 @@
package runtime
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTransitionAcceptsAllAllowedPairs(t *testing.T) {
for from, tos := range AllowedTransitions() {
for _, to := range tos {
t.Run(string(from)+"->"+string(to), func(t *testing.T) {
assert.NoError(t, Transition(from, to))
})
}
}
}
func TestTransitionRejectsForbiddenPairs(t *testing.T) {
allowed := AllowedTransitions()
allowedSet := make(map[transitionKey]struct{})
for from, tos := range allowed {
for _, to := range tos {
allowedSet[transitionKey{from: from, to: to}] = struct{}{}
}
}
for _, from := range AllStatuses() {
for _, to := range AllStatuses() {
if _, ok := allowedSet[transitionKey{from: from, to: to}]; ok {
continue
}
t.Run(string(from)+"->"+string(to), func(t *testing.T) {
err := Transition(from, to)
require.Error(t, err)
var typed *InvalidTransitionError
assert.True(t, errors.As(err, &typed))
assert.Equal(t, from, typed.From)
assert.Equal(t, to, typed.To)
assert.True(t, errors.Is(err, ErrInvalidTransition))
})
}
}
}
func TestTransitionRejectsUnknownStatus(t *testing.T) {
tests := []struct {
name string
from Status
to Status
}{
{"unknown from", "exotic", StatusRunning},
{"unknown to", StatusRunning, "exotic"},
{"both unknown", "from-x", "to-y"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := Transition(tt.from, tt.to)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrInvalidTransition))
})
}
}
func TestAllowedTransitionsIncludesExpectedFlows(t *testing.T) {
allowed := AllowedTransitions()
must := func(from Status, expected Status) {
t.Helper()
got := allowed[from]
assert.Containsf(t, got, expected,
"expected %q in transitions from %q, got %v",
expected, from, got)
}
must(StatusStarting, StatusRunning)
must(StatusRunning, StatusGenerationInProgress)
must(StatusGenerationInProgress, StatusRunning)
must(StatusGenerationInProgress, StatusGenerationFailed)
must(StatusGenerationInProgress, StatusFinished)
must(StatusGenerationFailed, StatusGenerationInProgress)
must(StatusRunning, StatusEngineUnreachable)
must(StatusEngineUnreachable, StatusRunning)
must(StatusRunning, StatusStopped)
must(StatusGenerationInProgress, StatusStopped)
must(StatusGenerationFailed, StatusStopped)
must(StatusEngineUnreachable, StatusStopped)
}
@@ -0,0 +1,59 @@
// Package schedule wraps `pkg/cronutil` with the force-next-turn skip
// rule used by Game Master's scheduler.
//
// The wrapper is pure: callers pass the current `skip_next_tick` flag
// and the wrapper returns both the next firing time and a boolean that
// reports whether the flag was consumed. The runtime-record store is
// responsible for persisting the cleared flag; this package never
// touches it.
//
// `gamemaster/README.md §Force-next-turn` describes the rule:
//
// If `skip_next_tick=true`, advance by one extra cron step and clear
// the flag.
package schedule
import (
"time"
"galaxy/cronutil"
)
// Schedule wraps `cronutil.Schedule` with the GM-specific
// skip-next-tick semantics. The zero value is not usable; callers
// obtain a Schedule from Parse.
type Schedule struct {
inner cronutil.Schedule
}
// Parse parses expr as a five-field cron expression and returns the
// resulting Schedule. Parse returns an error if expr is rejected by the
// underlying cronutil parser.
func Parse(expr string) (Schedule, error) {
inner, err := cronutil.Parse(expr)
if err != nil {
return Schedule{}, err
}
return Schedule{inner: inner}, nil
}
// Next returns the next firing time strictly after `after`, honouring
// the skip flag.
//
// When `skip` is false, Next returns `cronutil.Schedule.Next(after)`
// and reports `skipConsumed=false`.
//
// When `skip` is true, Next computes the cron step immediately after
// `after`, then advances by one further cron step and returns that
// time with `skipConsumed=true`. The caller is responsible for
// persisting the cleared flag after observing `skipConsumed`.
//
// All returned times are in UTC; cronutil.Schedule already enforces
// UTC normalisation on its inputs and outputs.
func (s Schedule) Next(after time.Time, skip bool) (time.Time, bool) {
first := s.inner.Next(after)
if !skip {
return first, false
}
return s.inner.Next(first), true
}
@@ -0,0 +1,67 @@
package schedule
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseRejectsBadExpr(t *testing.T) {
_, err := Parse("")
assert.Error(t, err)
_, err = Parse("0 0 31 2 *") // valid syntactically but never fires; cronutil accepts it
// cronutil only validates syntax; an impossible date is still parsed.
// We assert by separately rejecting clearly invalid syntax:
_, err = Parse("not-a-cron")
assert.Error(t, err)
_, err = Parse("0 18 * *") // four fields
assert.Error(t, err)
_, err = Parse("0 0 * * * *") // six fields
assert.Error(t, err)
}
func TestNextNoSkip(t *testing.T) {
// Fires every day at 18:00 UTC.
sched, err := Parse("0 18 * * *")
require.NoError(t, err)
after := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
got, skipped := sched.Next(after, false)
assert.False(t, skipped)
assert.Equal(t, time.Date(2026, 4, 27, 18, 0, 0, 0, time.UTC), got)
assert.Equal(t, time.UTC, got.Location())
}
func TestNextWithSkipAdvancesOneStep(t *testing.T) {
sched, err := Parse("0 18 * * *")
require.NoError(t, err)
after := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
got, skipped := sched.Next(after, true)
assert.True(t, skipped)
// First slot would be 2026-04-27 18:00 UTC; the skip rule advances
// to 2026-04-28 18:00 UTC.
assert.Equal(t, time.Date(2026, 4, 28, 18, 0, 0, 0, time.UTC), got)
}
func TestNextNormalisesNonUTCInput(t *testing.T) {
sched, err := Parse("*/15 * * * *")
require.NoError(t, err)
moscow := time.FixedZone("MSK", 3*60*60)
// 2026-04-27 15:30 MSK = 2026-04-27 12:30 UTC; next 15-minute slot
// in UTC is 12:45.
after := time.Date(2026, 4, 27, 15, 30, 0, 0, moscow)
got, skipped := sched.Next(after, false)
assert.False(t, skipped)
assert.Equal(t, time.Date(2026, 4, 27, 12, 45, 0, 0, time.UTC), got)
assert.Equal(t, time.UTC, got.Location())
}
+43
View File
@@ -0,0 +1,43 @@
package logging
import "context"
// requestIDKey is the unexported context key under which the HTTP layer
// stores the request id propagated from the X-Request-Id header.
type requestIDKey struct{}
// WithRequestID returns a child context that carries requestID. An empty
// requestID returns ctx unchanged so callers do not have to branch.
func WithRequestID(ctx context.Context, requestID string) context.Context {
if ctx == nil || requestID == "" {
return ctx
}
return context.WithValue(ctx, requestIDKey{}, requestID)
}
// RequestIDFromContext returns the request id stored on ctx by
// WithRequestID, or an empty string when no value is present.
func RequestIDFromContext(ctx context.Context) string {
if ctx == nil {
return ""
}
value, _ := ctx.Value(requestIDKey{}).(string)
return value
}
// ContextAttrs returns slog key-value pairs that materialise the frozen
// `gamemaster/README.md` §Observability log fields `request_id`,
// `trace_id`, and `span_id` from ctx. Pairs whose value is empty are
// omitted so logs stay tight.
func ContextAttrs(ctx context.Context) []any {
if ctx == nil {
return nil
}
var attrs []any
if requestID := RequestIDFromContext(ctx); requestID != "" {
attrs = append(attrs, "request_id", requestID)
}
attrs = append(attrs, TraceAttrsFromContext(ctx)...)
return attrs
}
+45
View File
@@ -0,0 +1,45 @@
// Package logging configures the Game Master process logger and provides
// context-aware helpers for trace fields.
package logging
import (
"context"
"fmt"
"log/slog"
"os"
"strings"
"go.opentelemetry.io/otel/trace"
)
// New constructs the process-wide JSON logger from level.
func New(level string) (*slog.Logger, error) {
var slogLevel slog.Level
if err := slogLevel.UnmarshalText([]byte(strings.TrimSpace(level))); err != nil {
return nil, fmt.Errorf("build logger: %w", err)
}
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slogLevel,
})), nil
}
// TraceAttrsFromContext returns slog key-value pairs for the active
// OpenTelemetry span when ctx carries a valid span context. The keys match
// the frozen `gamemaster/README.md` §Observability log fields `trace_id`
// and `span_id`.
func TraceAttrsFromContext(ctx context.Context) []any {
if ctx == nil {
return nil
}
spanContext := trace.SpanContextFromContext(ctx)
if !spanContext.IsValid() {
return nil
}
return []any{
"trace_id", spanContext.TraceID().String(),
"span_id", spanContext.SpanID().String(),
}
}
+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")

Some files were not shown because too many files have changed in this diff Show More