// 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=&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)