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())
}