feat: gamemaster

This commit is contained in:
Ilia Denisov
2026-05-03 07:59:03 +02:00
committed by GitHub
parent a7cee15115
commit 3e2622757e
229 changed files with 41521 additions and 1098 deletions
@@ -0,0 +1,441 @@
// Package engineclient provides the trusted-internal HTTP client Game
// Master uses to talk to the engine container. The adapter implements
// `ports.EngineClient` over the routes documented in
// `galaxy/game/openapi.yaml`:
//
// - admin paths under `/api/v1/admin/*` (init, status, turn,
// race/banish);
// - player paths under `/api/v1/{command, order, report}`.
//
// The engine endpoint URL is per-call (Game Master keeps it on
// `runtime_records.engine_endpoint`), so the client does not bind a
// base URL at construction time. Only the per-call timeouts are wired
// through `Config`: `CallTimeout` covers turn-generation-class
// operations, `ProbeTimeout` covers inspect-style reads.
package engineclient
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"galaxy/gamemaster/internal/ports"
)
const (
pathAdminInit = "/api/v1/admin/init"
pathAdminStatus = "/api/v1/admin/status"
pathAdminTurn = "/api/v1/admin/turn"
pathAdminRaceBanish = "/api/v1/admin/race/banish"
pathPlayerCommand = "/api/v1/command"
pathPlayerOrder = "/api/v1/order"
pathPlayerReport = "/api/v1/report"
)
// Config configures one HTTP-backed engine client.
type Config struct {
// CallTimeout bounds turn-generation-class operations: init, turn,
// banish, command, order. Mirrors `GAMEMASTER_ENGINE_CALL_TIMEOUT`.
CallTimeout time.Duration
// ProbeTimeout bounds inspect-style reads: status, report. Mirrors
// `GAMEMASTER_ENGINE_PROBE_TIMEOUT`.
ProbeTimeout time.Duration
}
// Client speaks REST/JSON to the engine container.
type Client struct {
callTimeout time.Duration
probeTimeout time.Duration
httpClient *http.Client
closeIdleConnections func()
}
// NewClient constructs an engine client with `otelhttp`-instrumented
// transport cloned from `http.DefaultTransport`. The returned `Close`
// hook releases idle connections owned by that transport.
func NewClient(cfg Config) (*Client, error) {
transport, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return nil, errors.New("new engine client: default transport is not *http.Transport")
}
cloned := transport.Clone()
return newClient(cfg, &http.Client{Transport: otelhttp.NewTransport(cloned)}, cloned.CloseIdleConnections)
}
func newClient(cfg Config, httpClient *http.Client, closeIdleConnections func()) (*Client, error) {
switch {
case cfg.CallTimeout <= 0:
return nil, errors.New("new engine client: call timeout must be positive")
case cfg.ProbeTimeout <= 0:
return nil, errors.New("new engine client: probe timeout must be positive")
case httpClient == nil:
return nil, errors.New("new engine client: http client must not be nil")
}
return &Client{
callTimeout: cfg.CallTimeout,
probeTimeout: cfg.ProbeTimeout,
httpClient: httpClient,
closeIdleConnections: closeIdleConnections,
}, nil
}
// Close releases idle HTTP connections owned by the underlying
// transport. Safe to call multiple times.
func (client *Client) Close() error {
if client == nil || client.closeIdleConnections == nil {
return nil
}
client.closeIdleConnections()
return nil
}
// Init calls POST /api/v1/admin/init.
func (client *Client) Init(ctx context.Context, baseURL string, request ports.InitRequest) (ports.StateResponse, error) {
if err := client.validateBase(baseURL); err != nil {
return ports.StateResponse{}, err
}
if len(request.Races) == 0 {
return ports.StateResponse{}, errors.New("engine init: races must not be empty")
}
body, err := encodeInitRequest(request)
if err != nil {
return ports.StateResponse{}, fmt.Errorf("engine init: encode request: %w", err)
}
payload, status, doErr := client.doRequest(ctx, http.MethodPost, baseURL+pathAdminInit, body, client.callTimeout)
if doErr != nil {
return ports.StateResponse{}, fmt.Errorf("%w: engine init: %w", ports.ErrEngineUnreachable, doErr)
}
switch status {
case http.StatusOK, http.StatusCreated:
return decodeStateResponse(payload, "engine init")
case http.StatusBadRequest:
return ports.StateResponse{}, fmt.Errorf("%w: engine init: %s", ports.ErrEngineValidation, summariseEngineError(payload, status))
default:
return ports.StateResponse{}, fmt.Errorf("%w: engine init: %s", ports.ErrEngineUnreachable, summariseEngineError(payload, status))
}
}
// Status calls GET /api/v1/admin/status.
func (client *Client) Status(ctx context.Context, baseURL string) (ports.StateResponse, error) {
if err := client.validateBase(baseURL); err != nil {
return ports.StateResponse{}, err
}
payload, status, doErr := client.doRequest(ctx, http.MethodGet, baseURL+pathAdminStatus, nil, client.probeTimeout)
if doErr != nil {
return ports.StateResponse{}, fmt.Errorf("%w: engine status: %w", ports.ErrEngineUnreachable, doErr)
}
switch status {
case http.StatusOK:
return decodeStateResponse(payload, "engine status")
case http.StatusBadRequest:
return ports.StateResponse{}, fmt.Errorf("%w: engine status: %s", ports.ErrEngineValidation, summariseEngineError(payload, status))
default:
return ports.StateResponse{}, fmt.Errorf("%w: engine status: %s", ports.ErrEngineUnreachable, summariseEngineError(payload, status))
}
}
// Turn calls PUT /api/v1/admin/turn.
func (client *Client) Turn(ctx context.Context, baseURL string) (ports.StateResponse, error) {
if err := client.validateBase(baseURL); err != nil {
return ports.StateResponse{}, err
}
payload, status, doErr := client.doRequest(ctx, http.MethodPut, baseURL+pathAdminTurn, nil, client.callTimeout)
if doErr != nil {
return ports.StateResponse{}, fmt.Errorf("%w: engine turn: %w", ports.ErrEngineUnreachable, doErr)
}
switch status {
case http.StatusOK:
return decodeStateResponse(payload, "engine turn")
case http.StatusBadRequest:
return ports.StateResponse{}, fmt.Errorf("%w: engine turn: %s", ports.ErrEngineValidation, summariseEngineError(payload, status))
default:
return ports.StateResponse{}, fmt.Errorf("%w: engine turn: %s", ports.ErrEngineUnreachable, summariseEngineError(payload, status))
}
}
// BanishRace calls POST /api/v1/admin/race/banish with body
// `{race_name}`. Engine returns 204 on success.
func (client *Client) BanishRace(ctx context.Context, baseURL, raceName string) error {
if err := client.validateBase(baseURL); err != nil {
return err
}
if strings.TrimSpace(raceName) == "" {
return errors.New("engine banish: race name must not be empty")
}
body, err := json.Marshal(banishRequestEnvelope{RaceName: raceName})
if err != nil {
return fmt.Errorf("engine banish: encode request: %w", err)
}
payload, status, doErr := client.doRequest(ctx, http.MethodPost, baseURL+pathAdminRaceBanish, body, client.callTimeout)
if doErr != nil {
return fmt.Errorf("%w: engine banish: %w", ports.ErrEngineUnreachable, doErr)
}
switch status {
case http.StatusNoContent, http.StatusOK:
return nil
case http.StatusBadRequest:
return fmt.Errorf("%w: engine banish: %s", ports.ErrEngineValidation, summariseEngineError(payload, status))
default:
return fmt.Errorf("%w: engine banish: %s", ports.ErrEngineUnreachable, summariseEngineError(payload, status))
}
}
// ExecuteCommands calls PUT /api/v1/command with payload forwarded
// verbatim. The engine response body is returned verbatim; on 4xx the
// body is returned alongside `ports.ErrEngineValidation` so callers can
// forward the per-command errors.
func (client *Client) ExecuteCommands(ctx context.Context, baseURL string, payload json.RawMessage) (json.RawMessage, error) {
return client.forwardPlayerWrite(ctx, baseURL, pathPlayerCommand, payload, "engine command")
}
// PutOrders calls PUT /api/v1/order with the same forwarding semantics
// as ExecuteCommands.
func (client *Client) PutOrders(ctx context.Context, baseURL string, payload json.RawMessage) (json.RawMessage, error) {
return client.forwardPlayerWrite(ctx, baseURL, pathPlayerOrder, payload, "engine order")
}
// GetReport calls GET /api/v1/report?player=<raceName>&turn=<turn> and
// returns the engine response body verbatim.
func (client *Client) GetReport(ctx context.Context, baseURL, raceName string, turn int) (json.RawMessage, error) {
if err := client.validateBase(baseURL); err != nil {
return nil, err
}
if strings.TrimSpace(raceName) == "" {
return nil, errors.New("engine report: race name must not be empty")
}
if turn < 0 {
return nil, fmt.Errorf("engine report: turn must not be negative, got %d", turn)
}
values := url.Values{}
values.Set("player", raceName)
values.Set("turn", strconv.Itoa(turn))
target := baseURL + pathPlayerReport + "?" + values.Encode()
body, status, doErr := client.doRequest(ctx, http.MethodGet, target, nil, client.probeTimeout)
if doErr != nil {
return nil, fmt.Errorf("%w: engine report: %w", ports.ErrEngineUnreachable, doErr)
}
switch status {
case http.StatusOK:
if len(body) == 0 {
return nil, fmt.Errorf("%w: engine report: empty response body", ports.ErrEngineProtocolViolation)
}
return json.RawMessage(body), nil
case http.StatusBadRequest:
return json.RawMessage(body), fmt.Errorf("%w: engine report: %s", ports.ErrEngineValidation, summariseEngineError(body, status))
default:
return nil, fmt.Errorf("%w: engine report: %s", ports.ErrEngineUnreachable, summariseEngineError(body, status))
}
}
func (client *Client) forwardPlayerWrite(ctx context.Context, baseURL, requestPath string, payload json.RawMessage, opLabel string) (json.RawMessage, error) {
if err := client.validateBase(baseURL); err != nil {
return nil, err
}
if len(bytes.TrimSpace(payload)) == 0 {
return nil, fmt.Errorf("%s: payload must not be empty", opLabel)
}
body, status, doErr := client.doRequest(ctx, http.MethodPut, baseURL+requestPath, []byte(payload), client.callTimeout)
if doErr != nil {
return nil, fmt.Errorf("%w: %s: %w", ports.ErrEngineUnreachable, opLabel, doErr)
}
switch status {
case http.StatusNoContent, http.StatusOK:
if len(body) == 0 {
return nil, nil
}
return json.RawMessage(body), nil
case http.StatusBadRequest:
return json.RawMessage(body), fmt.Errorf("%w: %s: %s", ports.ErrEngineValidation, opLabel, summariseEngineError(body, status))
default:
return nil, fmt.Errorf("%w: %s: %s", ports.ErrEngineUnreachable, opLabel, summariseEngineError(body, status))
}
}
// validateBase rejects nil clients, nil/cancelled contexts, and
// malformed engine endpoints up-front so transport-layer plumbing does
// not need to handle them.
func (client *Client) validateBase(baseURL string) error {
if client == nil || client.httpClient == nil {
return errors.New("engine client: nil client")
}
if strings.TrimSpace(baseURL) == "" {
return errors.New("engine client: base url must not be empty")
}
parsed, err := url.Parse(baseURL)
if err != nil {
return fmt.Errorf("engine client: parse base url: %w", err)
}
if parsed.Scheme == "" || parsed.Host == "" {
return fmt.Errorf("engine client: base url %q must be absolute", baseURL)
}
return nil
}
func (client *Client) doRequest(ctx context.Context, method, target string, body []byte, timeout time.Duration) ([]byte, int, error) {
if ctx == nil {
return nil, 0, errors.New("nil context")
}
if err := ctx.Err(); err != nil {
return nil, 0, err
}
attemptCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
var reader io.Reader
if len(body) > 0 {
reader = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(attemptCtx, method, target, reader)
if err != nil {
return nil, 0, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Accept", "application/json")
if len(body) > 0 {
req.Header.Set("Content-Type", "application/json")
}
resp, err := client.httpClient.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, fmt.Errorf("read response body: %w", err)
}
return respBody, resp.StatusCode, nil
}
// encodeInitRequest serialises ports.InitRequest into the engine spec
// shape (`InitRequest`/`InitRace`).
func encodeInitRequest(request ports.InitRequest) ([]byte, error) {
envelope := initRequestEnvelope{Races: make([]initRaceEnvelope, 0, len(request.Races))}
for _, race := range request.Races {
if strings.TrimSpace(race.RaceName) == "" {
return nil, errors.New("init race: race name must not be empty")
}
envelope.Races = append(envelope.Races, initRaceEnvelope{RaceName: race.RaceName})
}
return json.Marshal(envelope)
}
// decodeStateResponse decodes the engine StateResponse payload into the
// port-level StateResponse projection. Unknown fields are tolerated;
// missing required ones surface as ErrEngineProtocolViolation.
func decodeStateResponse(payload []byte, opLabel string) (ports.StateResponse, error) {
if len(payload) == 0 {
return ports.StateResponse{}, fmt.Errorf("%w: %s: empty response body", ports.ErrEngineProtocolViolation, opLabel)
}
var envelope stateResponseEnvelope
decoder := json.NewDecoder(bytes.NewReader(payload))
if err := decoder.Decode(&envelope); err != nil {
return ports.StateResponse{}, fmt.Errorf("%w: %s: decode body: %w", ports.ErrEngineProtocolViolation, opLabel, err)
}
if strings.TrimSpace(envelope.ID) == "" {
return ports.StateResponse{}, fmt.Errorf("%w: %s: missing id", ports.ErrEngineProtocolViolation, opLabel)
}
if envelope.Player == nil {
return ports.StateResponse{}, fmt.Errorf("%w: %s: missing player array", ports.ErrEngineProtocolViolation, opLabel)
}
state := ports.StateResponse{
Turn: envelope.Turn,
Finished: envelope.Finished,
Players: make([]ports.PlayerState, 0, len(envelope.Player)),
}
for index, player := range envelope.Player {
if strings.TrimSpace(player.RaceName) == "" {
return ports.StateResponse{}, fmt.Errorf("%w: %s: player[%d] missing raceName", ports.ErrEngineProtocolViolation, opLabel, index)
}
if strings.TrimSpace(player.ID) == "" {
return ports.StateResponse{}, fmt.Errorf("%w: %s: player[%d] missing id", ports.ErrEngineProtocolViolation, opLabel, index)
}
if player.Planets < 0 {
return ports.StateResponse{}, fmt.Errorf("%w: %s: player[%d] negative planets", ports.ErrEngineProtocolViolation, opLabel, index)
}
if math.IsNaN(player.Population) || math.IsInf(player.Population, 0) || player.Population < 0 {
return ports.StateResponse{}, fmt.Errorf("%w: %s: player[%d] invalid population", ports.ErrEngineProtocolViolation, opLabel, index)
}
state.Players = append(state.Players, ports.PlayerState{
RaceName: player.RaceName,
EnginePlayerUUID: player.ID,
Planets: player.Planets,
Population: int(math.Round(player.Population)),
})
}
return state, nil
}
// summariseEngineError extracts a short, human-readable summary from
// the engine's validation/internal-error envelopes for the wrapped
// error message.
func summariseEngineError(payload []byte, status int) string {
trimmed := bytes.TrimSpace(payload)
if len(trimmed) == 0 {
return fmt.Sprintf("status=%d", status)
}
var envelope engineErrorEnvelope
if err := json.Unmarshal(trimmed, &envelope); err == nil {
switch {
case envelope.GenericError != "":
return fmt.Sprintf("status=%d generic_error=%q code=%d", status, envelope.GenericError, envelope.Code)
case envelope.Error != "":
return fmt.Sprintf("status=%d error=%q", status, envelope.Error)
}
}
return fmt.Sprintf("status=%d", status)
}
// stateResponseEnvelope mirrors `StateResponse` from
// `game/openapi.yaml`. Unknown fields are tolerated by encoding/json.
type stateResponseEnvelope struct {
ID string `json:"id"`
Turn int `json:"turn"`
Stage int `json:"stage"`
Player []playerStateEnvelope `json:"player"`
Finished bool `json:"finished"`
}
// playerStateEnvelope mirrors `PlayerState`. Population is `number`
// per the engine spec, so the adapter decodes into float64 and rounds
// to the port-level int (engine in practice always returns whole
// numbers; rounding is a defensive guard against floating-point
// noise).
type playerStateEnvelope struct {
ID string `json:"id"`
RaceName string `json:"raceName"`
Planets int `json:"planets"`
Population float64 `json:"population"`
Extinct bool `json:"extinct"`
}
type initRequestEnvelope struct {
Races []initRaceEnvelope `json:"races"`
}
type initRaceEnvelope struct {
RaceName string `json:"raceName"`
}
type banishRequestEnvelope struct {
RaceName string `json:"race_name"`
}
type engineErrorEnvelope struct {
Error string `json:"error"`
GenericError string `json:"generic_error"`
Code int `json:"code"`
}
// Compile-time assertion: Client implements ports.EngineClient.
var _ ports.EngineClient = (*Client)(nil)
@@ -0,0 +1,363 @@
package engineclient
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"galaxy/gamemaster/internal/ports"
)
func newTestClient(t *testing.T, callTimeout, probeTimeout time.Duration) *Client {
t.Helper()
client, err := NewClient(Config{CallTimeout: callTimeout, ProbeTimeout: probeTimeout})
require.NoError(t, err)
t.Cleanup(func() { _ = client.Close() })
return client
}
func TestNewClientValidatesConfig(t *testing.T) {
cases := map[string]Config{
"non-positive call timeout": {CallTimeout: 0, ProbeTimeout: time.Second},
"non-positive probe timeout": {CallTimeout: time.Second, ProbeTimeout: 0},
}
for name, cfg := range cases {
t.Run(name, func(t *testing.T) {
_, err := NewClient(cfg)
require.Error(t, err)
})
}
}
func TestInitHappyPath(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/api/v1/admin/init", r.URL.Path)
require.Equal(t, "application/json", r.Header.Get("Content-Type"))
body, err := io.ReadAll(r.Body)
require.NoError(t, err)
var got initRequestEnvelope
require.NoError(t, json.Unmarshal(body, &got))
require.Equal(t, []initRaceEnvelope{{RaceName: "Human"}, {RaceName: "Klingon"}}, got.Races)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{
"id": "00000000-0000-0000-0000-000000000001",
"turn": 0,
"stage": 0,
"finished": false,
"player": [
{"id":"00000000-0000-0000-0000-000000000010","raceName":"Human","planets":3,"population":1500,"extinct":false},
{"id":"00000000-0000-0000-0000-000000000011","raceName":"Klingon","planets":3,"population":1500,"extinct":false}
]
}`))
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
state, err := client.Init(context.Background(), server.URL, ports.InitRequest{
Races: []ports.InitRace{{RaceName: "Human"}, {RaceName: "Klingon"}},
})
require.NoError(t, err)
assert.Equal(t, 0, state.Turn)
assert.False(t, state.Finished)
require.Len(t, state.Players, 2)
assert.Equal(t, "Human", state.Players[0].RaceName)
assert.Equal(t, "00000000-0000-0000-0000-000000000010", state.Players[0].EnginePlayerUUID)
assert.Equal(t, 3, state.Players[0].Planets)
assert.Equal(t, 1500, state.Players[0].Population)
}
func TestInitRejectsEmptyRaces(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("must not contact engine on empty races")
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
_, err := client.Init(context.Background(), server.URL, ports.InitRequest{})
require.Error(t, err)
}
func TestInitValidationErrorMapsToEngineValidation(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"races must contain at least 10 entries"}`))
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
_, err := client.Init(context.Background(), server.URL, ports.InitRequest{
Races: []ports.InitRace{{RaceName: "X"}},
})
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrEngineValidation))
assert.Contains(t, err.Error(), "must contain at least 10")
}
func TestInitInternalErrorMapsToUnreachable(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"generic_error":"boom","code":42}`))
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
_, err := client.Init(context.Background(), server.URL, ports.InitRequest{Races: []ports.InitRace{{RaceName: "X"}}})
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrEngineUnreachable))
assert.Contains(t, err.Error(), "code=42")
}
func TestStatusHappyPath(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
require.Equal(t, "/api/v1/admin/status", r.URL.Path)
_, _ = w.Write([]byte(`{
"id": "g-1",
"turn": 5,
"stage": 0,
"finished": false,
"player": [
{"id":"p-1","raceName":"Human","planets":4,"population":1700.0,"extinct":false}
]
}`))
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
state, err := client.Status(context.Background(), server.URL)
require.NoError(t, err)
assert.Equal(t, 5, state.Turn)
require.Len(t, state.Players, 1)
assert.Equal(t, "Human", state.Players[0].RaceName)
assert.Equal(t, 1700, state.Players[0].Population)
}
func TestStatusUsesProbeTimeout(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(120 * time.Millisecond)
_, _ = w.Write([]byte(`{}`))
}))
defer server.Close()
client := newTestClient(t, time.Second, 30*time.Millisecond)
_, err := client.Status(context.Background(), server.URL)
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrEngineUnreachable))
}
func TestTurnFinishedFlagPropagates(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPut, r.Method)
require.Equal(t, "/api/v1/admin/turn", r.URL.Path)
_, _ = w.Write([]byte(`{
"id":"g","turn":42,"stage":0,"finished":true,
"player":[{"id":"p1","raceName":"Human","planets":0,"population":0,"extinct":true}]
}`))
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
state, err := client.Turn(context.Background(), server.URL)
require.NoError(t, err)
assert.Equal(t, 42, state.Turn)
assert.True(t, state.Finished)
}
func TestDecodeProtocolViolations(t *testing.T) {
cases := map[string]string{
"missing id": `{"turn":0,"stage":0,"finished":false,"player":[]}`,
"missing player": `{"id":"g","turn":0,"stage":0,"finished":false}`,
"missing race name": `{"id":"g","turn":0,"stage":0,"finished":false,"player":[{"id":"p","planets":0,"population":0,"extinct":false}]}`,
"missing player id": `{"id":"g","turn":0,"stage":0,"finished":false,"player":[{"raceName":"X","planets":0,"population":0,"extinct":false}]}`,
"negative planets": `{"id":"g","turn":0,"stage":0,"finished":false,"player":[{"id":"p","raceName":"X","planets":-1,"population":0,"extinct":false}]}`,
"infinite population": `{"id":"g","turn":0,"stage":0,"finished":false,"player":[{"id":"p","raceName":"X","planets":1,"population":1e400,"extinct":false}]}`,
}
for name, body := range cases {
t.Run(name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(body))
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
_, err := client.Status(context.Background(), server.URL)
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrEngineProtocolViolation), "case %q: %v", name, err)
})
}
}
func TestBanishRaceHappyPath(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/api/v1/admin/race/banish", r.URL.Path)
var got banishRequestEnvelope
require.NoError(t, json.NewDecoder(r.Body).Decode(&got))
assert.Equal(t, "Klingon", got.RaceName)
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
require.NoError(t, client.BanishRace(context.Background(), server.URL, "Klingon"))
}
func TestBanishRaceRejectsBlankName(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Fatal("must not contact engine on blank race name")
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
require.Error(t, client.BanishRace(context.Background(), server.URL, " "))
}
func TestBanishRaceValidationError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"unknown race"}`))
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
err := client.BanishRace(context.Background(), server.URL, "Vulcan")
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrEngineValidation))
}
func TestExecuteCommandsHappyPath(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPut, r.Method)
require.Equal(t, "/api/v1/command", r.URL.Path)
body, _ := io.ReadAll(r.Body)
assert.JSONEq(t, `{"actor":"Human","cmd":[]}`, string(body))
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
body, err := client.ExecuteCommands(context.Background(), server.URL, json.RawMessage(`{"actor":"Human","cmd":[]}`))
require.NoError(t, err)
assert.Nil(t, body)
}
func TestExecuteCommandsValidationReturnsBody(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"bad command"}`))
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
body, err := client.ExecuteCommands(context.Background(), server.URL, json.RawMessage(`{"actor":"Human","cmd":[{}]}`))
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrEngineValidation))
assert.JSONEq(t, `{"error":"bad command"}`, string(body))
}
func TestExecuteCommandsRejectsEmptyPayload(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Fatal("must not contact engine with empty payload")
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
_, err := client.ExecuteCommands(context.Background(), server.URL, json.RawMessage(` `))
require.Error(t, err)
}
func TestPutOrdersHappyPath(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPut, r.Method)
require.Equal(t, "/api/v1/order", r.URL.Path)
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
body, err := client.PutOrders(context.Background(), server.URL, json.RawMessage(`{"actor":"Human","cmd":[]}`))
require.NoError(t, err)
assert.Nil(t, body)
}
func TestGetReportHappyPath(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
require.Equal(t, "/api/v1/report", r.URL.Path)
assert.Equal(t, "Human", r.URL.Query().Get("player"))
assert.Equal(t, "7", r.URL.Query().Get("turn"))
_, _ = w.Write([]byte(`{"version":"1","turn":7,"race":"Human"}`))
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
body, err := client.GetReport(context.Background(), server.URL, "Human", 7)
require.NoError(t, err)
assert.JSONEq(t, `{"version":"1","turn":7,"race":"Human"}`, string(body))
}
func TestGetReportEmptyBodyIsProtocolViolation(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
_, err := client.GetReport(context.Background(), server.URL, "Human", 0)
require.Error(t, err)
assert.True(t, errors.Is(err, ports.ErrEngineProtocolViolation))
}
func TestGetReportRejectsBadInput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Fatal("must not contact engine on bad input")
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
_, err := client.GetReport(context.Background(), server.URL, " ", 0)
require.Error(t, err)
_, err = client.GetReport(context.Background(), server.URL, "Human", -1)
require.Error(t, err)
}
func TestValidateBaseRejectsBadURLs(t *testing.T) {
client := newTestClient(t, time.Second, time.Second)
_, err := client.Status(context.Background(), "")
require.Error(t, err)
_, err = client.Status(context.Background(), "engine:8080")
require.Error(t, err)
require.Contains(t, err.Error(), "absolute")
}
func TestCancelledContextSurfaces(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Fatal("must not contact engine with cancelled context")
}))
defer server.Close()
client := newTestClient(t, time.Second, time.Second)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := client.Status(ctx, server.URL)
require.Error(t, err)
assert.True(t, errors.Is(err, context.Canceled))
}
func TestSummariseEngineErrorFallback(t *testing.T) {
got := summariseEngineError([]byte("not json"), 502)
assert.True(t, strings.Contains(got, "status=502"))
}
func TestCloseIsIdempotent(t *testing.T) {
client := newTestClient(t, time.Second, time.Second)
require.NoError(t, client.Close())
require.NoError(t, client.Close())
}