969c0480ba
Engine wire change: Report.battle switched from []uuid.UUID to
[]BattleSummary{id, planet, shots} so the map can place battle
markers without N extra fetches. FBS schema + generated Go/TS
regenerated; transcoder + report controller updated; openapi
adds the BattleSummary schema with a freeze test.
Backend gateway forwards engine GET /api/v1/battle/:turn/:uuid as
/api/v1/user/games/{game_id}/battles/{turn}/{battle_id} (handler
plus engineclient.FetchBattle, contract test stub, openapi spec).
UI:
- BattleViewer (lib/battle-player/) is a logically isolated SVG
radial scene that consumes a BattleReport prop. Planet at the
centre, races on the outer ring at equal angular spacing, race
clusters by (race, className) with <class>:<numLeft> labels;
observer groups (inBattle: false) are not drawn; eliminated
races drop out and survivors re-distribute on the next frame.
- Shot line per frame: red on destroyed, green otherwise; erased
on the next frame. Playback controls: play/pause + step ± +
rewind + 1x/2x/4x speed (400/200/100 ms per frame).
- Page wrapper (lib/active-view/battle.svelte) loads BattleReport
via api/battle-fetch.ts; synthetic-gameId prefix routes to a
fixture loader, otherwise REST through the gateway. Always-
visible <ol> text protocol satisfies the accessibility ask.
- section-battles.svelte links every battle UUID into the viewer.
- map/battle-markers.ts: yellow X cross of 2 LinePrim through the
corners of the planet's circumscribed square (stroke width
clamps from 1 px at 1 shot to 5 px at 100+ shots); bombing
marker is a stroke-only ring (yellow when damaged, red when
wiped). Wired into state-binding.ts; click handler dispatches
battle clicks to the viewer and bombing clicks to the matching
Reports row.
- i18n keys for the viewer in en + ru.
Docs: ui/docs/battle-viewer-ux.md, FUNCTIONAL.md §6.5 + ru
mirror, ui/PLAN.md Phase 27 decisions + deferred TODOs (push
event, richer class visuals, animated re-distribution).
Tests: Vitest unit (radial layout + timeline frame builder +
marker stroke formula + marker primitives), Playwright e2e for
the viewer (Reports link → viewer, playback step, not-found),
backend engineclient FetchBattle (200 / 404 / bad input), engine
openapi freezes (BattleReport, BattleReportGroup,
BattleActionReport, BattleSummary, Report.battle items).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
405 lines
15 KiB
Go
405 lines
15 KiB
Go
package engineclient
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
|
|
|
"galaxy/model/rest"
|
|
)
|
|
|
|
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"
|
|
pathPlayerBattle = "/api/v1/battle"
|
|
pathHealthz = "/healthz"
|
|
)
|
|
|
|
// Config configures one HTTP-backed engine client.
|
|
type Config struct {
|
|
// CallTimeout bounds turn-generation-class operations: init, turn,
|
|
// banish, command, order. Mirrors `BACKEND_ENGINE_CALL_TIMEOUT`.
|
|
CallTimeout time.Duration
|
|
|
|
// ProbeTimeout bounds inspect-style reads: status, report, healthz.
|
|
// Mirrors `BACKEND_ENGINE_PROBE_TIMEOUT`.
|
|
ProbeTimeout time.Duration
|
|
}
|
|
|
|
// Client is the engine HTTP client. The zero value is not usable — use
|
|
// NewClient.
|
|
type Client struct {
|
|
callTimeout time.Duration
|
|
probeTimeout time.Duration
|
|
httpClient *http.Client
|
|
closeIdleConnections func()
|
|
}
|
|
|
|
// NewClient constructs a Client with an `otelhttp`-instrumented
|
|
// transport cloned from `http.DefaultTransport`. Close releases idle
|
|
// connections owned by the cloned transport.
|
|
func NewClient(cfg Config) (*Client, error) {
|
|
transport, ok := http.DefaultTransport.(*http.Transport)
|
|
if !ok {
|
|
return nil, errors.New("engineclient: default transport is not *http.Transport")
|
|
}
|
|
cloned := transport.Clone()
|
|
return newClient(cfg, &http.Client{Transport: otelhttp.NewTransport(cloned)}, cloned.CloseIdleConnections)
|
|
}
|
|
|
|
// NewClientWithHTTP constructs a Client around a caller-supplied
|
|
// `*http.Client`. Used in tests to inject `httptest`-backed transports.
|
|
func NewClientWithHTTP(cfg Config, hc *http.Client) (*Client, error) {
|
|
return newClient(cfg, hc, nil)
|
|
}
|
|
|
|
func newClient(cfg Config, hc *http.Client, closeIdle func()) (*Client, error) {
|
|
switch {
|
|
case cfg.CallTimeout <= 0:
|
|
return nil, errors.New("engineclient: call timeout must be positive")
|
|
case cfg.ProbeTimeout <= 0:
|
|
return nil, errors.New("engineclient: probe timeout must be positive")
|
|
case hc == nil:
|
|
return nil, errors.New("engineclient: http client must not be nil")
|
|
}
|
|
return &Client{
|
|
callTimeout: cfg.CallTimeout,
|
|
probeTimeout: cfg.ProbeTimeout,
|
|
httpClient: hc,
|
|
closeIdleConnections: closeIdle,
|
|
}, nil
|
|
}
|
|
|
|
// Close releases idle HTTP connections owned by the underlying
|
|
// transport. Safe to call multiple times.
|
|
func (c *Client) Close() error {
|
|
if c == nil || c.closeIdleConnections == nil {
|
|
return nil
|
|
}
|
|
c.closeIdleConnections()
|
|
return nil
|
|
}
|
|
|
|
// Init calls `POST /api/v1/admin/init`.
|
|
func (c *Client) Init(ctx context.Context, baseURL string, request rest.InitRequest) (rest.StateResponse, error) {
|
|
if err := validateBaseURL(baseURL); err != nil {
|
|
return rest.StateResponse{}, err
|
|
}
|
|
body, err := json.Marshal(request)
|
|
if err != nil {
|
|
return rest.StateResponse{}, fmt.Errorf("engineclient init: encode request: %w", err)
|
|
}
|
|
payload, status, doErr := c.doRequest(ctx, http.MethodPost, baseURL+pathAdminInit, body, c.callTimeout)
|
|
if doErr != nil {
|
|
return rest.StateResponse{}, fmt.Errorf("%w: engine init: %w", ErrEngineUnreachable, doErr)
|
|
}
|
|
switch status {
|
|
case http.StatusOK, http.StatusCreated:
|
|
return decodeStateResponse(payload, "engine init")
|
|
case http.StatusBadRequest:
|
|
return rest.StateResponse{}, fmt.Errorf("%w: engine init: %s", ErrEngineValidation, summariseEngineError(payload, status))
|
|
default:
|
|
return rest.StateResponse{}, fmt.Errorf("%w: engine init: %s", ErrEngineUnreachable, summariseEngineError(payload, status))
|
|
}
|
|
}
|
|
|
|
// Status calls `GET /api/v1/admin/status`.
|
|
func (c *Client) Status(ctx context.Context, baseURL string) (rest.StateResponse, error) {
|
|
if err := validateBaseURL(baseURL); err != nil {
|
|
return rest.StateResponse{}, err
|
|
}
|
|
payload, status, doErr := c.doRequest(ctx, http.MethodGet, baseURL+pathAdminStatus, nil, c.probeTimeout)
|
|
if doErr != nil {
|
|
return rest.StateResponse{}, fmt.Errorf("%w: engine status: %w", ErrEngineUnreachable, doErr)
|
|
}
|
|
switch status {
|
|
case http.StatusOK:
|
|
return decodeStateResponse(payload, "engine status")
|
|
case http.StatusBadRequest:
|
|
return rest.StateResponse{}, fmt.Errorf("%w: engine status: %s", ErrEngineValidation, summariseEngineError(payload, status))
|
|
default:
|
|
return rest.StateResponse{}, fmt.Errorf("%w: engine status: %s", ErrEngineUnreachable, summariseEngineError(payload, status))
|
|
}
|
|
}
|
|
|
|
// Turn calls `PUT /api/v1/admin/turn`.
|
|
func (c *Client) Turn(ctx context.Context, baseURL string) (rest.StateResponse, error) {
|
|
if err := validateBaseURL(baseURL); err != nil {
|
|
return rest.StateResponse{}, err
|
|
}
|
|
payload, status, doErr := c.doRequest(ctx, http.MethodPut, baseURL+pathAdminTurn, nil, c.callTimeout)
|
|
if doErr != nil {
|
|
return rest.StateResponse{}, fmt.Errorf("%w: engine turn: %w", ErrEngineUnreachable, doErr)
|
|
}
|
|
switch status {
|
|
case http.StatusOK:
|
|
return decodeStateResponse(payload, "engine turn")
|
|
case http.StatusBadRequest:
|
|
return rest.StateResponse{}, fmt.Errorf("%w: engine turn: %s", ErrEngineValidation, summariseEngineError(payload, status))
|
|
default:
|
|
return rest.StateResponse{}, fmt.Errorf("%w: engine turn: %s", ErrEngineUnreachable, summariseEngineError(payload, status))
|
|
}
|
|
}
|
|
|
|
// BanishRace calls `POST /api/v1/admin/race/banish` with body
|
|
// `{race_name}`. Engine returns 204 on success.
|
|
func (c *Client) BanishRace(ctx context.Context, baseURL, raceName string) error {
|
|
if err := validateBaseURL(baseURL); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(raceName) == "" {
|
|
return errors.New("engineclient banish: race name must not be empty")
|
|
}
|
|
body, err := json.Marshal(rest.BanishRequest{RaceName: raceName})
|
|
if err != nil {
|
|
return fmt.Errorf("engineclient banish: encode: %w", err)
|
|
}
|
|
payload, status, doErr := c.doRequest(ctx, http.MethodPost, baseURL+pathAdminRaceBanish, body, c.callTimeout)
|
|
if doErr != nil {
|
|
return fmt.Errorf("%w: engine banish: %w", ErrEngineUnreachable, doErr)
|
|
}
|
|
switch status {
|
|
case http.StatusNoContent, http.StatusOK:
|
|
return nil
|
|
case http.StatusBadRequest:
|
|
return fmt.Errorf("%w: engine banish: %s", ErrEngineValidation, summariseEngineError(payload, status))
|
|
default:
|
|
return fmt.Errorf("%w: engine banish: %s", 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 ErrEngineValidation so callers can
|
|
// forward the per-command error.
|
|
func (c *Client) ExecuteCommands(ctx context.Context, baseURL string, payload json.RawMessage) (json.RawMessage, error) {
|
|
return c.forwardPlayerWrite(ctx, baseURL, pathPlayerCommand, payload, "engine command")
|
|
}
|
|
|
|
// PutOrders calls `PUT /api/v1/order` with the same forwarding
|
|
// semantics as ExecuteCommands.
|
|
func (c *Client) PutOrders(ctx context.Context, baseURL string, payload json.RawMessage) (json.RawMessage, error) {
|
|
return c.forwardPlayerWrite(ctx, baseURL, pathPlayerOrder, payload, "engine order")
|
|
}
|
|
|
|
// GetOrder calls `GET /api/v1/order?player=<raceName>&turn=<turn>` and
|
|
// returns the engine response body verbatim. A `204 No Content` body
|
|
// is signalled by `(nil, http.StatusNoContent, nil)` so callers can
|
|
// surface "no stored order" without parsing the empty payload.
|
|
// Other non-`200` statuses come back wrapped in `ErrEngineValidation`
|
|
// (4xx) or `ErrEngineUnreachable` (everything else), matching the
|
|
// existing player-write conventions.
|
|
func (c *Client) GetOrder(ctx context.Context, baseURL, raceName string, turn int) (json.RawMessage, int, error) {
|
|
if err := validateBaseURL(baseURL); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if strings.TrimSpace(raceName) == "" {
|
|
return nil, 0, errors.New("engineclient order get: race name must not be empty")
|
|
}
|
|
if turn < 0 {
|
|
return nil, 0, fmt.Errorf("engineclient order get: turn must not be negative, got %d", turn)
|
|
}
|
|
values := url.Values{}
|
|
values.Set("player", raceName)
|
|
values.Set("turn", strconv.Itoa(turn))
|
|
target := baseURL + pathPlayerOrder + "?" + values.Encode()
|
|
body, status, doErr := c.doRequest(ctx, http.MethodGet, target, nil, c.probeTimeout)
|
|
if doErr != nil {
|
|
return nil, 0, fmt.Errorf("%w: engine order get: %w", ErrEngineUnreachable, doErr)
|
|
}
|
|
switch status {
|
|
case http.StatusOK:
|
|
if len(body) == 0 {
|
|
return nil, status, fmt.Errorf("%w: engine order get: empty response body", ErrEngineProtocolViolation)
|
|
}
|
|
return json.RawMessage(body), status, nil
|
|
case http.StatusNoContent:
|
|
return nil, status, nil
|
|
case http.StatusBadRequest, http.StatusConflict:
|
|
return json.RawMessage(body), status, fmt.Errorf("%w: engine order get: %s", ErrEngineValidation, summariseEngineError(body, status))
|
|
default:
|
|
return nil, status, fmt.Errorf("%w: engine order get: %s", ErrEngineUnreachable, summariseEngineError(body, status))
|
|
}
|
|
}
|
|
|
|
// GetReport calls `GET /api/v1/report?player=<raceName>&turn=<turn>`
|
|
// and returns the engine response body verbatim.
|
|
func (c *Client) GetReport(ctx context.Context, baseURL, raceName string, turn int) (json.RawMessage, error) {
|
|
if err := validateBaseURL(baseURL); err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(raceName) == "" {
|
|
return nil, errors.New("engineclient report: race name must not be empty")
|
|
}
|
|
if turn < 0 {
|
|
return nil, fmt.Errorf("engineclient 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 := c.doRequest(ctx, http.MethodGet, target, nil, c.probeTimeout)
|
|
if doErr != nil {
|
|
return nil, fmt.Errorf("%w: engine report: %w", ErrEngineUnreachable, doErr)
|
|
}
|
|
switch status {
|
|
case http.StatusOK:
|
|
if len(body) == 0 {
|
|
return nil, fmt.Errorf("%w: engine report: empty response body", ErrEngineProtocolViolation)
|
|
}
|
|
return json.RawMessage(body), nil
|
|
case http.StatusBadRequest:
|
|
return json.RawMessage(body), fmt.Errorf("%w: engine report: %s", ErrEngineValidation, summariseEngineError(body, status))
|
|
default:
|
|
return nil, fmt.Errorf("%w: engine report: %s", ErrEngineUnreachable, summariseEngineError(body, status))
|
|
}
|
|
}
|
|
|
|
// FetchBattle calls `GET /api/v1/battle/<turn>/<battleID>` and returns
|
|
// the engine response body verbatim alongside the engine status code.
|
|
// 200 carries the BattleReport JSON; 404 means the battle is unknown
|
|
// and the body may be empty. Other 4xx statuses come back wrapped in
|
|
// ErrEngineValidation, everything else in ErrEngineUnreachable.
|
|
func (c *Client) FetchBattle(ctx context.Context, baseURL string, turn int, battleID string) (json.RawMessage, int, error) {
|
|
if err := validateBaseURL(baseURL); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if turn < 0 {
|
|
return nil, 0, fmt.Errorf("engineclient battle get: turn must not be negative, got %d", turn)
|
|
}
|
|
if strings.TrimSpace(battleID) == "" {
|
|
return nil, 0, errors.New("engineclient battle get: battle id must not be empty")
|
|
}
|
|
target := baseURL + pathPlayerBattle + "/" + strconv.Itoa(turn) + "/" + url.PathEscape(battleID)
|
|
body, status, doErr := c.doRequest(ctx, http.MethodGet, target, nil, c.probeTimeout)
|
|
if doErr != nil {
|
|
return nil, 0, fmt.Errorf("%w: engine battle get: %w", ErrEngineUnreachable, doErr)
|
|
}
|
|
switch status {
|
|
case http.StatusOK:
|
|
if len(body) == 0 {
|
|
return nil, status, fmt.Errorf("%w: engine battle get: empty response body", ErrEngineProtocolViolation)
|
|
}
|
|
return json.RawMessage(body), status, nil
|
|
case http.StatusNotFound:
|
|
return nil, status, nil
|
|
case http.StatusBadRequest, http.StatusConflict:
|
|
return json.RawMessage(body), status, fmt.Errorf("%w: engine battle get: %s", ErrEngineValidation, summariseEngineError(body, status))
|
|
default:
|
|
return nil, status, fmt.Errorf("%w: engine battle get: %s", ErrEngineUnreachable, summariseEngineError(body, status))
|
|
}
|
|
}
|
|
|
|
// Healthz calls `GET /healthz`. Returns nil on 2xx.
|
|
func (c *Client) Healthz(ctx context.Context, baseURL string) error {
|
|
if err := validateBaseURL(baseURL); err != nil {
|
|
return err
|
|
}
|
|
body, status, doErr := c.doRequest(ctx, http.MethodGet, baseURL+pathHealthz, nil, c.probeTimeout)
|
|
if doErr != nil {
|
|
return fmt.Errorf("%w: engine healthz: %w", ErrEngineUnreachable, doErr)
|
|
}
|
|
if status/100 == 2 {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("%w: engine healthz: %s", ErrEngineUnreachable, summariseEngineError(body, status))
|
|
}
|
|
|
|
func (c *Client) forwardPlayerWrite(ctx context.Context, baseURL, requestPath string, payload json.RawMessage, opLabel string) (json.RawMessage, error) {
|
|
if err := validateBaseURL(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 := c.doRequest(ctx, http.MethodPut, baseURL+requestPath, []byte(payload), c.callTimeout)
|
|
if doErr != nil {
|
|
return nil, fmt.Errorf("%w: %s: %w", ErrEngineUnreachable, opLabel, doErr)
|
|
}
|
|
switch status {
|
|
case http.StatusOK, http.StatusAccepted:
|
|
return json.RawMessage(body), nil
|
|
case http.StatusBadRequest, http.StatusConflict:
|
|
return json.RawMessage(body), fmt.Errorf("%w: %s: %s", ErrEngineValidation, opLabel, summariseEngineError(body, status))
|
|
default:
|
|
return nil, fmt.Errorf("%w: %s: %s", ErrEngineUnreachable, opLabel, summariseEngineError(body, status))
|
|
}
|
|
}
|
|
|
|
func (c *Client) doRequest(ctx context.Context, method, target string, body []byte, timeout time.Duration) ([]byte, int, error) {
|
|
reqCtx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
|
|
var reader io.Reader
|
|
if body != nil {
|
|
reader = bytes.NewReader(body)
|
|
}
|
|
req, err := http.NewRequestWithContext(reqCtx, method, target, reader)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("build request: %w", err)
|
|
}
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
payload, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, resp.StatusCode, fmt.Errorf("read body: %w", err)
|
|
}
|
|
return payload, resp.StatusCode, nil
|
|
}
|
|
|
|
func validateBaseURL(baseURL string) error {
|
|
if strings.TrimSpace(baseURL) == "" {
|
|
return errors.New("engineclient: baseURL must not be empty")
|
|
}
|
|
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
|
|
return fmt.Errorf("engineclient: baseURL %q must start with http:// or https://", baseURL)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func decodeStateResponse(body []byte, op string) (rest.StateResponse, error) {
|
|
if len(bytes.TrimSpace(body)) == 0 {
|
|
return rest.StateResponse{}, fmt.Errorf("%w: %s: empty body", ErrEngineProtocolViolation, op)
|
|
}
|
|
var out rest.StateResponse
|
|
if err := json.Unmarshal(body, &out); err != nil {
|
|
return rest.StateResponse{}, fmt.Errorf("%w: %s: %v", ErrEngineProtocolViolation, op, err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func summariseEngineError(body []byte, status int) string {
|
|
if len(body) == 0 {
|
|
return fmt.Sprintf("status=%d", status)
|
|
}
|
|
trimmed := strings.TrimSpace(string(body))
|
|
if len(trimmed) > 256 {
|
|
trimmed = trimmed[:256] + "…"
|
|
}
|
|
return fmt.Sprintf("status=%d body=%s", status, trimmed)
|
|
}
|