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)
|
||||
Reference in New Issue
Block a user