feat: gamemaster
This commit is contained in:
@@ -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(¤t); 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,
|
||||
¤tImageRef,
|
||||
¤tEngineVersion,
|
||||
&turnSchedule,
|
||||
¤tTurn,
|
||||
&nextGenerationAt,
|
||||
&skipNextTick,
|
||||
&engineHealth,
|
||||
&createdAt,
|
||||
&updatedAt,
|
||||
&startedAt,
|
||||
&stoppedAt,
|
||||
&finishedAt,
|
||||
); err != nil {
|
||||
return runtime.RuntimeRecord{}, err
|
||||
}
|
||||
return runtime.RuntimeRecord{
|
||||
GameID: gameID,
|
||||
Status: runtime.Status(status),
|
||||
EngineEndpoint: engineEndpoint,
|
||||
CurrentImageRef: currentImageRef,
|
||||
CurrentEngineVersion: currentEngineVersion,
|
||||
TurnSchedule: turnSchedule,
|
||||
CurrentTurn: int(currentTurn),
|
||||
NextGenerationAt: sqlx.TimePtrFromNullable(nextGenerationAt),
|
||||
SkipNextTick: skipNextTick,
|
||||
EngineHealth: engineHealth,
|
||||
CreatedAt: createdAt.UTC(),
|
||||
UpdatedAt: updatedAt.UTC(),
|
||||
StartedAt: sqlx.TimePtrFromNullable(startedAt),
|
||||
StoppedAt: sqlx.TimePtrFromNullable(stoppedAt),
|
||||
FinishedAt: sqlx.TimePtrFromNullable(finishedAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Ensure Store satisfies the ports.RuntimeRecordStore interface at
|
||||
// compile time.
|
||||
var _ ports.RuntimeRecordStore = (*Store)(nil)
|
||||
@@ -0,0 +1,718 @@
|
||||
package runtimerecordstore_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/gamemaster/internal/adapters/postgres/internal/pgtest"
|
||||
"galaxy/gamemaster/internal/adapters/postgres/runtimerecordstore"
|
||||
"galaxy/gamemaster/internal/domain/runtime"
|
||||
"galaxy/gamemaster/internal/ports"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) { pgtest.RunMain(m) }
|
||||
|
||||
func newStore(t *testing.T) *runtimerecordstore.Store {
|
||||
t.Helper()
|
||||
pgtest.TruncateAll(t)
|
||||
store, err := runtimerecordstore.New(runtimerecordstore.Config{
|
||||
DB: pgtest.Ensure(t).Pool(),
|
||||
OperationTimeout: pgtest.OperationTimeout,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return store
|
||||
}
|
||||
|
||||
func startingRecord(gameID string, createdAt time.Time) runtime.RuntimeRecord {
|
||||
return runtime.RuntimeRecord{
|
||||
GameID: gameID,
|
||||
Status: runtime.StatusStarting,
|
||||
EngineEndpoint: "http://galaxy-game-" + gameID + ":8080",
|
||||
CurrentImageRef: "ghcr.io/galaxy/game:v1.2.3",
|
||||
CurrentEngineVersion: "v1.2.3",
|
||||
TurnSchedule: "0 18 * * *",
|
||||
CurrentTurn: 0,
|
||||
EngineHealth: "",
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
func runningRecord(gameID string, createdAt time.Time, nextGen time.Time) runtime.RuntimeRecord {
|
||||
startedAt := createdAt.Add(time.Second)
|
||||
return runtime.RuntimeRecord{
|
||||
GameID: gameID,
|
||||
Status: runtime.StatusRunning,
|
||||
EngineEndpoint: "http://galaxy-game-" + gameID + ":8080",
|
||||
CurrentImageRef: "ghcr.io/galaxy/game:v1.2.3",
|
||||
CurrentEngineVersion: "v1.2.3",
|
||||
TurnSchedule: "0 18 * * *",
|
||||
CurrentTurn: 1,
|
||||
NextGenerationAt: &nextGen,
|
||||
EngineHealth: "healthy",
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: startedAt,
|
||||
StartedAt: &startedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsInvalidConfig(t *testing.T) {
|
||||
_, err := runtimerecordstore.New(runtimerecordstore.Config{})
|
||||
require.Error(t, err)
|
||||
|
||||
store, err := runtimerecordstore.New(runtimerecordstore.Config{
|
||||
DB: pgtest.Ensure(t).Pool(),
|
||||
OperationTimeout: 0,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Nil(t, store)
|
||||
}
|
||||
|
||||
func TestInsertGetRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
record := startingRecord("game-001", now)
|
||||
|
||||
require.NoError(t, store.Insert(ctx, record))
|
||||
|
||||
got, err := store.Get(ctx, record.GameID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, record.GameID, got.GameID)
|
||||
assert.Equal(t, runtime.StatusStarting, got.Status)
|
||||
assert.Equal(t, record.EngineEndpoint, got.EngineEndpoint)
|
||||
assert.Equal(t, record.CurrentImageRef, got.CurrentImageRef)
|
||||
assert.Equal(t, record.CurrentEngineVersion, got.CurrentEngineVersion)
|
||||
assert.Equal(t, record.TurnSchedule, got.TurnSchedule)
|
||||
assert.Equal(t, 0, got.CurrentTurn)
|
||||
assert.Nil(t, got.NextGenerationAt)
|
||||
assert.False(t, got.SkipNextTick)
|
||||
assert.Equal(t, "", got.EngineHealth)
|
||||
assert.True(t, got.CreatedAt.Equal(now), "created_at: want %v, got %v", now, got.CreatedAt)
|
||||
assert.Equal(t, time.UTC, got.CreatedAt.Location())
|
||||
assert.True(t, got.UpdatedAt.Equal(now))
|
||||
assert.Equal(t, time.UTC, got.UpdatedAt.Location())
|
||||
assert.Nil(t, got.StartedAt)
|
||||
assert.Nil(t, got.StoppedAt)
|
||||
assert.Nil(t, got.FinishedAt)
|
||||
}
|
||||
|
||||
func TestInsertRejectsDuplicate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
record := startingRecord("game-001", now)
|
||||
require.NoError(t, store.Insert(ctx, record))
|
||||
|
||||
err := store.Insert(ctx, record)
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, runtime.ErrConflict), "want ErrConflict, got %v", err)
|
||||
}
|
||||
|
||||
func TestInsertRejectsInvalidRecord(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
bad := runtime.RuntimeRecord{} // empty
|
||||
err := store.Insert(ctx, bad)
|
||||
require.Error(t, err)
|
||||
require.False(t, errors.Is(err, runtime.ErrConflict))
|
||||
}
|
||||
|
||||
func TestGetReturnsErrNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
_, err := store.Get(ctx, "missing")
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, runtime.ErrNotFound))
|
||||
}
|
||||
|
||||
func TestUpdateStatusStartingToRunningSetsStartedAt(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, store.Insert(ctx, startingRecord("game-001", created)))
|
||||
|
||||
now := created.Add(2 * time.Second)
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: "game-001",
|
||||
ExpectedFrom: runtime.StatusStarting,
|
||||
To: runtime.StatusRunning,
|
||||
Now: now,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, runtime.StatusRunning, got.Status)
|
||||
require.NotNil(t, got.StartedAt)
|
||||
assert.True(t, got.StartedAt.Equal(now))
|
||||
assert.True(t, got.UpdatedAt.Equal(now))
|
||||
assert.Nil(t, got.StoppedAt)
|
||||
assert.Nil(t, got.FinishedAt)
|
||||
}
|
||||
|
||||
func TestUpdateStatusToFinishedSetsFinishedAt(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
nextGen := created.Add(time.Hour)
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
|
||||
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: "game-001",
|
||||
ExpectedFrom: runtime.StatusRunning,
|
||||
To: runtime.StatusGenerationInProgress,
|
||||
Now: created.Add(2 * time.Second),
|
||||
}))
|
||||
|
||||
finishAt := created.Add(time.Hour)
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: "game-001",
|
||||
ExpectedFrom: runtime.StatusGenerationInProgress,
|
||||
To: runtime.StatusFinished,
|
||||
Now: finishAt,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, runtime.StatusFinished, got.Status)
|
||||
require.NotNil(t, got.FinishedAt)
|
||||
assert.True(t, got.FinishedAt.Equal(finishAt))
|
||||
assert.True(t, got.UpdatedAt.Equal(finishAt))
|
||||
}
|
||||
|
||||
func TestUpdateStatusToStoppedSetsStoppedAt(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
nextGen := created.Add(time.Hour)
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
|
||||
|
||||
stopAt := created.Add(2 * time.Hour)
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: "game-001",
|
||||
ExpectedFrom: runtime.StatusRunning,
|
||||
To: runtime.StatusStopped,
|
||||
Now: stopAt,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, runtime.StatusStopped, got.Status)
|
||||
require.NotNil(t, got.StoppedAt)
|
||||
assert.True(t, got.StoppedAt.Equal(stopAt))
|
||||
require.NotNil(t, got.StartedAt, "started_at must remain set after stop")
|
||||
assert.Nil(t, got.FinishedAt)
|
||||
}
|
||||
|
||||
func TestUpdateStatusEngineUnreachableRecoveryKeepsStartedAt(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
nextGen := created.Add(time.Hour)
|
||||
original := runningRecord("game-001", created, nextGen)
|
||||
require.NoError(t, store.Insert(ctx, original))
|
||||
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: "game-001",
|
||||
ExpectedFrom: runtime.StatusRunning,
|
||||
To: runtime.StatusEngineUnreachable,
|
||||
Now: created.Add(time.Minute),
|
||||
}))
|
||||
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: "game-001",
|
||||
ExpectedFrom: runtime.StatusEngineUnreachable,
|
||||
To: runtime.StatusRunning,
|
||||
Now: created.Add(2 * time.Minute),
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, runtime.StatusRunning, got.Status)
|
||||
require.NotNil(t, got.StartedAt)
|
||||
assert.True(t, got.StartedAt.Equal(*original.StartedAt),
|
||||
"recovery transition must not overwrite started_at")
|
||||
}
|
||||
|
||||
func TestUpdateStatusOptionalFields(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
nextGen := created.Add(time.Hour)
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
|
||||
|
||||
healthy := "engine_unreachable_summary"
|
||||
imageRef := "ghcr.io/galaxy/game:v1.2.4"
|
||||
engineVersion := "v1.2.4"
|
||||
now := created.Add(time.Minute)
|
||||
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: "game-001",
|
||||
ExpectedFrom: runtime.StatusRunning,
|
||||
To: runtime.StatusGenerationInProgress,
|
||||
Now: now,
|
||||
EngineHealthSummary: &healthy,
|
||||
CurrentImageRef: &imageRef,
|
||||
CurrentEngineVersion: &engineVersion,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, runtime.StatusGenerationInProgress, got.Status)
|
||||
assert.Equal(t, healthy, got.EngineHealth)
|
||||
assert.Equal(t, imageRef, got.CurrentImageRef)
|
||||
assert.Equal(t, engineVersion, got.CurrentEngineVersion)
|
||||
}
|
||||
|
||||
func TestUpdateStatusOnMissingReturnsNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: "ghost",
|
||||
ExpectedFrom: runtime.StatusRunning,
|
||||
To: runtime.StatusStopped,
|
||||
Now: time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, runtime.ErrNotFound), "want ErrNotFound, got %v", err)
|
||||
}
|
||||
|
||||
func TestUpdateStatusStaleCASReturnsConflict(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, store.Insert(ctx, startingRecord("game-001", created)))
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: "game-001",
|
||||
ExpectedFrom: runtime.StatusRunning,
|
||||
To: runtime.StatusStopped,
|
||||
Now: created.Add(time.Second),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, runtime.ErrConflict), "want ErrConflict, got %v", err)
|
||||
}
|
||||
|
||||
func TestUpdateStatusConcurrentCAS(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
nextGen := created.Add(time.Hour)
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
|
||||
|
||||
const concurrency = 8
|
||||
results := make([]error, concurrency)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(concurrency)
|
||||
for index := range concurrency {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
results[index] = store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: "game-001",
|
||||
ExpectedFrom: runtime.StatusRunning,
|
||||
To: runtime.StatusStopped,
|
||||
Now: created.Add(time.Duration(index+1) * time.Second),
|
||||
})
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
wins, conflicts := 0, 0
|
||||
for _, err := range results {
|
||||
switch {
|
||||
case err == nil:
|
||||
wins++
|
||||
case errors.Is(err, runtime.ErrConflict):
|
||||
conflicts++
|
||||
default:
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, wins, "exactly one caller must win the CAS race")
|
||||
assert.Equal(t, concurrency-1, conflicts, "the rest must observe runtime.ErrConflict")
|
||||
}
|
||||
|
||||
func TestUpdateImageHappy(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
nextGen := created.Add(time.Hour)
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
|
||||
|
||||
now := nextGen.Add(time.Second)
|
||||
require.NoError(t, store.UpdateImage(ctx, ports.UpdateImageInput{
|
||||
GameID: "game-001",
|
||||
ExpectedStatus: runtime.StatusRunning,
|
||||
CurrentImageRef: "ghcr.io/galaxy/game:v1.2.4",
|
||||
CurrentEngineVersion: "v1.2.4",
|
||||
Now: now,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, runtime.StatusRunning, got.Status, "patch must not change status")
|
||||
assert.Equal(t, "ghcr.io/galaxy/game:v1.2.4", got.CurrentImageRef)
|
||||
assert.Equal(t, "v1.2.4", got.CurrentEngineVersion)
|
||||
assert.True(t, got.UpdatedAt.Equal(now))
|
||||
require.NotNil(t, got.NextGenerationAt, "next_generation_at must remain untouched")
|
||||
assert.True(t, got.NextGenerationAt.Equal(nextGen))
|
||||
assert.Equal(t, 1, got.CurrentTurn, "current_turn must remain untouched")
|
||||
}
|
||||
|
||||
func TestUpdateImageStaleStatusReturnsConflict(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, store.Insert(ctx, startingRecord("game-001", created)))
|
||||
|
||||
err := store.UpdateImage(ctx, ports.UpdateImageInput{
|
||||
GameID: "game-001",
|
||||
ExpectedStatus: runtime.StatusRunning,
|
||||
CurrentImageRef: "ghcr.io/galaxy/game:v1.2.4",
|
||||
CurrentEngineVersion: "v1.2.4",
|
||||
Now: created.Add(time.Second),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, runtime.ErrConflict), "want ErrConflict, got %v", err)
|
||||
}
|
||||
|
||||
func TestUpdateImageOnMissingReturnsNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
err := store.UpdateImage(ctx, ports.UpdateImageInput{
|
||||
GameID: "ghost",
|
||||
ExpectedStatus: runtime.StatusRunning,
|
||||
CurrentImageRef: "ghcr.io/galaxy/game:v1.2.4",
|
||||
CurrentEngineVersion: "v1.2.4",
|
||||
Now: time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, runtime.ErrNotFound), "want ErrNotFound, got %v", err)
|
||||
}
|
||||
|
||||
func TestUpdateImageRejectsInvalidInput(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
err := store.UpdateImage(ctx, ports.UpdateImageInput{
|
||||
GameID: "",
|
||||
ExpectedStatus: runtime.StatusRunning,
|
||||
CurrentImageRef: "ghcr.io/galaxy/game:v1.2.4",
|
||||
CurrentEngineVersion: "v1.2.4",
|
||||
Now: time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.False(t, errors.Is(err, runtime.ErrConflict))
|
||||
require.False(t, errors.Is(err, runtime.ErrNotFound))
|
||||
}
|
||||
|
||||
func TestUpdateEngineHealthHappy(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
nextGen := created.Add(time.Hour)
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
|
||||
|
||||
now := nextGen.Add(2 * time.Second)
|
||||
require.NoError(t, store.UpdateEngineHealth(ctx, ports.UpdateEngineHealthInput{
|
||||
GameID: "game-001",
|
||||
EngineHealthSummary: "probe_failed",
|
||||
Now: now,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, runtime.StatusRunning, got.Status, "engine health update must not change status")
|
||||
assert.Equal(t, "probe_failed", got.EngineHealth)
|
||||
assert.True(t, got.UpdatedAt.Equal(now))
|
||||
require.NotNil(t, got.NextGenerationAt, "next_generation_at must remain untouched")
|
||||
assert.True(t, got.NextGenerationAt.Equal(nextGen))
|
||||
assert.Equal(t, 1, got.CurrentTurn, "current_turn must remain untouched")
|
||||
}
|
||||
|
||||
func TestUpdateEngineHealthAcceptsEmptySummary(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
nextGen := created.Add(time.Hour)
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
|
||||
|
||||
now := nextGen.Add(time.Second)
|
||||
require.NoError(t, store.UpdateEngineHealth(ctx, ports.UpdateEngineHealthInput{
|
||||
GameID: "game-001",
|
||||
EngineHealthSummary: "",
|
||||
Now: now,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "", got.EngineHealth)
|
||||
}
|
||||
|
||||
func TestUpdateEngineHealthOnMissingReturnsNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
err := store.UpdateEngineHealth(ctx, ports.UpdateEngineHealthInput{
|
||||
GameID: "ghost",
|
||||
EngineHealthSummary: "exited",
|
||||
Now: time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, runtime.ErrNotFound), "want ErrNotFound, got %v", err)
|
||||
}
|
||||
|
||||
func TestUpdateEngineHealthRejectsInvalidInput(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
err := store.UpdateEngineHealth(ctx, ports.UpdateEngineHealthInput{
|
||||
GameID: "",
|
||||
EngineHealthSummary: "healthy",
|
||||
Now: time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.False(t, errors.Is(err, runtime.ErrConflict))
|
||||
require.False(t, errors.Is(err, runtime.ErrNotFound))
|
||||
}
|
||||
|
||||
func TestUpdateEngineHealthAppliesFromAnyStatus(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, store.Insert(ctx, startingRecord("game-001", created)))
|
||||
|
||||
now := created.Add(time.Second)
|
||||
require.NoError(t, store.UpdateEngineHealth(ctx, ports.UpdateEngineHealthInput{
|
||||
GameID: "game-001",
|
||||
EngineHealthSummary: "exited",
|
||||
Now: now,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, runtime.StatusStarting, got.Status, "no status mutation expected")
|
||||
assert.Equal(t, "exited", got.EngineHealth)
|
||||
}
|
||||
|
||||
func TestUpdateSchedulingHappy(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
nextGen := created.Add(time.Hour)
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
|
||||
|
||||
updated := nextGen.Add(time.Hour)
|
||||
now := nextGen.Add(time.Second)
|
||||
require.NoError(t, store.UpdateScheduling(ctx, ports.UpdateSchedulingInput{
|
||||
GameID: "game-001",
|
||||
NextGenerationAt: &updated,
|
||||
SkipNextTick: true,
|
||||
CurrentTurn: 5,
|
||||
Now: now,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got.NextGenerationAt)
|
||||
assert.True(t, got.NextGenerationAt.Equal(updated))
|
||||
assert.True(t, got.SkipNextTick)
|
||||
assert.Equal(t, 5, got.CurrentTurn)
|
||||
assert.True(t, got.UpdatedAt.Equal(now))
|
||||
}
|
||||
|
||||
func TestUpdateSchedulingClearsNextGen(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
nextGen := created.Add(time.Hour)
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
|
||||
|
||||
now := nextGen.Add(time.Second)
|
||||
require.NoError(t, store.UpdateScheduling(ctx, ports.UpdateSchedulingInput{
|
||||
GameID: "game-001",
|
||||
NextGenerationAt: nil,
|
||||
SkipNextTick: false,
|
||||
CurrentTurn: 0,
|
||||
Now: now,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, got.NextGenerationAt)
|
||||
assert.False(t, got.SkipNextTick)
|
||||
}
|
||||
|
||||
func TestUpdateSchedulingOnMissingReturnsNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
err := store.UpdateScheduling(ctx, ports.UpdateSchedulingInput{
|
||||
GameID: "ghost",
|
||||
CurrentTurn: 0,
|
||||
Now: time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, runtime.ErrNotFound))
|
||||
}
|
||||
|
||||
func TestListDueRunning(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
createdEarlier := time.Date(2026, time.April, 27, 10, 0, 0, 0, time.UTC)
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
due := created.Add(-time.Minute) // due before now
|
||||
future := created.Add(time.Hour) // not due yet
|
||||
|
||||
dueRecord := runningRecord("game-due", created, due)
|
||||
require.NoError(t, store.Insert(ctx, dueRecord))
|
||||
|
||||
futureRecord := runningRecord("game-future", created, future)
|
||||
require.NoError(t, store.Insert(ctx, futureRecord))
|
||||
|
||||
// A stopped record whose next_generation_at is in the past must
|
||||
// still be excluded by the running-status filter.
|
||||
stoppedRecord := startingRecord("game-stopped", createdEarlier)
|
||||
stoppedRecord.Status = runtime.StatusStopped
|
||||
startedAt := createdEarlier.Add(time.Second)
|
||||
stoppedAt := createdEarlier.Add(time.Minute)
|
||||
stoppedRecord.StartedAt = &startedAt
|
||||
stoppedRecord.StoppedAt = &stoppedAt
|
||||
stoppedRecord.UpdatedAt = stoppedAt
|
||||
stalePast := created.Add(-30 * time.Minute)
|
||||
stoppedRecord.NextGenerationAt = &stalePast
|
||||
require.NoError(t, store.Insert(ctx, stoppedRecord))
|
||||
|
||||
results, err := store.ListDueRunning(ctx, created)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results, 1)
|
||||
assert.Equal(t, "game-due", results[0].GameID)
|
||||
}
|
||||
|
||||
func TestListByStatus(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-r1", created, created.Add(time.Hour))))
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-r2", created, created.Add(time.Hour))))
|
||||
require.NoError(t, store.Insert(ctx, startingRecord("game-s1", created)))
|
||||
|
||||
running, err := store.ListByStatus(ctx, runtime.StatusRunning)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, running, 2)
|
||||
assert.Equal(t, "game-r1", running[0].GameID)
|
||||
assert.Equal(t, "game-r2", running[1].GameID)
|
||||
|
||||
starting, err := store.ListByStatus(ctx, runtime.StatusStarting)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, starting, 1)
|
||||
assert.Equal(t, "game-s1", starting[0].GameID)
|
||||
|
||||
finished, err := store.ListByStatus(ctx, runtime.StatusFinished)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, finished)
|
||||
}
|
||||
|
||||
func TestListReturnsEveryRecordOrderedByCreatedAtDesc(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
earliest := time.Date(2026, time.April, 27, 10, 0, 0, 0, time.UTC)
|
||||
middle := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
latest := time.Date(2026, time.April, 27, 14, 0, 0, 0, time.UTC)
|
||||
|
||||
require.NoError(t, store.Insert(ctx, startingRecord("game-earliest", earliest)))
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-middle", middle, middle.Add(time.Hour))))
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-latest", latest, latest.Add(time.Hour))))
|
||||
|
||||
records, err := store.List(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, records, 3)
|
||||
assert.Equal(t, "game-latest", records[0].GameID)
|
||||
assert.Equal(t, "game-middle", records[1].GameID)
|
||||
assert.Equal(t, "game-earliest", records[2].GameID)
|
||||
}
|
||||
|
||||
func TestListReturnsEmptySliceWhenStoreIsEmpty(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
records, err := store.List(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, records)
|
||||
}
|
||||
|
||||
func TestListByStatusUnknownRejected(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
_, err := store.ListByStatus(ctx, runtime.Status("exotic"))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestListDueRunningRejectsZeroNow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
_, err := store.ListDueRunning(ctx, time.Time{})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestGetRejectsEmptyGameID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
_, err := store.Get(ctx, "")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDeleteIdempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, store.Insert(ctx, startingRecord("game-001", now)))
|
||||
|
||||
require.NoError(t, store.Delete(ctx, "game-001"))
|
||||
|
||||
_, err := store.Get(ctx, "game-001")
|
||||
require.ErrorIs(t, err, runtime.ErrNotFound)
|
||||
|
||||
// Second call must be a no-op.
|
||||
require.NoError(t, store.Delete(ctx, "game-001"))
|
||||
}
|
||||
|
||||
func TestDeleteRejectsEmptyGameID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
require.Error(t, store.Delete(ctx, ""))
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Package redisstate hosts the Game Master Redis adapters that share a
|
||||
// single keyspace. The sole sibling subpackage in v1 is
|
||||
// `streamoffsets` (the per-consumer offset for the
|
||||
// runtime:health_events stream); membership cache lives in process and
|
||||
// does not touch Redis.
|
||||
//
|
||||
// The package itself only declares the keyspace; concrete stores live
|
||||
// in nested packages so dependencies (miniredis, testcontainers) stay
|
||||
// out of consumer build graphs that do not need them.
|
||||
package redisstate
|
||||
|
||||
import "encoding/base64"
|
||||
|
||||
// defaultPrefix is the mandatory `gamemaster:` namespace prefix shared
|
||||
// by every Game Master Redis key.
|
||||
const defaultPrefix = "gamemaster:"
|
||||
|
||||
// Keyspace builds the Game Master Redis keys. The namespace covers
|
||||
// stream consumer offsets in v1.
|
||||
//
|
||||
// Dynamic key segments are encoded with base64url so raw key structure
|
||||
// does not depend on caller-provided characters; this matches the
|
||||
// encoding chosen by `lobby/internal/adapters/redisstate.Keyspace` and
|
||||
// `rtmanager/internal/adapters/redisstate.Keyspace`.
|
||||
type Keyspace struct{}
|
||||
|
||||
// StreamOffset returns the Redis key that stores the last successfully
|
||||
// processed entry id for one Redis Stream consumer. The streamLabel is
|
||||
// the short logical identifier of the consumer (e.g. `health_events`),
|
||||
// not the full stream name; it stays stable when the underlying stream
|
||||
// key is renamed.
|
||||
func (Keyspace) StreamOffset(streamLabel string) string {
|
||||
return defaultPrefix + "stream_offsets:" + encodeKeyComponent(streamLabel)
|
||||
}
|
||||
|
||||
func encodeKeyComponent(value string) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(value))
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Package streamoffsets implements the Redis-backed adapter for
|
||||
// `ports.StreamOffsetStore`.
|
||||
//
|
||||
// In v1 the only consumer that calls Load/Save is the
|
||||
// runtime:health_events worker (PLAN stage 18). Keys are produced by
|
||||
// `redisstate.Keyspace.StreamOffset`, mirroring the lobby and rtmanager
|
||||
// patterns.
|
||||
package streamoffsets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"galaxy/gamemaster/internal/adapters/redisstate"
|
||||
"galaxy/gamemaster/internal/ports"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// Config configures one Redis-backed stream-offset store. The store
|
||||
// does not own the redis client lifecycle; the caller (typically the
|
||||
// service runtime) opens and closes it.
|
||||
type Config struct {
|
||||
Client *redis.Client
|
||||
}
|
||||
|
||||
// Store persists Game Master stream consumer offsets in Redis.
|
||||
type Store struct {
|
||||
client *redis.Client
|
||||
keys redisstate.Keyspace
|
||||
}
|
||||
|
||||
// New constructs one Redis-backed stream-offset store from cfg.
|
||||
func New(cfg Config) (*Store, error) {
|
||||
if cfg.Client == nil {
|
||||
return nil, errors.New("new gamemaster stream offset store: nil redis client")
|
||||
}
|
||||
return &Store{
|
||||
client: cfg.Client,
|
||||
keys: redisstate.Keyspace{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Load returns the last processed entry id for streamLabel when one
|
||||
// is stored. A missing key returns ("", false, nil).
|
||||
func (store *Store) Load(ctx context.Context, streamLabel string) (string, bool, error) {
|
||||
if store == nil || store.client == nil {
|
||||
return "", false, errors.New("load gamemaster stream offset: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return "", false, errors.New("load gamemaster stream offset: nil context")
|
||||
}
|
||||
if strings.TrimSpace(streamLabel) == "" {
|
||||
return "", false, errors.New("load gamemaster stream offset: stream label must not be empty")
|
||||
}
|
||||
|
||||
value, err := store.client.Get(ctx, store.keys.StreamOffset(streamLabel)).Result()
|
||||
switch {
|
||||
case errors.Is(err, redis.Nil):
|
||||
return "", false, nil
|
||||
case err != nil:
|
||||
return "", false, fmt.Errorf("load gamemaster stream offset: %w", err)
|
||||
}
|
||||
return value, true, nil
|
||||
}
|
||||
|
||||
// Save stores entryID as the new offset for streamLabel. The key has
|
||||
// no TTL — offsets are durable and only overwritten by subsequent
|
||||
// Saves.
|
||||
func (store *Store) Save(ctx context.Context, streamLabel, entryID string) error {
|
||||
if store == nil || store.client == nil {
|
||||
return errors.New("save gamemaster stream offset: nil store")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("save gamemaster stream offset: nil context")
|
||||
}
|
||||
if strings.TrimSpace(streamLabel) == "" {
|
||||
return errors.New("save gamemaster stream offset: stream label must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(entryID) == "" {
|
||||
return errors.New("save gamemaster stream offset: entry id must not be empty")
|
||||
}
|
||||
|
||||
if err := store.client.Set(ctx, store.keys.StreamOffset(streamLabel), entryID, 0).Err(); err != nil {
|
||||
return fmt.Errorf("save gamemaster stream offset: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensure Store satisfies the ports.StreamOffsetStore interface at
|
||||
// compile time.
|
||||
var _ ports.StreamOffsetStore = (*Store)(nil)
|
||||
@@ -0,0 +1,93 @@
|
||||
package streamoffsets_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"galaxy/gamemaster/internal/adapters/redisstate"
|
||||
"galaxy/gamemaster/internal/adapters/redisstate/streamoffsets"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newOffsetStore(t *testing.T) (*streamoffsets.Store, *miniredis.Miniredis) {
|
||||
t.Helper()
|
||||
server := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: server.Addr()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
store, err := streamoffsets.New(streamoffsets.Config{Client: client})
|
||||
require.NoError(t, err)
|
||||
return store, server
|
||||
}
|
||||
|
||||
func TestNewRejectsNilClient(t *testing.T) {
|
||||
_, err := streamoffsets.New(streamoffsets.Config{})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestLoadMissingReturnsNotFound(t *testing.T) {
|
||||
store, _ := newOffsetStore(t)
|
||||
id, found, err := store.Load(context.Background(), "health_events")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, found)
|
||||
assert.Empty(t, id)
|
||||
}
|
||||
|
||||
func TestSaveLoadRoundTrip(t *testing.T) {
|
||||
store, server := newOffsetStore(t)
|
||||
|
||||
const entryID = "1700000000000-0"
|
||||
require.NoError(t, store.Save(context.Background(), "health_events", entryID))
|
||||
|
||||
id, found, err := store.Load(context.Background(), "health_events")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, found)
|
||||
assert.Equal(t, entryID, id)
|
||||
|
||||
// Verify the namespace prefix lands as expected.
|
||||
expectedKey := redisstate.Keyspace{}.StreamOffset("health_events")
|
||||
assert.True(t, server.Exists(expectedKey),
|
||||
"key %q must exist after Save", expectedKey)
|
||||
}
|
||||
|
||||
func TestSaveOverwritesPreviousValue(t *testing.T) {
|
||||
store, _ := newOffsetStore(t)
|
||||
|
||||
require.NoError(t, store.Save(context.Background(), "health_events", "1-0"))
|
||||
require.NoError(t, store.Save(context.Background(), "health_events", "2-0"))
|
||||
|
||||
id, found, err := store.Load(context.Background(), "health_events")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, found)
|
||||
assert.Equal(t, "2-0", id)
|
||||
}
|
||||
|
||||
func TestSaveRejectsBadInputs(t *testing.T) {
|
||||
store, _ := newOffsetStore(t)
|
||||
|
||||
require.Error(t, store.Save(context.Background(), "", "1-0"))
|
||||
require.Error(t, store.Save(context.Background(), "health_events", ""))
|
||||
//nolint:staticcheck // intentional nil ctx test
|
||||
require.Error(t, store.Save(nil, "health_events", "1-0"))
|
||||
}
|
||||
|
||||
func TestLoadRejectsBadInputs(t *testing.T) {
|
||||
store, _ := newOffsetStore(t)
|
||||
|
||||
_, _, err := store.Load(context.Background(), "")
|
||||
require.Error(t, err)
|
||||
//nolint:staticcheck // intentional nil ctx test
|
||||
_, _, err = store.Load(nil, "health_events")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestNilStoreOperationsRejected(t *testing.T) {
|
||||
var store *streamoffsets.Store
|
||||
_, _, err := store.Load(context.Background(), "health_events")
|
||||
require.Error(t, err)
|
||||
require.Error(t, store.Save(context.Background(), "health_events", "1-0"))
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// Package rtmclient provides the trusted-internal Runtime Manager
|
||||
// REST client Game Master uses for synchronous lifecycle operations
|
||||
// against an already-running container. Two routes are mounted:
|
||||
//
|
||||
// - POST /api/v1/internal/runtimes/{game_id}/stop
|
||||
// - POST /api/v1/internal/runtimes/{game_id}/patch
|
||||
//
|
||||
// `Restart` is reserved per `gamemaster/PLAN.md` Stage 10 and is not
|
||||
// part of the v1 surface.
|
||||
package rtmclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
|
||||
"galaxy/gamemaster/internal/ports"
|
||||
)
|
||||
|
||||
const (
|
||||
stopPathTemplate = "/api/v1/internal/runtimes/%s/stop"
|
||||
patchPathTemplate = "/api/v1/internal/runtimes/%s/patch"
|
||||
)
|
||||
|
||||
// Config configures one HTTP-backed Runtime Manager internal client.
|
||||
type Config struct {
|
||||
// BaseURL stores the absolute base URL of the Runtime Manager
|
||||
// internal HTTP listener (e.g. `http://rtmanager:8096`).
|
||||
BaseURL string
|
||||
|
||||
// RequestTimeout bounds one outbound stop/patch request.
|
||||
RequestTimeout time.Duration
|
||||
}
|
||||
|
||||
// Client speaks REST/JSON to the Runtime Manager internal API.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
requestTimeout time.Duration
|
||||
httpClient *http.Client
|
||||
closeIdleConnections func()
|
||||
}
|
||||
|
||||
type stopRequestEnvelope struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type patchRequestEnvelope struct {
|
||||
ImageRef string `json:"image_ref"`
|
||||
}
|
||||
|
||||
type errorEnvelope struct {
|
||||
Error *errorBody `json:"error"`
|
||||
}
|
||||
|
||||
type errorBody struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// NewClient constructs an RTM internal client with otelhttp-wrapped
|
||||
// transport cloned from `http.DefaultTransport`. Call `Close` to
|
||||
// release idle connections at shutdown.
|
||||
func NewClient(cfg Config) (*Client, error) {
|
||||
transport, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
return nil, errors.New("new rtm client: default transport is not *http.Transport")
|
||||
}
|
||||
cloned := transport.Clone()
|
||||
return newClient(cfg, &http.Client{Transport: otelhttp.NewTransport(cloned)}, cloned.CloseIdleConnections)
|
||||
}
|
||||
|
||||
func newClient(cfg Config, httpClient *http.Client, closeIdleConnections func()) (*Client, error) {
|
||||
switch {
|
||||
case strings.TrimSpace(cfg.BaseURL) == "":
|
||||
return nil, errors.New("new rtm client: base url must not be empty")
|
||||
case cfg.RequestTimeout <= 0:
|
||||
return nil, errors.New("new rtm client: request timeout must be positive")
|
||||
case httpClient == nil:
|
||||
return nil, errors.New("new rtm client: http client must not be nil")
|
||||
}
|
||||
parsed, err := url.Parse(strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new rtm client: parse base url: %w", err)
|
||||
}
|
||||
if parsed.Scheme == "" || parsed.Host == "" {
|
||||
return nil, errors.New("new rtm client: base url must be absolute")
|
||||
}
|
||||
return &Client{
|
||||
baseURL: parsed.String(),
|
||||
requestTimeout: cfg.RequestTimeout,
|
||||
httpClient: httpClient,
|
||||
closeIdleConnections: closeIdleConnections,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close releases idle HTTP connections owned by the underlying
|
||||
// transport. Safe to call multiple times.
|
||||
func (client *Client) Close() error {
|
||||
if client == nil || client.closeIdleConnections == nil {
|
||||
return nil
|
||||
}
|
||||
client.closeIdleConnections()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop calls POST /api/v1/internal/runtimes/{game_id}/stop with body
|
||||
// `{reason}`. Any non-success outcome is wrapped with
|
||||
// `ports.ErrRTMUnavailable`.
|
||||
func (client *Client) Stop(ctx context.Context, gameID, reason string) error {
|
||||
if err := client.validate(ctx, gameID); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(reason) == "" {
|
||||
return errors.New("rtm stop: reason must not be empty")
|
||||
}
|
||||
body, err := json.Marshal(stopRequestEnvelope{Reason: reason})
|
||||
if err != nil {
|
||||
return fmt.Errorf("rtm stop: encode request: %w", err)
|
||||
}
|
||||
return client.callMutation(ctx, fmt.Sprintf(stopPathTemplate, url.PathEscape(gameID)), body, "rtm stop")
|
||||
}
|
||||
|
||||
// Patch calls POST /api/v1/internal/runtimes/{game_id}/patch with body
|
||||
// `{image_ref}`. A `409 conflict` from RTM (semver violation) is also
|
||||
// wrapped with `ports.ErrRTMUnavailable`; the underlying `error_code`
|
||||
// is preserved in the wrapped error message so callers can branch on
|
||||
// the substring if needed.
|
||||
func (client *Client) Patch(ctx context.Context, gameID, imageRef string) error {
|
||||
if err := client.validate(ctx, gameID); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(imageRef) == "" {
|
||||
return errors.New("rtm patch: image ref must not be empty")
|
||||
}
|
||||
body, err := json.Marshal(patchRequestEnvelope{ImageRef: imageRef})
|
||||
if err != nil {
|
||||
return fmt.Errorf("rtm patch: encode request: %w", err)
|
||||
}
|
||||
return client.callMutation(ctx, fmt.Sprintf(patchPathTemplate, url.PathEscape(gameID)), body, "rtm patch")
|
||||
}
|
||||
|
||||
func (client *Client) validate(ctx context.Context, gameID string) error {
|
||||
if client == nil || client.httpClient == nil {
|
||||
return errors.New("rtm client: nil client")
|
||||
}
|
||||
if ctx == nil {
|
||||
return errors.New("rtm client: nil context")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(gameID) == "" {
|
||||
return errors.New("rtm client: game id must not be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (client *Client) callMutation(ctx context.Context, requestPath string, body []byte, opLabel string) error {
|
||||
payload, statusCode, err := client.doRequest(ctx, http.MethodPost, requestPath, body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s: %w", ports.ErrRTMUnavailable, opLabel, err)
|
||||
}
|
||||
if statusCode >= 200 && statusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
errorCode := decodeErrorCode(payload)
|
||||
if errorCode != "" {
|
||||
return fmt.Errorf("%w: %s: unexpected status %d (error_code=%s)", ports.ErrRTMUnavailable, opLabel, statusCode, errorCode)
|
||||
}
|
||||
return fmt.Errorf("%w: %s: unexpected status %d", ports.ErrRTMUnavailable, opLabel, statusCode)
|
||||
}
|
||||
|
||||
func (client *Client) doRequest(ctx context.Context, method, requestPath string, body []byte) ([]byte, int, error) {
|
||||
attemptCtx, cancel := context.WithTimeout(ctx, client.requestTimeout)
|
||||
defer cancel()
|
||||
|
||||
var reader io.Reader
|
||||
if len(body) > 0 {
|
||||
reader = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(attemptCtx, method, client.baseURL+requestPath, reader)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if len(body) > 0 {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := client.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, resp.StatusCode, fmt.Errorf("read response body: %w", err)
|
||||
}
|
||||
return respBody, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func decodeErrorCode(payload []byte) string {
|
||||
if len(payload) == 0 {
|
||||
return ""
|
||||
}
|
||||
var envelope errorEnvelope
|
||||
if err := json.Unmarshal(payload, &envelope); err != nil {
|
||||
return ""
|
||||
}
|
||||
if envelope.Error == nil {
|
||||
return ""
|
||||
}
|
||||
return envelope.Error.Code
|
||||
}
|
||||
|
||||
// Compile-time assertion: Client implements ports.RTMClient.
|
||||
var _ ports.RTMClient = (*Client)(nil)
|
||||
@@ -0,0 +1,156 @@
|
||||
package rtmclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"galaxy/gamemaster/internal/ports"
|
||||
)
|
||||
|
||||
func newTestClient(t *testing.T, baseURL string, timeout time.Duration) *Client {
|
||||
t.Helper()
|
||||
client, err := NewClient(Config{BaseURL: baseURL, RequestTimeout: timeout})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
return client
|
||||
}
|
||||
|
||||
func TestNewClientValidatesConfig(t *testing.T) {
|
||||
cases := map[string]Config{
|
||||
"empty base url": {BaseURL: "", RequestTimeout: time.Second},
|
||||
"non-absolute": {BaseURL: "rtm:8096", RequestTimeout: time.Second},
|
||||
"zero timeout": {BaseURL: "http://rtm:8096", RequestTimeout: 0},
|
||||
"negative timeout": {BaseURL: "http://rtm:8096", RequestTimeout: -time.Second},
|
||||
}
|
||||
for name, cfg := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := NewClient(cfg)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopHappyPath(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, http.MethodPost, r.Method)
|
||||
require.Equal(t, "/api/v1/internal/runtimes/game-1/stop", r.URL.Path)
|
||||
require.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
var got stopRequestEnvelope
|
||||
require.NoError(t, json.Unmarshal(body, &got))
|
||||
assert.Equal(t, "admin_request", got.Reason)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"game_id":"game-1","status":"stopped"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(t, server.URL, time.Second)
|
||||
require.NoError(t, client.Stop(context.Background(), "game-1", "admin_request"))
|
||||
}
|
||||
|
||||
func TestStopRejectsBadInput(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
t.Fatal("must not contact rtm on bad input")
|
||||
}))
|
||||
defer server.Close()
|
||||
client := newTestClient(t, server.URL, time.Second)
|
||||
|
||||
require.Error(t, client.Stop(context.Background(), " ", "admin_request"))
|
||||
require.Error(t, client.Stop(context.Background(), "g", " "))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
err := client.Stop(ctx, "g", "admin_request")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, context.Canceled))
|
||||
}
|
||||
|
||||
func TestStopInternalError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":"internal_error","message":"boom"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(t, server.URL, time.Second)
|
||||
err := client.Stop(context.Background(), "g", "admin_request")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ports.ErrRTMUnavailable))
|
||||
assert.Contains(t, err.Error(), "internal_error")
|
||||
}
|
||||
|
||||
func TestStopTimeoutMapsToUnavailable(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(t, server.URL, 30*time.Millisecond)
|
||||
err := client.Stop(context.Background(), "g", "admin_request")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ports.ErrRTMUnavailable))
|
||||
}
|
||||
|
||||
func TestPatchHappyPath(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, http.MethodPost, r.Method)
|
||||
require.Equal(t, "/api/v1/internal/runtimes/g/patch", r.URL.Path)
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
var got patchRequestEnvelope
|
||||
require.NoError(t, json.Unmarshal(body, &got))
|
||||
assert.Equal(t, "galaxy/game:1.2.4", got.ImageRef)
|
||||
_, _ = w.Write([]byte(`{"game_id":"g","status":"running"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(t, server.URL, time.Second)
|
||||
require.NoError(t, client.Patch(context.Background(), "g", "galaxy/game:1.2.4"))
|
||||
}
|
||||
|
||||
func TestPatchSemverConflictMapsToUnavailable(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":"semver_patch_only","message":"cross-major patch not allowed"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(t, server.URL, time.Second)
|
||||
err := client.Patch(context.Background(), "g", "galaxy/game:2.0.0")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ports.ErrRTMUnavailable))
|
||||
assert.Contains(t, err.Error(), "semver_patch_only")
|
||||
}
|
||||
|
||||
func TestPatchRejectsBadInput(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
t.Fatal("must not contact rtm on bad input")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := newTestClient(t, server.URL, time.Second)
|
||||
require.Error(t, client.Patch(context.Background(), " ", "galaxy/game:1.0.0"))
|
||||
require.Error(t, client.Patch(context.Background(), "g", " "))
|
||||
}
|
||||
|
||||
func TestCloseIsIdempotent(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
client := newTestClient(t, server.URL, time.Second)
|
||||
require.NoError(t, client.Stop(context.Background(), "g", "admin_request"))
|
||||
require.NoError(t, client.Close())
|
||||
require.NoError(t, client.Close())
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/gamemaster/internal/api/internalhttp/handlers"
|
||||
"galaxy/gamemaster/internal/domain/engineversion"
|
||||
"galaxy/gamemaster/internal/domain/operation"
|
||||
domainruntime "galaxy/gamemaster/internal/domain/runtime"
|
||||
"galaxy/gamemaster/internal/service/adminbanish"
|
||||
"galaxy/gamemaster/internal/service/adminforce"
|
||||
"galaxy/gamemaster/internal/service/adminpatch"
|
||||
"galaxy/gamemaster/internal/service/adminstop"
|
||||
"galaxy/gamemaster/internal/service/commandexecute"
|
||||
engineversionsvc "galaxy/gamemaster/internal/service/engineversion"
|
||||
"galaxy/gamemaster/internal/service/livenessreply"
|
||||
"galaxy/gamemaster/internal/service/orderput"
|
||||
"galaxy/gamemaster/internal/service/registerruntime"
|
||||
"galaxy/gamemaster/internal/service/reportget"
|
||||
"galaxy/gamemaster/internal/service/turngeneration"
|
||||
|
||||
"github.com/getkin/kin-openapi/openapi3"
|
||||
"github.com/getkin/kin-openapi/openapi3filter"
|
||||
"github.com/getkin/kin-openapi/routers"
|
||||
"github.com/getkin/kin-openapi/routers/legacy"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestInternalRESTConformance loads the OpenAPI specification, drives
|
||||
// every internal REST operation against the live listener backed by
|
||||
// stub services, and validates each request and response body
|
||||
// against the spec via `openapi3filter.ValidateRequest` and
|
||||
// `openapi3filter.ValidateResponse`. Failure-path response shapes
|
||||
// are intentionally out of scope here; per-handler tests under
|
||||
// `handlers/<op>_test.go` cover the failure branches.
|
||||
func TestInternalRESTConformance(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := loadConformanceSpec(t)
|
||||
|
||||
router, err := legacy.NewRouter(doc)
|
||||
require.NoError(t, err)
|
||||
|
||||
deps := newConformanceDeps()
|
||||
server, err := NewServer(newConformanceConfig(), Dependencies{
|
||||
Logger: nil,
|
||||
Telemetry: nil,
|
||||
Readiness: nil,
|
||||
RuntimeRecords: deps.runtimeRecords,
|
||||
RegisterRuntime: deps.registerRuntime,
|
||||
ForceNextTurn: deps.forceNextTurn,
|
||||
StopRuntime: deps.stopRuntime,
|
||||
PatchRuntime: deps.patchRuntime,
|
||||
BanishRace: deps.banishRace,
|
||||
InvalidateMemberships: deps.membership,
|
||||
GameLiveness: deps.liveness,
|
||||
EngineVersions: deps.engineVersions,
|
||||
CommandExecute: deps.commandExecute,
|
||||
PutOrders: deps.putOrders,
|
||||
GetReport: deps.getReport,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cases := []conformanceCase{
|
||||
{name: "internalHealthz", method: http.MethodGet, path: "/healthz"},
|
||||
{name: "internalReadyz", method: http.MethodGet, path: "/readyz"},
|
||||
{
|
||||
name: "internalRegisterRuntime",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/games/" + conformanceGameID + "/register-runtime",
|
||||
contentType: "application/json",
|
||||
body: `{
|
||||
"engine_endpoint": "http://galaxy-game-` + conformanceGameID + `:8080",
|
||||
"members": [{"user_id": "user-1", "race_name": "Aelinari"}],
|
||||
"target_engine_version": "1.2.3",
|
||||
"turn_schedule": "0 18 * * *"
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "internalBanishRace",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/games/" + conformanceGameID + "/race/Aelinari/banish",
|
||||
expectedStatus: http.StatusNoContent,
|
||||
},
|
||||
{
|
||||
name: "internalInvalidateMemberships",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/games/" + conformanceGameID + "/memberships/invalidate",
|
||||
expectedStatus: http.StatusNoContent,
|
||||
},
|
||||
{
|
||||
name: "internalGameLiveness",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/internal/games/" + conformanceGameID + "/liveness",
|
||||
},
|
||||
{name: "internalListRuntimes", method: http.MethodGet, path: "/api/v1/internal/runtimes"},
|
||||
{
|
||||
name: "internalGetRuntime",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/internal/runtimes/" + conformanceGameID,
|
||||
},
|
||||
{
|
||||
name: "internalForceNextTurn",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/runtimes/" + conformanceGameID + "/force-next-turn",
|
||||
},
|
||||
{
|
||||
name: "internalStopRuntime",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/runtimes/" + conformanceGameID + "/stop",
|
||||
contentType: "application/json",
|
||||
body: `{"reason":"admin_request"}`,
|
||||
},
|
||||
{
|
||||
name: "internalPatchRuntime",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/runtimes/" + conformanceGameID + "/patch",
|
||||
contentType: "application/json",
|
||||
body: `{"version":"1.2.4"}`,
|
||||
},
|
||||
{name: "internalListEngineVersions", method: http.MethodGet, path: "/api/v1/internal/engine-versions"},
|
||||
{
|
||||
name: "internalCreateEngineVersion",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/engine-versions",
|
||||
contentType: "application/json",
|
||||
body: `{"version":"1.2.5","image_ref":"galaxy/game:1.2.5"}`,
|
||||
expectedStatus: http.StatusCreated,
|
||||
},
|
||||
{
|
||||
name: "internalGetEngineVersion",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/internal/engine-versions/1.2.3",
|
||||
},
|
||||
{
|
||||
name: "internalUpdateEngineVersion",
|
||||
method: http.MethodPatch,
|
||||
path: "/api/v1/internal/engine-versions/1.2.3",
|
||||
contentType: "application/json",
|
||||
body: `{"image_ref":"galaxy/game:1.2.3-patch"}`,
|
||||
},
|
||||
{
|
||||
name: "internalDeprecateEngineVersion",
|
||||
method: http.MethodDelete,
|
||||
path: "/api/v1/internal/engine-versions/1.2.3",
|
||||
expectedStatus: http.StatusNoContent,
|
||||
},
|
||||
{
|
||||
name: "internalResolveEngineVersionImageRef",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/internal/engine-versions/1.2.3/image-ref",
|
||||
},
|
||||
{
|
||||
name: "internalExecuteCommands",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/games/" + conformanceGameID + "/commands",
|
||||
contentType: "application/json",
|
||||
body: `{"commands":[{"name":"build","args":{}}]}`,
|
||||
extraHeaders: map[string]string{userIDHeader: conformanceUserID},
|
||||
},
|
||||
{
|
||||
name: "internalPutOrders",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/games/" + conformanceGameID + "/orders",
|
||||
contentType: "application/json",
|
||||
body: `{"commands":[{"name":"move","args":{}}]}`,
|
||||
extraHeaders: map[string]string{userIDHeader: conformanceUserID},
|
||||
},
|
||||
{
|
||||
name: "internalGetReport",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/internal/games/" + conformanceGameID + "/reports/0",
|
||||
extraHeaders: map[string]string{userIDHeader: conformanceUserID},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
runConformanceCase(t, server.handler, router, tc)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
conformanceGameID = "game-conformance"
|
||||
conformanceUserID = "user-conformance"
|
||||
conformanceServerURL = "http://localhost:8097"
|
||||
userIDHeader = "X-User-ID"
|
||||
)
|
||||
|
||||
type conformanceCase struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
contentType string
|
||||
body string
|
||||
expectedStatus int
|
||||
extraHeaders map[string]string
|
||||
}
|
||||
|
||||
func runConformanceCase(t *testing.T, handler http.Handler, router routers.Router, tc conformanceCase) {
|
||||
t.Helper()
|
||||
|
||||
expectedStatus := tc.expectedStatus
|
||||
if expectedStatus == 0 {
|
||||
expectedStatus = http.StatusOK
|
||||
}
|
||||
|
||||
var bodyReader io.Reader
|
||||
if tc.body != "" {
|
||||
bodyReader = strings.NewReader(tc.body)
|
||||
}
|
||||
request := httptest.NewRequest(tc.method, tc.path, bodyReader)
|
||||
if tc.contentType != "" {
|
||||
request.Header.Set("Content-Type", tc.contentType)
|
||||
}
|
||||
request.Header.Set("X-Galaxy-Caller", "admin")
|
||||
for key, value := range tc.extraHeaders {
|
||||
request.Header.Set(key, value)
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
require.Equalf(t, expectedStatus, recorder.Code,
|
||||
"operation %s returned %d: %s", tc.name, recorder.Code, recorder.Body.String())
|
||||
|
||||
validationURL := conformanceServerURL + tc.path
|
||||
validationRequest := httptest.NewRequest(tc.method, validationURL, bodyReaderFor(tc.body))
|
||||
if tc.contentType != "" {
|
||||
validationRequest.Header.Set("Content-Type", tc.contentType)
|
||||
}
|
||||
validationRequest.Header.Set("X-Galaxy-Caller", "admin")
|
||||
for key, value := range tc.extraHeaders {
|
||||
validationRequest.Header.Set(key, value)
|
||||
}
|
||||
|
||||
route, pathParams, err := router.FindRoute(validationRequest)
|
||||
require.NoError(t, err)
|
||||
|
||||
requestInput := &openapi3filter.RequestValidationInput{
|
||||
Request: validationRequest,
|
||||
PathParams: pathParams,
|
||||
Route: route,
|
||||
Options: &openapi3filter.Options{
|
||||
IncludeResponseStatus: true,
|
||||
},
|
||||
}
|
||||
require.NoError(t, openapi3filter.ValidateRequest(context.Background(), requestInput))
|
||||
|
||||
responseInput := &openapi3filter.ResponseValidationInput{
|
||||
RequestValidationInput: requestInput,
|
||||
Status: recorder.Code,
|
||||
Header: recorder.Header(),
|
||||
Options: &openapi3filter.Options{
|
||||
IncludeResponseStatus: true,
|
||||
},
|
||||
}
|
||||
responseInput.SetBodyBytes(recorder.Body.Bytes())
|
||||
require.NoError(t, openapi3filter.ValidateResponse(context.Background(), responseInput))
|
||||
}
|
||||
|
||||
func loadConformanceSpec(t *testing.T) *openapi3.T {
|
||||
t.Helper()
|
||||
|
||||
_, thisFile, _, ok := runtime.Caller(0)
|
||||
require.True(t, ok)
|
||||
|
||||
specPath := filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "api", "internal-openapi.yaml")
|
||||
loader := openapi3.NewLoader()
|
||||
doc, err := loader.LoadFromFile(specPath)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, doc.Validate(context.Background()))
|
||||
return doc
|
||||
}
|
||||
|
||||
func bodyReaderFor(raw string) io.Reader {
|
||||
if raw == "" {
|
||||
return http.NoBody
|
||||
}
|
||||
return bytes.NewBufferString(raw)
|
||||
}
|
||||
|
||||
func newConformanceConfig() Config {
|
||||
return Config{
|
||||
Addr: ":0",
|
||||
ReadHeaderTimeout: time.Second,
|
||||
ReadTimeout: time.Second,
|
||||
WriteTimeout: time.Second,
|
||||
IdleTimeout: time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// conformanceDeps groups the stub collaborators handed to the listener.
|
||||
type conformanceDeps struct {
|
||||
runtimeRecords *conformanceRuntimeRecords
|
||||
registerRuntime *conformanceRegister
|
||||
forceNextTurn *conformanceForce
|
||||
stopRuntime *conformanceStop
|
||||
patchRuntime *conformancePatch
|
||||
banishRace *conformanceBanish
|
||||
membership *conformanceMembership
|
||||
liveness *conformanceLiveness
|
||||
engineVersions *conformanceEngineVersions
|
||||
commandExecute *conformanceCommands
|
||||
putOrders *conformanceOrders
|
||||
getReport *conformanceReport
|
||||
}
|
||||
|
||||
func newConformanceDeps() *conformanceDeps {
|
||||
return &conformanceDeps{
|
||||
runtimeRecords: newConformanceRuntimeRecords(),
|
||||
registerRuntime: &conformanceRegister{},
|
||||
forceNextTurn: &conformanceForce{},
|
||||
stopRuntime: &conformanceStop{},
|
||||
patchRuntime: &conformancePatch{},
|
||||
banishRace: &conformanceBanish{},
|
||||
membership: &conformanceMembership{},
|
||||
liveness: &conformanceLiveness{},
|
||||
engineVersions: newConformanceEngineVersions(),
|
||||
commandExecute: &conformanceCommands{},
|
||||
putOrders: &conformanceOrders{},
|
||||
getReport: &conformanceReport{},
|
||||
}
|
||||
}
|
||||
|
||||
// conformanceRecord builds a canonical running runtime record used
|
||||
// by every stub service.
|
||||
func conformanceRuntimeRecord() domainruntime.RuntimeRecord {
|
||||
moment := time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC)
|
||||
next := moment.Add(time.Minute)
|
||||
started := moment
|
||||
return domainruntime.RuntimeRecord{
|
||||
GameID: conformanceGameID,
|
||||
Status: domainruntime.StatusRunning,
|
||||
EngineEndpoint: "http://galaxy-game-" + conformanceGameID + ":8080",
|
||||
CurrentImageRef: "galaxy/game:1.2.3",
|
||||
CurrentEngineVersion: "1.2.3",
|
||||
TurnSchedule: "0 18 * * *",
|
||||
CurrentTurn: 0,
|
||||
NextGenerationAt: &next,
|
||||
SkipNextTick: false,
|
||||
EngineHealth: "healthy",
|
||||
CreatedAt: moment,
|
||||
UpdatedAt: moment,
|
||||
StartedAt: &started,
|
||||
}
|
||||
}
|
||||
|
||||
func conformanceEngineVersionRecord(version string) engineversion.EngineVersion {
|
||||
moment := time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC)
|
||||
return engineversion.EngineVersion{
|
||||
Version: version,
|
||||
ImageRef: "galaxy/game:" + version,
|
||||
Options: nil,
|
||||
Status: engineversion.StatusActive,
|
||||
CreatedAt: moment,
|
||||
UpdatedAt: moment,
|
||||
}
|
||||
}
|
||||
|
||||
// conformanceRuntimeRecords is an in-memory store seeded with the
|
||||
// canonical record so the get/list endpoints have something to return.
|
||||
type conformanceRuntimeRecords struct {
|
||||
mu sync.Mutex
|
||||
stored map[string]domainruntime.RuntimeRecord
|
||||
}
|
||||
|
||||
func newConformanceRuntimeRecords() *conformanceRuntimeRecords {
|
||||
return &conformanceRuntimeRecords{
|
||||
stored: map[string]domainruntime.RuntimeRecord{
|
||||
conformanceGameID: conformanceRuntimeRecord(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *conformanceRuntimeRecords) Get(_ context.Context, gameID string) (domainruntime.RuntimeRecord, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
record, ok := s.stored[gameID]
|
||||
if !ok {
|
||||
return domainruntime.RuntimeRecord{}, domainruntime.ErrNotFound
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *conformanceRuntimeRecords) List(_ context.Context) ([]domainruntime.RuntimeRecord, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]domainruntime.RuntimeRecord, 0, len(s.stored))
|
||||
for _, record := range s.stored {
|
||||
out = append(out, record)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *conformanceRuntimeRecords) ListByStatus(_ context.Context, status domainruntime.Status) ([]domainruntime.RuntimeRecord, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]domainruntime.RuntimeRecord, 0, len(s.stored))
|
||||
for _, record := range s.stored {
|
||||
if record.Status == status {
|
||||
out = append(out, record)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type conformanceRegister struct{}
|
||||
|
||||
func (s *conformanceRegister) Handle(_ context.Context, _ registerruntime.Input) (registerruntime.Result, error) {
|
||||
return registerruntime.Result{
|
||||
Record: conformanceRuntimeRecord(),
|
||||
Outcome: operation.OutcomeSuccess,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type conformanceForce struct{}
|
||||
|
||||
func (s *conformanceForce) Handle(_ context.Context, _ adminforce.Input) (adminforce.Result, error) {
|
||||
return adminforce.Result{
|
||||
TurnGeneration: turngeneration.Result{Record: conformanceRuntimeRecord()},
|
||||
SkipScheduled: true,
|
||||
Outcome: operation.OutcomeSuccess,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type conformanceStop struct{}
|
||||
|
||||
func (s *conformanceStop) Handle(_ context.Context, _ adminstop.Input) (adminstop.Result, error) {
|
||||
rec := conformanceRuntimeRecord()
|
||||
rec.Status = domainruntime.StatusStopped
|
||||
stopped := rec.UpdatedAt.Add(time.Second)
|
||||
rec.StoppedAt = &stopped
|
||||
rec.UpdatedAt = stopped
|
||||
return adminstop.Result{Record: rec, Outcome: operation.OutcomeSuccess}, nil
|
||||
}
|
||||
|
||||
type conformancePatch struct{}
|
||||
|
||||
func (s *conformancePatch) Handle(_ context.Context, in adminpatch.Input) (adminpatch.Result, error) {
|
||||
rec := conformanceRuntimeRecord()
|
||||
if in.Version != "" {
|
||||
rec.CurrentImageRef = "galaxy/game:" + in.Version
|
||||
rec.CurrentEngineVersion = in.Version
|
||||
}
|
||||
return adminpatch.Result{Record: rec, Outcome: operation.OutcomeSuccess}, nil
|
||||
}
|
||||
|
||||
type conformanceBanish struct{}
|
||||
|
||||
func (s *conformanceBanish) Handle(_ context.Context, _ adminbanish.Input) (adminbanish.Result, error) {
|
||||
return adminbanish.Result{Outcome: operation.OutcomeSuccess}, nil
|
||||
}
|
||||
|
||||
type conformanceMembership struct{}
|
||||
|
||||
func (m *conformanceMembership) Invalidate(string) {}
|
||||
|
||||
type conformanceLiveness struct{}
|
||||
|
||||
func (s *conformanceLiveness) Handle(_ context.Context, _ livenessreply.Input) (livenessreply.Result, error) {
|
||||
return livenessreply.Result{
|
||||
Ready: true,
|
||||
Status: domainruntime.StatusRunning,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type conformanceEngineVersions struct {
|
||||
mu sync.Mutex
|
||||
versions map[string]engineversion.EngineVersion
|
||||
}
|
||||
|
||||
func newConformanceEngineVersions() *conformanceEngineVersions {
|
||||
return &conformanceEngineVersions{
|
||||
versions: map[string]engineversion.EngineVersion{
|
||||
"1.2.3": conformanceEngineVersionRecord("1.2.3"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *conformanceEngineVersions) List(_ context.Context, _ *engineversion.Status) ([]engineversion.EngineVersion, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]engineversion.EngineVersion, 0, len(s.versions))
|
||||
for _, version := range s.versions {
|
||||
out = append(out, version)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *conformanceEngineVersions) Get(_ context.Context, version string) (engineversion.EngineVersion, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
v, ok := s.versions[version]
|
||||
if !ok {
|
||||
return engineversion.EngineVersion{}, engineversionsvc.ErrNotFound
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (s *conformanceEngineVersions) ResolveImageRef(_ context.Context, version string) (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
v, ok := s.versions[version]
|
||||
if !ok {
|
||||
return "", engineversionsvc.ErrNotFound
|
||||
}
|
||||
return v.ImageRef, nil
|
||||
}
|
||||
|
||||
func (s *conformanceEngineVersions) Create(_ context.Context, in engineversionsvc.CreateInput) (engineversion.EngineVersion, error) {
|
||||
rec := engineversion.EngineVersion{
|
||||
Version: in.Version,
|
||||
ImageRef: in.ImageRef,
|
||||
Options: in.Options,
|
||||
Status: engineversion.StatusActive,
|
||||
CreatedAt: time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC),
|
||||
UpdatedAt: time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC),
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.versions[in.Version] = rec
|
||||
s.mu.Unlock()
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
func (s *conformanceEngineVersions) Update(_ context.Context, in engineversionsvc.UpdateInput) (engineversion.EngineVersion, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
rec, ok := s.versions[in.Version]
|
||||
if !ok {
|
||||
return engineversion.EngineVersion{}, engineversionsvc.ErrNotFound
|
||||
}
|
||||
if in.ImageRef != nil {
|
||||
rec.ImageRef = *in.ImageRef
|
||||
}
|
||||
if in.Status != nil {
|
||||
rec.Status = *in.Status
|
||||
}
|
||||
rec.UpdatedAt = time.Date(2026, 4, 30, 13, 0, 0, 0, time.UTC)
|
||||
s.versions[in.Version] = rec
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
func (s *conformanceEngineVersions) Deprecate(_ context.Context, in engineversionsvc.DeprecateInput) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
rec, ok := s.versions[in.Version]
|
||||
if !ok {
|
||||
return engineversionsvc.ErrNotFound
|
||||
}
|
||||
rec.Status = engineversion.StatusDeprecated
|
||||
rec.UpdatedAt = time.Date(2026, 4, 30, 14, 0, 0, 0, time.UTC)
|
||||
s.versions[in.Version] = rec
|
||||
return nil
|
||||
}
|
||||
|
||||
type conformanceCommands struct{}
|
||||
|
||||
func (s *conformanceCommands) Handle(_ context.Context, _ commandexecute.Input) (commandexecute.Result, error) {
|
||||
return commandexecute.Result{
|
||||
Outcome: operation.OutcomeSuccess,
|
||||
RawResponse: json.RawMessage(`{"results":[]}`),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type conformanceOrders struct{}
|
||||
|
||||
func (s *conformanceOrders) Handle(_ context.Context, _ orderput.Input) (orderput.Result, error) {
|
||||
return orderput.Result{
|
||||
Outcome: operation.OutcomeSuccess,
|
||||
RawResponse: json.RawMessage(`{"results":[]}`),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type conformanceReport struct{}
|
||||
|
||||
func (s *conformanceReport) Handle(_ context.Context, _ reportget.Input) (reportget.Result, error) {
|
||||
return reportget.Result{
|
||||
Outcome: operation.OutcomeSuccess,
|
||||
RawResponse: json.RawMessage(`{"player":"Aelinari","turn":0}`),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Compile-time guards that the stubs satisfy the handler-level
|
||||
// service interfaces accepted by the listener.
|
||||
var (
|
||||
_ handlers.RegisterRuntimeService = (*conformanceRegister)(nil)
|
||||
_ handlers.ForceNextTurnService = (*conformanceForce)(nil)
|
||||
_ handlers.StopRuntimeService = (*conformanceStop)(nil)
|
||||
_ handlers.PatchRuntimeService = (*conformancePatch)(nil)
|
||||
_ handlers.BanishRaceService = (*conformanceBanish)(nil)
|
||||
_ handlers.MembershipInvalidator = (*conformanceMembership)(nil)
|
||||
_ handlers.LivenessService = (*conformanceLiveness)(nil)
|
||||
_ handlers.EngineVersionService = (*conformanceEngineVersions)(nil)
|
||||
_ handlers.CommandExecuteService = (*conformanceCommands)(nil)
|
||||
_ handlers.OrderPutService = (*conformanceOrders)(nil)
|
||||
_ handlers.ReportGetService = (*conformanceReport)(nil)
|
||||
_ handlers.RuntimeRecordsReader = (*conformanceRuntimeRecords)(nil)
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"galaxy/gamemaster/internal/domain/operation"
|
||||
"galaxy/gamemaster/internal/service/adminbanish"
|
||||
)
|
||||
|
||||
// newBanishRaceHandler returns the handler for
|
||||
// `POST /api/v1/internal/games/{game_id}/race/{race_name}/banish`. The
|
||||
// request has no body; both identifiers come from the URL path.
|
||||
// Success returns `204 No Content`.
|
||||
func newBanishRaceHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.banish_race")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.BanishRace == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "banish race service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
gameID, ok := extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
raceName, ok := extractRaceName(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := deps.BanishRace.Handle(request.Context(), adminbanish.Input{
|
||||
GameID: gameID,
|
||||
RaceName: raceName,
|
||||
OpSource: resolveOpSource(request),
|
||||
SourceRef: requestSourceRef(request),
|
||||
})
|
||||
if err != nil {
|
||||
logger.ErrorContext(request.Context(), "banish race service errored",
|
||||
"game_id", gameID,
|
||||
"race_name", raceName,
|
||||
"err", err.Error(),
|
||||
)
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "banish race service failed")
|
||||
return
|
||||
}
|
||||
|
||||
if result.Outcome == operation.OutcomeFailure {
|
||||
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
writeNoContent(writer)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"galaxy/gamemaster/internal/domain/engineversion"
|
||||
"galaxy/gamemaster/internal/domain/operation"
|
||||
"galaxy/gamemaster/internal/domain/runtime"
|
||||
engineversionsvc "galaxy/gamemaster/internal/service/engineversion"
|
||||
)
|
||||
|
||||
// jsonContentType is the Content-Type used by every internal REST
|
||||
// response body except the engine pass-through bodies which retain
|
||||
// the engine's chosen Content-Type.
|
||||
const jsonContentType = "application/json; charset=utf-8"
|
||||
|
||||
// callerHeader is the optional caller-classification header used to
|
||||
// attribute each request to a specific entry point. Documented in
|
||||
// `gamemaster/README.md` §«Internal REST API». Missing or unknown
|
||||
// values map to OpSourceAdminRest.
|
||||
const callerHeader = "X-Galaxy-Caller"
|
||||
|
||||
// userIDHeader carries the verified player identity propagated by
|
||||
// Edge Gateway on hot-path operations. Required for
|
||||
// `internalExecuteCommands`, `internalPutOrders`, and
|
||||
// `internalGetReport`.
|
||||
const userIDHeader = "X-User-ID"
|
||||
|
||||
// requestIDHeader is read into `operation_log.source_ref` when present
|
||||
// so REST callers can correlate audit rows with their requests.
|
||||
const requestIDHeader = "X-Request-ID"
|
||||
|
||||
// gameIDPathParam, raceNamePathParam, versionPathParam, turnPathParam
|
||||
// mirror the parameter names declared in
|
||||
// `gamemaster/api/internal-openapi.yaml`.
|
||||
const (
|
||||
gameIDPathParam = "game_id"
|
||||
raceNamePathParam = "race_name"
|
||||
versionPathParam = "version"
|
||||
turnPathParam = "turn"
|
||||
)
|
||||
|
||||
// Stable error codes used by the handler layer when no service result
|
||||
// is available (e.g., the service is not wired or the request shape
|
||||
// failed pre-decode validation). The values match the vocabulary
|
||||
// frozen by `gamemaster/README.md §Error Model` and
|
||||
// `gamemaster/api/internal-openapi.yaml`.
|
||||
const (
|
||||
errorCodeInvalidRequest = "invalid_request"
|
||||
errorCodeForbidden = "forbidden"
|
||||
errorCodeRuntimeNotFound = "runtime_not_found"
|
||||
errorCodeEngineVersionNotFound = "engine_version_not_found"
|
||||
errorCodeEngineVersionInUse = "engine_version_in_use"
|
||||
errorCodeConflict = "conflict"
|
||||
errorCodeRuntimeNotRunning = "runtime_not_running"
|
||||
errorCodeSemverPatchOnly = "semver_patch_only"
|
||||
errorCodeEngineUnreachable = "engine_unreachable"
|
||||
errorCodeEngineValidationError = "engine_validation_error"
|
||||
errorCodeEngineProtocolError = "engine_protocol_violation"
|
||||
errorCodeServiceUnavailable = "service_unavailable"
|
||||
errorCodeInternal = "internal_error"
|
||||
)
|
||||
|
||||
// errorBody mirrors the `error` element of the OpenAPI ErrorResponse
|
||||
// schema.
|
||||
type errorBody struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// errorResponse mirrors the OpenAPI ErrorResponse envelope.
|
||||
type errorResponse struct {
|
||||
Error errorBody `json:"error"`
|
||||
}
|
||||
|
||||
// runtimeRecordResponse mirrors the OpenAPI RuntimeRecord schema.
|
||||
// Required timestamps are always present and encode as int64 UTC
|
||||
// milliseconds; optional ones use `*int64` so an absent value is
|
||||
// omitted from the JSON form (rather than encoded as `null`).
|
||||
type runtimeRecordResponse struct {
|
||||
GameID string `json:"game_id"`
|
||||
RuntimeStatus string `json:"runtime_status"`
|
||||
EngineEndpoint string `json:"engine_endpoint"`
|
||||
CurrentImageRef string `json:"current_image_ref"`
|
||||
CurrentEngineVersion string `json:"current_engine_version"`
|
||||
TurnSchedule string `json:"turn_schedule"`
|
||||
CurrentTurn int `json:"current_turn"`
|
||||
NextGenerationAt int64 `json:"next_generation_at"`
|
||||
SkipNextTick bool `json:"skip_next_tick"`
|
||||
EngineHealthSummary string `json:"engine_health_summary"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
StartedAt *int64 `json:"started_at,omitempty"`
|
||||
StoppedAt *int64 `json:"stopped_at,omitempty"`
|
||||
FinishedAt *int64 `json:"finished_at,omitempty"`
|
||||
}
|
||||
|
||||
// runtimeListResponse mirrors the OpenAPI RuntimeListResponse schema.
|
||||
// Runtimes is always non-nil so an empty result encodes as
|
||||
// `{"runtimes":[]}` rather than `{"runtimes":null}`.
|
||||
type runtimeListResponse struct {
|
||||
Runtimes []runtimeRecordResponse `json:"runtimes"`
|
||||
}
|
||||
|
||||
// engineVersionResponse mirrors the OpenAPI EngineVersion schema.
|
||||
// Options is a `json.RawMessage` so the engine-side document passes
|
||||
// through verbatim.
|
||||
type engineVersionResponse struct {
|
||||
Version string `json:"version"`
|
||||
ImageRef string `json:"image_ref"`
|
||||
Options json.RawMessage `json:"options"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
// engineVersionListResponse mirrors the OpenAPI
|
||||
// EngineVersionListResponse schema.
|
||||
type engineVersionListResponse struct {
|
||||
Versions []engineVersionResponse `json:"versions"`
|
||||
}
|
||||
|
||||
// imageRefResponse mirrors the OpenAPI ImageRefResponse schema.
|
||||
type imageRefResponse struct {
|
||||
ImageRef string `json:"image_ref"`
|
||||
}
|
||||
|
||||
// livenessResponse mirrors the OpenAPI LivenessResponse schema.
|
||||
type livenessResponse struct {
|
||||
Ready bool `json:"ready"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// encodeRuntimeRecord turns a domain RuntimeRecord into its wire shape.
|
||||
// Required `next_generation_at` encodes as `0` when the record carries
|
||||
// no scheduled tick (e.g., status=starting before the first
|
||||
// scheduling write); optional lifecycle timestamps are omitted when
|
||||
// nil.
|
||||
func encodeRuntimeRecord(record runtime.RuntimeRecord) runtimeRecordResponse {
|
||||
resp := runtimeRecordResponse{
|
||||
GameID: record.GameID,
|
||||
RuntimeStatus: string(record.Status),
|
||||
EngineEndpoint: record.EngineEndpoint,
|
||||
CurrentImageRef: record.CurrentImageRef,
|
||||
CurrentEngineVersion: record.CurrentEngineVersion,
|
||||
TurnSchedule: record.TurnSchedule,
|
||||
CurrentTurn: record.CurrentTurn,
|
||||
SkipNextTick: record.SkipNextTick,
|
||||
EngineHealthSummary: record.EngineHealth,
|
||||
CreatedAt: record.CreatedAt.UTC().UnixMilli(),
|
||||
UpdatedAt: record.UpdatedAt.UTC().UnixMilli(),
|
||||
}
|
||||
if record.NextGenerationAt != nil {
|
||||
resp.NextGenerationAt = record.NextGenerationAt.UTC().UnixMilli()
|
||||
}
|
||||
if record.StartedAt != nil {
|
||||
v := record.StartedAt.UTC().UnixMilli()
|
||||
resp.StartedAt = &v
|
||||
}
|
||||
if record.StoppedAt != nil {
|
||||
v := record.StoppedAt.UTC().UnixMilli()
|
||||
resp.StoppedAt = &v
|
||||
}
|
||||
if record.FinishedAt != nil {
|
||||
v := record.FinishedAt.UTC().UnixMilli()
|
||||
resp.FinishedAt = &v
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// encodeRuntimeList turns a domain RuntimeRecord slice into a wire
|
||||
// list response. records may be nil (empty store); the result still
|
||||
// carries an empty Runtimes slice so the JSON form is `{"runtimes":[]}`.
|
||||
func encodeRuntimeList(records []runtime.RuntimeRecord) runtimeListResponse {
|
||||
resp := runtimeListResponse{
|
||||
Runtimes: make([]runtimeRecordResponse, 0, len(records)),
|
||||
}
|
||||
for _, record := range records {
|
||||
resp.Runtimes = append(resp.Runtimes, encodeRuntimeRecord(record))
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// encodeEngineVersion turns a domain EngineVersion into its wire shape.
|
||||
// Empty Options bytes encode as the JSON object literal `{}` to
|
||||
// satisfy the schema (`type: object`).
|
||||
func encodeEngineVersion(version engineversion.EngineVersion) engineVersionResponse {
|
||||
options := json.RawMessage(version.Options)
|
||||
if len(options) == 0 {
|
||||
options = json.RawMessage("{}")
|
||||
}
|
||||
return engineVersionResponse{
|
||||
Version: version.Version,
|
||||
ImageRef: version.ImageRef,
|
||||
Options: options,
|
||||
Status: string(version.Status),
|
||||
CreatedAt: version.CreatedAt.UTC().UnixMilli(),
|
||||
UpdatedAt: version.UpdatedAt.UTC().UnixMilli(),
|
||||
}
|
||||
}
|
||||
|
||||
// encodeEngineVersionList turns a slice of domain EngineVersions into
|
||||
// a wire list response. The Versions slice is always non-nil.
|
||||
func encodeEngineVersionList(versions []engineversion.EngineVersion) engineVersionListResponse {
|
||||
resp := engineVersionListResponse{
|
||||
Versions: make([]engineVersionResponse, 0, len(versions)),
|
||||
}
|
||||
for _, version := range versions {
|
||||
resp.Versions = append(resp.Versions, encodeEngineVersion(version))
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// writeJSON writes payload as a JSON response with the given status
|
||||
// code.
|
||||
func writeJSON(writer http.ResponseWriter, statusCode int, payload any) {
|
||||
writer.Header().Set("Content-Type", jsonContentType)
|
||||
writer.WriteHeader(statusCode)
|
||||
_ = json.NewEncoder(writer).Encode(payload)
|
||||
}
|
||||
|
||||
// writeNoContent writes `204 No Content` with no body. The
|
||||
// Content-Type header is intentionally omitted so kin-openapi's
|
||||
// response validator does not look for a body.
|
||||
func writeNoContent(writer http.ResponseWriter) {
|
||||
writer.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// writeRawJSON writes raw, already-encoded JSON bytes as the response
|
||||
// body with the given status code. Used by the hot-path handlers
|
||||
// where the engine's response body is forwarded verbatim.
|
||||
func writeRawJSON(writer http.ResponseWriter, statusCode int, body []byte) {
|
||||
writer.Header().Set("Content-Type", jsonContentType)
|
||||
writer.WriteHeader(statusCode)
|
||||
_, _ = writer.Write(body)
|
||||
}
|
||||
|
||||
// writeError writes the canonical error envelope at statusCode.
|
||||
func writeError(writer http.ResponseWriter, statusCode int, code, message string) {
|
||||
writeJSON(writer, statusCode, errorResponse{
|
||||
Error: errorBody{Code: code, Message: message},
|
||||
})
|
||||
}
|
||||
|
||||
// writeFailure writes the canonical error envelope using the HTTP
|
||||
// status mapped from code via mapErrorCodeToStatus. Used by every
|
||||
// service-backed handler when its service returns
|
||||
// `Outcome=failure`.
|
||||
func writeFailure(writer http.ResponseWriter, code, message string) {
|
||||
writeError(writer, mapErrorCodeToStatus(code), code, message)
|
||||
}
|
||||
|
||||
// mapErrorCodeToStatus maps a stable error code to the HTTP status
|
||||
// declared by `gamemaster/api/internal-openapi.yaml`. Unknown codes
|
||||
// degrade to 500 so a future error code that ships ahead of its
|
||||
// handler-layer mapping still produces a structurally valid response.
|
||||
func mapErrorCodeToStatus(code string) int {
|
||||
switch code {
|
||||
case errorCodeInvalidRequest:
|
||||
return http.StatusBadRequest
|
||||
case errorCodeForbidden:
|
||||
return http.StatusForbidden
|
||||
case errorCodeRuntimeNotFound, errorCodeEngineVersionNotFound:
|
||||
return http.StatusNotFound
|
||||
case errorCodeConflict,
|
||||
errorCodeRuntimeNotRunning,
|
||||
errorCodeSemverPatchOnly,
|
||||
errorCodeEngineVersionInUse:
|
||||
return http.StatusConflict
|
||||
case errorCodeEngineUnreachable,
|
||||
errorCodeEngineValidationError,
|
||||
errorCodeEngineProtocolError:
|
||||
return http.StatusBadGateway
|
||||
case errorCodeServiceUnavailable:
|
||||
return http.StatusServiceUnavailable
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
// mapServiceError translates one of the `engineversionsvc` sentinel
|
||||
// errors into the corresponding HTTP status, error code, and message.
|
||||
// Unknown errors degrade to `500 internal_error`.
|
||||
func mapServiceError(err error) (int, string, string) {
|
||||
switch {
|
||||
case errors.Is(err, engineversionsvc.ErrInvalidRequest):
|
||||
return http.StatusBadRequest, errorCodeInvalidRequest, err.Error()
|
||||
case errors.Is(err, engineversionsvc.ErrNotFound):
|
||||
return http.StatusNotFound, errorCodeEngineVersionNotFound, err.Error()
|
||||
case errors.Is(err, engineversionsvc.ErrConflict):
|
||||
return http.StatusConflict, errorCodeConflict, err.Error()
|
||||
case errors.Is(err, engineversionsvc.ErrInUse):
|
||||
return http.StatusConflict, errorCodeEngineVersionInUse, err.Error()
|
||||
case errors.Is(err, engineversionsvc.ErrServiceUnavailable):
|
||||
return http.StatusServiceUnavailable, errorCodeServiceUnavailable, err.Error()
|
||||
default:
|
||||
return http.StatusInternalServerError, errorCodeInternal, "internal server error"
|
||||
}
|
||||
}
|
||||
|
||||
// decodeStrictJSON decodes one request body into target with strict
|
||||
// JSON semantics: unknown fields are rejected and trailing content is
|
||||
// rejected. Mirrors the helper used by lobby and rtmanager.
|
||||
func decodeStrictJSON(body io.Reader, target any) error {
|
||||
decoder := json.NewDecoder(body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if decoder.More() {
|
||||
return errors.New("unexpected trailing content after JSON body")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readRawJSONBody returns the raw request body provided it parses as
|
||||
// a JSON value. The hot-path handlers use this helper because the
|
||||
// envelope is engine-owned (`additionalProperties: true` on
|
||||
// ExecuteCommandsRequest / PutOrdersRequest); strict decoding would
|
||||
// reject legitimate extra fields.
|
||||
func readRawJSONBody(reader io.Reader) ([]byte, error) {
|
||||
if reader == nil {
|
||||
return nil, errors.New("request body is required")
|
||||
}
|
||||
body, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return nil, errors.New("request body is required")
|
||||
}
|
||||
if !json.Valid(body) {
|
||||
return nil, errors.New("request body is not valid JSON")
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// extractGameID pulls the {game_id} path variable from request. An
|
||||
// empty or whitespace-only value writes a `400 invalid_request` and
|
||||
// returns ok=false so callers can short-circuit.
|
||||
func extractGameID(writer http.ResponseWriter, request *http.Request) (string, bool) {
|
||||
raw := request.PathValue(gameIDPathParam)
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, "game id is required")
|
||||
return "", false
|
||||
}
|
||||
return raw, true
|
||||
}
|
||||
|
||||
// extractRaceName pulls the {race_name} path variable.
|
||||
func extractRaceName(writer http.ResponseWriter, request *http.Request) (string, bool) {
|
||||
raw := request.PathValue(raceNamePathParam)
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, "race name is required")
|
||||
return "", false
|
||||
}
|
||||
return raw, true
|
||||
}
|
||||
|
||||
// extractVersion pulls the {version} path variable.
|
||||
func extractVersion(writer http.ResponseWriter, request *http.Request) (string, bool) {
|
||||
raw := request.PathValue(versionPathParam)
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, "version is required")
|
||||
return "", false
|
||||
}
|
||||
return raw, true
|
||||
}
|
||||
|
||||
// extractUserID pulls the verified player identity from the
|
||||
// X-User-ID header. The hot-path operations require this header per
|
||||
// the OpenAPI spec; absent or whitespace-only values short-circuit
|
||||
// with `400 invalid_request`.
|
||||
func extractUserID(writer http.ResponseWriter, request *http.Request) (string, bool) {
|
||||
raw := strings.TrimSpace(request.Header.Get(userIDHeader))
|
||||
if raw == "" {
|
||||
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, "X-User-ID header is required")
|
||||
return "", false
|
||||
}
|
||||
return raw, true
|
||||
}
|
||||
|
||||
// resolveOpSource maps the X-Galaxy-Caller header value to an
|
||||
// `operation.OpSource`. Missing or unknown values default to
|
||||
// OpSourceAdminRest, matching the documented contract in
|
||||
// `gamemaster/README.md` §«Internal REST API».
|
||||
func resolveOpSource(request *http.Request) operation.OpSource {
|
||||
switch strings.ToLower(strings.TrimSpace(request.Header.Get(callerHeader))) {
|
||||
case "gateway":
|
||||
return operation.OpSourceGatewayPlayer
|
||||
case "lobby":
|
||||
return operation.OpSourceLobbyInternal
|
||||
case "admin":
|
||||
return operation.OpSourceAdminRest
|
||||
default:
|
||||
return operation.OpSourceAdminRest
|
||||
}
|
||||
}
|
||||
|
||||
// requestSourceRef returns an opaque per-request reference recorded
|
||||
// in `operation_log.source_ref`. v1 reads the X-Request-ID header
|
||||
// when present so callers may correlate REST requests with audit
|
||||
// rows.
|
||||
func requestSourceRef(request *http.Request) string {
|
||||
return strings.TrimSpace(request.Header.Get(requestIDHeader))
|
||||
}
|
||||
|
||||
// loggerFor returns a logger annotated with the operation tag. Each
|
||||
// handler scopes its logs by op so operators filtering on
|
||||
// `op=internal_rest.<operation>` see exactly the lifecycle they care
|
||||
// about.
|
||||
func loggerFor(parent *slog.Logger, op string) *slog.Logger {
|
||||
if parent == nil {
|
||||
parent = slog.Default()
|
||||
}
|
||||
return parent.With("component", "internal_http.handlers", "op", op)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/gamemaster/internal/domain/engineversion"
|
||||
"galaxy/gamemaster/internal/domain/operation"
|
||||
"galaxy/gamemaster/internal/domain/runtime"
|
||||
engineversionsvc "galaxy/gamemaster/internal/service/engineversion"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMapErrorCodeToStatusCoversEveryDocumentedCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string]int{
|
||||
errorCodeInvalidRequest: http.StatusBadRequest,
|
||||
errorCodeForbidden: http.StatusForbidden,
|
||||
errorCodeRuntimeNotFound: http.StatusNotFound,
|
||||
errorCodeEngineVersionNotFound: http.StatusNotFound,
|
||||
errorCodeConflict: http.StatusConflict,
|
||||
errorCodeRuntimeNotRunning: http.StatusConflict,
|
||||
errorCodeSemverPatchOnly: http.StatusConflict,
|
||||
errorCodeEngineVersionInUse: http.StatusConflict,
|
||||
errorCodeEngineUnreachable: http.StatusBadGateway,
|
||||
errorCodeEngineValidationError: http.StatusBadGateway,
|
||||
errorCodeEngineProtocolError: http.StatusBadGateway,
|
||||
errorCodeServiceUnavailable: http.StatusServiceUnavailable,
|
||||
errorCodeInternal: http.StatusInternalServerError,
|
||||
"unknown_code": http.StatusInternalServerError,
|
||||
}
|
||||
|
||||
for code, expected := range cases {
|
||||
assert.Equalf(t, expected, mapErrorCodeToStatus(code), "code %q", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapServiceErrorMapsEverySentinel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
err error
|
||||
status int
|
||||
code string
|
||||
}{
|
||||
{engineversionsvc.ErrInvalidRequest, http.StatusBadRequest, errorCodeInvalidRequest},
|
||||
{engineversionsvc.ErrNotFound, http.StatusNotFound, errorCodeEngineVersionNotFound},
|
||||
{engineversionsvc.ErrConflict, http.StatusConflict, errorCodeConflict},
|
||||
{engineversionsvc.ErrInUse, http.StatusConflict, errorCodeEngineVersionInUse},
|
||||
{engineversionsvc.ErrServiceUnavailable, http.StatusServiceUnavailable, errorCodeServiceUnavailable},
|
||||
{errors.New("plain go error"), http.StatusInternalServerError, errorCodeInternal},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
status, code, _ := mapServiceError(tc.err)
|
||||
assert.Equalf(t, tc.status, status, "status for %v", tc.err)
|
||||
assert.Equalf(t, tc.code, code, "code for %v", tc.err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveOpSourceMapsCallerHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string]operation.OpSource{
|
||||
"": operation.OpSourceAdminRest,
|
||||
"unknown": operation.OpSourceAdminRest,
|
||||
"GATEWAY": operation.OpSourceGatewayPlayer,
|
||||
" lobby ": operation.OpSourceLobbyInternal,
|
||||
"admin": operation.OpSourceAdminRest,
|
||||
}
|
||||
|
||||
for value, expected := range cases {
|
||||
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
if value != "" {
|
||||
request.Header.Set(callerHeader, value)
|
||||
}
|
||||
assert.Equalf(t, expected, resolveOpSource(request), "header %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestSourceRefReadsXRequestID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
assert.Empty(t, requestSourceRef(request))
|
||||
|
||||
request.Header.Set(requestIDHeader, " trace-123 ")
|
||||
assert.Equal(t, "trace-123", requestSourceRef(request))
|
||||
}
|
||||
|
||||
func TestDecodeStrictJSONRejectsUnknownFieldsAndTrailingContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type input struct {
|
||||
Field string `json:"field"`
|
||||
}
|
||||
|
||||
var ok input
|
||||
require.NoError(t, decodeStrictJSON(strings.NewReader(`{"field":"value"}`), &ok))
|
||||
assert.Equal(t, "value", ok.Field)
|
||||
|
||||
var rejected input
|
||||
err := decodeStrictJSON(strings.NewReader(`{"field":"v","extra":1}`), &rejected)
|
||||
require.Error(t, err)
|
||||
|
||||
var trailing input
|
||||
err = decodeStrictJSON(strings.NewReader(`{"field":"v"}{"another":true}`), &trailing)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestReadRawJSONBodyValidatesPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body, err := readRawJSONBody(strings.NewReader(`{"commands":[]}`))
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, `{"commands":[]}`, string(body))
|
||||
|
||||
_, err = readRawJSONBody(strings.NewReader(""))
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = readRawJSONBody(strings.NewReader("not json"))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestEncodeRuntimeRecordIncludesEveryRequiredField(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
moment := time.Date(2026, 5, 1, 9, 30, 0, 0, time.UTC)
|
||||
next := moment.Add(time.Minute)
|
||||
record := runtime.RuntimeRecord{
|
||||
GameID: "game-1",
|
||||
Status: runtime.StatusRunning,
|
||||
EngineEndpoint: "http://example:8080",
|
||||
CurrentImageRef: "galaxy/game:1.2.3",
|
||||
CurrentEngineVersion: "1.2.3",
|
||||
TurnSchedule: "0 18 * * *",
|
||||
CurrentTurn: 7,
|
||||
NextGenerationAt: &next,
|
||||
SkipNextTick: true,
|
||||
EngineHealth: "healthy",
|
||||
CreatedAt: moment,
|
||||
UpdatedAt: moment,
|
||||
StartedAt: &moment,
|
||||
}
|
||||
|
||||
encoded := encodeRuntimeRecord(record)
|
||||
assert.Equal(t, "game-1", encoded.GameID)
|
||||
assert.Equal(t, "running", encoded.RuntimeStatus)
|
||||
assert.Equal(t, moment.UnixMilli(), encoded.CreatedAt)
|
||||
assert.Equal(t, next.UnixMilli(), encoded.NextGenerationAt)
|
||||
require.NotNil(t, encoded.StartedAt)
|
||||
assert.Equal(t, moment.UnixMilli(), *encoded.StartedAt)
|
||||
assert.Nil(t, encoded.StoppedAt)
|
||||
assert.Nil(t, encoded.FinishedAt)
|
||||
}
|
||||
|
||||
func TestEncodeRuntimeRecordZerosNextGenerationWhenNil(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
moment := time.Date(2026, 5, 1, 9, 30, 0, 0, time.UTC)
|
||||
record := runtime.RuntimeRecord{
|
||||
GameID: "game-1",
|
||||
Status: runtime.StatusStarting,
|
||||
EngineEndpoint: "http://example:8080",
|
||||
CurrentImageRef: "galaxy/game:1.2.3",
|
||||
CurrentEngineVersion: "1.2.3",
|
||||
TurnSchedule: "0 18 * * *",
|
||||
CreatedAt: moment,
|
||||
UpdatedAt: moment,
|
||||
}
|
||||
|
||||
encoded := encodeRuntimeRecord(record)
|
||||
assert.Equal(t, int64(0), encoded.NextGenerationAt)
|
||||
assert.Nil(t, encoded.StartedAt)
|
||||
}
|
||||
|
||||
func TestEncodeEngineVersionDefaultsEmptyOptionsToObject(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
moment := time.Date(2026, 5, 1, 9, 30, 0, 0, time.UTC)
|
||||
encoded := encodeEngineVersion(engineversion.EngineVersion{
|
||||
Version: "1.2.3",
|
||||
ImageRef: "galaxy/game:1.2.3",
|
||||
Status: engineversion.StatusActive,
|
||||
CreatedAt: moment,
|
||||
UpdatedAt: moment,
|
||||
})
|
||||
assert.Equal(t, "{}", string(encoded.Options))
|
||||
assert.Equal(t, "active", encoded.Status)
|
||||
}
|
||||
|
||||
func TestEncodeRuntimeListAlwaysReturnsNonNilSlice(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resp := encodeRuntimeList(nil)
|
||||
require.NotNil(t, resp.Runtimes)
|
||||
assert.Empty(t, resp.Runtimes)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
engineversionsvc "galaxy/gamemaster/internal/service/engineversion"
|
||||
)
|
||||
|
||||
// createEngineVersionRequestBody mirrors the OpenAPI
|
||||
// CreateEngineVersionRequest schema.
|
||||
type createEngineVersionRequestBody struct {
|
||||
Version string `json:"version"`
|
||||
ImageRef string `json:"image_ref"`
|
||||
Options json.RawMessage `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
// newCreateEngineVersionHandler returns the handler for
|
||||
// `POST /api/v1/internal/engine-versions`.
|
||||
func newCreateEngineVersionHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.create_engine_version")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.EngineVersions == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "engine version service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
var body createEngineVersionRequestBody
|
||||
if err := decodeStrictJSON(request.Body, &body); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
record, err := deps.EngineVersions.Create(request.Context(), engineversionsvc.CreateInput{
|
||||
Version: body.Version,
|
||||
ImageRef: body.ImageRef,
|
||||
Options: []byte(body.Options),
|
||||
OpSource: resolveOpSource(request),
|
||||
SourceRef: requestSourceRef(request),
|
||||
})
|
||||
if err != nil {
|
||||
logger.ErrorContext(request.Context(), "create engine version failed", "err", err.Error())
|
||||
status, code, message := mapServiceError(err)
|
||||
writeError(writer, status, code, message)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusCreated, encodeEngineVersion(record))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
engineversionsvc "galaxy/gamemaster/internal/service/engineversion"
|
||||
)
|
||||
|
||||
// newDeprecateEngineVersionHandler returns the handler for
|
||||
// `DELETE /api/v1/internal/engine-versions/{version}`. The endpoint
|
||||
// flips the row's status to `deprecated` (decision D2 in
|
||||
// `gamemaster/docs/stage19-internal-rest-handlers.md`); hard removal
|
||||
// is reserved for future Admin Service operations and not exposed
|
||||
// here.
|
||||
func newDeprecateEngineVersionHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.deprecate_engine_version")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.EngineVersions == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "engine version service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
version, ok := extractVersion(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := deps.EngineVersions.Deprecate(request.Context(), engineversionsvc.DeprecateInput{
|
||||
Version: version,
|
||||
OpSource: resolveOpSource(request),
|
||||
SourceRef: requestSourceRef(request),
|
||||
}); err != nil {
|
||||
logger.ErrorContext(request.Context(), "deprecate engine version failed",
|
||||
"version", version,
|
||||
"err", err.Error(),
|
||||
)
|
||||
status, code, message := mapServiceError(err)
|
||||
writeError(writer, status, code, message)
|
||||
return
|
||||
}
|
||||
|
||||
writeNoContent(writer)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"galaxy/gamemaster/internal/domain/operation"
|
||||
"galaxy/gamemaster/internal/service/commandexecute"
|
||||
)
|
||||
|
||||
// newExecuteCommandsHandler returns the handler for
|
||||
// `POST /api/v1/internal/games/{game_id}/commands`. The request body
|
||||
// is engine-owned (`additionalProperties: true`) and is forwarded to
|
||||
// the service as a `json.RawMessage`. The response on success is the
|
||||
// engine's payload byte-for-byte; failure outcomes use the canonical
|
||||
// error envelope per the OpenAPI contract.
|
||||
func newExecuteCommandsHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.execute_commands")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.CommandExecute == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "command execute service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
gameID, ok := extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID, ok := extractUserID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, err := readRawJSONBody(request.Body)
|
||||
if err != nil {
|
||||
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result, err := deps.CommandExecute.Handle(request.Context(), commandexecute.Input{
|
||||
GameID: gameID,
|
||||
UserID: userID,
|
||||
Payload: body,
|
||||
})
|
||||
if err != nil {
|
||||
logger.ErrorContext(request.Context(), "command execute service errored",
|
||||
"game_id", gameID,
|
||||
"user_id", userID,
|
||||
"err", err.Error(),
|
||||
)
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "command execute service failed")
|
||||
return
|
||||
}
|
||||
|
||||
if result.Outcome == operation.OutcomeFailure {
|
||||
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
writeRawJSON(writer, http.StatusOK, []byte(result.RawResponse))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"galaxy/gamemaster/internal/domain/operation"
|
||||
"galaxy/gamemaster/internal/service/adminforce"
|
||||
)
|
||||
|
||||
// newForceNextTurnHandler returns the handler for
|
||||
// `POST /api/v1/internal/runtimes/{game_id}/force-next-turn`. The
|
||||
// request has no body; the handler delegates to
|
||||
// `adminforce.Service.Handle` and encodes the resulting runtime
|
||||
// record on success.
|
||||
func newForceNextTurnHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.force_next_turn")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.ForceNextTurn == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "force next turn service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
gameID, ok := extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := deps.ForceNextTurn.Handle(request.Context(), adminforce.Input{
|
||||
GameID: gameID,
|
||||
OpSource: resolveOpSource(request),
|
||||
SourceRef: requestSourceRef(request),
|
||||
})
|
||||
if err != nil {
|
||||
logger.ErrorContext(request.Context(), "force next turn service errored",
|
||||
"game_id", gameID,
|
||||
"err", err.Error(),
|
||||
)
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "force next turn service failed")
|
||||
return
|
||||
}
|
||||
|
||||
if result.Outcome == operation.OutcomeFailure {
|
||||
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeRuntimeRecord(result.TurnGeneration.Record))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"galaxy/gamemaster/internal/service/livenessreply"
|
||||
)
|
||||
|
||||
// newGameLivenessHandler returns the handler for
|
||||
// `GET /api/v1/internal/games/{game_id}/liveness`. The endpoint
|
||||
// always responds with 200 + LivenessResponse; Go-level errors
|
||||
// returned by the service map to 500 / 503 according to their
|
||||
// embedded error code prefix.
|
||||
func newGameLivenessHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.game_liveness")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.GameLiveness == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "game liveness service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
gameID, ok := extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := deps.GameLiveness.Handle(request.Context(), livenessreply.Input{GameID: gameID})
|
||||
if err != nil {
|
||||
logger.ErrorContext(request.Context(), "game liveness service errored",
|
||||
"game_id", gameID,
|
||||
"err", err.Error(),
|
||||
)
|
||||
switch {
|
||||
case strings.HasPrefix(err.Error(), livenessreply.ErrorCodeInvalidRequest+":"):
|
||||
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, err.Error())
|
||||
case strings.HasPrefix(err.Error(), livenessreply.ErrorCodeServiceUnavailable+":"):
|
||||
writeError(writer, http.StatusServiceUnavailable, errorCodeServiceUnavailable, "service unavailable")
|
||||
default:
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "game liveness service failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, livenessResponse{
|
||||
Ready: result.Ready,
|
||||
Status: string(result.Status),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package handlers
|
||||
|
||||
import "net/http"
|
||||
|
||||
// newGetEngineVersionHandler returns the handler for
|
||||
// `GET /api/v1/internal/engine-versions/{version}`.
|
||||
func newGetEngineVersionHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.get_engine_version")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.EngineVersions == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "engine version service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
version, ok := extractVersion(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record, err := deps.EngineVersions.Get(request.Context(), version)
|
||||
if err != nil {
|
||||
logger.ErrorContext(request.Context(), "get engine version failed",
|
||||
"version", version,
|
||||
"err", err.Error(),
|
||||
)
|
||||
status, code, message := mapServiceError(err)
|
||||
writeError(writer, status, code, message)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeEngineVersion(record))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"galaxy/gamemaster/internal/domain/operation"
|
||||
"galaxy/gamemaster/internal/service/reportget"
|
||||
)
|
||||
|
||||
// newGetReportHandler returns the handler for
|
||||
// `GET /api/v1/internal/games/{game_id}/reports/{turn}`. Path
|
||||
// validation rejects non-numeric or negative turn values with
|
||||
// `400 invalid_request` before the service is touched.
|
||||
func newGetReportHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.get_report")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.GetReport == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "get report service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
gameID, ok := extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID, ok := extractUserID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
raw := strings.TrimSpace(request.PathValue(turnPathParam))
|
||||
if raw == "" {
|
||||
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, "turn is required")
|
||||
return
|
||||
}
|
||||
turn, err := strconv.Atoi(raw)
|
||||
if err != nil || turn < 0 {
|
||||
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, "turn must be a non-negative integer")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := deps.GetReport.Handle(request.Context(), reportget.Input{
|
||||
GameID: gameID,
|
||||
UserID: userID,
|
||||
Turn: turn,
|
||||
})
|
||||
if err != nil {
|
||||
logger.ErrorContext(request.Context(), "get report service errored",
|
||||
"game_id", gameID,
|
||||
"user_id", userID,
|
||||
"turn", turn,
|
||||
"err", err.Error(),
|
||||
)
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "get report service failed")
|
||||
return
|
||||
}
|
||||
|
||||
if result.Outcome == operation.OutcomeFailure {
|
||||
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
writeRawJSON(writer, http.StatusOK, []byte(result.RawResponse))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"galaxy/gamemaster/internal/domain/runtime"
|
||||
)
|
||||
|
||||
// newGetRuntimeHandler returns the handler for
|
||||
// `GET /api/v1/internal/runtimes/{game_id}`. Reads from
|
||||
// `RuntimeRecordsReader.Get` and translates `runtime.ErrNotFound` to
|
||||
// `404 runtime_not_found`.
|
||||
func newGetRuntimeHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.get_runtime")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.RuntimeRecords == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "runtime records store is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
gameID, ok := extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
record, err := deps.RuntimeRecords.Get(request.Context(), gameID)
|
||||
if err != nil {
|
||||
if errors.Is(err, runtime.ErrNotFound) {
|
||||
writeError(writer, http.StatusNotFound, errorCodeRuntimeNotFound, "runtime not found")
|
||||
return
|
||||
}
|
||||
logger.ErrorContext(request.Context(), "get runtime record failed",
|
||||
"game_id", gameID,
|
||||
"err", err.Error(),
|
||||
)
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "failed to read runtime record")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeRuntimeRecord(record))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Package handlers serves the trusted internal REST surface of Game
|
||||
// Master frozen by `gamemaster/api/internal-openapi.yaml`. The package
|
||||
// owns one HandlerFunc per OpenAPI operation; route registration goes
|
||||
// through Register so the listener (`internal/api/internalhttp`) keeps
|
||||
// its lifecycle code separate from the per-operation logic. Handlers
|
||||
// delegate every business decision to the `internal/service/*`
|
||||
// packages and never decode engine-owned hot-path payloads.
|
||||
//
|
||||
// The pattern mirrors `rtmanager/internal/api/internalhttp/handlers`
|
||||
// so a reader familiar with one service can find their way around the
|
||||
// other.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Route paths frozen by `gamemaster/api/internal-openapi.yaml`. The
|
||||
// values match the operation IDs asserted in
|
||||
// `gamemaster/contract_openapi_test.go`; renaming any of them is a
|
||||
// contract change.
|
||||
const (
|
||||
registerRuntimePath = "/api/v1/internal/games/{game_id}/register-runtime"
|
||||
banishRacePath = "/api/v1/internal/games/{game_id}/race/{race_name}/banish"
|
||||
invalidateMembershipsPath = "/api/v1/internal/games/{game_id}/memberships/invalidate"
|
||||
gameLivenessPath = "/api/v1/internal/games/{game_id}/liveness"
|
||||
listRuntimesPath = "/api/v1/internal/runtimes"
|
||||
getRuntimePath = "/api/v1/internal/runtimes/{game_id}"
|
||||
forceNextTurnPath = "/api/v1/internal/runtimes/{game_id}/force-next-turn"
|
||||
stopRuntimePath = "/api/v1/internal/runtimes/{game_id}/stop"
|
||||
patchRuntimePath = "/api/v1/internal/runtimes/{game_id}/patch"
|
||||
listEngineVersionsPath = "/api/v1/internal/engine-versions"
|
||||
createEngineVersionPath = "/api/v1/internal/engine-versions"
|
||||
engineVersionItemPath = "/api/v1/internal/engine-versions/{version}"
|
||||
resolveEngineVersionImageRefPath = "/api/v1/internal/engine-versions/{version}/image-ref"
|
||||
executeCommandsPath = "/api/v1/internal/games/{game_id}/commands"
|
||||
putOrdersPath = "/api/v1/internal/games/{game_id}/orders"
|
||||
getReportPath = "/api/v1/internal/games/{game_id}/reports/{turn}"
|
||||
)
|
||||
|
||||
// Dependencies bundles the collaborators required to serve the
|
||||
// gateway-, Lobby-, and Admin-facing internal REST surface. Any port
|
||||
// may be nil; in that case the routes that depend on it return
|
||||
// `500 internal_error` with the message «service is not wired». This
|
||||
// mirrors the rtmanager handlers' guard so partially-wired listener
|
||||
// tests do not crash on routes they do not exercise.
|
||||
type Dependencies struct {
|
||||
// Logger receives structured per-handler logs. nil falls back to
|
||||
// slog.Default.
|
||||
Logger *slog.Logger
|
||||
|
||||
// RuntimeRecords backs the read-only list/get runtime endpoints.
|
||||
// Reads do not produce operation_log rows, mirroring
|
||||
// `rtmanager/docs/services.md` §18.
|
||||
RuntimeRecords RuntimeRecordsReader
|
||||
|
||||
// RegisterRuntime is the orchestrator for the
|
||||
// `internalRegisterRuntime` operation.
|
||||
RegisterRuntime RegisterRuntimeService
|
||||
|
||||
// ForceNextTurn drives the synchronous force-next-turn flow.
|
||||
ForceNextTurn ForceNextTurnService
|
||||
|
||||
// StopRuntime drives the admin stop flow.
|
||||
StopRuntime StopRuntimeService
|
||||
|
||||
// PatchRuntime drives the admin patch flow.
|
||||
PatchRuntime PatchRuntimeService
|
||||
|
||||
// BanishRace drives the engine race-banish flow.
|
||||
BanishRace BanishRaceService
|
||||
|
||||
// InvalidateMemberships purges the in-process membership cache for a
|
||||
// game id; backed by `service/membership.Cache.Invalidate`.
|
||||
InvalidateMemberships MembershipInvalidator
|
||||
|
||||
// GameLiveness returns the current runtime status without
|
||||
// contacting the engine.
|
||||
GameLiveness LivenessService
|
||||
|
||||
// EngineVersions exposes the multi-method engine-version registry
|
||||
// service (List/Get/ResolveImageRef/Create/Update/Deprecate).
|
||||
EngineVersions EngineVersionService
|
||||
|
||||
// CommandExecute forwards a player command batch to the engine.
|
||||
CommandExecute CommandExecuteService
|
||||
|
||||
// PutOrders forwards a player order batch to the engine.
|
||||
PutOrders OrderPutService
|
||||
|
||||
// GetReport reads a per-player turn report from the engine.
|
||||
GetReport ReportGetService
|
||||
}
|
||||
|
||||
// Register attaches every internal REST route to mux. The function is
|
||||
// idempotent against the listener-level probes (`/healthz`,
|
||||
// `/readyz`); the probe routes are owned by the listener and remain
|
||||
// disjoint from the paths registered here.
|
||||
func Register(mux *http.ServeMux, deps Dependencies) {
|
||||
mux.HandleFunc(http.MethodPost+" "+registerRuntimePath, newRegisterRuntimeHandler(deps))
|
||||
mux.HandleFunc(http.MethodGet+" "+getRuntimePath, newGetRuntimeHandler(deps))
|
||||
mux.HandleFunc(http.MethodGet+" "+listRuntimesPath, newListRuntimesHandler(deps))
|
||||
mux.HandleFunc(http.MethodPost+" "+forceNextTurnPath, newForceNextTurnHandler(deps))
|
||||
mux.HandleFunc(http.MethodPost+" "+stopRuntimePath, newStopRuntimeHandler(deps))
|
||||
mux.HandleFunc(http.MethodPost+" "+patchRuntimePath, newPatchRuntimeHandler(deps))
|
||||
mux.HandleFunc(http.MethodPost+" "+banishRacePath, newBanishRaceHandler(deps))
|
||||
mux.HandleFunc(http.MethodPost+" "+invalidateMembershipsPath, newInvalidateMembershipsHandler(deps))
|
||||
mux.HandleFunc(http.MethodGet+" "+gameLivenessPath, newGameLivenessHandler(deps))
|
||||
mux.HandleFunc(http.MethodGet+" "+listEngineVersionsPath, newListEngineVersionsHandler(deps))
|
||||
mux.HandleFunc(http.MethodPost+" "+createEngineVersionPath, newCreateEngineVersionHandler(deps))
|
||||
mux.HandleFunc(http.MethodGet+" "+engineVersionItemPath, newGetEngineVersionHandler(deps))
|
||||
mux.HandleFunc(http.MethodPatch+" "+engineVersionItemPath, newUpdateEngineVersionHandler(deps))
|
||||
mux.HandleFunc(http.MethodDelete+" "+engineVersionItemPath, newDeprecateEngineVersionHandler(deps))
|
||||
mux.HandleFunc(http.MethodGet+" "+resolveEngineVersionImageRefPath, newResolveEngineVersionImageRefHandler(deps))
|
||||
mux.HandleFunc(http.MethodPost+" "+executeCommandsPath, newExecuteCommandsHandler(deps))
|
||||
mux.HandleFunc(http.MethodPost+" "+putOrdersPath, newPutOrdersHandler(deps))
|
||||
mux.HandleFunc(http.MethodGet+" "+getReportPath, newGetReportHandler(deps))
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/gamemaster/internal/api/internalhttp/handlers"
|
||||
"galaxy/gamemaster/internal/api/internalhttp/handlers/mocks"
|
||||
"galaxy/gamemaster/internal/domain/engineversion"
|
||||
"galaxy/gamemaster/internal/domain/operation"
|
||||
"galaxy/gamemaster/internal/domain/runtime"
|
||||
"galaxy/gamemaster/internal/service/adminstop"
|
||||
"galaxy/gamemaster/internal/service/commandexecute"
|
||||
engineversionsvc "galaxy/gamemaster/internal/service/engineversion"
|
||||
"galaxy/gamemaster/internal/service/livenessreply"
|
||||
"galaxy/gamemaster/internal/service/registerruntime"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// driveHandler builds a fresh ServeMux + handler set bound to deps,
|
||||
// fires one request, and returns the recorder.
|
||||
func driveHandler(t *testing.T, deps handlers.Dependencies, method, path string, body io.Reader, headers map[string]string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
handlers.Register(mux, deps)
|
||||
request := httptest.NewRequest(method, path, body)
|
||||
for key, value := range headers {
|
||||
request.Header.Set(key, value)
|
||||
}
|
||||
if body != nil {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recorder, request)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func decodeErrorBody(t *testing.T, recorder *httptest.ResponseRecorder) (string, string) {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body))
|
||||
return body.Error.Code, body.Error.Message
|
||||
}
|
||||
|
||||
func TestRegisterRuntimeHandlerHappyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
|
||||
moment := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
|
||||
record := runtime.RuntimeRecord{
|
||||
GameID: "game-1",
|
||||
Status: runtime.StatusRunning,
|
||||
EngineEndpoint: "http://engine:8080",
|
||||
CurrentImageRef: "galaxy/game:1.2.3",
|
||||
CurrentEngineVersion: "1.2.3",
|
||||
TurnSchedule: "0 18 * * *",
|
||||
CreatedAt: moment,
|
||||
UpdatedAt: moment,
|
||||
}
|
||||
|
||||
registerSvc := mocks.NewMockRegisterRuntimeService(ctrl)
|
||||
registerSvc.EXPECT().
|
||||
Handle(gomock.Any(), gomock.AssignableToTypeOf(registerruntime.Input{})).
|
||||
DoAndReturn(func(_ context.Context, in registerruntime.Input) (registerruntime.Result, error) {
|
||||
assert.Equal(t, "game-1", in.GameID)
|
||||
assert.Equal(t, "http://engine:8080", in.EngineEndpoint)
|
||||
assert.Equal(t, operation.OpSourceLobbyInternal, in.OpSource)
|
||||
require.Len(t, in.Members, 1)
|
||||
return registerruntime.Result{Record: record, Outcome: operation.OutcomeSuccess}, nil
|
||||
})
|
||||
|
||||
body := strings.NewReader(`{
|
||||
"engine_endpoint": "http://engine:8080",
|
||||
"members": [{"user_id":"u1","race_name":"Aelinari"}],
|
||||
"target_engine_version": "1.2.3",
|
||||
"turn_schedule": "0 18 * * *"
|
||||
}`)
|
||||
recorder := driveHandler(t,
|
||||
handlers.Dependencies{RegisterRuntime: registerSvc},
|
||||
http.MethodPost,
|
||||
"/api/v1/internal/games/game-1/register-runtime",
|
||||
body,
|
||||
map[string]string{"X-Galaxy-Caller": "lobby"},
|
||||
)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
assert.Contains(t, recorder.Body.String(), `"game_id":"game-1"`)
|
||||
}
|
||||
|
||||
func TestRegisterRuntimeHandlerRejectsUnknownFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
registerSvc := mocks.NewMockRegisterRuntimeService(ctrl)
|
||||
// no expectations — handler must short-circuit before calling.
|
||||
|
||||
body := strings.NewReader(`{"engine_endpoint":"http://e","extra":1}`)
|
||||
recorder := driveHandler(t,
|
||||
handlers.Dependencies{RegisterRuntime: registerSvc},
|
||||
http.MethodPost,
|
||||
"/api/v1/internal/games/game-1/register-runtime",
|
||||
body,
|
||||
nil,
|
||||
)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
code, _ := decodeErrorBody(t, recorder)
|
||||
assert.Equal(t, "invalid_request", code)
|
||||
}
|
||||
|
||||
func TestRegisterRuntimeHandlerWiresFailureCodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
errCode string
|
||||
wantStatus int
|
||||
}{
|
||||
{"invalid_request", registerruntime.ErrorCodeInvalidRequest, http.StatusBadRequest},
|
||||
{"conflict", registerruntime.ErrorCodeConflict, http.StatusConflict},
|
||||
{"engine_version_not_found", registerruntime.ErrorCodeEngineVersionNotFound, http.StatusNotFound},
|
||||
{"engine_unreachable", registerruntime.ErrorCodeEngineUnreachable, http.StatusBadGateway},
|
||||
{"service_unavailable", registerruntime.ErrorCodeServiceUnavailable, http.StatusServiceUnavailable},
|
||||
{"internal_error", registerruntime.ErrorCodeInternal, http.StatusInternalServerError},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
svc := mocks.NewMockRegisterRuntimeService(ctrl)
|
||||
svc.EXPECT().
|
||||
Handle(gomock.Any(), gomock.Any()).
|
||||
Return(registerruntime.Result{
|
||||
Outcome: operation.OutcomeFailure,
|
||||
ErrorCode: tc.errCode,
|
||||
ErrorMessage: tc.errCode + " details",
|
||||
}, nil)
|
||||
|
||||
body := strings.NewReader(`{
|
||||
"engine_endpoint": "http://e",
|
||||
"members":[{"user_id":"u1","race_name":"r"}],
|
||||
"target_engine_version":"1.0.0",
|
||||
"turn_schedule":"* * * * *"
|
||||
}`)
|
||||
recorder := driveHandler(t,
|
||||
handlers.Dependencies{RegisterRuntime: svc},
|
||||
http.MethodPost,
|
||||
"/api/v1/internal/games/game-1/register-runtime",
|
||||
body,
|
||||
nil,
|
||||
)
|
||||
|
||||
assert.Equal(t, tc.wantStatus, recorder.Code)
|
||||
code, _ := decodeErrorBody(t, recorder)
|
||||
assert.Equal(t, tc.errCode, code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRuntimeHandlerNilServiceReturns500(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := strings.NewReader(`{"engine_endpoint":"http://e"}`)
|
||||
recorder := driveHandler(t,
|
||||
handlers.Dependencies{},
|
||||
http.MethodPost,
|
||||
"/api/v1/internal/games/game-1/register-runtime",
|
||||
body,
|
||||
nil,
|
||||
)
|
||||
require.Equal(t, http.StatusInternalServerError, recorder.Code)
|
||||
code, _ := decodeErrorBody(t, recorder)
|
||||
assert.Equal(t, "internal_error", code)
|
||||
}
|
||||
|
||||
func TestStopRuntimeHandlerForwardsReason(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
|
||||
moment := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
|
||||
record := runtime.RuntimeRecord{
|
||||
GameID: "game-1",
|
||||
Status: runtime.StatusStopped,
|
||||
EngineEndpoint: "http://engine:8080",
|
||||
CurrentImageRef: "galaxy/game:1.2.3",
|
||||
CurrentEngineVersion: "1.2.3",
|
||||
TurnSchedule: "0 18 * * *",
|
||||
CreatedAt: moment,
|
||||
UpdatedAt: moment,
|
||||
}
|
||||
|
||||
stopSvc := mocks.NewMockStopRuntimeService(ctrl)
|
||||
stopSvc.EXPECT().
|
||||
Handle(gomock.Any(), gomock.AssignableToTypeOf(adminstop.Input{})).
|
||||
DoAndReturn(func(_ context.Context, in adminstop.Input) (adminstop.Result, error) {
|
||||
assert.Equal(t, "admin_request", in.Reason)
|
||||
return adminstop.Result{Record: record, Outcome: operation.OutcomeSuccess}, nil
|
||||
})
|
||||
|
||||
body := strings.NewReader(`{"reason":"admin_request"}`)
|
||||
recorder := driveHandler(t,
|
||||
handlers.Dependencies{StopRuntime: stopSvc},
|
||||
http.MethodPost,
|
||||
"/api/v1/internal/runtimes/game-1/stop",
|
||||
body,
|
||||
nil,
|
||||
)
|
||||
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
func TestGetEngineVersionHandlerMapsNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
svc := mocks.NewMockEngineVersionService(ctrl)
|
||||
svc.EXPECT().
|
||||
Get(gomock.Any(), "9.9.9").
|
||||
Return(engineversion.EngineVersion{}, engineversionsvc.ErrNotFound)
|
||||
|
||||
recorder := driveHandler(t,
|
||||
handlers.Dependencies{EngineVersions: svc},
|
||||
http.MethodGet,
|
||||
"/api/v1/internal/engine-versions/9.9.9",
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, recorder.Code)
|
||||
code, _ := decodeErrorBody(t, recorder)
|
||||
assert.Equal(t, "engine_version_not_found", code)
|
||||
}
|
||||
|
||||
func TestListEngineVersionsHandlerRejectsUnknownStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
svc := mocks.NewMockEngineVersionService(ctrl)
|
||||
// no expectations — short-circuits.
|
||||
|
||||
recorder := driveHandler(t,
|
||||
handlers.Dependencies{EngineVersions: svc},
|
||||
http.MethodGet,
|
||||
"/api/v1/internal/engine-versions?status=mystery",
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
code, _ := decodeErrorBody(t, recorder)
|
||||
assert.Equal(t, "invalid_request", code)
|
||||
}
|
||||
|
||||
func TestDeprecateEngineVersionReturns204(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
svc := mocks.NewMockEngineVersionService(ctrl)
|
||||
svc.EXPECT().
|
||||
Deprecate(gomock.Any(), gomock.AssignableToTypeOf(engineversionsvc.DeprecateInput{})).
|
||||
Return(nil)
|
||||
|
||||
recorder := driveHandler(t,
|
||||
handlers.Dependencies{EngineVersions: svc},
|
||||
http.MethodDelete,
|
||||
"/api/v1/internal/engine-versions/1.0.0",
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
assert.Equal(t, http.StatusNoContent, recorder.Code)
|
||||
assert.Empty(t, recorder.Body.String())
|
||||
}
|
||||
|
||||
func TestDeprecateEngineVersionDoesNotReportInUse(t *testing.T) {
|
||||
t.Parallel()
|
||||
// D2: the DELETE endpoint flips status; the handler does not call
|
||||
// Service.Delete and therefore can never produce
|
||||
// `engine_version_in_use`. Deprecate's own error vocabulary is
|
||||
// limited to invalid_request / not_found / service_unavailable.
|
||||
ctrl := gomock.NewController(t)
|
||||
svc := mocks.NewMockEngineVersionService(ctrl)
|
||||
svc.EXPECT().
|
||||
Deprecate(gomock.Any(), gomock.Any()).
|
||||
Return(engineversionsvc.ErrNotFound)
|
||||
|
||||
recorder := driveHandler(t,
|
||||
handlers.Dependencies{EngineVersions: svc},
|
||||
http.MethodDelete,
|
||||
"/api/v1/internal/engine-versions/9.9.9",
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
assert.Equal(t, http.StatusNotFound, recorder.Code)
|
||||
}
|
||||
|
||||
func TestExecuteCommandsRequiresUserIDHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
svc := mocks.NewMockCommandExecuteService(ctrl)
|
||||
// short-circuit before service is touched.
|
||||
|
||||
recorder := driveHandler(t,
|
||||
handlers.Dependencies{CommandExecute: svc},
|
||||
http.MethodPost,
|
||||
"/api/v1/internal/games/game-1/commands",
|
||||
strings.NewReader(`{"commands":[]}`),
|
||||
nil,
|
||||
)
|
||||
assert.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
code, msg := decodeErrorBody(t, recorder)
|
||||
assert.Equal(t, "invalid_request", code)
|
||||
assert.Contains(t, msg, "X-User-ID")
|
||||
}
|
||||
|
||||
func TestExecuteCommandsRejectsInvalidJSONBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
svc := mocks.NewMockCommandExecuteService(ctrl)
|
||||
|
||||
recorder := driveHandler(t,
|
||||
handlers.Dependencies{CommandExecute: svc},
|
||||
http.MethodPost,
|
||||
"/api/v1/internal/games/game-1/commands",
|
||||
strings.NewReader("not json"),
|
||||
map[string]string{"X-User-ID": "u1"},
|
||||
)
|
||||
assert.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
code, _ := decodeErrorBody(t, recorder)
|
||||
assert.Equal(t, "invalid_request", code)
|
||||
}
|
||||
|
||||
func TestExecuteCommandsForwardsRawResponseOnSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
svc := mocks.NewMockCommandExecuteService(ctrl)
|
||||
svc.EXPECT().
|
||||
Handle(gomock.Any(), gomock.AssignableToTypeOf(commandexecute.Input{})).
|
||||
DoAndReturn(func(_ context.Context, in commandexecute.Input) (commandexecute.Result, error) {
|
||||
assert.Equal(t, "game-1", in.GameID)
|
||||
assert.Equal(t, "u1", in.UserID)
|
||||
assert.JSONEq(t, `{"commands":[{"name":"build"}]}`, string(in.Payload))
|
||||
return commandexecute.Result{
|
||||
Outcome: operation.OutcomeSuccess,
|
||||
RawResponse: []byte(`{"results":[{"ok":true}]}`),
|
||||
}, nil
|
||||
})
|
||||
|
||||
recorder := driveHandler(t,
|
||||
handlers.Dependencies{CommandExecute: svc},
|
||||
http.MethodPost,
|
||||
"/api/v1/internal/games/game-1/commands",
|
||||
strings.NewReader(`{"commands":[{"name":"build"}]}`),
|
||||
map[string]string{"X-User-ID": "u1"},
|
||||
)
|
||||
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
assert.JSONEq(t, `{"results":[{"ok":true}]}`, recorder.Body.String())
|
||||
}
|
||||
|
||||
func TestInvalidateMembershipsAlwaysReturns204(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
cache := mocks.NewMockMembershipInvalidator(ctrl)
|
||||
cache.EXPECT().Invalidate("game-7").Times(1)
|
||||
|
||||
recorder := driveHandler(t,
|
||||
handlers.Dependencies{InvalidateMemberships: cache},
|
||||
http.MethodPost,
|
||||
"/api/v1/internal/games/game-7/memberships/invalidate",
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
assert.Equal(t, http.StatusNoContent, recorder.Code)
|
||||
}
|
||||
|
||||
func TestGameLivenessHandlerMapsServiceUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
svc := mocks.NewMockLivenessService(ctrl)
|
||||
svc.EXPECT().
|
||||
Handle(gomock.Any(), livenessreply.Input{GameID: "game-1"}).
|
||||
Return(livenessreply.Result{}, errors.New(livenessreply.ErrorCodeServiceUnavailable+": store ping"))
|
||||
|
||||
recorder := driveHandler(t,
|
||||
handlers.Dependencies{GameLiveness: svc},
|
||||
http.MethodGet,
|
||||
"/api/v1/internal/games/game-1/liveness",
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
assert.Equal(t, http.StatusServiceUnavailable, recorder.Code)
|
||||
code, _ := decodeErrorBody(t, recorder)
|
||||
assert.Equal(t, "service_unavailable", code)
|
||||
}
|
||||
|
||||
func TestGetReportRejectsNegativeTurn(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctrl := gomock.NewController(t)
|
||||
svc := mocks.NewMockReportGetService(ctrl)
|
||||
// short-circuits.
|
||||
|
||||
recorder := driveHandler(t,
|
||||
handlers.Dependencies{GetReport: svc},
|
||||
http.MethodGet,
|
||||
"/api/v1/internal/games/game-1/reports/-3",
|
||||
nil,
|
||||
map[string]string{"X-User-ID": "u1"},
|
||||
)
|
||||
assert.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
code, _ := decodeErrorBody(t, recorder)
|
||||
assert.Equal(t, "invalid_request", code)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package handlers
|
||||
|
||||
import "net/http"
|
||||
|
||||
// newInvalidateMembershipsHandler returns the handler for
|
||||
// `POST /api/v1/internal/games/{game_id}/memberships/invalidate`. The
|
||||
// underlying cache invalidation is a fire-and-forget local operation,
|
||||
// so the handler always responds with `204 No Content` once the path
|
||||
// parameter validates.
|
||||
func newInvalidateMembershipsHandler(deps Dependencies) http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.InvalidateMemberships == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "membership cache invalidator is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
gameID, ok := extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
deps.InvalidateMemberships.Invalidate(gameID)
|
||||
writeNoContent(writer)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"galaxy/gamemaster/internal/domain/engineversion"
|
||||
)
|
||||
|
||||
// newListEngineVersionsHandler returns the handler for
|
||||
// `GET /api/v1/internal/engine-versions`. The optional `status`
|
||||
// query parameter narrows the result.
|
||||
func newListEngineVersionsHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.list_engine_versions")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.EngineVersions == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "engine version service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
var statusFilter *engineversion.Status
|
||||
raw := strings.TrimSpace(request.URL.Query().Get("status"))
|
||||
if raw != "" {
|
||||
candidate := engineversion.Status(raw)
|
||||
if !candidate.IsKnown() {
|
||||
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, "status query parameter is unsupported")
|
||||
return
|
||||
}
|
||||
statusFilter = &candidate
|
||||
}
|
||||
|
||||
versions, err := deps.EngineVersions.List(request.Context(), statusFilter)
|
||||
if err != nil {
|
||||
logger.ErrorContext(request.Context(), "list engine versions failed", "err", err.Error())
|
||||
status, code, message := mapServiceError(err)
|
||||
writeError(writer, status, code, message)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeEngineVersionList(versions))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"galaxy/gamemaster/internal/domain/runtime"
|
||||
)
|
||||
|
||||
// newListRuntimesHandler returns the handler for
|
||||
// `GET /api/v1/internal/runtimes`. The optional `status` query
|
||||
// parameter narrows the result; an unknown value short-circuits with
|
||||
// `400 invalid_request`. Records are returned ordered by
|
||||
// `created_at DESC` (the underlying store guarantees the ordering).
|
||||
func newListRuntimesHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.list_runtimes")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.RuntimeRecords == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "runtime records store is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := request.Context()
|
||||
|
||||
raw := strings.TrimSpace(request.URL.Query().Get("status"))
|
||||
if raw == "" {
|
||||
records, err := deps.RuntimeRecords.List(ctx)
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, "list runtime records failed", "err", err.Error())
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "failed to list runtime records")
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, encodeRuntimeList(records))
|
||||
return
|
||||
}
|
||||
|
||||
status := runtime.Status(raw)
|
||||
if !status.IsKnown() {
|
||||
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, "status query parameter is unsupported")
|
||||
return
|
||||
}
|
||||
|
||||
records, err := deps.RuntimeRecords.ListByStatus(ctx, status)
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, "list runtime records by status failed",
|
||||
"status", string(status),
|
||||
"err", err.Error(),
|
||||
)
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "failed to list runtime records")
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, encodeRuntimeList(records))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: galaxy/gamemaster/internal/api/internalhttp/handlers (interfaces: RegisterRuntimeService,ForceNextTurnService,StopRuntimeService,PatchRuntimeService,BanishRaceService,LivenessService,CommandExecuteService,OrderPutService,ReportGetService,MembershipInvalidator,EngineVersionService,RuntimeRecordsReader)
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -destination=./mocks/mock_services.go -package=mocks galaxy/gamemaster/internal/api/internalhttp/handlers RegisterRuntimeService,ForceNextTurnService,StopRuntimeService,PatchRuntimeService,BanishRaceService,LivenessService,CommandExecuteService,OrderPutService,ReportGetService,MembershipInvalidator,EngineVersionService,RuntimeRecordsReader
|
||||
//
|
||||
|
||||
// Package mocks is a generated GoMock package.
|
||||
package mocks
|
||||
|
||||
import (
|
||||
context "context"
|
||||
engineversion "galaxy/gamemaster/internal/domain/engineversion"
|
||||
runtime "galaxy/gamemaster/internal/domain/runtime"
|
||||
adminbanish "galaxy/gamemaster/internal/service/adminbanish"
|
||||
adminforce "galaxy/gamemaster/internal/service/adminforce"
|
||||
adminpatch "galaxy/gamemaster/internal/service/adminpatch"
|
||||
adminstop "galaxy/gamemaster/internal/service/adminstop"
|
||||
commandexecute "galaxy/gamemaster/internal/service/commandexecute"
|
||||
engineversion0 "galaxy/gamemaster/internal/service/engineversion"
|
||||
livenessreply "galaxy/gamemaster/internal/service/livenessreply"
|
||||
orderput "galaxy/gamemaster/internal/service/orderput"
|
||||
registerruntime "galaxy/gamemaster/internal/service/registerruntime"
|
||||
reportget "galaxy/gamemaster/internal/service/reportget"
|
||||
reflect "reflect"
|
||||
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// MockRegisterRuntimeService is a mock of RegisterRuntimeService interface.
|
||||
type MockRegisterRuntimeService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockRegisterRuntimeServiceMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockRegisterRuntimeServiceMockRecorder is the mock recorder for MockRegisterRuntimeService.
|
||||
type MockRegisterRuntimeServiceMockRecorder struct {
|
||||
mock *MockRegisterRuntimeService
|
||||
}
|
||||
|
||||
// NewMockRegisterRuntimeService creates a new mock instance.
|
||||
func NewMockRegisterRuntimeService(ctrl *gomock.Controller) *MockRegisterRuntimeService {
|
||||
mock := &MockRegisterRuntimeService{ctrl: ctrl}
|
||||
mock.recorder = &MockRegisterRuntimeServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockRegisterRuntimeService) EXPECT() *MockRegisterRuntimeServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Handle mocks base method.
|
||||
func (m *MockRegisterRuntimeService) Handle(ctx context.Context, in registerruntime.Input) (registerruntime.Result, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Handle", ctx, in)
|
||||
ret0, _ := ret[0].(registerruntime.Result)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Handle indicates an expected call of Handle.
|
||||
func (mr *MockRegisterRuntimeServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockRegisterRuntimeService)(nil).Handle), ctx, in)
|
||||
}
|
||||
|
||||
// MockForceNextTurnService is a mock of ForceNextTurnService interface.
|
||||
type MockForceNextTurnService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockForceNextTurnServiceMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockForceNextTurnServiceMockRecorder is the mock recorder for MockForceNextTurnService.
|
||||
type MockForceNextTurnServiceMockRecorder struct {
|
||||
mock *MockForceNextTurnService
|
||||
}
|
||||
|
||||
// NewMockForceNextTurnService creates a new mock instance.
|
||||
func NewMockForceNextTurnService(ctrl *gomock.Controller) *MockForceNextTurnService {
|
||||
mock := &MockForceNextTurnService{ctrl: ctrl}
|
||||
mock.recorder = &MockForceNextTurnServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockForceNextTurnService) EXPECT() *MockForceNextTurnServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Handle mocks base method.
|
||||
func (m *MockForceNextTurnService) Handle(ctx context.Context, in adminforce.Input) (adminforce.Result, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Handle", ctx, in)
|
||||
ret0, _ := ret[0].(adminforce.Result)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Handle indicates an expected call of Handle.
|
||||
func (mr *MockForceNextTurnServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockForceNextTurnService)(nil).Handle), ctx, in)
|
||||
}
|
||||
|
||||
// MockStopRuntimeService is a mock of StopRuntimeService interface.
|
||||
type MockStopRuntimeService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockStopRuntimeServiceMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockStopRuntimeServiceMockRecorder is the mock recorder for MockStopRuntimeService.
|
||||
type MockStopRuntimeServiceMockRecorder struct {
|
||||
mock *MockStopRuntimeService
|
||||
}
|
||||
|
||||
// NewMockStopRuntimeService creates a new mock instance.
|
||||
func NewMockStopRuntimeService(ctrl *gomock.Controller) *MockStopRuntimeService {
|
||||
mock := &MockStopRuntimeService{ctrl: ctrl}
|
||||
mock.recorder = &MockStopRuntimeServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockStopRuntimeService) EXPECT() *MockStopRuntimeServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Handle mocks base method.
|
||||
func (m *MockStopRuntimeService) Handle(ctx context.Context, in adminstop.Input) (adminstop.Result, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Handle", ctx, in)
|
||||
ret0, _ := ret[0].(adminstop.Result)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Handle indicates an expected call of Handle.
|
||||
func (mr *MockStopRuntimeServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockStopRuntimeService)(nil).Handle), ctx, in)
|
||||
}
|
||||
|
||||
// MockPatchRuntimeService is a mock of PatchRuntimeService interface.
|
||||
type MockPatchRuntimeService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockPatchRuntimeServiceMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockPatchRuntimeServiceMockRecorder is the mock recorder for MockPatchRuntimeService.
|
||||
type MockPatchRuntimeServiceMockRecorder struct {
|
||||
mock *MockPatchRuntimeService
|
||||
}
|
||||
|
||||
// NewMockPatchRuntimeService creates a new mock instance.
|
||||
func NewMockPatchRuntimeService(ctrl *gomock.Controller) *MockPatchRuntimeService {
|
||||
mock := &MockPatchRuntimeService{ctrl: ctrl}
|
||||
mock.recorder = &MockPatchRuntimeServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockPatchRuntimeService) EXPECT() *MockPatchRuntimeServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Handle mocks base method.
|
||||
func (m *MockPatchRuntimeService) Handle(ctx context.Context, in adminpatch.Input) (adminpatch.Result, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Handle", ctx, in)
|
||||
ret0, _ := ret[0].(adminpatch.Result)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Handle indicates an expected call of Handle.
|
||||
func (mr *MockPatchRuntimeServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockPatchRuntimeService)(nil).Handle), ctx, in)
|
||||
}
|
||||
|
||||
// MockBanishRaceService is a mock of BanishRaceService interface.
|
||||
type MockBanishRaceService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockBanishRaceServiceMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockBanishRaceServiceMockRecorder is the mock recorder for MockBanishRaceService.
|
||||
type MockBanishRaceServiceMockRecorder struct {
|
||||
mock *MockBanishRaceService
|
||||
}
|
||||
|
||||
// NewMockBanishRaceService creates a new mock instance.
|
||||
func NewMockBanishRaceService(ctrl *gomock.Controller) *MockBanishRaceService {
|
||||
mock := &MockBanishRaceService{ctrl: ctrl}
|
||||
mock.recorder = &MockBanishRaceServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockBanishRaceService) EXPECT() *MockBanishRaceServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Handle mocks base method.
|
||||
func (m *MockBanishRaceService) Handle(ctx context.Context, in adminbanish.Input) (adminbanish.Result, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Handle", ctx, in)
|
||||
ret0, _ := ret[0].(adminbanish.Result)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Handle indicates an expected call of Handle.
|
||||
func (mr *MockBanishRaceServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockBanishRaceService)(nil).Handle), ctx, in)
|
||||
}
|
||||
|
||||
// MockLivenessService is a mock of LivenessService interface.
|
||||
type MockLivenessService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockLivenessServiceMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockLivenessServiceMockRecorder is the mock recorder for MockLivenessService.
|
||||
type MockLivenessServiceMockRecorder struct {
|
||||
mock *MockLivenessService
|
||||
}
|
||||
|
||||
// NewMockLivenessService creates a new mock instance.
|
||||
func NewMockLivenessService(ctrl *gomock.Controller) *MockLivenessService {
|
||||
mock := &MockLivenessService{ctrl: ctrl}
|
||||
mock.recorder = &MockLivenessServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockLivenessService) EXPECT() *MockLivenessServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Handle mocks base method.
|
||||
func (m *MockLivenessService) Handle(ctx context.Context, in livenessreply.Input) (livenessreply.Result, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Handle", ctx, in)
|
||||
ret0, _ := ret[0].(livenessreply.Result)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Handle indicates an expected call of Handle.
|
||||
func (mr *MockLivenessServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockLivenessService)(nil).Handle), ctx, in)
|
||||
}
|
||||
|
||||
// MockCommandExecuteService is a mock of CommandExecuteService interface.
|
||||
type MockCommandExecuteService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockCommandExecuteServiceMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockCommandExecuteServiceMockRecorder is the mock recorder for MockCommandExecuteService.
|
||||
type MockCommandExecuteServiceMockRecorder struct {
|
||||
mock *MockCommandExecuteService
|
||||
}
|
||||
|
||||
// NewMockCommandExecuteService creates a new mock instance.
|
||||
func NewMockCommandExecuteService(ctrl *gomock.Controller) *MockCommandExecuteService {
|
||||
mock := &MockCommandExecuteService{ctrl: ctrl}
|
||||
mock.recorder = &MockCommandExecuteServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockCommandExecuteService) EXPECT() *MockCommandExecuteServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Handle mocks base method.
|
||||
func (m *MockCommandExecuteService) Handle(ctx context.Context, in commandexecute.Input) (commandexecute.Result, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Handle", ctx, in)
|
||||
ret0, _ := ret[0].(commandexecute.Result)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Handle indicates an expected call of Handle.
|
||||
func (mr *MockCommandExecuteServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockCommandExecuteService)(nil).Handle), ctx, in)
|
||||
}
|
||||
|
||||
// MockOrderPutService is a mock of OrderPutService interface.
|
||||
type MockOrderPutService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockOrderPutServiceMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockOrderPutServiceMockRecorder is the mock recorder for MockOrderPutService.
|
||||
type MockOrderPutServiceMockRecorder struct {
|
||||
mock *MockOrderPutService
|
||||
}
|
||||
|
||||
// NewMockOrderPutService creates a new mock instance.
|
||||
func NewMockOrderPutService(ctrl *gomock.Controller) *MockOrderPutService {
|
||||
mock := &MockOrderPutService{ctrl: ctrl}
|
||||
mock.recorder = &MockOrderPutServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockOrderPutService) EXPECT() *MockOrderPutServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Handle mocks base method.
|
||||
func (m *MockOrderPutService) Handle(ctx context.Context, in orderput.Input) (orderput.Result, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Handle", ctx, in)
|
||||
ret0, _ := ret[0].(orderput.Result)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Handle indicates an expected call of Handle.
|
||||
func (mr *MockOrderPutServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockOrderPutService)(nil).Handle), ctx, in)
|
||||
}
|
||||
|
||||
// MockReportGetService is a mock of ReportGetService interface.
|
||||
type MockReportGetService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockReportGetServiceMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockReportGetServiceMockRecorder is the mock recorder for MockReportGetService.
|
||||
type MockReportGetServiceMockRecorder struct {
|
||||
mock *MockReportGetService
|
||||
}
|
||||
|
||||
// NewMockReportGetService creates a new mock instance.
|
||||
func NewMockReportGetService(ctrl *gomock.Controller) *MockReportGetService {
|
||||
mock := &MockReportGetService{ctrl: ctrl}
|
||||
mock.recorder = &MockReportGetServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockReportGetService) EXPECT() *MockReportGetServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Handle mocks base method.
|
||||
func (m *MockReportGetService) Handle(ctx context.Context, in reportget.Input) (reportget.Result, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Handle", ctx, in)
|
||||
ret0, _ := ret[0].(reportget.Result)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Handle indicates an expected call of Handle.
|
||||
func (mr *MockReportGetServiceMockRecorder) Handle(ctx, in any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Handle", reflect.TypeOf((*MockReportGetService)(nil).Handle), ctx, in)
|
||||
}
|
||||
|
||||
// MockMembershipInvalidator is a mock of MembershipInvalidator interface.
|
||||
type MockMembershipInvalidator struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockMembershipInvalidatorMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockMembershipInvalidatorMockRecorder is the mock recorder for MockMembershipInvalidator.
|
||||
type MockMembershipInvalidatorMockRecorder struct {
|
||||
mock *MockMembershipInvalidator
|
||||
}
|
||||
|
||||
// NewMockMembershipInvalidator creates a new mock instance.
|
||||
func NewMockMembershipInvalidator(ctrl *gomock.Controller) *MockMembershipInvalidator {
|
||||
mock := &MockMembershipInvalidator{ctrl: ctrl}
|
||||
mock.recorder = &MockMembershipInvalidatorMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockMembershipInvalidator) EXPECT() *MockMembershipInvalidatorMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Invalidate mocks base method.
|
||||
func (m *MockMembershipInvalidator) Invalidate(gameID string) {
|
||||
m.ctrl.T.Helper()
|
||||
m.ctrl.Call(m, "Invalidate", gameID)
|
||||
}
|
||||
|
||||
// Invalidate indicates an expected call of Invalidate.
|
||||
func (mr *MockMembershipInvalidatorMockRecorder) Invalidate(gameID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Invalidate", reflect.TypeOf((*MockMembershipInvalidator)(nil).Invalidate), gameID)
|
||||
}
|
||||
|
||||
// MockEngineVersionService is a mock of EngineVersionService interface.
|
||||
type MockEngineVersionService struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockEngineVersionServiceMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockEngineVersionServiceMockRecorder is the mock recorder for MockEngineVersionService.
|
||||
type MockEngineVersionServiceMockRecorder struct {
|
||||
mock *MockEngineVersionService
|
||||
}
|
||||
|
||||
// NewMockEngineVersionService creates a new mock instance.
|
||||
func NewMockEngineVersionService(ctrl *gomock.Controller) *MockEngineVersionService {
|
||||
mock := &MockEngineVersionService{ctrl: ctrl}
|
||||
mock.recorder = &MockEngineVersionServiceMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockEngineVersionService) EXPECT() *MockEngineVersionServiceMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Create mocks base method.
|
||||
func (m *MockEngineVersionService) Create(ctx context.Context, in engineversion0.CreateInput) (engineversion.EngineVersion, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Create", ctx, in)
|
||||
ret0, _ := ret[0].(engineversion.EngineVersion)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Create indicates an expected call of Create.
|
||||
func (mr *MockEngineVersionServiceMockRecorder) Create(ctx, in any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockEngineVersionService)(nil).Create), ctx, in)
|
||||
}
|
||||
|
||||
// Deprecate mocks base method.
|
||||
func (m *MockEngineVersionService) Deprecate(ctx context.Context, in engineversion0.DeprecateInput) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Deprecate", ctx, in)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// Deprecate indicates an expected call of Deprecate.
|
||||
func (mr *MockEngineVersionServiceMockRecorder) Deprecate(ctx, in any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Deprecate", reflect.TypeOf((*MockEngineVersionService)(nil).Deprecate), ctx, in)
|
||||
}
|
||||
|
||||
// Get mocks base method.
|
||||
func (m *MockEngineVersionService) Get(ctx context.Context, version string) (engineversion.EngineVersion, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Get", ctx, version)
|
||||
ret0, _ := ret[0].(engineversion.EngineVersion)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Get indicates an expected call of Get.
|
||||
func (mr *MockEngineVersionServiceMockRecorder) Get(ctx, version any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockEngineVersionService)(nil).Get), ctx, version)
|
||||
}
|
||||
|
||||
// List mocks base method.
|
||||
func (m *MockEngineVersionService) List(ctx context.Context, statusFilter *engineversion.Status) ([]engineversion.EngineVersion, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "List", ctx, statusFilter)
|
||||
ret0, _ := ret[0].([]engineversion.EngineVersion)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// List indicates an expected call of List.
|
||||
func (mr *MockEngineVersionServiceMockRecorder) List(ctx, statusFilter any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockEngineVersionService)(nil).List), ctx, statusFilter)
|
||||
}
|
||||
|
||||
// ResolveImageRef mocks base method.
|
||||
func (m *MockEngineVersionService) ResolveImageRef(ctx context.Context, version string) (string, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ResolveImageRef", ctx, version)
|
||||
ret0, _ := ret[0].(string)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// ResolveImageRef indicates an expected call of ResolveImageRef.
|
||||
func (mr *MockEngineVersionServiceMockRecorder) ResolveImageRef(ctx, version any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveImageRef", reflect.TypeOf((*MockEngineVersionService)(nil).ResolveImageRef), ctx, version)
|
||||
}
|
||||
|
||||
// Update mocks base method.
|
||||
func (m *MockEngineVersionService) Update(ctx context.Context, in engineversion0.UpdateInput) (engineversion.EngineVersion, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Update", ctx, in)
|
||||
ret0, _ := ret[0].(engineversion.EngineVersion)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Update indicates an expected call of Update.
|
||||
func (mr *MockEngineVersionServiceMockRecorder) Update(ctx, in any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockEngineVersionService)(nil).Update), ctx, in)
|
||||
}
|
||||
|
||||
// MockRuntimeRecordsReader is a mock of RuntimeRecordsReader interface.
|
||||
type MockRuntimeRecordsReader struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockRuntimeRecordsReaderMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockRuntimeRecordsReaderMockRecorder is the mock recorder for MockRuntimeRecordsReader.
|
||||
type MockRuntimeRecordsReaderMockRecorder struct {
|
||||
mock *MockRuntimeRecordsReader
|
||||
}
|
||||
|
||||
// NewMockRuntimeRecordsReader creates a new mock instance.
|
||||
func NewMockRuntimeRecordsReader(ctrl *gomock.Controller) *MockRuntimeRecordsReader {
|
||||
mock := &MockRuntimeRecordsReader{ctrl: ctrl}
|
||||
mock.recorder = &MockRuntimeRecordsReaderMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockRuntimeRecordsReader) EXPECT() *MockRuntimeRecordsReaderMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Get mocks base method.
|
||||
func (m *MockRuntimeRecordsReader) Get(ctx context.Context, gameID string) (runtime.RuntimeRecord, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Get", ctx, gameID)
|
||||
ret0, _ := ret[0].(runtime.RuntimeRecord)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// Get indicates an expected call of Get.
|
||||
func (mr *MockRuntimeRecordsReaderMockRecorder) Get(ctx, gameID any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockRuntimeRecordsReader)(nil).Get), ctx, gameID)
|
||||
}
|
||||
|
||||
// List mocks base method.
|
||||
func (m *MockRuntimeRecordsReader) List(ctx context.Context) ([]runtime.RuntimeRecord, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "List", ctx)
|
||||
ret0, _ := ret[0].([]runtime.RuntimeRecord)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// List indicates an expected call of List.
|
||||
func (mr *MockRuntimeRecordsReaderMockRecorder) List(ctx any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockRuntimeRecordsReader)(nil).List), ctx)
|
||||
}
|
||||
|
||||
// ListByStatus mocks base method.
|
||||
func (m *MockRuntimeRecordsReader) ListByStatus(ctx context.Context, status runtime.Status) ([]runtime.RuntimeRecord, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ListByStatus", ctx, status)
|
||||
ret0, _ := ret[0].([]runtime.RuntimeRecord)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// ListByStatus indicates an expected call of ListByStatus.
|
||||
func (mr *MockRuntimeRecordsReaderMockRecorder) ListByStatus(ctx, status any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByStatus", reflect.TypeOf((*MockRuntimeRecordsReader)(nil).ListByStatus), ctx, status)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"galaxy/gamemaster/internal/domain/operation"
|
||||
"galaxy/gamemaster/internal/service/adminpatch"
|
||||
)
|
||||
|
||||
// patchRuntimeRequestBody mirrors the OpenAPI PatchRuntimeRequest
|
||||
// schema.
|
||||
type patchRuntimeRequestBody struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// newPatchRuntimeHandler returns the handler for
|
||||
// `POST /api/v1/internal/runtimes/{game_id}/patch`.
|
||||
func newPatchRuntimeHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.patch_runtime")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.PatchRuntime == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "patch runtime service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
gameID, ok := extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var body patchRuntimeRequestBody
|
||||
if err := decodeStrictJSON(request.Body, &body); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result, err := deps.PatchRuntime.Handle(request.Context(), adminpatch.Input{
|
||||
GameID: gameID,
|
||||
Version: body.Version,
|
||||
OpSource: resolveOpSource(request),
|
||||
SourceRef: requestSourceRef(request),
|
||||
})
|
||||
if err != nil {
|
||||
logger.ErrorContext(request.Context(), "patch runtime service errored",
|
||||
"game_id", gameID,
|
||||
"err", err.Error(),
|
||||
)
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "patch runtime service failed")
|
||||
return
|
||||
}
|
||||
|
||||
if result.Outcome == operation.OutcomeFailure {
|
||||
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeRuntimeRecord(result.Record))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"galaxy/gamemaster/internal/domain/operation"
|
||||
"galaxy/gamemaster/internal/service/orderput"
|
||||
)
|
||||
|
||||
// newPutOrdersHandler returns the handler for
|
||||
// `POST /api/v1/internal/games/{game_id}/orders`. The shape and
|
||||
// semantics mirror executeCommands: engine-owned body, raw JSON
|
||||
// pass-through on success, error envelope on failure.
|
||||
func newPutOrdersHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.put_orders")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.PutOrders == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "put orders service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
gameID, ok := extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID, ok := extractUserID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, err := readRawJSONBody(request.Body)
|
||||
if err != nil {
|
||||
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result, err := deps.PutOrders.Handle(request.Context(), orderput.Input{
|
||||
GameID: gameID,
|
||||
UserID: userID,
|
||||
Payload: body,
|
||||
})
|
||||
if err != nil {
|
||||
logger.ErrorContext(request.Context(), "put orders service errored",
|
||||
"game_id", gameID,
|
||||
"user_id", userID,
|
||||
"err", err.Error(),
|
||||
)
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "put orders service failed")
|
||||
return
|
||||
}
|
||||
|
||||
if result.Outcome == operation.OutcomeFailure {
|
||||
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
writeRawJSON(writer, http.StatusOK, []byte(result.RawResponse))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"galaxy/gamemaster/internal/domain/operation"
|
||||
"galaxy/gamemaster/internal/service/registerruntime"
|
||||
)
|
||||
|
||||
// registerRuntimeRequestBody mirrors the OpenAPI
|
||||
// RegisterRuntimeRequest schema. Strict decoding rejects unknown
|
||||
// fields.
|
||||
type registerRuntimeRequestBody struct {
|
||||
EngineEndpoint string `json:"engine_endpoint"`
|
||||
Members []registerRuntimeMemberBody `json:"members"`
|
||||
TargetEngineVersion string `json:"target_engine_version"`
|
||||
TurnSchedule string `json:"turn_schedule"`
|
||||
}
|
||||
|
||||
// registerRuntimeMemberBody mirrors the OpenAPI
|
||||
// RegisterRuntimeMember schema.
|
||||
type registerRuntimeMemberBody struct {
|
||||
UserID string `json:"user_id"`
|
||||
RaceName string `json:"race_name"`
|
||||
}
|
||||
|
||||
// newRegisterRuntimeHandler returns the handler for
|
||||
// `POST /api/v1/internal/games/{game_id}/register-runtime`.
|
||||
func newRegisterRuntimeHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.register_runtime")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.RegisterRuntime == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "register runtime service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
gameID, ok := extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var body registerRuntimeRequestBody
|
||||
if err := decodeStrictJSON(request.Body, &body); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
members := make([]registerruntime.Member, 0, len(body.Members))
|
||||
for _, member := range body.Members {
|
||||
members = append(members, registerruntime.Member{
|
||||
UserID: member.UserID,
|
||||
RaceName: member.RaceName,
|
||||
})
|
||||
}
|
||||
|
||||
result, err := deps.RegisterRuntime.Handle(request.Context(), registerruntime.Input{
|
||||
GameID: gameID,
|
||||
EngineEndpoint: body.EngineEndpoint,
|
||||
Members: members,
|
||||
TargetEngineVersion: body.TargetEngineVersion,
|
||||
TurnSchedule: body.TurnSchedule,
|
||||
OpSource: resolveOpSource(request),
|
||||
SourceRef: requestSourceRef(request),
|
||||
})
|
||||
if err != nil {
|
||||
logger.ErrorContext(request.Context(), "register runtime service errored",
|
||||
"game_id", gameID,
|
||||
"err", err.Error(),
|
||||
)
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "register runtime service failed")
|
||||
return
|
||||
}
|
||||
|
||||
if result.Outcome == operation.OutcomeFailure {
|
||||
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeRuntimeRecord(result.Record))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package handlers
|
||||
|
||||
import "net/http"
|
||||
|
||||
// newResolveEngineVersionImageRefHandler returns the handler for
|
||||
// `GET /api/v1/internal/engine-versions/{version}/image-ref`. It is
|
||||
// the hot-path Lobby calls before publishing a `runtime:start_jobs`
|
||||
// envelope; the response carries only the image reference.
|
||||
func newResolveEngineVersionImageRefHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.resolve_image_ref")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.EngineVersions == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "engine version service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
version, ok := extractVersion(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
imageRef, err := deps.EngineVersions.ResolveImageRef(request.Context(), version)
|
||||
if err != nil {
|
||||
logger.ErrorContext(request.Context(), "resolve image ref failed",
|
||||
"version", version,
|
||||
"err", err.Error(),
|
||||
)
|
||||
status, code, message := mapServiceError(err)
|
||||
writeError(writer, status, code, message)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, imageRefResponse{ImageRef: imageRef})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"galaxy/gamemaster/internal/domain/engineversion"
|
||||
"galaxy/gamemaster/internal/domain/runtime"
|
||||
"galaxy/gamemaster/internal/service/adminbanish"
|
||||
"galaxy/gamemaster/internal/service/adminforce"
|
||||
"galaxy/gamemaster/internal/service/adminpatch"
|
||||
"galaxy/gamemaster/internal/service/adminstop"
|
||||
"galaxy/gamemaster/internal/service/commandexecute"
|
||||
engineversionsvc "galaxy/gamemaster/internal/service/engineversion"
|
||||
"galaxy/gamemaster/internal/service/livenessreply"
|
||||
"galaxy/gamemaster/internal/service/orderput"
|
||||
"galaxy/gamemaster/internal/service/registerruntime"
|
||||
"galaxy/gamemaster/internal/service/reportget"
|
||||
)
|
||||
|
||||
//go:generate go run go.uber.org/mock/mockgen -destination=./mocks/mock_services.go -package=mocks galaxy/gamemaster/internal/api/internalhttp/handlers RegisterRuntimeService,ForceNextTurnService,StopRuntimeService,PatchRuntimeService,BanishRaceService,LivenessService,CommandExecuteService,OrderPutService,ReportGetService,MembershipInvalidator,EngineVersionService,RuntimeRecordsReader
|
||||
|
||||
// RegisterRuntimeService wires the `internalRegisterRuntime` handler
|
||||
// to the underlying register-runtime orchestrator.
|
||||
type RegisterRuntimeService interface {
|
||||
Handle(ctx context.Context, in registerruntime.Input) (registerruntime.Result, error)
|
||||
}
|
||||
|
||||
// ForceNextTurnService wires the `internalForceNextTurn` handler.
|
||||
type ForceNextTurnService interface {
|
||||
Handle(ctx context.Context, in adminforce.Input) (adminforce.Result, error)
|
||||
}
|
||||
|
||||
// StopRuntimeService wires the `internalStopRuntime` handler.
|
||||
type StopRuntimeService interface {
|
||||
Handle(ctx context.Context, in adminstop.Input) (adminstop.Result, error)
|
||||
}
|
||||
|
||||
// PatchRuntimeService wires the `internalPatchRuntime` handler.
|
||||
type PatchRuntimeService interface {
|
||||
Handle(ctx context.Context, in adminpatch.Input) (adminpatch.Result, error)
|
||||
}
|
||||
|
||||
// BanishRaceService wires the `internalBanishRace` handler.
|
||||
type BanishRaceService interface {
|
||||
Handle(ctx context.Context, in adminbanish.Input) (adminbanish.Result, error)
|
||||
}
|
||||
|
||||
// LivenessService wires the `internalGameLiveness` handler.
|
||||
type LivenessService interface {
|
||||
Handle(ctx context.Context, in livenessreply.Input) (livenessreply.Result, error)
|
||||
}
|
||||
|
||||
// CommandExecuteService wires the `internalExecuteCommands` handler.
|
||||
type CommandExecuteService interface {
|
||||
Handle(ctx context.Context, in commandexecute.Input) (commandexecute.Result, error)
|
||||
}
|
||||
|
||||
// OrderPutService wires the `internalPutOrders` handler.
|
||||
type OrderPutService interface {
|
||||
Handle(ctx context.Context, in orderput.Input) (orderput.Result, error)
|
||||
}
|
||||
|
||||
// ReportGetService wires the `internalGetReport` handler.
|
||||
type ReportGetService interface {
|
||||
Handle(ctx context.Context, in reportget.Input) (reportget.Result, error)
|
||||
}
|
||||
|
||||
// MembershipInvalidator wires the `internalInvalidateMemberships`
|
||||
// handler. Backed by `service/membership.Cache.Invalidate`.
|
||||
type MembershipInvalidator interface {
|
||||
// Invalidate purges the in-process membership cache entry for
|
||||
// gameID. The call is fire-and-forget and never returns an error;
|
||||
// missing entries are a no-op.
|
||||
Invalidate(gameID string)
|
||||
}
|
||||
|
||||
// EngineVersionService wires every engine-version registry handler. The
|
||||
// service exposes one Go-error-returning method per OpenAPI operation;
|
||||
// the handler layer translates the wrapped sentinel errors into
|
||||
// `engine_version_*` codes via `mapServiceError`.
|
||||
type EngineVersionService interface {
|
||||
List(ctx context.Context, statusFilter *engineversion.Status) ([]engineversion.EngineVersion, error)
|
||||
Get(ctx context.Context, version string) (engineversion.EngineVersion, error)
|
||||
ResolveImageRef(ctx context.Context, version string) (string, error)
|
||||
Create(ctx context.Context, in engineversionsvc.CreateInput) (engineversion.EngineVersion, error)
|
||||
Update(ctx context.Context, in engineversionsvc.UpdateInput) (engineversion.EngineVersion, error)
|
||||
Deprecate(ctx context.Context, in engineversionsvc.DeprecateInput) error
|
||||
}
|
||||
|
||||
// RuntimeRecordsReader exposes the read-only subset of
|
||||
// `ports.RuntimeRecordStore` required by the get/list runtime
|
||||
// handlers. The narrower surface keeps the handler layer from
|
||||
// inadvertently mutating runtime state.
|
||||
type RuntimeRecordsReader interface {
|
||||
Get(ctx context.Context, gameID string) (runtime.RuntimeRecord, error)
|
||||
List(ctx context.Context) ([]runtime.RuntimeRecord, error)
|
||||
ListByStatus(ctx context.Context, status runtime.Status) ([]runtime.RuntimeRecord, error)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"galaxy/gamemaster/internal/domain/operation"
|
||||
"galaxy/gamemaster/internal/service/adminstop"
|
||||
)
|
||||
|
||||
// stopRuntimeRequestBody mirrors the OpenAPI StopRuntimeRequest
|
||||
// schema.
|
||||
type stopRuntimeRequestBody struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// newStopRuntimeHandler returns the handler for
|
||||
// `POST /api/v1/internal/runtimes/{game_id}/stop`.
|
||||
func newStopRuntimeHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.stop_runtime")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.StopRuntime == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "stop runtime service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
gameID, ok := extractGameID(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var body stopRuntimeRequestBody
|
||||
if err := decodeStrictJSON(request.Body, &body); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result, err := deps.StopRuntime.Handle(request.Context(), adminstop.Input{
|
||||
GameID: gameID,
|
||||
Reason: body.Reason,
|
||||
OpSource: resolveOpSource(request),
|
||||
SourceRef: requestSourceRef(request),
|
||||
})
|
||||
if err != nil {
|
||||
logger.ErrorContext(request.Context(), "stop runtime service errored",
|
||||
"game_id", gameID,
|
||||
"err", err.Error(),
|
||||
)
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "stop runtime service failed")
|
||||
return
|
||||
}
|
||||
|
||||
if result.Outcome == operation.OutcomeFailure {
|
||||
writeFailure(writer, result.ErrorCode, result.ErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeRuntimeRecord(result.Record))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"galaxy/gamemaster/internal/domain/engineversion"
|
||||
engineversionsvc "galaxy/gamemaster/internal/service/engineversion"
|
||||
)
|
||||
|
||||
// updateEngineVersionRequestBody mirrors the OpenAPI
|
||||
// UpdateEngineVersionRequest schema. Every field is optional; the
|
||||
// service rejects calls with no fields set as `invalid_request`.
|
||||
type updateEngineVersionRequestBody struct {
|
||||
ImageRef *string `json:"image_ref,omitempty"`
|
||||
Options *json.RawMessage `json:"options,omitempty"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// newUpdateEngineVersionHandler returns the handler for
|
||||
// `PATCH /api/v1/internal/engine-versions/{version}`.
|
||||
func newUpdateEngineVersionHandler(deps Dependencies) http.HandlerFunc {
|
||||
logger := loggerFor(deps.Logger, "internal_rest.update_engine_version")
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if deps.EngineVersions == nil {
|
||||
writeError(writer, http.StatusInternalServerError, errorCodeInternal, "engine version service is not wired")
|
||||
return
|
||||
}
|
||||
|
||||
version, ok := extractVersion(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var body updateEngineVersionRequestBody
|
||||
if err := decodeStrictJSON(request.Body, &body); err != nil {
|
||||
writeError(writer, http.StatusBadRequest, errorCodeInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
input := engineversionsvc.UpdateInput{
|
||||
Version: version,
|
||||
ImageRef: body.ImageRef,
|
||||
OpSource: resolveOpSource(request),
|
||||
SourceRef: requestSourceRef(request),
|
||||
}
|
||||
if body.Options != nil {
|
||||
optionBytes := []byte(*body.Options)
|
||||
input.Options = &optionBytes
|
||||
}
|
||||
if body.Status != nil {
|
||||
candidate := engineversion.Status(*body.Status)
|
||||
input.Status = &candidate
|
||||
}
|
||||
|
||||
record, err := deps.EngineVersions.Update(request.Context(), input)
|
||||
if err != nil {
|
||||
logger.ErrorContext(request.Context(), "update engine version failed",
|
||||
"version", version,
|
||||
"err", err.Error(),
|
||||
)
|
||||
status, code, message := mapServiceError(err)
|
||||
writeError(writer, status, code, message)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, encodeEngineVersion(record))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
// Package internalhttp provides the trusted internal HTTP listener
|
||||
// used by the runnable Game Master process. It exposes the `/healthz`
|
||||
// and `/readyz` probes plus every internal REST operation declared in
|
||||
// `gamemaster/api/internal-openapi.yaml`. Per-operation handlers live
|
||||
// in the nested `handlers` package; this file owns the listener
|
||||
// lifecycle and the probe routes only.
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"galaxy/gamemaster/internal/api/internalhttp/handlers"
|
||||
"galaxy/gamemaster/internal/telemetry"
|
||||
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
)
|
||||
|
||||
const jsonContentType = "application/json; charset=utf-8"
|
||||
|
||||
// errorCodeServiceUnavailable mirrors the stable error code declared in
|
||||
// `gamemaster/api/internal-openapi.yaml` §Error Model.
|
||||
const errorCodeServiceUnavailable = "service_unavailable"
|
||||
|
||||
// HealthzPath and ReadyzPath are the internal probe routes documented in
|
||||
// `gamemaster/api/internal-openapi.yaml`.
|
||||
const (
|
||||
HealthzPath = "/healthz"
|
||||
ReadyzPath = "/readyz"
|
||||
)
|
||||
|
||||
// ReadinessProbe reports whether the dependencies the listener guards
|
||||
// (PostgreSQL, Redis) are reachable. A non-nil error is reported to the
|
||||
// caller as `503 service_unavailable` with the wrapped message.
|
||||
type ReadinessProbe interface {
|
||||
Check(ctx context.Context) error
|
||||
}
|
||||
|
||||
// Config describes the trusted internal HTTP listener owned by Game
|
||||
// Master.
|
||||
type Config struct {
|
||||
// Addr is the TCP listen address used by the internal HTTP server.
|
||||
Addr string
|
||||
|
||||
// ReadHeaderTimeout bounds how long the listener may spend reading
|
||||
// request headers before the server rejects the connection.
|
||||
ReadHeaderTimeout time.Duration
|
||||
|
||||
// ReadTimeout bounds how long the listener may spend reading one
|
||||
// request.
|
||||
ReadTimeout time.Duration
|
||||
|
||||
// WriteTimeout bounds how long the listener may spend writing one
|
||||
// response.
|
||||
WriteTimeout time.Duration
|
||||
|
||||
// IdleTimeout bounds how long the listener keeps an idle keep-alive
|
||||
// connection open.
|
||||
IdleTimeout time.Duration
|
||||
}
|
||||
|
||||
// Validate reports whether cfg contains a usable internal HTTP listener
|
||||
// configuration.
|
||||
func (cfg Config) Validate() error {
|
||||
switch {
|
||||
case cfg.Addr == "":
|
||||
return errors.New("internal HTTP addr must not be empty")
|
||||
case cfg.ReadHeaderTimeout <= 0:
|
||||
return errors.New("internal HTTP read header timeout must be positive")
|
||||
case cfg.ReadTimeout <= 0:
|
||||
return errors.New("internal HTTP read timeout must be positive")
|
||||
case cfg.WriteTimeout <= 0:
|
||||
return errors.New("internal HTTP write timeout must be positive")
|
||||
case cfg.IdleTimeout <= 0:
|
||||
return errors.New("internal HTTP idle timeout must be positive")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Dependencies describes the collaborators used by the internal HTTP
|
||||
// transport layer. The probe-only fields (Logger, Telemetry,
|
||||
// Readiness) drive `/healthz` and `/readyz`; the remaining fields
|
||||
// pass through to the per-operation handlers registered by
|
||||
// `handlers.Register`.
|
||||
type Dependencies struct {
|
||||
// Logger writes structured listener lifecycle logs. When nil,
|
||||
// slog.Default is used.
|
||||
Logger *slog.Logger
|
||||
|
||||
// Telemetry records low-cardinality probe metrics and lifecycle
|
||||
// events.
|
||||
Telemetry *telemetry.Runtime
|
||||
|
||||
// Readiness reports whether PG / Redis are reachable. A nil
|
||||
// readiness probe makes `/readyz` always answer `200`; the runtime
|
||||
// always supplies a real probe in production wiring.
|
||||
Readiness ReadinessProbe
|
||||
|
||||
// RuntimeRecords backs the read-only list/get runtime endpoints.
|
||||
RuntimeRecords handlers.RuntimeRecordsReader
|
||||
|
||||
// RegisterRuntime is the orchestrator for `internalRegisterRuntime`.
|
||||
RegisterRuntime handlers.RegisterRuntimeService
|
||||
|
||||
// ForceNextTurn drives the synchronous force-next-turn flow.
|
||||
ForceNextTurn handlers.ForceNextTurnService
|
||||
|
||||
// StopRuntime drives the admin stop flow.
|
||||
StopRuntime handlers.StopRuntimeService
|
||||
|
||||
// PatchRuntime drives the admin patch flow.
|
||||
PatchRuntime handlers.PatchRuntimeService
|
||||
|
||||
// BanishRace drives the engine race-banish flow.
|
||||
BanishRace handlers.BanishRaceService
|
||||
|
||||
// InvalidateMemberships purges the in-process membership cache.
|
||||
InvalidateMemberships handlers.MembershipInvalidator
|
||||
|
||||
// GameLiveness returns the current runtime status without
|
||||
// contacting the engine.
|
||||
GameLiveness handlers.LivenessService
|
||||
|
||||
// EngineVersions exposes the multi-method engine-version registry
|
||||
// service.
|
||||
EngineVersions handlers.EngineVersionService
|
||||
|
||||
// CommandExecute forwards a player command batch to the engine.
|
||||
CommandExecute handlers.CommandExecuteService
|
||||
|
||||
// PutOrders forwards a player order batch to the engine.
|
||||
PutOrders handlers.OrderPutService
|
||||
|
||||
// GetReport reads a per-player turn report from the engine.
|
||||
GetReport handlers.ReportGetService
|
||||
}
|
||||
|
||||
// Server owns the trusted internal HTTP listener exposed by Game Master.
|
||||
type Server struct {
|
||||
cfg Config
|
||||
|
||||
handler http.Handler
|
||||
logger *slog.Logger
|
||||
metrics *telemetry.Runtime
|
||||
|
||||
stateMu sync.RWMutex
|
||||
server *http.Server
|
||||
listener net.Listener
|
||||
}
|
||||
|
||||
// NewServer constructs one trusted internal HTTP server for cfg and deps.
|
||||
func NewServer(cfg Config, deps Dependencies) (*Server, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("new internal HTTP server: %w", err)
|
||||
}
|
||||
|
||||
logger := deps.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
handler: newHandler(deps, logger),
|
||||
logger: logger.With("component", "internal_http"),
|
||||
metrics: deps.Telemetry,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Addr returns the currently bound listener address after Run is called.
|
||||
// It returns an empty string if the server has not yet bound a listener.
|
||||
func (server *Server) Addr() string {
|
||||
server.stateMu.RLock()
|
||||
defer server.stateMu.RUnlock()
|
||||
if server.listener == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return server.listener.Addr().String()
|
||||
}
|
||||
|
||||
// Run binds the configured listener and serves the internal HTTP surface
|
||||
// until Shutdown closes the server.
|
||||
func (server *Server) Run(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("run internal HTTP server: nil context")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", server.cfg.Addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("run internal HTTP server: listen on %q: %w", server.cfg.Addr, err)
|
||||
}
|
||||
|
||||
httpServer := &http.Server{
|
||||
Handler: server.handler,
|
||||
ReadHeaderTimeout: server.cfg.ReadHeaderTimeout,
|
||||
ReadTimeout: server.cfg.ReadTimeout,
|
||||
WriteTimeout: server.cfg.WriteTimeout,
|
||||
IdleTimeout: server.cfg.IdleTimeout,
|
||||
}
|
||||
|
||||
server.stateMu.Lock()
|
||||
server.server = httpServer
|
||||
server.listener = listener
|
||||
server.stateMu.Unlock()
|
||||
|
||||
server.logger.Info("gamemaster internal HTTP server started", "addr", listener.Addr().String())
|
||||
|
||||
defer func() {
|
||||
server.stateMu.Lock()
|
||||
server.server = nil
|
||||
server.listener = nil
|
||||
server.stateMu.Unlock()
|
||||
}()
|
||||
|
||||
err = httpServer.Serve(listener)
|
||||
switch {
|
||||
case err == nil:
|
||||
return nil
|
||||
case errors.Is(err, http.ErrServerClosed):
|
||||
server.logger.Info("gamemaster internal HTTP server stopped")
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("run internal HTTP server: serve on %q: %w", server.cfg.Addr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown gracefully stops the internal HTTP server within ctx.
|
||||
func (server *Server) Shutdown(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("shutdown internal HTTP server: nil context")
|
||||
}
|
||||
|
||||
server.stateMu.RLock()
|
||||
httpServer := server.server
|
||||
server.stateMu.RUnlock()
|
||||
|
||||
if httpServer == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := httpServer.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return fmt.Errorf("shutdown internal HTTP server: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func newHandler(deps Dependencies, logger *slog.Logger) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET "+HealthzPath, handleHealthz)
|
||||
mux.HandleFunc("GET "+ReadyzPath, handleReadyz(deps.Readiness, logger))
|
||||
handlers.Register(mux, handlers.Dependencies{
|
||||
Logger: logger,
|
||||
RuntimeRecords: deps.RuntimeRecords,
|
||||
RegisterRuntime: deps.RegisterRuntime,
|
||||
ForceNextTurn: deps.ForceNextTurn,
|
||||
StopRuntime: deps.StopRuntime,
|
||||
PatchRuntime: deps.PatchRuntime,
|
||||
BanishRace: deps.BanishRace,
|
||||
InvalidateMemberships: deps.InvalidateMemberships,
|
||||
GameLiveness: deps.GameLiveness,
|
||||
EngineVersions: deps.EngineVersions,
|
||||
CommandExecute: deps.CommandExecute,
|
||||
PutOrders: deps.PutOrders,
|
||||
GetReport: deps.GetReport,
|
||||
})
|
||||
|
||||
metrics := deps.Telemetry
|
||||
options := []otelhttp.Option{}
|
||||
if metrics != nil {
|
||||
options = append(options,
|
||||
otelhttp.WithTracerProvider(metrics.TracerProvider()),
|
||||
otelhttp.WithMeterProvider(metrics.MeterProvider()),
|
||||
)
|
||||
}
|
||||
|
||||
return otelhttp.NewHandler(withObservability(mux, metrics), "gamemaster.internal_http", options...)
|
||||
}
|
||||
|
||||
func withObservability(next http.Handler, metrics *telemetry.Runtime) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
startedAt := time.Now()
|
||||
recorder := &statusRecorder{
|
||||
ResponseWriter: writer,
|
||||
statusCode: http.StatusOK,
|
||||
}
|
||||
|
||||
next.ServeHTTP(recorder, request)
|
||||
|
||||
route := request.Pattern
|
||||
switch recorder.statusCode {
|
||||
case http.StatusMethodNotAllowed:
|
||||
route = "method_not_allowed"
|
||||
case http.StatusNotFound:
|
||||
route = "not_found"
|
||||
case 0:
|
||||
route = "unmatched"
|
||||
}
|
||||
if route == "" {
|
||||
route = "unmatched"
|
||||
}
|
||||
|
||||
if metrics != nil {
|
||||
metrics.RecordInternalHTTPRequest(
|
||||
request.Context(),
|
||||
[]attribute.KeyValue{
|
||||
attribute.String("route", route),
|
||||
attribute.String("method", request.Method),
|
||||
attribute.String("status_code", strconv.Itoa(recorder.statusCode)),
|
||||
},
|
||||
time.Since(startedAt),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func handleHealthz(writer http.ResponseWriter, _ *http.Request) {
|
||||
writeStatusResponse(writer, http.StatusOK, "ok")
|
||||
}
|
||||
|
||||
func handleReadyz(probe ReadinessProbe, logger *slog.Logger) http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if probe == nil {
|
||||
writeStatusResponse(writer, http.StatusOK, "ready")
|
||||
return
|
||||
}
|
||||
|
||||
if err := probe.Check(request.Context()); err != nil {
|
||||
logger.WarnContext(request.Context(), "gamemaster readiness probe failed",
|
||||
"err", err.Error(),
|
||||
)
|
||||
writeServiceUnavailable(writer, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeStatusResponse(writer, http.StatusOK, "ready")
|
||||
}
|
||||
}
|
||||
|
||||
func writeStatusResponse(writer http.ResponseWriter, statusCode int, status string) {
|
||||
writer.Header().Set("Content-Type", jsonContentType)
|
||||
writer.WriteHeader(statusCode)
|
||||
_ = json.NewEncoder(writer).Encode(statusResponse{Status: status})
|
||||
}
|
||||
|
||||
func writeServiceUnavailable(writer http.ResponseWriter, message string) {
|
||||
writer.Header().Set("Content-Type", jsonContentType)
|
||||
writer.WriteHeader(http.StatusServiceUnavailable)
|
||||
_ = json.NewEncoder(writer).Encode(errorResponse{
|
||||
Error: errorBody{
|
||||
Code: errorCodeServiceUnavailable,
|
||||
Message: message,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type statusResponse struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type errorBody struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type errorResponse struct {
|
||||
Error errorBody `json:"error"`
|
||||
}
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (recorder *statusRecorder) WriteHeader(statusCode int) {
|
||||
recorder.statusCode = statusCode
|
||||
recorder.ResponseWriter.WriteHeader(statusCode)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newTestConfig() Config {
|
||||
return Config{
|
||||
Addr: ":0",
|
||||
ReadHeaderTimeout: time.Second,
|
||||
ReadTimeout: time.Second,
|
||||
WriteTimeout: time.Second,
|
||||
IdleTimeout: time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
type stubReadiness struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (probe stubReadiness) Check(_ context.Context) error {
|
||||
return probe.err
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T, deps Dependencies) http.Handler {
|
||||
t.Helper()
|
||||
server, err := NewServer(newTestConfig(), deps)
|
||||
require.NoError(t, err)
|
||||
return server.handler
|
||||
}
|
||||
|
||||
func TestHealthzReturnsOK(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestServer(t, Dependencies{})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, HealthzPath, nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, jsonContentType, rec.Header().Get("Content-Type"))
|
||||
|
||||
var body statusResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
|
||||
require.Equal(t, "ok", body.Status)
|
||||
}
|
||||
|
||||
func TestReadyzReturnsReadyWhenProbeIsNil(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestServer(t, Dependencies{})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, ReadyzPath, nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var body statusResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
|
||||
require.Equal(t, "ready", body.Status)
|
||||
}
|
||||
|
||||
func TestReadyzReturnsReadyWhenProbeSucceeds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestServer(t, Dependencies{Readiness: stubReadiness{}})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, ReadyzPath, nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var body statusResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
|
||||
require.Equal(t, "ready", body.Status)
|
||||
}
|
||||
|
||||
func TestReadyzReturnsServiceUnavailableWhenProbeFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestServer(t, Dependencies{
|
||||
Readiness: stubReadiness{err: errors.New("postgres ping: connection refused")},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, ReadyzPath, nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusServiceUnavailable, rec.Code)
|
||||
require.Equal(t, jsonContentType, rec.Header().Get("Content-Type"))
|
||||
|
||||
var body errorResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
|
||||
require.Equal(t, errorCodeServiceUnavailable, body.Error.Code)
|
||||
require.True(t, strings.Contains(body.Error.Message, "postgres"))
|
||||
}
|
||||
|
||||
func TestNewServerRejectsInvalidConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := NewServer(Config{}, Dependencies{})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRunBindsListenerAndShutsDown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server, err := NewServer(newTestConfig(), Dependencies{})
|
||||
require.NoError(t, err)
|
||||
|
||||
runErr := make(chan error, 1)
|
||||
go func() {
|
||||
runErr <- server.Run(t.Context())
|
||||
}()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return server.Addr() != ""
|
||||
}, time.Second, 10*time.Millisecond, "listener should bind quickly")
|
||||
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer shutdownCancel()
|
||||
require.NoError(t, server.Shutdown(shutdownCtx))
|
||||
|
||||
select {
|
||||
case err := <-runErr:
|
||||
require.NoError(t, err)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("server did not return after shutdown")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// Package app wires the Game Master process lifecycle and coordinates
|
||||
// component startup and graceful shutdown.
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"galaxy/gamemaster/internal/config"
|
||||
)
|
||||
|
||||
// Component is a long-lived Game Master subsystem that participates in
|
||||
// coordinated startup and graceful shutdown.
|
||||
type Component interface {
|
||||
// Run starts the component and blocks until it stops.
|
||||
Run(context.Context) error
|
||||
|
||||
// Shutdown stops the component within the provided timeout-bounded
|
||||
// context.
|
||||
Shutdown(context.Context) error
|
||||
}
|
||||
|
||||
// App owns the process-level lifecycle of Game Master and its registered
|
||||
// components.
|
||||
type App struct {
|
||||
cfg config.Config
|
||||
components []Component
|
||||
}
|
||||
|
||||
// New constructs App with a defensive copy of the supplied components.
|
||||
func New(cfg config.Config, components ...Component) *App {
|
||||
clonedComponents := append([]Component(nil), components...)
|
||||
|
||||
return &App{
|
||||
cfg: cfg,
|
||||
components: clonedComponents,
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts all configured components, waits for cancellation or the
|
||||
// first component failure, and then executes best-effort graceful
|
||||
// shutdown.
|
||||
func (app *App) Run(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("run gamemaster app: nil context")
|
||||
}
|
||||
if err := app.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(app.components) == 0 {
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
results := make(chan componentResult, len(app.components))
|
||||
var runWaitGroup sync.WaitGroup
|
||||
|
||||
for index, component := range app.components {
|
||||
runWaitGroup.Add(1)
|
||||
|
||||
go func(componentIndex int, component Component) {
|
||||
defer runWaitGroup.Done()
|
||||
results <- componentResult{
|
||||
index: componentIndex,
|
||||
err: component.Run(runCtx),
|
||||
}
|
||||
}(index, component)
|
||||
}
|
||||
|
||||
var runErr error
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case result := <-results:
|
||||
runErr = classifyComponentResult(ctx, result)
|
||||
}
|
||||
|
||||
cancel()
|
||||
|
||||
shutdownErr := app.shutdownComponents()
|
||||
waitErr := app.waitForComponents(&runWaitGroup)
|
||||
|
||||
return errors.Join(runErr, shutdownErr, waitErr)
|
||||
}
|
||||
|
||||
type componentResult struct {
|
||||
index int
|
||||
err error
|
||||
}
|
||||
|
||||
func (app *App) validate() error {
|
||||
if app.cfg.ShutdownTimeout <= 0 {
|
||||
return fmt.Errorf("run gamemaster app: shutdown timeout must be positive, got %s", app.cfg.ShutdownTimeout)
|
||||
}
|
||||
|
||||
for index, component := range app.components {
|
||||
if component == nil {
|
||||
return fmt.Errorf("run gamemaster app: component %d is nil", index)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func classifyComponentResult(parentCtx context.Context, result componentResult) error {
|
||||
switch {
|
||||
case result.err == nil:
|
||||
if parentCtx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("run gamemaster app: component %d exited without error before shutdown", result.index)
|
||||
case errors.Is(result.err, context.Canceled) && parentCtx.Err() != nil:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("run gamemaster app: component %d: %w", result.index, result.err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) shutdownComponents() error {
|
||||
var shutdownWaitGroup sync.WaitGroup
|
||||
errs := make(chan error, len(app.components))
|
||||
|
||||
for index, component := range app.components {
|
||||
shutdownWaitGroup.Add(1)
|
||||
|
||||
go func(componentIndex int, component Component) {
|
||||
defer shutdownWaitGroup.Done()
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), app.cfg.ShutdownTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := component.Shutdown(shutdownCtx); err != nil {
|
||||
errs <- fmt.Errorf("shutdown gamemaster component %d: %w", componentIndex, err)
|
||||
}
|
||||
}(index, component)
|
||||
}
|
||||
|
||||
shutdownWaitGroup.Wait()
|
||||
close(errs)
|
||||
|
||||
var joined error
|
||||
for err := range errs {
|
||||
joined = errors.Join(joined, err)
|
||||
}
|
||||
|
||||
return joined
|
||||
}
|
||||
|
||||
func (app *App) waitForComponents(runWaitGroup *sync.WaitGroup) error {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
runWaitGroup.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
waitCtx, cancel := context.WithTimeout(context.Background(), app.cfg.ShutdownTimeout)
|
||||
defer cancel()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-waitCtx.Done():
|
||||
return fmt.Errorf("wait for gamemaster components: %w", waitCtx.Err())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/gamemaster/internal/config"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type fakeComponent struct {
|
||||
runErr error
|
||||
shutdownErr error
|
||||
runHook func(context.Context) error
|
||||
shutdownHook func(context.Context) error
|
||||
runCount atomic.Int32
|
||||
downCount atomic.Int32
|
||||
blockForCtx bool
|
||||
}
|
||||
|
||||
func (component *fakeComponent) Run(ctx context.Context) error {
|
||||
component.runCount.Add(1)
|
||||
if component.runHook != nil {
|
||||
return component.runHook(ctx)
|
||||
}
|
||||
if component.blockForCtx {
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
return component.runErr
|
||||
}
|
||||
|
||||
func (component *fakeComponent) Shutdown(ctx context.Context) error {
|
||||
component.downCount.Add(1)
|
||||
if component.shutdownHook != nil {
|
||||
return component.shutdownHook(ctx)
|
||||
}
|
||||
|
||||
return component.shutdownErr
|
||||
}
|
||||
|
||||
func newCfg() config.Config {
|
||||
return config.Config{ShutdownTimeout: time.Second}
|
||||
}
|
||||
|
||||
func TestAppRunWithoutComponentsBlocksUntilContextDone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := New(newCfg())
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
require.NoError(t, app.Run(ctx))
|
||||
}
|
||||
|
||||
func TestAppRunReturnsOnContextCancel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
component := &fakeComponent{blockForCtx: true}
|
||||
app := New(newCfg(), component)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
require.NoError(t, app.Run(ctx))
|
||||
assert.EqualValues(t, 1, component.runCount.Load())
|
||||
assert.EqualValues(t, 1, component.downCount.Load())
|
||||
}
|
||||
|
||||
func TestAppRunPropagatesComponentFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
failure := errors.New("boom")
|
||||
component := &fakeComponent{runErr: failure}
|
||||
app := New(newCfg(), component)
|
||||
|
||||
err := app.Run(context.Background())
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, failure)
|
||||
assert.EqualValues(t, 1, component.downCount.Load())
|
||||
}
|
||||
|
||||
func TestAppRunFailsOnNilContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := New(newCfg())
|
||||
var ctx context.Context
|
||||
require.Error(t, app.Run(ctx))
|
||||
}
|
||||
|
||||
func TestAppRunFailsOnNonPositiveShutdownTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := New(config.Config{}, &fakeComponent{})
|
||||
require.Error(t, app.Run(context.Background()))
|
||||
}
|
||||
|
||||
func TestAppRunFailsOnNilComponent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := New(newCfg(), nil)
|
||||
require.Error(t, app.Run(context.Background()))
|
||||
}
|
||||
|
||||
func TestAppRunFlagsCleanExitBeforeShutdown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
component := &fakeComponent{}
|
||||
app := New(newCfg(), component)
|
||||
|
||||
err := app.Run(context.Background())
|
||||
require.Error(t, err)
|
||||
require.True(t, strings.Contains(err.Error(), "exited without error"))
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"galaxy/redisconn"
|
||||
|
||||
"galaxy/gamemaster/internal/config"
|
||||
"galaxy/gamemaster/internal/telemetry"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// newRedisClient builds the master Redis client from cfg via the shared
|
||||
// `pkg/redisconn` helper. Replica clients are not opened in this iteration
|
||||
// per ARCHITECTURE.md §Persistence Backends; they will be wired when read
|
||||
// routing is introduced.
|
||||
func newRedisClient(cfg config.RedisConfig) *redis.Client {
|
||||
return redisconn.NewMasterClient(cfg.Conn)
|
||||
}
|
||||
|
||||
// instrumentRedisClient attaches the OpenTelemetry tracing and metrics
|
||||
// instrumentation to client when telemetryRuntime is available. The
|
||||
// actual instrumentation lives in `pkg/redisconn` so every Galaxy service
|
||||
// shares one surface.
|
||||
func instrumentRedisClient(redisClient *redis.Client, telemetryRuntime *telemetry.Runtime) error {
|
||||
if redisClient == nil {
|
||||
return errors.New("instrument redis client: nil client")
|
||||
}
|
||||
if telemetryRuntime == nil {
|
||||
return nil
|
||||
}
|
||||
return redisconn.Instrument(redisClient,
|
||||
redisconn.WithTracerProvider(telemetryRuntime.TracerProvider()),
|
||||
redisconn.WithMeterProvider(telemetryRuntime.MeterProvider()),
|
||||
)
|
||||
}
|
||||
|
||||
// pingRedis performs a single Redis PING bounded by
|
||||
// cfg.Conn.OperationTimeout to confirm that the configured Redis endpoint
|
||||
// is reachable at startup.
|
||||
func pingRedis(ctx context.Context, cfg config.RedisConfig, redisClient *redis.Client) error {
|
||||
return redisconn.Ping(ctx, redisClient, cfg.Conn.OperationTimeout)
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"galaxy/postgres"
|
||||
"galaxy/redisconn"
|
||||
|
||||
"galaxy/gamemaster/internal/adapters/postgres/migrations"
|
||||
"galaxy/gamemaster/internal/api/internalhttp"
|
||||
"galaxy/gamemaster/internal/config"
|
||||
"galaxy/gamemaster/internal/telemetry"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// Runtime owns the runnable Game Master process plus the cleanup
|
||||
// functions that release runtime resources after shutdown.
|
||||
type Runtime struct {
|
||||
cfg config.Config
|
||||
|
||||
app *App
|
||||
|
||||
wiring *wiring
|
||||
|
||||
internalServer *internalhttp.Server
|
||||
|
||||
cleanupFns []func() error
|
||||
}
|
||||
|
||||
// NewRuntime constructs the runnable Game Master process from cfg.
|
||||
//
|
||||
// The runtime opens one shared `*redis.Client`, one `*sql.DB`, and one
|
||||
// OpenTelemetry runtime; all are released in reverse construction order
|
||||
// on shutdown. Embedded goose migrations apply synchronously after the
|
||||
// PostgreSQL pool is opened and pinged, before any listener is constructed.
|
||||
func NewRuntime(ctx context.Context, cfg config.Config, logger *slog.Logger) (*Runtime, error) {
|
||||
if ctx == nil {
|
||||
return nil, errors.New("new gamemaster runtime: nil context")
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("new gamemaster runtime: %w", err)
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
runtime := &Runtime{
|
||||
cfg: cfg,
|
||||
}
|
||||
|
||||
cleanupOnError := func(err error) (*Runtime, error) {
|
||||
if cleanupErr := runtime.Close(); cleanupErr != nil {
|
||||
return nil, fmt.Errorf("%w; cleanup: %w", err, cleanupErr)
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
telemetryRuntime, err := telemetry.NewProcess(ctx, telemetry.ProcessConfig{
|
||||
ServiceName: cfg.Telemetry.ServiceName,
|
||||
TracesExporter: cfg.Telemetry.TracesExporter,
|
||||
MetricsExporter: cfg.Telemetry.MetricsExporter,
|
||||
TracesProtocol: cfg.Telemetry.TracesProtocol,
|
||||
MetricsProtocol: cfg.Telemetry.MetricsProtocol,
|
||||
StdoutTracesEnabled: cfg.Telemetry.StdoutTracesEnabled,
|
||||
StdoutMetricsEnabled: cfg.Telemetry.StdoutMetricsEnabled,
|
||||
}, logger)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new gamemaster runtime: telemetry: %w", err))
|
||||
}
|
||||
runtime.cleanupFns = append(runtime.cleanupFns, func() error {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
|
||||
defer cancel()
|
||||
return telemetryRuntime.Shutdown(shutdownCtx)
|
||||
})
|
||||
|
||||
redisClient := newRedisClient(cfg.Redis)
|
||||
if err := instrumentRedisClient(redisClient, telemetryRuntime); err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new gamemaster runtime: %w", err))
|
||||
}
|
||||
runtime.cleanupFns = append(runtime.cleanupFns, func() error {
|
||||
err := redisClient.Close()
|
||||
if errors.Is(err, redis.ErrClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
})
|
||||
if err := pingRedis(ctx, cfg.Redis, redisClient); err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new gamemaster runtime: %w", err))
|
||||
}
|
||||
|
||||
pgPool, err := postgres.OpenPrimary(ctx, cfg.Postgres.Conn,
|
||||
postgres.WithTracerProvider(telemetryRuntime.TracerProvider()),
|
||||
postgres.WithMeterProvider(telemetryRuntime.MeterProvider()),
|
||||
)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new gamemaster runtime: open postgres: %w", err))
|
||||
}
|
||||
runtime.cleanupFns = append(runtime.cleanupFns, pgPool.Close)
|
||||
unregisterPGStats, err := postgres.InstrumentDBStats(pgPool,
|
||||
postgres.WithMeterProvider(telemetryRuntime.MeterProvider()),
|
||||
)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new gamemaster runtime: instrument postgres: %w", err))
|
||||
}
|
||||
runtime.cleanupFns = append(runtime.cleanupFns, func() error {
|
||||
return unregisterPGStats()
|
||||
})
|
||||
if err := postgres.Ping(ctx, pgPool, cfg.Postgres.Conn.OperationTimeout); err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new gamemaster runtime: ping postgres: %w", err))
|
||||
}
|
||||
if err := postgres.RunMigrations(ctx, pgPool, migrations.FS(), "."); err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new gamemaster runtime: run postgres migrations: %w", err))
|
||||
}
|
||||
|
||||
wiring, err := newWiring(cfg, redisClient, pgPool, time.Now, logger, telemetryRuntime)
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new gamemaster runtime: wiring: %w", err))
|
||||
}
|
||||
runtime.wiring = wiring
|
||||
runtime.cleanupFns = append(runtime.cleanupFns, wiring.close)
|
||||
|
||||
probe := newReadinessProbe(pgPool, redisClient, cfg)
|
||||
|
||||
internalServer, err := internalhttp.NewServer(internalhttp.Config{
|
||||
Addr: cfg.InternalHTTP.Addr,
|
||||
ReadHeaderTimeout: cfg.InternalHTTP.ReadHeaderTimeout,
|
||||
ReadTimeout: cfg.InternalHTTP.ReadTimeout,
|
||||
WriteTimeout: cfg.InternalHTTP.WriteTimeout,
|
||||
IdleTimeout: cfg.InternalHTTP.IdleTimeout,
|
||||
}, internalhttp.Dependencies{
|
||||
Logger: logger,
|
||||
Telemetry: telemetryRuntime,
|
||||
Readiness: probe,
|
||||
RuntimeRecords: wiring.runtimeRecords,
|
||||
RegisterRuntime: wiring.registerRuntimeSvc,
|
||||
ForceNextTurn: wiring.forceNextTurnSvc,
|
||||
StopRuntime: wiring.stopRuntimeSvc,
|
||||
PatchRuntime: wiring.patchRuntimeSvc,
|
||||
BanishRace: wiring.banishRaceSvc,
|
||||
InvalidateMemberships: wiring.membershipCache,
|
||||
GameLiveness: wiring.livenessSvc,
|
||||
EngineVersions: wiring.engineVersionSvc,
|
||||
CommandExecute: wiring.commandExecuteSvc,
|
||||
PutOrders: wiring.orderPutSvc,
|
||||
GetReport: wiring.reportGetSvc,
|
||||
})
|
||||
if err != nil {
|
||||
return cleanupOnError(fmt.Errorf("new gamemaster runtime: internal HTTP server: %w", err))
|
||||
}
|
||||
runtime.internalServer = internalServer
|
||||
|
||||
runtime.app = New(cfg,
|
||||
internalServer,
|
||||
wiring.schedulerTicker,
|
||||
wiring.healthEventsConsumer,
|
||||
)
|
||||
|
||||
return runtime, nil
|
||||
}
|
||||
|
||||
// InternalServer returns the internal HTTP server owned by runtime. It is
|
||||
// primarily exposed for tests; production code should not depend on it.
|
||||
func (runtime *Runtime) InternalServer() *internalhttp.Server {
|
||||
if runtime == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return runtime.internalServer
|
||||
}
|
||||
|
||||
// Run serves the internal HTTP listener until ctx is canceled or one
|
||||
// component fails.
|
||||
func (runtime *Runtime) Run(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return errors.New("run gamemaster runtime: nil context")
|
||||
}
|
||||
if runtime == nil {
|
||||
return errors.New("run gamemaster runtime: nil runtime")
|
||||
}
|
||||
if runtime.app == nil {
|
||||
return errors.New("run gamemaster runtime: nil app")
|
||||
}
|
||||
|
||||
return runtime.app.Run(ctx)
|
||||
}
|
||||
|
||||
// Close releases every runtime dependency in reverse construction order.
|
||||
// Close is safe to call multiple times.
|
||||
func (runtime *Runtime) Close() error {
|
||||
if runtime == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var joined error
|
||||
for index := len(runtime.cleanupFns) - 1; index >= 0; index-- {
|
||||
if err := runtime.cleanupFns[index](); err != nil {
|
||||
joined = errors.Join(joined, err)
|
||||
}
|
||||
}
|
||||
runtime.cleanupFns = nil
|
||||
|
||||
return joined
|
||||
}
|
||||
|
||||
// readinessProbe pings every steady-state dependency the listener
|
||||
// guards: PostgreSQL primary and Redis master.
|
||||
type readinessProbe struct {
|
||||
pgPool *sql.DB
|
||||
redisClient *redis.Client
|
||||
|
||||
postgresTimeout time.Duration
|
||||
redisTimeout time.Duration
|
||||
}
|
||||
|
||||
func newReadinessProbe(pgPool *sql.DB, redisClient *redis.Client, cfg config.Config) *readinessProbe {
|
||||
return &readinessProbe{
|
||||
pgPool: pgPool,
|
||||
redisClient: redisClient,
|
||||
postgresTimeout: cfg.Postgres.Conn.OperationTimeout,
|
||||
redisTimeout: cfg.Redis.Conn.OperationTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
// Check pings PostgreSQL and Redis. The first failing dependency aborts
|
||||
// the check so callers see a single, actionable error.
|
||||
func (probe *readinessProbe) Check(ctx context.Context) error {
|
||||
if err := postgres.Ping(ctx, probe.pgPool, probe.postgresTimeout); err != nil {
|
||||
return err
|
||||
}
|
||||
return redisconn.Ping(ctx, probe.redisClient, probe.redisTimeout)
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"galaxy/gamemaster/internal/adapters/engineclient"
|
||||
"galaxy/gamemaster/internal/adapters/lobbyclient"
|
||||
"galaxy/gamemaster/internal/adapters/lobbyeventspublisher"
|
||||
"galaxy/gamemaster/internal/adapters/notificationpublisher"
|
||||
"galaxy/gamemaster/internal/adapters/postgres/engineversionstore"
|
||||
"galaxy/gamemaster/internal/adapters/postgres/operationlog"
|
||||
"galaxy/gamemaster/internal/adapters/postgres/playermappingstore"
|
||||
"galaxy/gamemaster/internal/adapters/postgres/runtimerecordstore"
|
||||
"galaxy/gamemaster/internal/adapters/redisstate/streamoffsets"
|
||||
"galaxy/gamemaster/internal/adapters/rtmclient"
|
||||
"galaxy/gamemaster/internal/config"
|
||||
"galaxy/gamemaster/internal/service/adminbanish"
|
||||
"galaxy/gamemaster/internal/service/adminforce"
|
||||
"galaxy/gamemaster/internal/service/adminpatch"
|
||||
"galaxy/gamemaster/internal/service/adminstop"
|
||||
"galaxy/gamemaster/internal/service/commandexecute"
|
||||
engineversionsvc "galaxy/gamemaster/internal/service/engineversion"
|
||||
"galaxy/gamemaster/internal/service/livenessreply"
|
||||
"galaxy/gamemaster/internal/service/membership"
|
||||
"galaxy/gamemaster/internal/service/orderput"
|
||||
"galaxy/gamemaster/internal/service/registerruntime"
|
||||
"galaxy/gamemaster/internal/service/reportget"
|
||||
"galaxy/gamemaster/internal/service/scheduler"
|
||||
"galaxy/gamemaster/internal/service/turngeneration"
|
||||
"galaxy/gamemaster/internal/telemetry"
|
||||
"galaxy/gamemaster/internal/worker/healtheventsconsumer"
|
||||
"galaxy/gamemaster/internal/worker/schedulerticker"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// wiring owns the process-level singletons constructed once during
|
||||
// `NewRuntime` and consumed by every worker and HTTP handler. Stage
|
||||
// 19 grew the struct to hold every store, adapter, service and
|
||||
// worker required by the listener and the long-lived components.
|
||||
type wiring struct {
|
||||
cfg config.Config
|
||||
|
||||
redisClient *redis.Client
|
||||
pgPool *sql.DB
|
||||
|
||||
clock func() time.Time
|
||||
|
||||
logger *slog.Logger
|
||||
telemetry *telemetry.Runtime
|
||||
|
||||
// Stores.
|
||||
runtimeRecords *runtimerecordstore.Store
|
||||
engineVersions *engineversionstore.Store
|
||||
playerMappings *playermappingstore.Store
|
||||
operationLogs *operationlog.Store
|
||||
streamOffsets *streamoffsets.Store
|
||||
|
||||
// External adapters.
|
||||
engineClient *engineclient.Client
|
||||
lobbyClient *lobbyclient.Client
|
||||
rtmClient *rtmclient.Client
|
||||
notificationPublisher *notificationpublisher.Publisher
|
||||
lobbyEventsPublisher *lobbyeventspublisher.Publisher
|
||||
|
||||
// Services.
|
||||
membershipCache *membership.Cache
|
||||
registerRuntimeSvc *registerruntime.Service
|
||||
engineVersionSvc *engineversionsvc.Service
|
||||
stopRuntimeSvc *adminstop.Service
|
||||
forceNextTurnSvc *adminforce.Service
|
||||
patchRuntimeSvc *adminpatch.Service
|
||||
banishRaceSvc *adminbanish.Service
|
||||
livenessSvc *livenessreply.Service
|
||||
commandExecuteSvc *commandexecute.Service
|
||||
orderPutSvc *orderput.Service
|
||||
reportGetSvc *reportget.Service
|
||||
schedulerSvc *scheduler.Service
|
||||
turnGenerationSvc *turngeneration.Service
|
||||
|
||||
// Workers.
|
||||
schedulerTicker *schedulerticker.Worker
|
||||
healthEventsConsumer *healtheventsconsumer.Worker
|
||||
|
||||
// closers releases adapter-level resources at runtime shutdown.
|
||||
closers []func() error
|
||||
}
|
||||
|
||||
// newWiring constructs the process-level dependency set. It validates
|
||||
// every required collaborator so callers can rely on them being
|
||||
// non-nil. Construction proceeds in four phases: persistence stores,
|
||||
// external adapters, services, workers. Each phase is in its own
|
||||
// helper to keep the function readable.
|
||||
func newWiring(
|
||||
cfg config.Config,
|
||||
redisClient *redis.Client,
|
||||
pgPool *sql.DB,
|
||||
clock func() time.Time,
|
||||
logger *slog.Logger,
|
||||
telemetryRuntime *telemetry.Runtime,
|
||||
) (*wiring, error) {
|
||||
if redisClient == nil {
|
||||
return nil, errors.New("new gamemaster wiring: nil redis client")
|
||||
}
|
||||
if pgPool == nil {
|
||||
return nil, errors.New("new gamemaster wiring: nil postgres pool")
|
||||
}
|
||||
if clock == nil {
|
||||
clock = time.Now
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
if telemetryRuntime == nil {
|
||||
return nil, fmt.Errorf("new gamemaster wiring: nil telemetry runtime")
|
||||
}
|
||||
|
||||
w := &wiring{
|
||||
cfg: cfg,
|
||||
redisClient: redisClient,
|
||||
pgPool: pgPool,
|
||||
clock: clock,
|
||||
logger: logger,
|
||||
telemetry: telemetryRuntime,
|
||||
}
|
||||
|
||||
if err := w.buildPersistence(); err != nil {
|
||||
return nil, fmt.Errorf("new gamemaster wiring: persistence: %w", err)
|
||||
}
|
||||
if err := w.buildAdapters(); err != nil {
|
||||
return nil, fmt.Errorf("new gamemaster wiring: adapters: %w", err)
|
||||
}
|
||||
if err := w.buildServices(); err != nil {
|
||||
return nil, fmt.Errorf("new gamemaster wiring: services: %w", err)
|
||||
}
|
||||
if err := w.buildWorkers(); err != nil {
|
||||
return nil, fmt.Errorf("new gamemaster wiring: workers: %w", err)
|
||||
}
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// buildPersistence constructs the four PostgreSQL stores plus the
|
||||
// Redis-backed stream-offset store. The stores share the connection
|
||||
// pools opened by the runtime; their lifecycles are owned by the
|
||||
// runtime, not the wiring.
|
||||
func (w *wiring) buildPersistence() error {
|
||||
timeout := w.cfg.Postgres.Conn.OperationTimeout
|
||||
|
||||
runtimeRecords, err := runtimerecordstore.New(runtimerecordstore.Config{
|
||||
DB: w.pgPool,
|
||||
OperationTimeout: timeout,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("runtime record store: %w", err)
|
||||
}
|
||||
w.runtimeRecords = runtimeRecords
|
||||
|
||||
engineVersions, err := engineversionstore.New(engineversionstore.Config{
|
||||
DB: w.pgPool,
|
||||
OperationTimeout: timeout,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("engine version store: %w", err)
|
||||
}
|
||||
w.engineVersions = engineVersions
|
||||
|
||||
playerMappings, err := playermappingstore.New(playermappingstore.Config{
|
||||
DB: w.pgPool,
|
||||
OperationTimeout: timeout,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("player mapping store: %w", err)
|
||||
}
|
||||
w.playerMappings = playerMappings
|
||||
|
||||
operationLogs, err := operationlog.New(operationlog.Config{
|
||||
DB: w.pgPool,
|
||||
OperationTimeout: timeout,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("operation log store: %w", err)
|
||||
}
|
||||
w.operationLogs = operationLogs
|
||||
|
||||
streamOffsets, err := streamoffsets.New(streamoffsets.Config{Client: w.redisClient})
|
||||
if err != nil {
|
||||
return fmt.Errorf("stream offset store: %w", err)
|
||||
}
|
||||
w.streamOffsets = streamOffsets
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildAdapters constructs the HTTP clients (engine, Lobby, Runtime
|
||||
// Manager) and the two Redis Stream publishers. Their `Close` hooks
|
||||
// are appended to w.closers so idle TCP connections are released on
|
||||
// shutdown.
|
||||
func (w *wiring) buildAdapters() error {
|
||||
engine, err := engineclient.NewClient(engineclient.Config{
|
||||
CallTimeout: w.cfg.EngineClient.CallTimeout,
|
||||
ProbeTimeout: w.cfg.EngineClient.ProbeTimeout,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("engine client: %w", err)
|
||||
}
|
||||
w.engineClient = engine
|
||||
w.closers = append(w.closers, engine.Close)
|
||||
|
||||
lobby, err := lobbyclient.NewClient(lobbyclient.Config{
|
||||
BaseURL: w.cfg.Lobby.BaseURL,
|
||||
RequestTimeout: w.cfg.Lobby.Timeout,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("lobby client: %w", err)
|
||||
}
|
||||
w.lobbyClient = lobby
|
||||
w.closers = append(w.closers, lobby.Close)
|
||||
|
||||
rtm, err := rtmclient.NewClient(rtmclient.Config{
|
||||
BaseURL: w.cfg.RTM.BaseURL,
|
||||
RequestTimeout: w.cfg.RTM.Timeout,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("rtm client: %w", err)
|
||||
}
|
||||
w.rtmClient = rtm
|
||||
w.closers = append(w.closers, rtm.Close)
|
||||
|
||||
notification, err := notificationpublisher.NewPublisher(notificationpublisher.Config{
|
||||
Client: w.redisClient,
|
||||
Stream: w.cfg.Streams.NotificationIntents,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("notification publisher: %w", err)
|
||||
}
|
||||
w.notificationPublisher = notification
|
||||
|
||||
lobbyEvents, err := lobbyeventspublisher.NewPublisher(lobbyeventspublisher.Config{
|
||||
Client: w.redisClient,
|
||||
Stream: w.cfg.Streams.LobbyEvents,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("lobby events publisher: %w", err)
|
||||
}
|
||||
w.lobbyEventsPublisher = lobbyEvents
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildServices constructs every service-layer collaborator consumed
|
||||
// by the REST listener and the workers. Construction order matters
|
||||
// only between turngeneration → adminforce (the latter wraps the
|
||||
// former) and between membership cache → command/order/report
|
||||
// services.
|
||||
func (w *wiring) buildServices() error {
|
||||
cache, err := membership.NewCache(membership.Dependencies{
|
||||
Lobby: w.lobbyClient,
|
||||
Telemetry: w.telemetry,
|
||||
Logger: w.logger,
|
||||
Clock: w.clock,
|
||||
TTL: w.cfg.MembershipCache.TTL,
|
||||
MaxGames: w.cfg.MembershipCache.MaxGames,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("membership cache: %w", err)
|
||||
}
|
||||
w.membershipCache = cache
|
||||
|
||||
w.schedulerSvc = scheduler.New()
|
||||
|
||||
registerSvc, err := registerruntime.NewService(registerruntime.Dependencies{
|
||||
RuntimeRecords: w.runtimeRecords,
|
||||
EngineVersions: w.engineVersions,
|
||||
PlayerMappings: w.playerMappings,
|
||||
OperationLogs: w.operationLogs,
|
||||
Engine: w.engineClient,
|
||||
LobbyEvents: w.lobbyEventsPublisher,
|
||||
Telemetry: w.telemetry,
|
||||
Logger: w.logger,
|
||||
Clock: w.clock,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("register runtime service: %w", err)
|
||||
}
|
||||
w.registerRuntimeSvc = registerSvc
|
||||
|
||||
engineVersionSvc, err := engineversionsvc.NewService(engineversionsvc.Dependencies{
|
||||
EngineVersions: w.engineVersions,
|
||||
OperationLogs: w.operationLogs,
|
||||
Logger: w.logger,
|
||||
Clock: w.clock,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("engine version service: %w", err)
|
||||
}
|
||||
w.engineVersionSvc = engineVersionSvc
|
||||
|
||||
turnGen, err := turngeneration.NewService(turngeneration.Dependencies{
|
||||
RuntimeRecords: w.runtimeRecords,
|
||||
PlayerMappings: w.playerMappings,
|
||||
OperationLogs: w.operationLogs,
|
||||
Engine: w.engineClient,
|
||||
LobbyEvents: w.lobbyEventsPublisher,
|
||||
Notifications: w.notificationPublisher,
|
||||
Lobby: w.lobbyClient,
|
||||
Scheduler: w.schedulerSvc,
|
||||
Telemetry: w.telemetry,
|
||||
Logger: w.logger,
|
||||
Clock: w.clock,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("turn generation service: %w", err)
|
||||
}
|
||||
w.turnGenerationSvc = turnGen
|
||||
|
||||
stopSvc, err := adminstop.NewService(adminstop.Dependencies{
|
||||
RuntimeRecords: w.runtimeRecords,
|
||||
OperationLogs: w.operationLogs,
|
||||
RTM: w.rtmClient,
|
||||
LobbyEvents: w.lobbyEventsPublisher,
|
||||
Telemetry: w.telemetry,
|
||||
Logger: w.logger,
|
||||
Clock: w.clock,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("admin stop service: %w", err)
|
||||
}
|
||||
w.stopRuntimeSvc = stopSvc
|
||||
|
||||
forceSvc, err := adminforce.NewService(adminforce.Dependencies{
|
||||
RuntimeRecords: w.runtimeRecords,
|
||||
OperationLogs: w.operationLogs,
|
||||
TurnGeneration: turnGen,
|
||||
Telemetry: w.telemetry,
|
||||
Logger: w.logger,
|
||||
Clock: w.clock,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("admin force service: %w", err)
|
||||
}
|
||||
w.forceNextTurnSvc = forceSvc
|
||||
|
||||
patchSvc, err := adminpatch.NewService(adminpatch.Dependencies{
|
||||
RuntimeRecords: w.runtimeRecords,
|
||||
EngineVersions: w.engineVersions,
|
||||
OperationLogs: w.operationLogs,
|
||||
RTM: w.rtmClient,
|
||||
Telemetry: w.telemetry,
|
||||
Logger: w.logger,
|
||||
Clock: w.clock,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("admin patch service: %w", err)
|
||||
}
|
||||
w.patchRuntimeSvc = patchSvc
|
||||
|
||||
banishSvc, err := adminbanish.NewService(adminbanish.Dependencies{
|
||||
RuntimeRecords: w.runtimeRecords,
|
||||
PlayerMappings: w.playerMappings,
|
||||
OperationLogs: w.operationLogs,
|
||||
Engine: w.engineClient,
|
||||
Telemetry: w.telemetry,
|
||||
Logger: w.logger,
|
||||
Clock: w.clock,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("admin banish service: %w", err)
|
||||
}
|
||||
w.banishRaceSvc = banishSvc
|
||||
|
||||
livenessSvc, err := livenessreply.NewService(livenessreply.Dependencies{
|
||||
RuntimeRecords: w.runtimeRecords,
|
||||
Logger: w.logger,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("liveness reply service: %w", err)
|
||||
}
|
||||
w.livenessSvc = livenessSvc
|
||||
|
||||
commandSvc, err := commandexecute.NewService(commandexecute.Dependencies{
|
||||
RuntimeRecords: w.runtimeRecords,
|
||||
PlayerMappings: w.playerMappings,
|
||||
Membership: cache,
|
||||
Engine: w.engineClient,
|
||||
Telemetry: w.telemetry,
|
||||
Logger: w.logger,
|
||||
Clock: w.clock,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("command execute service: %w", err)
|
||||
}
|
||||
w.commandExecuteSvc = commandSvc
|
||||
|
||||
orderSvc, err := orderput.NewService(orderput.Dependencies{
|
||||
RuntimeRecords: w.runtimeRecords,
|
||||
PlayerMappings: w.playerMappings,
|
||||
Membership: cache,
|
||||
Engine: w.engineClient,
|
||||
Telemetry: w.telemetry,
|
||||
Logger: w.logger,
|
||||
Clock: w.clock,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("put orders service: %w", err)
|
||||
}
|
||||
w.orderPutSvc = orderSvc
|
||||
|
||||
reportSvc, err := reportget.NewService(reportget.Dependencies{
|
||||
RuntimeRecords: w.runtimeRecords,
|
||||
PlayerMappings: w.playerMappings,
|
||||
Membership: cache,
|
||||
Engine: w.engineClient,
|
||||
Telemetry: w.telemetry,
|
||||
Logger: w.logger,
|
||||
Clock: w.clock,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("get report service: %w", err)
|
||||
}
|
||||
w.reportGetSvc = reportSvc
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildWorkers constructs the long-lived components started by
|
||||
// `App.Run` alongside the listener: the per-second scheduler ticker
|
||||
// and the runtime:health_events consumer.
|
||||
func (w *wiring) buildWorkers() error {
|
||||
ticker, err := schedulerticker.NewWorker(schedulerticker.Dependencies{
|
||||
RuntimeRecords: w.runtimeRecords,
|
||||
TurnGeneration: w.turnGenerationSvc,
|
||||
Telemetry: w.telemetry,
|
||||
Interval: w.cfg.Scheduler.TickInterval,
|
||||
Clock: w.clock,
|
||||
Logger: w.logger,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("scheduler ticker: %w", err)
|
||||
}
|
||||
w.schedulerTicker = ticker
|
||||
|
||||
healthConsumer, err := healtheventsconsumer.NewWorker(healtheventsconsumer.Dependencies{
|
||||
Client: w.redisClient,
|
||||
Stream: w.cfg.Streams.HealthEvents,
|
||||
BlockTimeout: w.cfg.Streams.BlockTimeout,
|
||||
OffsetStore: w.streamOffsets,
|
||||
RuntimeRecords: w.runtimeRecords,
|
||||
LobbyEvents: w.lobbyEventsPublisher,
|
||||
Telemetry: w.telemetry,
|
||||
Clock: w.clock,
|
||||
Logger: w.logger,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("health events consumer: %w", err)
|
||||
}
|
||||
w.healthEventsConsumer = healthConsumer
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// close releases adapter-level resources owned by the wiring layer.
|
||||
// Returns the joined error of every closer; the caller is expected
|
||||
// to invoke this once during process shutdown. Closers run in LIFO
|
||||
// order so the resource opened last is released first.
|
||||
func (w *wiring) close() error {
|
||||
var joined error
|
||||
for index := len(w.closers) - 1; index >= 0; index-- {
|
||||
if err := w.closers[index](); err != nil {
|
||||
joined = errors.Join(joined, err)
|
||||
}
|
||||
}
|
||||
w.closers = nil
|
||||
return joined
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
// Package config loads the Game Master process configuration from
|
||||
// environment variables.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/postgres"
|
||||
"galaxy/redisconn"
|
||||
|
||||
"galaxy/gamemaster/internal/telemetry"
|
||||
)
|
||||
|
||||
const (
|
||||
envPrefix = "GAMEMASTER"
|
||||
|
||||
shutdownTimeoutEnvVar = "GAMEMASTER_SHUTDOWN_TIMEOUT"
|
||||
logLevelEnvVar = "GAMEMASTER_LOG_LEVEL"
|
||||
|
||||
internalHTTPAddrEnvVar = "GAMEMASTER_INTERNAL_HTTP_ADDR"
|
||||
internalHTTPReadHeaderTimeoutEnvVar = "GAMEMASTER_INTERNAL_HTTP_READ_HEADER_TIMEOUT"
|
||||
internalHTTPReadTimeoutEnvVar = "GAMEMASTER_INTERNAL_HTTP_READ_TIMEOUT"
|
||||
internalHTTPWriteTimeoutEnvVar = "GAMEMASTER_INTERNAL_HTTP_WRITE_TIMEOUT"
|
||||
internalHTTPIdleTimeoutEnvVar = "GAMEMASTER_INTERNAL_HTTP_IDLE_TIMEOUT"
|
||||
|
||||
lobbyEventsStreamEnvVar = "GAMEMASTER_REDIS_LOBBY_EVENTS_STREAM"
|
||||
healthEventsStreamEnvVar = "GAMEMASTER_REDIS_HEALTH_EVENTS_STREAM"
|
||||
notificationIntentsStreamEnvVar = "GAMEMASTER_REDIS_NOTIFICATION_INTENTS_STREAM"
|
||||
streamBlockTimeoutEnvVar = "GAMEMASTER_STREAM_BLOCK_TIMEOUT"
|
||||
|
||||
engineCallTimeoutEnvVar = "GAMEMASTER_ENGINE_CALL_TIMEOUT"
|
||||
engineProbeTimeoutEnvVar = "GAMEMASTER_ENGINE_PROBE_TIMEOUT"
|
||||
|
||||
lobbyInternalBaseURLEnvVar = "GAMEMASTER_LOBBY_INTERNAL_BASE_URL"
|
||||
lobbyInternalTimeoutEnvVar = "GAMEMASTER_LOBBY_INTERNAL_TIMEOUT"
|
||||
|
||||
rtmInternalBaseURLEnvVar = "GAMEMASTER_RTM_INTERNAL_BASE_URL"
|
||||
rtmInternalTimeoutEnvVar = "GAMEMASTER_RTM_INTERNAL_TIMEOUT"
|
||||
|
||||
schedulerTickIntervalEnvVar = "GAMEMASTER_SCHEDULER_TICK_INTERVAL"
|
||||
turnGenerationTimeoutEnvVar = "GAMEMASTER_TURN_GENERATION_TIMEOUT"
|
||||
membershipCacheTTLEnvVar = "GAMEMASTER_MEMBERSHIP_CACHE_TTL"
|
||||
membershipCacheMaxGamesEnvVar = "GAMEMASTER_MEMBERSHIP_CACHE_MAX_GAMES"
|
||||
|
||||
otelServiceNameEnvVar = "OTEL_SERVICE_NAME"
|
||||
otelTracesExporterEnvVar = "OTEL_TRACES_EXPORTER"
|
||||
otelMetricsExporterEnvVar = "OTEL_METRICS_EXPORTER"
|
||||
otelExporterOTLPProtocolEnvVar = "OTEL_EXPORTER_OTLP_PROTOCOL"
|
||||
otelExporterOTLPTracesProtocolEnvVar = "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL"
|
||||
otelExporterOTLPMetricsProtocolEnvVar = "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL"
|
||||
otelStdoutTracesEnabledEnvVar = "GAMEMASTER_OTEL_STDOUT_TRACES_ENABLED"
|
||||
otelStdoutMetricsEnabledEnvVar = "GAMEMASTER_OTEL_STDOUT_METRICS_ENABLED"
|
||||
|
||||
defaultShutdownTimeout = 30 * time.Second
|
||||
defaultLogLevel = "info"
|
||||
defaultInternalHTTPAddr = ":8097"
|
||||
defaultReadHeaderTimeout = 2 * time.Second
|
||||
defaultReadTimeout = 5 * time.Second
|
||||
defaultWriteTimeout = 30 * time.Second
|
||||
defaultIdleTimeout = 60 * time.Second
|
||||
|
||||
defaultLobbyEventsStream = "gm:lobby_events"
|
||||
defaultHealthEventsStream = "runtime:health_events"
|
||||
defaultNotificationIntentsStream = "notification:intents"
|
||||
defaultStreamBlockTimeout = 5 * time.Second
|
||||
|
||||
defaultEngineCallTimeout = 30 * time.Second
|
||||
defaultEngineProbeTimeout = 5 * time.Second
|
||||
|
||||
defaultLobbyInternalTimeout = 2 * time.Second
|
||||
defaultRTMInternalTimeout = 5 * time.Second
|
||||
|
||||
defaultSchedulerTickInterval = time.Second
|
||||
defaultTurnGenerationTimeout = 60 * time.Second
|
||||
defaultMembershipCacheTTL = 30 * time.Second
|
||||
defaultMembershipCacheMaxGames = 4096
|
||||
|
||||
defaultOTelServiceName = "galaxy-gamemaster"
|
||||
)
|
||||
|
||||
// Config stores the full Game Master process configuration.
|
||||
type Config struct {
|
||||
// ShutdownTimeout bounds graceful shutdown of every long-lived
|
||||
// component.
|
||||
ShutdownTimeout time.Duration
|
||||
|
||||
// Logging configures the process-wide structured logger.
|
||||
Logging LoggingConfig
|
||||
|
||||
// InternalHTTP configures the trusted internal HTTP listener.
|
||||
InternalHTTP InternalHTTPConfig
|
||||
|
||||
// Postgres configures the PostgreSQL-backed durable store consumed
|
||||
// via `pkg/postgres`.
|
||||
Postgres PostgresConfig
|
||||
|
||||
// Redis configures the shared Redis connection topology consumed via
|
||||
// `pkg/redisconn`.
|
||||
Redis RedisConfig
|
||||
|
||||
// Streams stores the stable Redis Stream names GM reads from and
|
||||
// writes to.
|
||||
Streams StreamsConfig
|
||||
|
||||
// EngineClient configures per-call timeouts of the engine HTTP
|
||||
// client.
|
||||
EngineClient EngineClientConfig
|
||||
|
||||
// Lobby configures the synchronous Lobby internal REST client.
|
||||
Lobby LobbyClientConfig
|
||||
|
||||
// RTM configures the synchronous Runtime Manager internal REST
|
||||
// client.
|
||||
RTM RTMClientConfig
|
||||
|
||||
// Scheduler configures the scheduler ticker worker and the per-turn
|
||||
// generation deadline.
|
||||
Scheduler SchedulerConfig
|
||||
|
||||
// MembershipCache configures the in-process membership cache.
|
||||
MembershipCache MembershipCacheConfig
|
||||
|
||||
// Telemetry configures the process-wide OpenTelemetry runtime.
|
||||
Telemetry TelemetryConfig
|
||||
}
|
||||
|
||||
// LoggingConfig configures the process-wide structured logger.
|
||||
type LoggingConfig struct {
|
||||
// Level stores the process log level accepted by log/slog.
|
||||
Level string
|
||||
}
|
||||
|
||||
// InternalHTTPConfig configures the trusted internal HTTP listener.
|
||||
type InternalHTTPConfig struct {
|
||||
// Addr stores the TCP listen address.
|
||||
Addr string
|
||||
|
||||
// ReadHeaderTimeout bounds request-header reading.
|
||||
ReadHeaderTimeout time.Duration
|
||||
|
||||
// ReadTimeout bounds reading one request.
|
||||
ReadTimeout time.Duration
|
||||
|
||||
// WriteTimeout bounds writing one response.
|
||||
WriteTimeout time.Duration
|
||||
|
||||
// IdleTimeout bounds how long keep-alive connections stay open.
|
||||
IdleTimeout time.Duration
|
||||
}
|
||||
|
||||
// Validate reports whether cfg stores a usable internal HTTP listener
|
||||
// configuration.
|
||||
func (cfg InternalHTTPConfig) Validate() error {
|
||||
switch {
|
||||
case strings.TrimSpace(cfg.Addr) == "":
|
||||
return fmt.Errorf("internal HTTP addr must not be empty")
|
||||
case !isTCPAddr(cfg.Addr):
|
||||
return fmt.Errorf("internal HTTP addr %q must use host:port form", cfg.Addr)
|
||||
case cfg.ReadHeaderTimeout <= 0:
|
||||
return fmt.Errorf("internal HTTP read header timeout must be positive")
|
||||
case cfg.ReadTimeout <= 0:
|
||||
return fmt.Errorf("internal HTTP read timeout must be positive")
|
||||
case cfg.WriteTimeout <= 0:
|
||||
return fmt.Errorf("internal HTTP write timeout must be positive")
|
||||
case cfg.IdleTimeout <= 0:
|
||||
return fmt.Errorf("internal HTTP idle timeout must be positive")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// PostgresConfig configures the PostgreSQL-backed durable store consumed
|
||||
// via `pkg/postgres`.
|
||||
type PostgresConfig struct {
|
||||
// Conn carries the primary plus replica DSN topology and pool tuning.
|
||||
Conn postgres.Config
|
||||
}
|
||||
|
||||
// Validate reports whether cfg stores a usable PostgreSQL configuration.
|
||||
func (cfg PostgresConfig) Validate() error {
|
||||
return cfg.Conn.Validate()
|
||||
}
|
||||
|
||||
// RedisConfig configures the Game Master Redis connection topology.
|
||||
type RedisConfig struct {
|
||||
// Conn carries the connection topology (master, replicas, password,
|
||||
// db, per-call timeout).
|
||||
Conn redisconn.Config
|
||||
}
|
||||
|
||||
// Validate reports whether cfg stores a usable Redis configuration.
|
||||
func (cfg RedisConfig) Validate() error {
|
||||
return cfg.Conn.Validate()
|
||||
}
|
||||
|
||||
// StreamsConfig stores the stable Redis Stream names used by Game Master.
|
||||
type StreamsConfig struct {
|
||||
// LobbyEvents stores the Redis Streams key GM publishes runtime
|
||||
// snapshot updates and game-finished events to.
|
||||
LobbyEvents string
|
||||
|
||||
// HealthEvents stores the Redis Streams key GM consumes runtime
|
||||
// health events from.
|
||||
HealthEvents string
|
||||
|
||||
// NotificationIntents stores the Redis Streams key GM publishes
|
||||
// notification intents to.
|
||||
NotificationIntents string
|
||||
|
||||
// BlockTimeout bounds the maximum blocking read window for stream
|
||||
// consumers.
|
||||
BlockTimeout time.Duration
|
||||
}
|
||||
|
||||
// Validate reports whether cfg stores usable stream names.
|
||||
func (cfg StreamsConfig) Validate() error {
|
||||
switch {
|
||||
case strings.TrimSpace(cfg.LobbyEvents) == "":
|
||||
return fmt.Errorf("redis lobby events stream must not be empty")
|
||||
case strings.TrimSpace(cfg.HealthEvents) == "":
|
||||
return fmt.Errorf("redis health events stream must not be empty")
|
||||
case strings.TrimSpace(cfg.NotificationIntents) == "":
|
||||
return fmt.Errorf("redis notification intents stream must not be empty")
|
||||
case cfg.BlockTimeout <= 0:
|
||||
return fmt.Errorf("redis stream block timeout must be positive")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// EngineClientConfig configures per-call timeouts of the engine HTTP
|
||||
// client.
|
||||
type EngineClientConfig struct {
|
||||
// CallTimeout bounds one full engine call (including turn generation
|
||||
// for large games).
|
||||
CallTimeout time.Duration
|
||||
|
||||
// ProbeTimeout bounds inspect-style reads against the engine.
|
||||
ProbeTimeout time.Duration
|
||||
}
|
||||
|
||||
// Validate reports whether cfg stores usable engine client timeouts.
|
||||
func (cfg EngineClientConfig) Validate() error {
|
||||
switch {
|
||||
case cfg.CallTimeout <= 0:
|
||||
return fmt.Errorf("engine call timeout must be positive")
|
||||
case cfg.ProbeTimeout <= 0:
|
||||
return fmt.Errorf("engine probe timeout must be positive")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// LobbyClientConfig configures the synchronous Lobby internal REST
|
||||
// client.
|
||||
type LobbyClientConfig struct {
|
||||
// BaseURL stores the trusted Lobby internal listener base URL.
|
||||
BaseURL string
|
||||
|
||||
// Timeout bounds one Lobby internal request.
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// Validate reports whether cfg stores a usable Lobby client
|
||||
// configuration.
|
||||
func (cfg LobbyClientConfig) Validate() error {
|
||||
switch {
|
||||
case strings.TrimSpace(cfg.BaseURL) == "":
|
||||
return fmt.Errorf("lobby internal base url must not be empty")
|
||||
case !isHTTPURL(cfg.BaseURL):
|
||||
return fmt.Errorf("lobby internal base url %q must be an absolute http(s) URL", cfg.BaseURL)
|
||||
case cfg.Timeout <= 0:
|
||||
return fmt.Errorf("lobby internal timeout must be positive")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// RTMClientConfig configures the synchronous Runtime Manager internal
|
||||
// REST client.
|
||||
type RTMClientConfig struct {
|
||||
// BaseURL stores the trusted Runtime Manager internal listener base
|
||||
// URL.
|
||||
BaseURL string
|
||||
|
||||
// Timeout bounds one Runtime Manager internal request.
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// Validate reports whether cfg stores a usable Runtime Manager client
|
||||
// configuration.
|
||||
func (cfg RTMClientConfig) Validate() error {
|
||||
switch {
|
||||
case strings.TrimSpace(cfg.BaseURL) == "":
|
||||
return fmt.Errorf("rtm internal base url must not be empty")
|
||||
case !isHTTPURL(cfg.BaseURL):
|
||||
return fmt.Errorf("rtm internal base url %q must be an absolute http(s) URL", cfg.BaseURL)
|
||||
case cfg.Timeout <= 0:
|
||||
return fmt.Errorf("rtm internal timeout must be positive")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// SchedulerConfig configures the scheduler ticker worker and the
|
||||
// per-turn generation deadline.
|
||||
type SchedulerConfig struct {
|
||||
// TickInterval is the period between two scheduler scans for due
|
||||
// runtime records.
|
||||
TickInterval time.Duration
|
||||
|
||||
// TurnGenerationTimeout bounds one engine `/admin/turn` call from
|
||||
// the scheduler's perspective.
|
||||
TurnGenerationTimeout time.Duration
|
||||
}
|
||||
|
||||
// Validate reports whether cfg stores usable scheduler timings.
|
||||
func (cfg SchedulerConfig) Validate() error {
|
||||
switch {
|
||||
case cfg.TickInterval <= 0:
|
||||
return fmt.Errorf("scheduler tick interval must be positive")
|
||||
case cfg.TurnGenerationTimeout <= 0:
|
||||
return fmt.Errorf("turn generation timeout must be positive")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MembershipCacheConfig configures the in-process membership cache.
|
||||
type MembershipCacheConfig struct {
|
||||
// TTL bounds how long an unobserved membership entry stays cached
|
||||
// before a forced reload from Lobby.
|
||||
TTL time.Duration
|
||||
|
||||
// MaxGames bounds how many games can populate the cache before
|
||||
// LRU eviction kicks in.
|
||||
MaxGames int
|
||||
}
|
||||
|
||||
// Validate reports whether cfg stores usable membership cache settings.
|
||||
func (cfg MembershipCacheConfig) Validate() error {
|
||||
switch {
|
||||
case cfg.TTL <= 0:
|
||||
return fmt.Errorf("membership cache ttl must be positive")
|
||||
case cfg.MaxGames <= 0:
|
||||
return fmt.Errorf("membership cache max games must be positive")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// TelemetryConfig configures the Game Master OpenTelemetry runtime.
|
||||
type TelemetryConfig struct {
|
||||
// ServiceName overrides the default OpenTelemetry service name.
|
||||
ServiceName string
|
||||
|
||||
// TracesExporter selects the external traces exporter. Supported
|
||||
// values are `none` and `otlp`.
|
||||
TracesExporter string
|
||||
|
||||
// MetricsExporter selects the external metrics exporter. Supported
|
||||
// values are `none` and `otlp`.
|
||||
MetricsExporter string
|
||||
|
||||
// TracesProtocol selects the OTLP traces protocol when
|
||||
// TracesExporter is `otlp`.
|
||||
TracesProtocol string
|
||||
|
||||
// MetricsProtocol selects the OTLP metrics protocol when
|
||||
// MetricsExporter is `otlp`.
|
||||
MetricsProtocol string
|
||||
|
||||
// StdoutTracesEnabled enables the additional stdout trace exporter
|
||||
// used for local development and debugging.
|
||||
StdoutTracesEnabled bool
|
||||
|
||||
// StdoutMetricsEnabled enables the additional stdout metric
|
||||
// exporter used for local development and debugging.
|
||||
StdoutMetricsEnabled bool
|
||||
}
|
||||
|
||||
// Validate reports whether cfg contains a supported OpenTelemetry
|
||||
// configuration.
|
||||
func (cfg TelemetryConfig) Validate() error {
|
||||
return telemetry.ProcessConfig{
|
||||
ServiceName: cfg.ServiceName,
|
||||
TracesExporter: cfg.TracesExporter,
|
||||
MetricsExporter: cfg.MetricsExporter,
|
||||
TracesProtocol: cfg.TracesProtocol,
|
||||
MetricsProtocol: cfg.MetricsProtocol,
|
||||
StdoutTracesEnabled: cfg.StdoutTracesEnabled,
|
||||
StdoutMetricsEnabled: cfg.StdoutMetricsEnabled,
|
||||
}.Validate()
|
||||
}
|
||||
|
||||
// DefaultConfig returns the default Game Master process configuration.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
ShutdownTimeout: defaultShutdownTimeout,
|
||||
Logging: LoggingConfig{
|
||||
Level: defaultLogLevel,
|
||||
},
|
||||
InternalHTTP: InternalHTTPConfig{
|
||||
Addr: defaultInternalHTTPAddr,
|
||||
ReadHeaderTimeout: defaultReadHeaderTimeout,
|
||||
ReadTimeout: defaultReadTimeout,
|
||||
WriteTimeout: defaultWriteTimeout,
|
||||
IdleTimeout: defaultIdleTimeout,
|
||||
},
|
||||
Postgres: PostgresConfig{
|
||||
Conn: postgres.DefaultConfig(),
|
||||
},
|
||||
Redis: RedisConfig{
|
||||
Conn: redisconn.DefaultConfig(),
|
||||
},
|
||||
Streams: StreamsConfig{
|
||||
LobbyEvents: defaultLobbyEventsStream,
|
||||
HealthEvents: defaultHealthEventsStream,
|
||||
NotificationIntents: defaultNotificationIntentsStream,
|
||||
BlockTimeout: defaultStreamBlockTimeout,
|
||||
},
|
||||
EngineClient: EngineClientConfig{
|
||||
CallTimeout: defaultEngineCallTimeout,
|
||||
ProbeTimeout: defaultEngineProbeTimeout,
|
||||
},
|
||||
Lobby: LobbyClientConfig{
|
||||
Timeout: defaultLobbyInternalTimeout,
|
||||
},
|
||||
RTM: RTMClientConfig{
|
||||
Timeout: defaultRTMInternalTimeout,
|
||||
},
|
||||
Scheduler: SchedulerConfig{
|
||||
TickInterval: defaultSchedulerTickInterval,
|
||||
TurnGenerationTimeout: defaultTurnGenerationTimeout,
|
||||
},
|
||||
MembershipCache: MembershipCacheConfig{
|
||||
TTL: defaultMembershipCacheTTL,
|
||||
MaxGames: defaultMembershipCacheMaxGames,
|
||||
},
|
||||
Telemetry: TelemetryConfig{
|
||||
ServiceName: defaultOTelServiceName,
|
||||
TracesExporter: "none",
|
||||
MetricsExporter: "none",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func validEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
t.Setenv("GAMEMASTER_INTERNAL_HTTP_ADDR", ":8097")
|
||||
t.Setenv("GAMEMASTER_POSTGRES_PRIMARY_DSN", "postgres://gm:secret@localhost:5432/galaxy?search_path=gamemaster&sslmode=disable")
|
||||
t.Setenv("GAMEMASTER_REDIS_MASTER_ADDR", "localhost:6379")
|
||||
t.Setenv("GAMEMASTER_REDIS_PASSWORD", "secret")
|
||||
t.Setenv("GAMEMASTER_LOBBY_INTERNAL_BASE_URL", "http://lobby:8095")
|
||||
t.Setenv("GAMEMASTER_RTM_INTERNAL_BASE_URL", "http://rtmanager:8096")
|
||||
}
|
||||
|
||||
func TestLoadFromEnvAcceptsDefaults(t *testing.T) {
|
||||
validEnv(t)
|
||||
|
||||
cfg, err := LoadFromEnv()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, ":8097", cfg.InternalHTTP.Addr)
|
||||
require.Equal(t, 30*time.Second, cfg.ShutdownTimeout)
|
||||
require.Equal(t, "info", cfg.Logging.Level)
|
||||
require.Equal(t, "gm:lobby_events", cfg.Streams.LobbyEvents)
|
||||
require.Equal(t, "runtime:health_events", cfg.Streams.HealthEvents)
|
||||
require.Equal(t, "notification:intents", cfg.Streams.NotificationIntents)
|
||||
require.Equal(t, 5*time.Second, cfg.Streams.BlockTimeout)
|
||||
require.Equal(t, 30*time.Second, cfg.EngineClient.CallTimeout)
|
||||
require.Equal(t, 5*time.Second, cfg.EngineClient.ProbeTimeout)
|
||||
require.Equal(t, "http://lobby:8095", cfg.Lobby.BaseURL)
|
||||
require.Equal(t, 2*time.Second, cfg.Lobby.Timeout)
|
||||
require.Equal(t, "http://rtmanager:8096", cfg.RTM.BaseURL)
|
||||
require.Equal(t, 5*time.Second, cfg.RTM.Timeout)
|
||||
require.Equal(t, time.Second, cfg.Scheduler.TickInterval)
|
||||
require.Equal(t, 60*time.Second, cfg.Scheduler.TurnGenerationTimeout)
|
||||
require.Equal(t, 30*time.Second, cfg.MembershipCache.TTL)
|
||||
require.Equal(t, 4096, cfg.MembershipCache.MaxGames)
|
||||
require.Equal(t, "galaxy-gamemaster", cfg.Telemetry.ServiceName)
|
||||
}
|
||||
|
||||
func TestLoadFromEnvHonoursOverrides(t *testing.T) {
|
||||
validEnv(t)
|
||||
t.Setenv("GAMEMASTER_INTERNAL_HTTP_ADDR", ":9097")
|
||||
t.Setenv("GAMEMASTER_REDIS_LOBBY_EVENTS_STREAM", "custom:lobby_events")
|
||||
t.Setenv("GAMEMASTER_ENGINE_CALL_TIMEOUT", "45s")
|
||||
t.Setenv("GAMEMASTER_SCHEDULER_TICK_INTERVAL", "500ms")
|
||||
t.Setenv("GAMEMASTER_MEMBERSHIP_CACHE_TTL", "60s")
|
||||
t.Setenv("GAMEMASTER_MEMBERSHIP_CACHE_MAX_GAMES", "1024")
|
||||
|
||||
cfg, err := LoadFromEnv()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, ":9097", cfg.InternalHTTP.Addr)
|
||||
require.Equal(t, "custom:lobby_events", cfg.Streams.LobbyEvents)
|
||||
require.Equal(t, 45*time.Second, cfg.EngineClient.CallTimeout)
|
||||
require.Equal(t, 500*time.Millisecond, cfg.Scheduler.TickInterval)
|
||||
require.Equal(t, 60*time.Second, cfg.MembershipCache.TTL)
|
||||
require.Equal(t, 1024, cfg.MembershipCache.MaxGames)
|
||||
}
|
||||
|
||||
func TestLoadFromEnvRequiresInternalHTTPAddr(t *testing.T) {
|
||||
t.Setenv("GAMEMASTER_POSTGRES_PRIMARY_DSN", "postgres://gm:secret@localhost:5432/galaxy")
|
||||
t.Setenv("GAMEMASTER_REDIS_MASTER_ADDR", "localhost:6379")
|
||||
t.Setenv("GAMEMASTER_REDIS_PASSWORD", "secret")
|
||||
t.Setenv("GAMEMASTER_LOBBY_INTERNAL_BASE_URL", "http://lobby:8095")
|
||||
t.Setenv("GAMEMASTER_RTM_INTERNAL_BASE_URL", "http://rtmanager:8096")
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "GAMEMASTER_INTERNAL_HTTP_ADDR")
|
||||
}
|
||||
|
||||
func TestLoadFromEnvRequiresLobbyBaseURL(t *testing.T) {
|
||||
t.Setenv("GAMEMASTER_INTERNAL_HTTP_ADDR", ":8097")
|
||||
t.Setenv("GAMEMASTER_POSTGRES_PRIMARY_DSN", "postgres://gm:secret@localhost:5432/galaxy")
|
||||
t.Setenv("GAMEMASTER_REDIS_MASTER_ADDR", "localhost:6379")
|
||||
t.Setenv("GAMEMASTER_REDIS_PASSWORD", "secret")
|
||||
t.Setenv("GAMEMASTER_RTM_INTERNAL_BASE_URL", "http://rtmanager:8096")
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "GAMEMASTER_LOBBY_INTERNAL_BASE_URL")
|
||||
}
|
||||
|
||||
func TestLoadFromEnvRequiresRTMBaseURL(t *testing.T) {
|
||||
t.Setenv("GAMEMASTER_INTERNAL_HTTP_ADDR", ":8097")
|
||||
t.Setenv("GAMEMASTER_POSTGRES_PRIMARY_DSN", "postgres://gm:secret@localhost:5432/galaxy")
|
||||
t.Setenv("GAMEMASTER_REDIS_MASTER_ADDR", "localhost:6379")
|
||||
t.Setenv("GAMEMASTER_REDIS_PASSWORD", "secret")
|
||||
t.Setenv("GAMEMASTER_LOBBY_INTERNAL_BASE_URL", "http://lobby:8095")
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "GAMEMASTER_RTM_INTERNAL_BASE_URL")
|
||||
}
|
||||
|
||||
func TestLoadFromEnvRejectsBadLogLevel(t *testing.T) {
|
||||
validEnv(t)
|
||||
t.Setenv("GAMEMASTER_LOG_LEVEL", "verbose")
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "GAMEMASTER_LOG_LEVEL")
|
||||
}
|
||||
|
||||
func TestLoadFromEnvRejectsBadDuration(t *testing.T) {
|
||||
validEnv(t)
|
||||
t.Setenv("GAMEMASTER_ENGINE_CALL_TIMEOUT", "thirty seconds")
|
||||
|
||||
_, err := LoadFromEnv()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "GAMEMASTER_ENGINE_CALL_TIMEOUT")
|
||||
}
|
||||
|
||||
func TestInternalHTTPValidateRejectsBadAddr(t *testing.T) {
|
||||
cfg := DefaultConfig().InternalHTTP
|
||||
cfg.Addr = "not-an-addr"
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "host:port")
|
||||
}
|
||||
|
||||
func TestStreamsValidateRequiresAllNames(t *testing.T) {
|
||||
cfg := DefaultConfig().Streams
|
||||
cfg.LobbyEvents = " "
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.True(t, strings.Contains(err.Error(), "lobby events"))
|
||||
}
|
||||
|
||||
func TestLobbyClientValidateRejectsBadURL(t *testing.T) {
|
||||
cfg := LobbyClientConfig{BaseURL: "ftp://lobby", Timeout: time.Second}
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "http(s)")
|
||||
}
|
||||
|
||||
func TestRTMClientValidateRejectsEmptyURL(t *testing.T) {
|
||||
cfg := RTMClientConfig{BaseURL: " ", Timeout: time.Second}
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "rtm internal base url")
|
||||
}
|
||||
|
||||
func TestSchedulerValidateRejectsZeroInterval(t *testing.T) {
|
||||
cfg := SchedulerConfig{TickInterval: 0, TurnGenerationTimeout: time.Second}
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "scheduler tick interval")
|
||||
}
|
||||
|
||||
func TestMembershipCacheValidateRejectsZero(t *testing.T) {
|
||||
cfg := MembershipCacheConfig{TTL: 0, MaxGames: 1}
|
||||
err := cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "ttl")
|
||||
|
||||
cfg = MembershipCacheConfig{TTL: time.Second, MaxGames: 0}
|
||||
err = cfg.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "max games")
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/postgres"
|
||||
"galaxy/redisconn"
|
||||
)
|
||||
|
||||
// LoadFromEnv builds Config from environment variables and validates the
|
||||
// resulting configuration.
|
||||
func LoadFromEnv() (Config, error) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
var err error
|
||||
|
||||
cfg.ShutdownTimeout, err = durationEnv(shutdownTimeoutEnvVar, cfg.ShutdownTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.Logging.Level = stringEnv(logLevelEnvVar, cfg.Logging.Level)
|
||||
|
||||
addr, ok := os.LookupEnv(internalHTTPAddrEnvVar)
|
||||
if !ok || strings.TrimSpace(addr) == "" {
|
||||
return Config{}, fmt.Errorf("%s must be set", internalHTTPAddrEnvVar)
|
||||
}
|
||||
cfg.InternalHTTP.Addr = strings.TrimSpace(addr)
|
||||
cfg.InternalHTTP.ReadHeaderTimeout, err = durationEnv(internalHTTPReadHeaderTimeoutEnvVar, cfg.InternalHTTP.ReadHeaderTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.InternalHTTP.ReadTimeout, err = durationEnv(internalHTTPReadTimeoutEnvVar, cfg.InternalHTTP.ReadTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.InternalHTTP.WriteTimeout, err = durationEnv(internalHTTPWriteTimeoutEnvVar, cfg.InternalHTTP.WriteTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.InternalHTTP.IdleTimeout, err = durationEnv(internalHTTPIdleTimeoutEnvVar, cfg.InternalHTTP.IdleTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
pgConn, err := postgres.LoadFromEnv(envPrefix)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Postgres.Conn = pgConn
|
||||
|
||||
redisConn, err := redisconn.LoadFromEnv(envPrefix)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Redis.Conn = redisConn
|
||||
|
||||
cfg.Streams.LobbyEvents = stringEnv(lobbyEventsStreamEnvVar, cfg.Streams.LobbyEvents)
|
||||
cfg.Streams.HealthEvents = stringEnv(healthEventsStreamEnvVar, cfg.Streams.HealthEvents)
|
||||
cfg.Streams.NotificationIntents = stringEnv(notificationIntentsStreamEnvVar, cfg.Streams.NotificationIntents)
|
||||
cfg.Streams.BlockTimeout, err = durationEnv(streamBlockTimeoutEnvVar, cfg.Streams.BlockTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.EngineClient.CallTimeout, err = durationEnv(engineCallTimeoutEnvVar, cfg.EngineClient.CallTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.EngineClient.ProbeTimeout, err = durationEnv(engineProbeTimeoutEnvVar, cfg.EngineClient.ProbeTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
lobbyURL, ok := os.LookupEnv(lobbyInternalBaseURLEnvVar)
|
||||
if !ok || strings.TrimSpace(lobbyURL) == "" {
|
||||
return Config{}, fmt.Errorf("%s must be set", lobbyInternalBaseURLEnvVar)
|
||||
}
|
||||
cfg.Lobby.BaseURL = strings.TrimSpace(lobbyURL)
|
||||
cfg.Lobby.Timeout, err = durationEnv(lobbyInternalTimeoutEnvVar, cfg.Lobby.Timeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
rtmURL, ok := os.LookupEnv(rtmInternalBaseURLEnvVar)
|
||||
if !ok || strings.TrimSpace(rtmURL) == "" {
|
||||
return Config{}, fmt.Errorf("%s must be set", rtmInternalBaseURLEnvVar)
|
||||
}
|
||||
cfg.RTM.BaseURL = strings.TrimSpace(rtmURL)
|
||||
cfg.RTM.Timeout, err = durationEnv(rtmInternalTimeoutEnvVar, cfg.RTM.Timeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.Scheduler.TickInterval, err = durationEnv(schedulerTickIntervalEnvVar, cfg.Scheduler.TickInterval)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Scheduler.TurnGenerationTimeout, err = durationEnv(turnGenerationTimeoutEnvVar, cfg.Scheduler.TurnGenerationTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.MembershipCache.TTL, err = durationEnv(membershipCacheTTLEnvVar, cfg.MembershipCache.TTL)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.MembershipCache.MaxGames, err = intEnv(membershipCacheMaxGamesEnvVar, cfg.MembershipCache.MaxGames)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.Telemetry.ServiceName = stringEnv(otelServiceNameEnvVar, cfg.Telemetry.ServiceName)
|
||||
cfg.Telemetry.TracesExporter = normalizeExporterValue(stringEnv(otelTracesExporterEnvVar, cfg.Telemetry.TracesExporter))
|
||||
cfg.Telemetry.MetricsExporter = normalizeExporterValue(stringEnv(otelMetricsExporterEnvVar, cfg.Telemetry.MetricsExporter))
|
||||
cfg.Telemetry.TracesProtocol = normalizeProtocolValue(
|
||||
os.Getenv(otelExporterOTLPTracesProtocolEnvVar),
|
||||
os.Getenv(otelExporterOTLPProtocolEnvVar),
|
||||
cfg.Telemetry.TracesProtocol,
|
||||
)
|
||||
cfg.Telemetry.MetricsProtocol = normalizeProtocolValue(
|
||||
os.Getenv(otelExporterOTLPMetricsProtocolEnvVar),
|
||||
os.Getenv(otelExporterOTLPProtocolEnvVar),
|
||||
cfg.Telemetry.MetricsProtocol,
|
||||
)
|
||||
cfg.Telemetry.StdoutTracesEnabled, err = boolEnv(otelStdoutTracesEnabledEnvVar, cfg.Telemetry.StdoutTracesEnabled)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Telemetry.StdoutMetricsEnabled, err = boolEnv(otelStdoutMetricsEnabledEnvVar, cfg.Telemetry.StdoutMetricsEnabled)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func stringEnv(name string, fallback string) string {
|
||||
value, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func durationEnv(name string, fallback time.Duration) (time.Duration, error) {
|
||||
value, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
parsed, err := time.ParseDuration(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: parse duration: %w", name, err)
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func intEnv(name string, fallback int) (int, error) {
|
||||
value, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s: parse int: %w", name, err)
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func boolEnv(name string, fallback bool) (bool, error) {
|
||||
value, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("%s: parse bool: %w", name, err)
|
||||
}
|
||||
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func normalizeExporterValue(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
switch trimmed {
|
||||
case "", "none":
|
||||
return "none"
|
||||
default:
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeProtocolValue(primary string, fallback string, defaultValue string) string {
|
||||
primary = strings.TrimSpace(primary)
|
||||
if primary != "" {
|
||||
return primary
|
||||
}
|
||||
|
||||
fallback = strings.TrimSpace(fallback)
|
||||
if fallback != "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return strings.TrimSpace(defaultValue)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Validate reports whether cfg stores a usable Game Master process
|
||||
// configuration.
|
||||
func (cfg Config) Validate() error {
|
||||
if cfg.ShutdownTimeout <= 0 {
|
||||
return fmt.Errorf("%s must be positive", shutdownTimeoutEnvVar)
|
||||
}
|
||||
if err := validateSlogLevel(cfg.Logging.Level); err != nil {
|
||||
return fmt.Errorf("%s: %w", logLevelEnvVar, err)
|
||||
}
|
||||
if err := cfg.InternalHTTP.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.Postgres.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.Redis.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.Streams.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.EngineClient.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.Lobby.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.RTM.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.Scheduler.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.MembershipCache.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.Telemetry.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSlogLevel(level string) error {
|
||||
var slogLevel slog.Level
|
||||
if err := slogLevel.UnmarshalText([]byte(strings.TrimSpace(level))); err != nil {
|
||||
return fmt.Errorf("invalid slog level %q: %w", level, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isTCPAddr(value string) bool {
|
||||
host, port, err := net.SplitHostPort(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if port == "" {
|
||||
return false
|
||||
}
|
||||
if host == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
return !strings.Contains(host, " ")
|
||||
}
|
||||
|
||||
func isHTTPURL(value string) bool {
|
||||
parsed, err := url.Parse(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return false
|
||||
}
|
||||
|
||||
return parsed.Host != ""
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Package engineversion defines the engine version registry domain
|
||||
// model owned by Game Master.
|
||||
//
|
||||
// The registry mirrors the durable shape of the `engine_versions`
|
||||
// PostgreSQL table (see
|
||||
// `galaxy/gamemaster/internal/adapters/postgres/migrations/00001_init.sql`)
|
||||
// and the user-visible status enum frozen in
|
||||
// `galaxy/gamemaster/api/internal-openapi.yaml`.
|
||||
//
|
||||
// `Options` is intentionally kept opaque ([]byte holding raw JSON) so
|
||||
// the v1 service does not impose a Go-side schema on the engine-owned
|
||||
// document. Schema-aware handling lands when an engine version actually
|
||||
// requires it; until then the registry is a pass-through store.
|
||||
package engineversion
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Status identifies one engine-version registry state.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
// StatusActive marks a version as deployable. Lobby's start flow
|
||||
// resolves image refs only against active versions.
|
||||
StatusActive Status = "active"
|
||||
|
||||
// StatusDeprecated marks a version as no longer offered for new
|
||||
// starts. Already-running games on a deprecated version are
|
||||
// unaffected; the runtime stays bound to the version it started on.
|
||||
StatusDeprecated Status = "deprecated"
|
||||
)
|
||||
|
||||
// IsKnown reports whether status belongs to the frozen engine-version
|
||||
// status vocabulary.
|
||||
func (status Status) IsKnown() bool {
|
||||
switch status {
|
||||
case StatusActive, StatusDeprecated:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// AllStatuses returns the frozen list of every engine-version status
|
||||
// value. The slice order is stable across calls.
|
||||
func AllStatuses() []Status {
|
||||
return []Status{StatusActive, StatusDeprecated}
|
||||
}
|
||||
|
||||
// EngineVersion stores one row of the `engine_versions` registry table.
|
||||
// Options carries the raw `jsonb` document verbatim so the registry
|
||||
// stays decoupled from any engine-side schema.
|
||||
type EngineVersion struct {
|
||||
// Version stores the canonical semver string (primary key).
|
||||
Version string
|
||||
|
||||
// ImageRef stores the Docker reference of the engine image.
|
||||
ImageRef string
|
||||
|
||||
// Options stores the engine-side options document as raw JSON. Empty
|
||||
// is treated as `{}` by adapters that hydrate the column.
|
||||
Options []byte
|
||||
|
||||
// Status reports whether the version is deployable (`active`) or
|
||||
// no longer offered for new starts (`deprecated`).
|
||||
Status Status
|
||||
|
||||
// CreatedAt stores the wall-clock at which the row was created.
|
||||
CreatedAt time.Time
|
||||
|
||||
// UpdatedAt stores the wall-clock of the most recent mutation.
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether record satisfies the engine-version
|
||||
// invariants implied by `engine_versions_status_chk` and the README
|
||||
// §Engine Version Registry surface.
|
||||
func (record EngineVersion) Validate() error {
|
||||
if strings.TrimSpace(record.Version) == "" {
|
||||
return fmt.Errorf("version must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(record.ImageRef) == "" {
|
||||
return fmt.Errorf("image ref must not be empty")
|
||||
}
|
||||
if !record.Status.IsKnown() {
|
||||
return fmt.Errorf("status %q is unsupported", record.Status)
|
||||
}
|
||||
if record.CreatedAt.IsZero() {
|
||||
return fmt.Errorf("created at must not be zero")
|
||||
}
|
||||
if record.UpdatedAt.IsZero() {
|
||||
return fmt.Errorf("updated at must not be zero")
|
||||
}
|
||||
if record.UpdatedAt.Before(record.CreatedAt) {
|
||||
return fmt.Errorf("updated at must not be before created at")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrNotFound reports that an engine-version lookup failed because no
|
||||
// matching row exists.
|
||||
var ErrNotFound = errors.New("engine version not found")
|
||||
|
||||
// ErrInUse reports that a hard-delete or deprecate operation was
|
||||
// rejected because the version is still referenced by a non-finished
|
||||
// runtime record.
|
||||
var ErrInUse = errors.New("engine version in use")
|
||||
|
||||
// ErrConflict reports that an engine-version mutation could not be
|
||||
// applied because a row with the same primary key already exists.
|
||||
// Adapters surface a PostgreSQL unique-violation through this sentinel
|
||||
// so the service layer maps it to a `conflict` REST envelope.
|
||||
var ErrConflict = errors.New("engine version already exists")
|
||||
|
||||
// ErrInvalidSemver reports that a semver string did not parse against
|
||||
// `golang.org/x/mod/semver`'s grammar.
|
||||
var ErrInvalidSemver = errors.New("invalid semver")
|
||||
@@ -0,0 +1,63 @@
|
||||
package engineversion
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func validVersion() EngineVersion {
|
||||
created := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
|
||||
return EngineVersion{
|
||||
Version: "v1.2.3",
|
||||
ImageRef: "ghcr.io/galaxy/game:v1.2.3",
|
||||
Options: []byte(`{"max_planets":120}`),
|
||||
Status: StatusActive,
|
||||
CreatedAt: created,
|
||||
UpdatedAt: created,
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusIsKnown(t *testing.T) {
|
||||
for _, status := range AllStatuses() {
|
||||
assert.True(t, status.IsKnown(), "want known: %q", status)
|
||||
}
|
||||
assert.False(t, Status("retired").IsKnown())
|
||||
assert.False(t, Status("").IsKnown())
|
||||
}
|
||||
|
||||
func TestEngineVersionValidateHappy(t *testing.T) {
|
||||
require.NoError(t, validVersion().Validate())
|
||||
}
|
||||
|
||||
func TestEngineVersionValidateAcceptsEmptyOptions(t *testing.T) {
|
||||
record := validVersion()
|
||||
record.Options = nil
|
||||
assert.NoError(t, record.Validate())
|
||||
}
|
||||
|
||||
func TestEngineVersionValidateRejects(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*EngineVersion)
|
||||
}{
|
||||
{"empty version", func(v *EngineVersion) { v.Version = "" }},
|
||||
{"empty image ref", func(v *EngineVersion) { v.ImageRef = "" }},
|
||||
{"unknown status", func(v *EngineVersion) { v.Status = "exotic" }},
|
||||
{"zero created at", func(v *EngineVersion) { v.CreatedAt = time.Time{} }},
|
||||
{"zero updated at", func(v *EngineVersion) { v.UpdatedAt = time.Time{} }},
|
||||
{"updated before created", func(v *EngineVersion) {
|
||||
v.UpdatedAt = v.CreatedAt.Add(-time.Minute)
|
||||
}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
record := validVersion()
|
||||
tt.mutate(&record)
|
||||
assert.Error(t, record.Validate())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package engineversion
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
// ParseSemver normalises version into the canonical "vMAJOR.MINOR.PATCH"
|
||||
// form expected by `golang.org/x/mod/semver` and reports a wrapped
|
||||
// ErrInvalidSemver when the resulting string is not a valid full semver.
|
||||
//
|
||||
// Whitespace is trimmed; a missing leading "v" is added before the
|
||||
// validity check so callers may pass either "1.2.3" or "v1.2.3". The
|
||||
// stripped base must carry exactly three dot-separated numeric
|
||||
// components — `golang.org/x/mod/semver` accepts shortened forms such
|
||||
// as "v1" or "v1.2", but the engine-version registry requires the full
|
||||
// triple, so this function rejects anything narrower.
|
||||
func ParseSemver(version string) (string, error) {
|
||||
candidate := strings.TrimSpace(version)
|
||||
if candidate == "" {
|
||||
return "", fmt.Errorf("%w: empty", ErrInvalidSemver)
|
||||
}
|
||||
if !strings.HasPrefix(candidate, "v") {
|
||||
candidate = "v" + candidate
|
||||
}
|
||||
if !semver.IsValid(candidate) {
|
||||
return "", fmt.Errorf("%w: %q", ErrInvalidSemver, version)
|
||||
}
|
||||
|
||||
base := candidate
|
||||
if i := strings.IndexAny(base, "-+"); i >= 0 {
|
||||
base = base[:i]
|
||||
}
|
||||
if strings.Count(base, ".") != 2 {
|
||||
return "", fmt.Errorf(
|
||||
"%w: %q (need vMAJOR.MINOR.PATCH)",
|
||||
ErrInvalidSemver, version,
|
||||
)
|
||||
}
|
||||
return candidate, nil
|
||||
}
|
||||
|
||||
// IsPatchUpgrade reports whether next is a same-major.minor upgrade of
|
||||
// current. Both inputs are parsed through ParseSemver so callers may
|
||||
// pass either bare or `v`-prefixed forms. A wrapped ErrInvalidSemver is
|
||||
// returned when either argument fails to parse; the boolean result is
|
||||
// undefined in that case.
|
||||
func IsPatchUpgrade(current, next string) (bool, error) {
|
||||
curr, err := ParseSemver(current)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("current: %w", err)
|
||||
}
|
||||
nxt, err := ParseSemver(next)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("next: %w", err)
|
||||
}
|
||||
return semver.MajorMinor(curr) == semver.MajorMinor(nxt), nil
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package engineversion
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseSemverNormalises(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"1.2.3", "v1.2.3"},
|
||||
{"v1.2.3", "v1.2.3"},
|
||||
{" v0.4.0 ", "v0.4.0"},
|
||||
{"v2.0.0-rc.1", "v2.0.0-rc.1"},
|
||||
{"v2.0.0+build.7", "v2.0.0+build.7"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got, err := ParseSemver(tt.input)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSemverRejects(t *testing.T) {
|
||||
tests := []string{
|
||||
"",
|
||||
" ",
|
||||
"latest",
|
||||
"1",
|
||||
"1.2",
|
||||
"v1.2",
|
||||
"1.2.3.4",
|
||||
"v1.2.x",
|
||||
}
|
||||
for _, input := range tests {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
_, err := ParseSemver(input)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrInvalidSemver))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPatchUpgrade(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
current string
|
||||
next string
|
||||
want bool
|
||||
}{
|
||||
{"same patch", "v1.2.3", "v1.2.3", true},
|
||||
{"patch bump", "v1.2.3", "v1.2.4", true},
|
||||
{"patch downgrade", "1.2.4", "1.2.0", true},
|
||||
{"prerelease patch", "v1.2.3", "v1.2.3-rc.1", true},
|
||||
{"minor bump", "v1.2.3", "v1.3.0", false},
|
||||
{"minor downgrade", "v1.2.3", "v1.1.9", false},
|
||||
{"major bump", "v1.2.3", "v2.0.0", false},
|
||||
{"major downgrade", "v2.0.0", "v1.9.9", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := IsPatchUpgrade(tt.current, tt.next)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPatchUpgradeRejectsBadInputs(t *testing.T) {
|
||||
_, err := IsPatchUpgrade("garbage", "v1.2.3")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrInvalidSemver))
|
||||
|
||||
_, err = IsPatchUpgrade("v1.2.3", "")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrInvalidSemver))
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
// Package operation defines the runtime-operation audit-log domain
|
||||
// types owned by Game Master.
|
||||
//
|
||||
// One OperationEntry maps to one row of the `operation_log` PostgreSQL
|
||||
// table (see
|
||||
// `galaxy/gamemaster/internal/adapters/postgres/migrations/00001_init.sql`).
|
||||
// The OpKind / OpSource / Outcome enums match the SQL CHECK constraints
|
||||
// verbatim and feed the telemetry counters declared in
|
||||
// `galaxy/gamemaster/README.md §Observability`.
|
||||
package operation
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// OpKind identifies the kind of operation Game Master performed.
|
||||
type OpKind string
|
||||
|
||||
const (
|
||||
// OpKindRegisterRuntime records a register-runtime operation
|
||||
// (engine init plus first transition to running).
|
||||
OpKindRegisterRuntime OpKind = "register_runtime"
|
||||
|
||||
// OpKindTurnGeneration records a turn-generation operation
|
||||
// (scheduler ticker or admin force).
|
||||
OpKindTurnGeneration OpKind = "turn_generation"
|
||||
|
||||
// OpKindForceNextTurn records the admin force-next-turn driver
|
||||
// (separate from the turn-generation entry it produces, so audit
|
||||
// callers can tell scheduler ticks from manual ones).
|
||||
OpKindForceNextTurn OpKind = "force_next_turn"
|
||||
|
||||
// OpKindBanish records a /admin/race/banish call against the
|
||||
// engine container.
|
||||
OpKindBanish OpKind = "banish"
|
||||
|
||||
// OpKindStop records the admin stop driver (the underlying RTM
|
||||
// stop call is recorded in Runtime Manager's own operation log).
|
||||
OpKindStop OpKind = "stop"
|
||||
|
||||
// OpKindPatch records the admin patch driver.
|
||||
OpKindPatch OpKind = "patch"
|
||||
|
||||
// OpKindEngineVersionCreate records a registry CREATE.
|
||||
OpKindEngineVersionCreate OpKind = "engine_version_create"
|
||||
|
||||
// OpKindEngineVersionUpdate records a registry PATCH.
|
||||
OpKindEngineVersionUpdate OpKind = "engine_version_update"
|
||||
|
||||
// OpKindEngineVersionDeprecate records a registry DELETE / soft
|
||||
// deprecate.
|
||||
OpKindEngineVersionDeprecate OpKind = "engine_version_deprecate"
|
||||
|
||||
// OpKindEngineVersionDelete records a registry hard delete: the
|
||||
// row is removed from `engine_versions` after the service layer
|
||||
// confirms no non-finished runtime still references it.
|
||||
OpKindEngineVersionDelete OpKind = "engine_version_delete"
|
||||
)
|
||||
|
||||
// IsKnown reports whether kind belongs to the frozen op-kind vocabulary.
|
||||
func (kind OpKind) IsKnown() bool {
|
||||
switch kind {
|
||||
case OpKindRegisterRuntime,
|
||||
OpKindTurnGeneration,
|
||||
OpKindForceNextTurn,
|
||||
OpKindBanish,
|
||||
OpKindStop,
|
||||
OpKindPatch,
|
||||
OpKindEngineVersionCreate,
|
||||
OpKindEngineVersionUpdate,
|
||||
OpKindEngineVersionDeprecate,
|
||||
OpKindEngineVersionDelete:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// AllOpKinds returns the frozen list of every op-kind value. The slice
|
||||
// order is stable across calls.
|
||||
func AllOpKinds() []OpKind {
|
||||
return []OpKind{
|
||||
OpKindRegisterRuntime,
|
||||
OpKindTurnGeneration,
|
||||
OpKindForceNextTurn,
|
||||
OpKindBanish,
|
||||
OpKindStop,
|
||||
OpKindPatch,
|
||||
OpKindEngineVersionCreate,
|
||||
OpKindEngineVersionUpdate,
|
||||
OpKindEngineVersionDeprecate,
|
||||
OpKindEngineVersionDelete,
|
||||
}
|
||||
}
|
||||
|
||||
// OpSource identifies where one operation entered Game Master.
|
||||
type OpSource string
|
||||
|
||||
const (
|
||||
// OpSourceGatewayPlayer identifies entries triggered by a verified
|
||||
// player command, order, or report read forwarded through Edge
|
||||
// Gateway.
|
||||
OpSourceGatewayPlayer OpSource = "gateway_player"
|
||||
|
||||
// OpSourceLobbyInternal identifies entries triggered by Game Lobby
|
||||
// over the trusted internal REST surface (register-runtime,
|
||||
// memberships invalidate, banish, liveness).
|
||||
OpSourceLobbyInternal OpSource = "lobby_internal"
|
||||
|
||||
// OpSourceAdminRest identifies entries triggered by Admin Service
|
||||
// (or system administrators today). The default when the
|
||||
// `X-Galaxy-Caller` header is missing or unrecognised.
|
||||
OpSourceAdminRest OpSource = "admin_rest"
|
||||
)
|
||||
|
||||
// IsKnown reports whether source belongs to the frozen op-source
|
||||
// vocabulary.
|
||||
func (source OpSource) IsKnown() bool {
|
||||
switch source {
|
||||
case OpSourceGatewayPlayer,
|
||||
OpSourceLobbyInternal,
|
||||
OpSourceAdminRest:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// AllOpSources returns the frozen list of every op-source value. The
|
||||
// slice order is stable across calls.
|
||||
func AllOpSources() []OpSource {
|
||||
return []OpSource{
|
||||
OpSourceGatewayPlayer,
|
||||
OpSourceLobbyInternal,
|
||||
OpSourceAdminRest,
|
||||
}
|
||||
}
|
||||
|
||||
// Outcome reports the high-level outcome of one operation.
|
||||
type Outcome string
|
||||
|
||||
const (
|
||||
// OutcomeSuccess reports that the operation completed without
|
||||
// surfacing an error.
|
||||
OutcomeSuccess Outcome = "success"
|
||||
|
||||
// OutcomeFailure reports that the operation surfaced a stable
|
||||
// error code recorded in OperationEntry.ErrorCode.
|
||||
OutcomeFailure Outcome = "failure"
|
||||
)
|
||||
|
||||
// IsKnown reports whether outcome belongs to the frozen outcome
|
||||
// vocabulary.
|
||||
func (outcome Outcome) IsKnown() bool {
|
||||
switch outcome {
|
||||
case OutcomeSuccess, OutcomeFailure:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// AllOutcomes returns the frozen list of every outcome value. The slice
|
||||
// order is stable across calls.
|
||||
func AllOutcomes() []Outcome {
|
||||
return []Outcome{OutcomeSuccess, OutcomeFailure}
|
||||
}
|
||||
|
||||
// OperationEntry stores one append-only audit row of the `operation_log`
|
||||
// table. ID is zero on records that have not been persisted yet; the
|
||||
// store assigns it from the table's bigserial column. FinishedAt is a
|
||||
// pointer because the column is nullable for in-flight rows even though
|
||||
// the service layer finalises the row in the same transaction.
|
||||
type OperationEntry struct {
|
||||
// ID identifies the persisted row. Zero before persistence.
|
||||
ID int64
|
||||
|
||||
// GameID identifies the platform game this operation acted on.
|
||||
GameID string
|
||||
|
||||
// OpKind classifies what the operation did.
|
||||
OpKind OpKind
|
||||
|
||||
// OpSource classifies how the operation entered Game Master.
|
||||
OpSource OpSource
|
||||
|
||||
// SourceRef stores an opaque per-source reference such as a request
|
||||
// id, a Redis Stream entry id, or an admin user id. Empty when the
|
||||
// source does not provide one.
|
||||
SourceRef string
|
||||
|
||||
// Outcome reports whether the operation succeeded or failed.
|
||||
Outcome Outcome
|
||||
|
||||
// ErrorCode stores the stable error code on failure. Empty on
|
||||
// success.
|
||||
ErrorCode string
|
||||
|
||||
// ErrorMessage stores the operator-readable detail on failure.
|
||||
// Empty on success.
|
||||
ErrorMessage string
|
||||
|
||||
// StartedAt stores the wall-clock at which the operation began.
|
||||
StartedAt time.Time
|
||||
|
||||
// FinishedAt stores the wall-clock at which the operation
|
||||
// finalised. Nil for in-flight rows.
|
||||
FinishedAt *time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether entry satisfies the operation-log invariants
|
||||
// implied by the SQL CHECK constraints and the README §Persistence
|
||||
// Layout listing.
|
||||
func (entry OperationEntry) Validate() error {
|
||||
if strings.TrimSpace(entry.GameID) == "" {
|
||||
return fmt.Errorf("game id must not be empty")
|
||||
}
|
||||
if !entry.OpKind.IsKnown() {
|
||||
return fmt.Errorf("op kind %q is unsupported", entry.OpKind)
|
||||
}
|
||||
if !entry.OpSource.IsKnown() {
|
||||
return fmt.Errorf("op source %q is unsupported", entry.OpSource)
|
||||
}
|
||||
if !entry.Outcome.IsKnown() {
|
||||
return fmt.Errorf("outcome %q is unsupported", entry.Outcome)
|
||||
}
|
||||
if entry.StartedAt.IsZero() {
|
||||
return fmt.Errorf("started at must not be zero")
|
||||
}
|
||||
if entry.FinishedAt != nil {
|
||||
if entry.FinishedAt.IsZero() {
|
||||
return fmt.Errorf("finished at must not be zero when present")
|
||||
}
|
||||
if entry.FinishedAt.Before(entry.StartedAt) {
|
||||
return fmt.Errorf("finished at must not be before started at")
|
||||
}
|
||||
}
|
||||
if entry.Outcome == OutcomeFailure && strings.TrimSpace(entry.ErrorCode) == "" {
|
||||
return fmt.Errorf("error code must not be empty for failure entries")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package operation
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func validSuccessEntry() OperationEntry {
|
||||
started := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
|
||||
finished := started.Add(time.Second)
|
||||
return OperationEntry{
|
||||
GameID: "game-1",
|
||||
OpKind: OpKindRegisterRuntime,
|
||||
OpSource: OpSourceLobbyInternal,
|
||||
Outcome: OutcomeSuccess,
|
||||
StartedAt: started,
|
||||
FinishedAt: &finished,
|
||||
}
|
||||
}
|
||||
|
||||
func validFailureEntry() OperationEntry {
|
||||
entry := validSuccessEntry()
|
||||
entry.Outcome = OutcomeFailure
|
||||
entry.ErrorCode = "engine_unreachable"
|
||||
entry.ErrorMessage = "engine returned 502"
|
||||
return entry
|
||||
}
|
||||
|
||||
func TestOpKindIsKnown(t *testing.T) {
|
||||
for _, kind := range AllOpKinds() {
|
||||
assert.True(t, kind.IsKnown(), "want known: %q", kind)
|
||||
}
|
||||
assert.False(t, OpKind("exotic").IsKnown())
|
||||
assert.Len(t, AllOpKinds(), 10)
|
||||
}
|
||||
|
||||
func TestOpSourceIsKnown(t *testing.T) {
|
||||
for _, src := range AllOpSources() {
|
||||
assert.True(t, src.IsKnown(), "want known: %q", src)
|
||||
}
|
||||
assert.False(t, OpSource("exotic").IsKnown())
|
||||
assert.Len(t, AllOpSources(), 3)
|
||||
}
|
||||
|
||||
func TestOutcomeIsKnown(t *testing.T) {
|
||||
for _, outcome := range AllOutcomes() {
|
||||
assert.True(t, outcome.IsKnown(), "want known: %q", outcome)
|
||||
}
|
||||
assert.False(t, Outcome("exotic").IsKnown())
|
||||
assert.Len(t, AllOutcomes(), 2)
|
||||
}
|
||||
|
||||
func TestOperationEntryValidateHappy(t *testing.T) {
|
||||
require.NoError(t, validSuccessEntry().Validate())
|
||||
require.NoError(t, validFailureEntry().Validate())
|
||||
}
|
||||
|
||||
func TestOperationEntryValidateAcceptsInFlight(t *testing.T) {
|
||||
entry := validSuccessEntry()
|
||||
entry.FinishedAt = nil
|
||||
assert.NoError(t, entry.Validate())
|
||||
}
|
||||
|
||||
func TestOperationEntryValidateRejects(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*OperationEntry)
|
||||
}{
|
||||
{"empty game id", func(e *OperationEntry) { e.GameID = "" }},
|
||||
{"unknown op kind", func(e *OperationEntry) { e.OpKind = "exotic" }},
|
||||
{"unknown op source", func(e *OperationEntry) { e.OpSource = "exotic" }},
|
||||
{"unknown outcome", func(e *OperationEntry) { e.Outcome = "exotic" }},
|
||||
{"zero started at", func(e *OperationEntry) { e.StartedAt = time.Time{} }},
|
||||
{"zero finished at when present", func(e *OperationEntry) {
|
||||
zero := time.Time{}
|
||||
e.FinishedAt = &zero
|
||||
}},
|
||||
{"finished before started", func(e *OperationEntry) {
|
||||
before := e.StartedAt.Add(-time.Second)
|
||||
e.FinishedAt = &before
|
||||
}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
entry := validSuccessEntry()
|
||||
tt.mutate(&entry)
|
||||
assert.Error(t, entry.Validate())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperationEntryValidateRejectsFailureWithoutCode(t *testing.T) {
|
||||
entry := validFailureEntry()
|
||||
entry.ErrorCode = ""
|
||||
assert.Error(t, entry.Validate())
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Package playermapping defines the durable mapping between platform
|
||||
// users and engine player handles owned by Game Master.
|
||||
//
|
||||
// One PlayerMapping mirrors one row of the `player_mappings` PostgreSQL
|
||||
// table (see
|
||||
// `galaxy/gamemaster/internal/adapters/postgres/migrations/00001_init.sql`).
|
||||
// The composite primary key `(game_id, user_id)` and the unique
|
||||
// `(game_id, race_name)` index live in the SQL schema; the domain model
|
||||
// captures the per-row invariants enforced from the application side.
|
||||
package playermapping
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PlayerMapping stores one (game_id, user_id) → (race_name,
|
||||
// engine_player_uuid) projection installed at register-runtime.
|
||||
type PlayerMapping struct {
|
||||
// GameID identifies the game owning this mapping.
|
||||
GameID string
|
||||
|
||||
// UserID identifies the platform user this mapping refers to.
|
||||
UserID string
|
||||
|
||||
// RaceName stores the in-game race name reserved for the user in
|
||||
// the original casing presented by the engine.
|
||||
RaceName string
|
||||
|
||||
// EnginePlayerUUID stores the engine-side player handle returned by
|
||||
// the engine /admin/init response.
|
||||
EnginePlayerUUID string
|
||||
|
||||
// CreatedAt stores the wall-clock at which the row was inserted.
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether mapping satisfies the player-mapping
|
||||
// invariants implied by the README §Persistence Layout / player_mappings
|
||||
// columns and the SQL primary-key + unique-index constraints.
|
||||
func (mapping PlayerMapping) Validate() error {
|
||||
if strings.TrimSpace(mapping.GameID) == "" {
|
||||
return fmt.Errorf("game id must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(mapping.UserID) == "" {
|
||||
return fmt.Errorf("user id must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(mapping.RaceName) == "" {
|
||||
return fmt.Errorf("race name must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(mapping.EnginePlayerUUID) == "" {
|
||||
return fmt.Errorf("engine player uuid must not be empty")
|
||||
}
|
||||
if mapping.CreatedAt.IsZero() {
|
||||
return fmt.Errorf("created at must not be zero")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrNotFound reports that a player-mapping lookup failed because no
|
||||
// matching row exists.
|
||||
var ErrNotFound = errors.New("player mapping not found")
|
||||
|
||||
// ErrConflict reports that a player-mapping insert could not be applied
|
||||
// because a row with the same `(game_id, user_id)` primary key or with
|
||||
// the same `(game_id, race_name)` unique pair already exists. Adapters
|
||||
// surface PostgreSQL unique-violations through this sentinel so the
|
||||
// service layer maps it to a `conflict` REST envelope.
|
||||
var ErrConflict = errors.New("player mapping already exists")
|
||||
@@ -0,0 +1,44 @@
|
||||
package playermapping
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func validMapping() PlayerMapping {
|
||||
return PlayerMapping{
|
||||
GameID: "game-1",
|
||||
UserID: "user-1",
|
||||
RaceName: "Aelinari",
|
||||
EnginePlayerUUID: "00000000-0000-0000-0000-000000000001",
|
||||
CreatedAt: time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC),
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayerMappingValidateHappy(t *testing.T) {
|
||||
require.NoError(t, validMapping().Validate())
|
||||
}
|
||||
|
||||
func TestPlayerMappingValidateRejects(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*PlayerMapping)
|
||||
}{
|
||||
{"empty game id", func(m *PlayerMapping) { m.GameID = "" }},
|
||||
{"empty user id", func(m *PlayerMapping) { m.UserID = "" }},
|
||||
{"empty race name", func(m *PlayerMapping) { m.RaceName = "" }},
|
||||
{"empty engine uuid", func(m *PlayerMapping) { m.EnginePlayerUUID = "" }},
|
||||
{"zero created at", func(m *PlayerMapping) { m.CreatedAt = time.Time{} }},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mapping := validMapping()
|
||||
tt.mutate(&mapping)
|
||||
assert.Error(t, mapping.Validate())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ErrNotFound reports that a runtime record was requested but does not
|
||||
// exist in the store.
|
||||
var ErrNotFound = errors.New("runtime record not found")
|
||||
|
||||
// ErrConflict reports that a runtime mutation could not be applied
|
||||
// because the record changed concurrently or failed a compare-and-swap
|
||||
// guard.
|
||||
var ErrConflict = errors.New("runtime record conflict")
|
||||
|
||||
// ErrInvalidTransition is the sentinel returned when Transition rejects
|
||||
// a `(from, to)` pair.
|
||||
var ErrInvalidTransition = errors.New("invalid runtime status transition")
|
||||
|
||||
// InvalidTransitionError stores the rejected `(from, to)` pair and wraps
|
||||
// ErrInvalidTransition so callers can match it with errors.Is.
|
||||
type InvalidTransitionError struct {
|
||||
// From stores the source status that was attempted to leave.
|
||||
From Status
|
||||
|
||||
// To stores the destination status that was attempted to enter.
|
||||
To Status
|
||||
}
|
||||
|
||||
// Error reports a human-readable summary of the rejected pair.
|
||||
func (err *InvalidTransitionError) Error() string {
|
||||
return fmt.Sprintf(
|
||||
"invalid runtime status transition from %q to %q",
|
||||
err.From, err.To,
|
||||
)
|
||||
}
|
||||
|
||||
// Unwrap returns ErrInvalidTransition so errors.Is recognizes the
|
||||
// sentinel.
|
||||
func (err *InvalidTransitionError) Unwrap() error {
|
||||
return ErrInvalidTransition
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
// Package runtime defines the runtime-record domain model, status
|
||||
// machine, and sentinel errors owned by Game Master.
|
||||
//
|
||||
// The package mirrors the durable shape of the `runtime_records`
|
||||
// PostgreSQL table (see
|
||||
// `galaxy/gamemaster/internal/adapters/postgres/migrations/00001_init.sql`).
|
||||
// Every status / transition / required-field rule already documented in
|
||||
// `galaxy/gamemaster/README.md` lives here as code so adapter and service
|
||||
// layers do not re-derive it.
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Status identifies one runtime-record lifecycle state.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
// StatusStarting reports that register-runtime has persisted the row
|
||||
// but the engine /admin/init call has not yet succeeded.
|
||||
StatusStarting Status = "starting"
|
||||
|
||||
// StatusRunning reports that the runtime is healthy and accepting
|
||||
// player commands and turn generation.
|
||||
StatusRunning Status = "running"
|
||||
|
||||
// StatusGenerationInProgress reports that the scheduler or admin
|
||||
// force-next-turn flow has CAS'd the row to drive turn generation.
|
||||
StatusGenerationInProgress Status = "generation_in_progress"
|
||||
|
||||
// StatusGenerationFailed reports that turn generation surfaced an
|
||||
// engine error and the runtime is awaiting manual recovery.
|
||||
StatusGenerationFailed Status = "generation_failed"
|
||||
|
||||
// StatusStopped reports that an admin stop has completed; the row
|
||||
// stays in PostgreSQL for audit.
|
||||
StatusStopped Status = "stopped"
|
||||
|
||||
// StatusEngineUnreachable reports that runtime:health_events observed
|
||||
// an engine container failure (exited, OOM, disappeared, or repeated
|
||||
// probe failures).
|
||||
StatusEngineUnreachable Status = "engine_unreachable"
|
||||
|
||||
// StatusFinished reports that the engine returned `finished:true` on
|
||||
// a turn-generation response. The state is terminal: the row stays
|
||||
// here indefinitely; operator cleanup is the only path out.
|
||||
StatusFinished Status = "finished"
|
||||
)
|
||||
|
||||
// IsKnown reports whether status belongs to the frozen runtime status
|
||||
// vocabulary.
|
||||
func (status Status) IsKnown() bool {
|
||||
switch status {
|
||||
case StatusStarting,
|
||||
StatusRunning,
|
||||
StatusGenerationInProgress,
|
||||
StatusGenerationFailed,
|
||||
StatusStopped,
|
||||
StatusEngineUnreachable,
|
||||
StatusFinished:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsTerminal reports whether status can no longer accept lifecycle
|
||||
// transitions. Per `gamemaster/README.md §Game Master status model`, only
|
||||
// `finished` is terminal; `stopped` may still be observed but is treated
|
||||
// as a non-terminal end-state for admin replay purposes (no transitions
|
||||
// out of it are wired in v1, but the state machine does not forbid them
|
||||
// architecturally).
|
||||
func (status Status) IsTerminal() bool {
|
||||
return status == StatusFinished
|
||||
}
|
||||
|
||||
// AllStatuses returns the frozen list of every runtime status value. The
|
||||
// slice order is stable across calls and matches the README §Persistence
|
||||
// Layout listing.
|
||||
func AllStatuses() []Status {
|
||||
return []Status{
|
||||
StatusStarting,
|
||||
StatusRunning,
|
||||
StatusGenerationInProgress,
|
||||
StatusGenerationFailed,
|
||||
StatusStopped,
|
||||
StatusEngineUnreachable,
|
||||
StatusFinished,
|
||||
}
|
||||
}
|
||||
|
||||
// RuntimeRecord stores one durable runtime record owned by Game Master.
|
||||
// It mirrors one row of the `runtime_records` table.
|
||||
//
|
||||
// NextGenerationAt is *time.Time so a missing tick (e.g., a row that has
|
||||
// just entered with status=starting) is unambiguous. StartedAt, StoppedAt,
|
||||
// and FinishedAt are *time.Time for the same reason and align with the
|
||||
// jet-generated model.
|
||||
type RuntimeRecord struct {
|
||||
// GameID identifies the platform game owning this runtime record.
|
||||
GameID string
|
||||
|
||||
// Status stores the current lifecycle state.
|
||||
Status Status
|
||||
|
||||
// EngineEndpoint stores the stable URL Game Master uses to reach the
|
||||
// engine container, in `http://galaxy-game-{game_id}:8080` form.
|
||||
EngineEndpoint string
|
||||
|
||||
// CurrentImageRef stores the Docker reference of the running engine
|
||||
// image (or the most recent one for stopped/finished records).
|
||||
CurrentImageRef string
|
||||
|
||||
// CurrentEngineVersion stores the semver of the currently-bound
|
||||
// engine version (registered in `engine_versions`).
|
||||
CurrentEngineVersion string
|
||||
|
||||
// TurnSchedule stores the five-field cron expression governing turn
|
||||
// generation, copied from the platform game record at
|
||||
// register-runtime time.
|
||||
TurnSchedule string
|
||||
|
||||
// CurrentTurn stores the last completed turn number; zero until the
|
||||
// first turn generates.
|
||||
CurrentTurn int
|
||||
|
||||
// NextGenerationAt stores the next due tick. Nil when no tick is
|
||||
// scheduled (e.g., status=starting, finished, stopped).
|
||||
NextGenerationAt *time.Time
|
||||
|
||||
// SkipNextTick is true when force-next-turn has set the skip flag
|
||||
// for the next regular tick. Cleared by the scheduler after the
|
||||
// first scheduled step is skipped.
|
||||
SkipNextTick bool
|
||||
|
||||
// EngineHealth stores the short text summary derived from
|
||||
// runtime:health_events; empty until the first health observation.
|
||||
EngineHealth string
|
||||
|
||||
// CreatedAt stores the wall-clock at which the record was created.
|
||||
CreatedAt time.Time
|
||||
|
||||
// UpdatedAt stores the wall-clock of the most recent mutation.
|
||||
UpdatedAt time.Time
|
||||
|
||||
// StartedAt stores the wall-clock at which the runtime first
|
||||
// transitioned to running. Non-nil once the status leaves starting.
|
||||
StartedAt *time.Time
|
||||
|
||||
// StoppedAt stores the wall-clock at which the runtime was stopped.
|
||||
// Non-nil when status is stopped.
|
||||
StoppedAt *time.Time
|
||||
|
||||
// FinishedAt stores the wall-clock at which the engine reported
|
||||
// finish. Non-nil when status is finished.
|
||||
FinishedAt *time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether record satisfies the runtime-record invariants
|
||||
// implied by README §Lifecycles and the SQL CHECK on `runtime_records`.
|
||||
func (record RuntimeRecord) Validate() error {
|
||||
if strings.TrimSpace(record.GameID) == "" {
|
||||
return fmt.Errorf("game id must not be empty")
|
||||
}
|
||||
if !record.Status.IsKnown() {
|
||||
return fmt.Errorf("status %q is unsupported", record.Status)
|
||||
}
|
||||
if strings.TrimSpace(record.EngineEndpoint) == "" {
|
||||
return fmt.Errorf("engine endpoint must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(record.CurrentImageRef) == "" {
|
||||
return fmt.Errorf("current image ref must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(record.CurrentEngineVersion) == "" {
|
||||
return fmt.Errorf("current engine version must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(record.TurnSchedule) == "" {
|
||||
return fmt.Errorf("turn schedule must not be empty")
|
||||
}
|
||||
if record.CurrentTurn < 0 {
|
||||
return fmt.Errorf("current turn must not be negative")
|
||||
}
|
||||
if record.CreatedAt.IsZero() {
|
||||
return fmt.Errorf("created at must not be zero")
|
||||
}
|
||||
if record.UpdatedAt.IsZero() {
|
||||
return fmt.Errorf("updated at must not be zero")
|
||||
}
|
||||
if record.UpdatedAt.Before(record.CreatedAt) {
|
||||
return fmt.Errorf("updated at must not be before created at")
|
||||
}
|
||||
|
||||
if record.NextGenerationAt != nil && record.NextGenerationAt.IsZero() {
|
||||
return fmt.Errorf("next generation at must not be zero when present")
|
||||
}
|
||||
|
||||
switch record.Status {
|
||||
case StatusStarting:
|
||||
if record.StartedAt != nil {
|
||||
return fmt.Errorf("started at must be nil for starting records")
|
||||
}
|
||||
|
||||
case StatusRunning,
|
||||
StatusGenerationInProgress,
|
||||
StatusGenerationFailed,
|
||||
StatusEngineUnreachable:
|
||||
if record.StartedAt == nil {
|
||||
return fmt.Errorf(
|
||||
"started at must not be nil for %s records",
|
||||
record.Status,
|
||||
)
|
||||
}
|
||||
if record.StartedAt.IsZero() {
|
||||
return fmt.Errorf("started at must not be zero when present")
|
||||
}
|
||||
|
||||
case StatusStopped:
|
||||
if record.StartedAt == nil {
|
||||
return fmt.Errorf("started at must not be nil for stopped records")
|
||||
}
|
||||
if record.StoppedAt == nil {
|
||||
return fmt.Errorf("stopped at must not be nil for stopped records")
|
||||
}
|
||||
if record.StoppedAt.IsZero() {
|
||||
return fmt.Errorf("stopped at must not be zero when present")
|
||||
}
|
||||
if record.StoppedAt.Before(*record.StartedAt) {
|
||||
return fmt.Errorf("stopped at must not be before started at")
|
||||
}
|
||||
|
||||
case StatusFinished:
|
||||
if record.StartedAt == nil {
|
||||
return fmt.Errorf("started at must not be nil for finished records")
|
||||
}
|
||||
if record.FinishedAt == nil {
|
||||
return fmt.Errorf("finished at must not be nil for finished records")
|
||||
}
|
||||
if record.FinishedAt.IsZero() {
|
||||
return fmt.Errorf("finished at must not be zero when present")
|
||||
}
|
||||
if record.FinishedAt.Before(*record.StartedAt) {
|
||||
return fmt.Errorf("finished at must not be before started at")
|
||||
}
|
||||
}
|
||||
|
||||
if record.StartedAt != nil && record.StartedAt.Before(record.CreatedAt) {
|
||||
return fmt.Errorf("started at must not be before created at")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func validRunningRecord() RuntimeRecord {
|
||||
created := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
|
||||
started := created.Add(time.Minute)
|
||||
updated := started.Add(time.Minute)
|
||||
next := updated.Add(time.Hour)
|
||||
return RuntimeRecord{
|
||||
GameID: "game-1",
|
||||
Status: StatusRunning,
|
||||
EngineEndpoint: "http://galaxy-game-1:8080",
|
||||
CurrentImageRef: "ghcr.io/galaxy/game:v1.2.3",
|
||||
CurrentEngineVersion: "v1.2.3",
|
||||
TurnSchedule: "0 18 * * *",
|
||||
CurrentTurn: 0,
|
||||
NextGenerationAt: &next,
|
||||
CreatedAt: created,
|
||||
UpdatedAt: updated,
|
||||
StartedAt: &started,
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusIsKnown(t *testing.T) {
|
||||
for _, status := range AllStatuses() {
|
||||
assert.True(t, status.IsKnown(), "want known: %q", status)
|
||||
}
|
||||
assert.False(t, Status("exotic").IsKnown())
|
||||
assert.False(t, Status("").IsKnown())
|
||||
}
|
||||
|
||||
func TestStatusIsTerminal(t *testing.T) {
|
||||
assert.True(t, StatusFinished.IsTerminal())
|
||||
for _, status := range AllStatuses() {
|
||||
if status == StatusFinished {
|
||||
continue
|
||||
}
|
||||
assert.False(t, status.IsTerminal(), "%q must not be terminal", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllStatusesStable(t *testing.T) {
|
||||
first := AllStatuses()
|
||||
second := AllStatuses()
|
||||
assert.Equal(t, first, second)
|
||||
assert.Len(t, first, 7)
|
||||
}
|
||||
|
||||
func TestRuntimeRecordValidateHappy(t *testing.T) {
|
||||
require.NoError(t, validRunningRecord().Validate())
|
||||
}
|
||||
|
||||
func TestRuntimeRecordValidateAcceptsStarting(t *testing.T) {
|
||||
record := validRunningRecord()
|
||||
record.Status = StatusStarting
|
||||
record.StartedAt = nil
|
||||
record.NextGenerationAt = nil
|
||||
|
||||
assert.NoError(t, record.Validate())
|
||||
}
|
||||
|
||||
func TestRuntimeRecordValidateRequiresFinishedAt(t *testing.T) {
|
||||
record := validRunningRecord()
|
||||
record.Status = StatusFinished
|
||||
record.FinishedAt = nil
|
||||
|
||||
assert.Error(t, record.Validate())
|
||||
|
||||
finished := record.UpdatedAt.Add(time.Minute)
|
||||
record.FinishedAt = &finished
|
||||
assert.NoError(t, record.Validate())
|
||||
}
|
||||
|
||||
func TestRuntimeRecordValidateRequiresStoppedAtForStopped(t *testing.T) {
|
||||
record := validRunningRecord()
|
||||
record.Status = StatusStopped
|
||||
assert.Error(t, record.Validate())
|
||||
|
||||
stopped := record.UpdatedAt.Add(time.Minute)
|
||||
record.StoppedAt = &stopped
|
||||
assert.NoError(t, record.Validate())
|
||||
}
|
||||
|
||||
func TestRuntimeRecordValidateRejects(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*RuntimeRecord)
|
||||
}{
|
||||
{"empty game id", func(r *RuntimeRecord) { r.GameID = "" }},
|
||||
{"unknown status", func(r *RuntimeRecord) { r.Status = "exotic" }},
|
||||
{"empty engine endpoint", func(r *RuntimeRecord) { r.EngineEndpoint = "" }},
|
||||
{"empty image ref", func(r *RuntimeRecord) { r.CurrentImageRef = "" }},
|
||||
{"empty engine version", func(r *RuntimeRecord) { r.CurrentEngineVersion = "" }},
|
||||
{"empty turn schedule", func(r *RuntimeRecord) { r.TurnSchedule = "" }},
|
||||
{"negative turn", func(r *RuntimeRecord) { r.CurrentTurn = -1 }},
|
||||
{"zero created at", func(r *RuntimeRecord) { r.CreatedAt = time.Time{} }},
|
||||
{"zero updated at", func(r *RuntimeRecord) { r.UpdatedAt = time.Time{} }},
|
||||
{"updated before created", func(r *RuntimeRecord) {
|
||||
r.UpdatedAt = r.CreatedAt.Add(-time.Minute)
|
||||
}},
|
||||
{"started before created", func(r *RuntimeRecord) {
|
||||
before := r.CreatedAt.Add(-time.Minute)
|
||||
r.StartedAt = &before
|
||||
}},
|
||||
{"running missing started at", func(r *RuntimeRecord) { r.StartedAt = nil }},
|
||||
{"starting with started at", func(r *RuntimeRecord) {
|
||||
r.Status = StatusStarting
|
||||
// keep StartedAt set
|
||||
}},
|
||||
{"zero next generation at", func(r *RuntimeRecord) {
|
||||
zero := time.Time{}
|
||||
r.NextGenerationAt = &zero
|
||||
}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
record := validRunningRecord()
|
||||
tt.mutate(&record)
|
||||
assert.Error(t, record.Validate())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package runtime
|
||||
|
||||
// transitionKey stores one `(from, to)` pair in the allowed-transitions
|
||||
// table.
|
||||
type transitionKey struct {
|
||||
from Status
|
||||
to Status
|
||||
}
|
||||
|
||||
// allowedTransitions enumerates the runtime-status transitions Game
|
||||
// Master is allowed to apply. The set mirrors the lifecycle flows frozen
|
||||
// in `galaxy/gamemaster/README.md §Lifecycles`:
|
||||
//
|
||||
// - starting → running: register-runtime CAS after a successful
|
||||
// engine /admin/init.
|
||||
// - running → generation_in_progress: scheduler ticker or admin
|
||||
// force-next-turn enters turn generation.
|
||||
// - generation_in_progress → running: turn generation succeeded with
|
||||
// `finished=false`.
|
||||
// - generation_in_progress → generation_failed: engine timeout or
|
||||
// 5xx during turn generation.
|
||||
// - generation_in_progress → finished: engine returned
|
||||
// `finished=true`; the state is terminal.
|
||||
// - generation_failed → generation_in_progress: admin force-next-turn
|
||||
// after manual recovery.
|
||||
// - running → engine_unreachable: runtime:health_events observed an
|
||||
// engine container failure (Stage 18 consumer).
|
||||
// - engine_unreachable → running: runtime:health_events observed a
|
||||
// recovery; reserved for the Stage 18 consumer; declared here so
|
||||
// Stage 18 needs no transitions edit.
|
||||
// - running → stopped, generation_in_progress → stopped,
|
||||
// generation_failed → stopped, engine_unreachable → stopped: admin
|
||||
// stop is allowed from every non-terminal status (README §Stop:
|
||||
// «CAS `runtime_records.status: * → stopped`»).
|
||||
var allowedTransitions = map[transitionKey]struct{}{
|
||||
{StatusStarting, StatusRunning}: {},
|
||||
|
||||
{StatusRunning, StatusGenerationInProgress}: {},
|
||||
|
||||
{StatusGenerationInProgress, StatusRunning}: {},
|
||||
{StatusGenerationInProgress, StatusGenerationFailed}: {},
|
||||
{StatusGenerationInProgress, StatusFinished}: {},
|
||||
{StatusGenerationFailed, StatusGenerationInProgress}: {},
|
||||
|
||||
{StatusRunning, StatusEngineUnreachable}: {},
|
||||
{StatusEngineUnreachable, StatusRunning}: {},
|
||||
|
||||
{StatusRunning, StatusStopped}: {},
|
||||
{StatusGenerationInProgress, StatusStopped}: {},
|
||||
{StatusGenerationFailed, StatusStopped}: {},
|
||||
{StatusEngineUnreachable, StatusStopped}: {},
|
||||
}
|
||||
|
||||
// AllowedTransitions returns a copy of the `(from, to)` allowed
|
||||
// transitions table used by Transition. The returned map is safe to
|
||||
// mutate; callers should not rely on iteration order.
|
||||
func AllowedTransitions() map[Status][]Status {
|
||||
result := make(map[Status][]Status)
|
||||
for key := range allowedTransitions {
|
||||
result[key.from] = append(result[key.from], key.to)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Transition reports whether from may transition to next. The function
|
||||
// returns nil when the pair is permitted, and an *InvalidTransitionError
|
||||
// wrapping ErrInvalidTransition otherwise. It does not touch any store
|
||||
// and is safe to call from any layer.
|
||||
func Transition(from Status, next Status) error {
|
||||
if !from.IsKnown() || !next.IsKnown() {
|
||||
return &InvalidTransitionError{From: from, To: next}
|
||||
}
|
||||
if _, ok := allowedTransitions[transitionKey{from: from, to: next}]; !ok {
|
||||
return &InvalidTransitionError{From: from, To: next}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTransitionAcceptsAllAllowedPairs(t *testing.T) {
|
||||
for from, tos := range AllowedTransitions() {
|
||||
for _, to := range tos {
|
||||
t.Run(string(from)+"->"+string(to), func(t *testing.T) {
|
||||
assert.NoError(t, Transition(from, to))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransitionRejectsForbiddenPairs(t *testing.T) {
|
||||
allowed := AllowedTransitions()
|
||||
allowedSet := make(map[transitionKey]struct{})
|
||||
for from, tos := range allowed {
|
||||
for _, to := range tos {
|
||||
allowedSet[transitionKey{from: from, to: to}] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
for _, from := range AllStatuses() {
|
||||
for _, to := range AllStatuses() {
|
||||
if _, ok := allowedSet[transitionKey{from: from, to: to}]; ok {
|
||||
continue
|
||||
}
|
||||
t.Run(string(from)+"->"+string(to), func(t *testing.T) {
|
||||
err := Transition(from, to)
|
||||
require.Error(t, err)
|
||||
var typed *InvalidTransitionError
|
||||
assert.True(t, errors.As(err, &typed))
|
||||
assert.Equal(t, from, typed.From)
|
||||
assert.Equal(t, to, typed.To)
|
||||
assert.True(t, errors.Is(err, ErrInvalidTransition))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransitionRejectsUnknownStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
from Status
|
||||
to Status
|
||||
}{
|
||||
{"unknown from", "exotic", StatusRunning},
|
||||
{"unknown to", StatusRunning, "exotic"},
|
||||
{"both unknown", "from-x", "to-y"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := Transition(tt.from, tt.to)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrInvalidTransition))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowedTransitionsIncludesExpectedFlows(t *testing.T) {
|
||||
allowed := AllowedTransitions()
|
||||
must := func(from Status, expected Status) {
|
||||
t.Helper()
|
||||
got := allowed[from]
|
||||
assert.Containsf(t, got, expected,
|
||||
"expected %q in transitions from %q, got %v",
|
||||
expected, from, got)
|
||||
}
|
||||
|
||||
must(StatusStarting, StatusRunning)
|
||||
must(StatusRunning, StatusGenerationInProgress)
|
||||
must(StatusGenerationInProgress, StatusRunning)
|
||||
must(StatusGenerationInProgress, StatusGenerationFailed)
|
||||
must(StatusGenerationInProgress, StatusFinished)
|
||||
must(StatusGenerationFailed, StatusGenerationInProgress)
|
||||
must(StatusRunning, StatusEngineUnreachable)
|
||||
must(StatusEngineUnreachable, StatusRunning)
|
||||
must(StatusRunning, StatusStopped)
|
||||
must(StatusGenerationInProgress, StatusStopped)
|
||||
must(StatusGenerationFailed, StatusStopped)
|
||||
must(StatusEngineUnreachable, StatusStopped)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Package schedule wraps `pkg/cronutil` with the force-next-turn skip
|
||||
// rule used by Game Master's scheduler.
|
||||
//
|
||||
// The wrapper is pure: callers pass the current `skip_next_tick` flag
|
||||
// and the wrapper returns both the next firing time and a boolean that
|
||||
// reports whether the flag was consumed. The runtime-record store is
|
||||
// responsible for persisting the cleared flag; this package never
|
||||
// touches it.
|
||||
//
|
||||
// `gamemaster/README.md §Force-next-turn` describes the rule:
|
||||
//
|
||||
// If `skip_next_tick=true`, advance by one extra cron step and clear
|
||||
// the flag.
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"galaxy/cronutil"
|
||||
)
|
||||
|
||||
// Schedule wraps `cronutil.Schedule` with the GM-specific
|
||||
// skip-next-tick semantics. The zero value is not usable; callers
|
||||
// obtain a Schedule from Parse.
|
||||
type Schedule struct {
|
||||
inner cronutil.Schedule
|
||||
}
|
||||
|
||||
// Parse parses expr as a five-field cron expression and returns the
|
||||
// resulting Schedule. Parse returns an error if expr is rejected by the
|
||||
// underlying cronutil parser.
|
||||
func Parse(expr string) (Schedule, error) {
|
||||
inner, err := cronutil.Parse(expr)
|
||||
if err != nil {
|
||||
return Schedule{}, err
|
||||
}
|
||||
return Schedule{inner: inner}, nil
|
||||
}
|
||||
|
||||
// Next returns the next firing time strictly after `after`, honouring
|
||||
// the skip flag.
|
||||
//
|
||||
// When `skip` is false, Next returns `cronutil.Schedule.Next(after)`
|
||||
// and reports `skipConsumed=false`.
|
||||
//
|
||||
// When `skip` is true, Next computes the cron step immediately after
|
||||
// `after`, then advances by one further cron step and returns that
|
||||
// time with `skipConsumed=true`. The caller is responsible for
|
||||
// persisting the cleared flag after observing `skipConsumed`.
|
||||
//
|
||||
// All returned times are in UTC; cronutil.Schedule already enforces
|
||||
// UTC normalisation on its inputs and outputs.
|
||||
func (s Schedule) Next(after time.Time, skip bool) (time.Time, bool) {
|
||||
first := s.inner.Next(after)
|
||||
if !skip {
|
||||
return first, false
|
||||
}
|
||||
return s.inner.Next(first), true
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseRejectsBadExpr(t *testing.T) {
|
||||
_, err := Parse("")
|
||||
assert.Error(t, err)
|
||||
|
||||
_, err = Parse("0 0 31 2 *") // valid syntactically but never fires; cronutil accepts it
|
||||
// cronutil only validates syntax; an impossible date is still parsed.
|
||||
// We assert by separately rejecting clearly invalid syntax:
|
||||
_, err = Parse("not-a-cron")
|
||||
assert.Error(t, err)
|
||||
|
||||
_, err = Parse("0 18 * *") // four fields
|
||||
assert.Error(t, err)
|
||||
|
||||
_, err = Parse("0 0 * * * *") // six fields
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestNextNoSkip(t *testing.T) {
|
||||
// Fires every day at 18:00 UTC.
|
||||
sched, err := Parse("0 18 * * *")
|
||||
require.NoError(t, err)
|
||||
|
||||
after := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
|
||||
got, skipped := sched.Next(after, false)
|
||||
|
||||
assert.False(t, skipped)
|
||||
assert.Equal(t, time.Date(2026, 4, 27, 18, 0, 0, 0, time.UTC), got)
|
||||
assert.Equal(t, time.UTC, got.Location())
|
||||
}
|
||||
|
||||
func TestNextWithSkipAdvancesOneStep(t *testing.T) {
|
||||
sched, err := Parse("0 18 * * *")
|
||||
require.NoError(t, err)
|
||||
|
||||
after := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
|
||||
got, skipped := sched.Next(after, true)
|
||||
|
||||
assert.True(t, skipped)
|
||||
// First slot would be 2026-04-27 18:00 UTC; the skip rule advances
|
||||
// to 2026-04-28 18:00 UTC.
|
||||
assert.Equal(t, time.Date(2026, 4, 28, 18, 0, 0, 0, time.UTC), got)
|
||||
}
|
||||
|
||||
func TestNextNormalisesNonUTCInput(t *testing.T) {
|
||||
sched, err := Parse("*/15 * * * *")
|
||||
require.NoError(t, err)
|
||||
|
||||
moscow := time.FixedZone("MSK", 3*60*60)
|
||||
// 2026-04-27 15:30 MSK = 2026-04-27 12:30 UTC; next 15-minute slot
|
||||
// in UTC is 12:45.
|
||||
after := time.Date(2026, 4, 27, 15, 30, 0, 0, moscow)
|
||||
|
||||
got, skipped := sched.Next(after, false)
|
||||
assert.False(t, skipped)
|
||||
assert.Equal(t, time.Date(2026, 4, 27, 12, 45, 0, 0, time.UTC), got)
|
||||
assert.Equal(t, time.UTC, got.Location())
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package logging
|
||||
|
||||
import "context"
|
||||
|
||||
// requestIDKey is the unexported context key under which the HTTP layer
|
||||
// stores the request id propagated from the X-Request-Id header.
|
||||
type requestIDKey struct{}
|
||||
|
||||
// WithRequestID returns a child context that carries requestID. An empty
|
||||
// requestID returns ctx unchanged so callers do not have to branch.
|
||||
func WithRequestID(ctx context.Context, requestID string) context.Context {
|
||||
if ctx == nil || requestID == "" {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, requestIDKey{}, requestID)
|
||||
}
|
||||
|
||||
// RequestIDFromContext returns the request id stored on ctx by
|
||||
// WithRequestID, or an empty string when no value is present.
|
||||
func RequestIDFromContext(ctx context.Context) string {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
value, _ := ctx.Value(requestIDKey{}).(string)
|
||||
return value
|
||||
}
|
||||
|
||||
// ContextAttrs returns slog key-value pairs that materialise the frozen
|
||||
// `gamemaster/README.md` §Observability log fields `request_id`,
|
||||
// `trace_id`, and `span_id` from ctx. Pairs whose value is empty are
|
||||
// omitted so logs stay tight.
|
||||
func ContextAttrs(ctx context.Context) []any {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var attrs []any
|
||||
if requestID := RequestIDFromContext(ctx); requestID != "" {
|
||||
attrs = append(attrs, "request_id", requestID)
|
||||
}
|
||||
attrs = append(attrs, TraceAttrsFromContext(ctx)...)
|
||||
return attrs
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Package logging configures the Game Master process logger and provides
|
||||
// context-aware helpers for trace fields.
|
||||
package logging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// New constructs the process-wide JSON logger from level.
|
||||
func New(level string) (*slog.Logger, error) {
|
||||
var slogLevel slog.Level
|
||||
if err := slogLevel.UnmarshalText([]byte(strings.TrimSpace(level))); err != nil {
|
||||
return nil, fmt.Errorf("build logger: %w", err)
|
||||
}
|
||||
|
||||
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slogLevel,
|
||||
})), nil
|
||||
}
|
||||
|
||||
// TraceAttrsFromContext returns slog key-value pairs for the active
|
||||
// OpenTelemetry span when ctx carries a valid span context. The keys match
|
||||
// the frozen `gamemaster/README.md` §Observability log fields `trace_id`
|
||||
// and `span_id`.
|
||||
func TraceAttrsFromContext(ctx context.Context) []any {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
spanContext := trace.SpanContextFromContext(ctx)
|
||||
if !spanContext.IsValid() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []any{
|
||||
"trace_id", spanContext.TraceID().String(),
|
||||
"span_id", spanContext.SpanID().String(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
)
|
||||
|
||||
//go:generate go run go.uber.org/mock/mockgen -destination=../adapters/mocks/mock_engineclient.go -package=mocks galaxy/gamemaster/internal/ports EngineClient
|
||||
|
||||
// EngineClient is the narrow surface Game Master uses against a running
|
||||
// engine container. The production adapter (Stage 12) speaks REST/JSON
|
||||
// against the engine routes documented in `galaxy/game/openapi.yaml`:
|
||||
//
|
||||
// - admin paths under `/api/v1/admin/*` (init, status, turn,
|
||||
// race/banish);
|
||||
// - player paths under `/api/v1/{command, order, report}`.
|
||||
//
|
||||
// The admin-path responses are typed (Init, Status, Turn) because GM
|
||||
// reads structured fields out of them (`current_turn`, `finished`,
|
||||
// per-player stats). The player-path payloads are forwarded verbatim:
|
||||
// the gateway transcodes FlatBuffers to JSON, GM passes the JSON
|
||||
// through, and the engine response is returned to the gateway
|
||||
// unchanged.
|
||||
type EngineClient interface {
|
||||
// Init calls POST /api/v1/admin/init. The returned StateResponse
|
||||
// carries the initial player roster used to install
|
||||
// `player_mappings`.
|
||||
Init(ctx context.Context, baseURL string, request InitRequest) (StateResponse, error)
|
||||
|
||||
// Status calls GET /api/v1/admin/status. Used by inspect surfaces
|
||||
// and by recovery flows.
|
||||
Status(ctx context.Context, baseURL string) (StateResponse, error)
|
||||
|
||||
// Turn calls PUT /api/v1/admin/turn. The returned StateResponse
|
||||
// carries the new turn number, the per-player stats projected into
|
||||
// `player_turn_stats`, and the `finished` flag.
|
||||
Turn(ctx context.Context, baseURL string) (StateResponse, error)
|
||||
|
||||
// BanishRace calls POST /api/v1/admin/race/banish with body
|
||||
// `{race_name}`. The engine returns 204 on success.
|
||||
BanishRace(ctx context.Context, baseURL, raceName string) error
|
||||
|
||||
// ExecuteCommands calls PUT /api/v1/command. The request payload
|
||||
// is forwarded verbatim; the engine response body is returned
|
||||
// verbatim.
|
||||
ExecuteCommands(ctx context.Context, baseURL string, payload json.RawMessage) (json.RawMessage, error)
|
||||
|
||||
// PutOrders calls PUT /api/v1/order with the same forwarding
|
||||
// semantics as ExecuteCommands.
|
||||
PutOrders(ctx context.Context, baseURL string, payload json.RawMessage) (json.RawMessage, error)
|
||||
|
||||
// GetReport calls GET /api/v1/report?player=<raceName>&turn=<turn>.
|
||||
// The engine response body is returned verbatim.
|
||||
GetReport(ctx context.Context, baseURL, raceName string, turn int) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
// InitRequest carries the race roster sent to the engine `/admin/init`
|
||||
// route. The shape mirrors `galaxy/game/openapi.yaml`'s `InitRequest`.
|
||||
type InitRequest struct {
|
||||
// Races stores the per-player race entries in the order returned
|
||||
// by Lobby's roster.
|
||||
Races []InitRace
|
||||
}
|
||||
|
||||
// InitRace stores one entry of an InitRequest.
|
||||
type InitRace struct {
|
||||
// RaceName stores the in-game race name reserved for the player.
|
||||
RaceName string
|
||||
}
|
||||
|
||||
// StateResponse is the typed projection of the engine's `StateResponse`
|
||||
// payload (`galaxy/game/openapi.yaml`). GM reads only the fields it
|
||||
// needs; the adapter is allowed to discard the rest.
|
||||
type StateResponse struct {
|
||||
// Turn stores the engine's current turn number.
|
||||
Turn int
|
||||
|
||||
// Players stores the per-player state entries returned by the
|
||||
// engine. Each entry is mapped into `player_turn_stats[]` by
|
||||
// resolving `RaceName` through `playermappingstore.ListByGame` to
|
||||
// the platform `user_id`.
|
||||
Players []PlayerState
|
||||
|
||||
// Finished reports whether the engine considers the game finished.
|
||||
// Becomes true on a turn-generation response when the engine's
|
||||
// finish condition is satisfied.
|
||||
Finished bool
|
||||
}
|
||||
|
||||
// PlayerState stores one entry of StateResponse.Players. The set of
|
||||
// fields is the minimum GM needs from the engine surface; the adapter
|
||||
// may decode additional fields and discard them.
|
||||
type PlayerState struct {
|
||||
// RaceName stores the in-game race name.
|
||||
RaceName string
|
||||
|
||||
// EnginePlayerUUID stores the engine-side player handle. Populated
|
||||
// from `/admin/init` and `/admin/status`.
|
||||
EnginePlayerUUID string
|
||||
|
||||
// Planets stores the planet count reported for this player on the
|
||||
// most recent turn.
|
||||
Planets int
|
||||
|
||||
// Population stores the population count reported for this player
|
||||
// on the most recent turn.
|
||||
Population int
|
||||
}
|
||||
|
||||
// ErrEngineUnreachable reports that the engine returned a transport
|
||||
// error or 5xx status code. Surfaced to callers as `engine_unreachable`.
|
||||
var ErrEngineUnreachable = errors.New("engine unreachable")
|
||||
|
||||
// ErrEngineProtocolViolation reports that the engine responded with a
|
||||
// payload that did not match the expected schema (missing required
|
||||
// fields, malformed JSON, unexpected types). Surfaced as
|
||||
// `engine_protocol_violation`.
|
||||
var ErrEngineProtocolViolation = errors.New("engine protocol violation")
|
||||
|
||||
// ErrEngineValidation reports that the engine returned 4xx with a
|
||||
// per-command result. Surfaced as `engine_validation_error`; the
|
||||
// engine's body is returned verbatim to the caller through the player
|
||||
// command/order forwarding paths.
|
||||
var ErrEngineValidation = errors.New("engine validation error")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user