ui/phase-27: battle viewer (radial scene, playback, map markers)
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>
This commit is contained in:
@@ -26,6 +26,7 @@ const (
|
||||
pathPlayerCommand = "/api/v1/command"
|
||||
pathPlayerOrder = "/api/v1/order"
|
||||
pathPlayerReport = "/api/v1/report"
|
||||
pathPlayerBattle = "/api/v1/battle"
|
||||
pathHealthz = "/healthz"
|
||||
)
|
||||
|
||||
@@ -269,6 +270,41 @@ func (c *Client) GetReport(ctx context.Context, baseURL, raceName string, turn i
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -257,6 +257,63 @@ func TestClientGetOrderRejectsBadInput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientFetchBattleForwardsPath(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Fatalf("unexpected method: %s", r.Method)
|
||||
}
|
||||
want := pathPlayerBattle + "/3/" + "11111111-1111-1111-1111-111111111111"
|
||||
if r.URL.Path != want {
|
||||
t.Fatalf("path = %q, want %q", r.URL.Path, want)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"id":"11111111-1111-1111-1111-111111111111","planet":4}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cli := newTestClient(t, srv)
|
||||
body, status, err := cli.FetchBattle(context.Background(), srv.URL, 3, "11111111-1111-1111-1111-111111111111")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchBattle: %v", err)
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("status = %d", status)
|
||||
}
|
||||
if !strings.Contains(string(body), `"planet":4`) {
|
||||
t.Fatalf("body = %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientFetchBattleNotFound(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cli := newTestClient(t, srv)
|
||||
body, status, err := cli.FetchBattle(context.Background(), srv.URL, 0, "11111111-1111-1111-1111-111111111111")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchBattle: %v", err)
|
||||
}
|
||||
if status != http.StatusNotFound {
|
||||
t.Fatalf("status = %d", status)
|
||||
}
|
||||
if body != nil {
|
||||
t.Fatalf("expected nil body on 404, got %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientFetchBattleRejectsBadInput(t *testing.T) {
|
||||
cli := newTestClient(t, httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("server must not be hit on bad input")
|
||||
})))
|
||||
if _, _, err := cli.FetchBattle(context.Background(), "http://example.com", -1, "11111111-1111-1111-1111-111111111111"); err == nil {
|
||||
t.Fatal("expected error on negative turn")
|
||||
}
|
||||
if _, _, err := cli.FetchBattle(context.Background(), "http://example.com", 0, ""); err == nil {
|
||||
t.Fatal("expected error on empty battle id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientHealthzSuccess(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != pathHealthz {
|
||||
|
||||
@@ -45,6 +45,7 @@ var pathParamStubs = map[string]string{
|
||||
"delivery_id": "00000000-0000-0000-0000-000000000006",
|
||||
"user_id": "00000000-0000-0000-0000-000000000007",
|
||||
"device_session_id": "00000000-0000-0000-0000-000000000008",
|
||||
"battle_id": "00000000-0000-0000-0000-000000000009",
|
||||
"id": "1.2.3",
|
||||
"username": "alice",
|
||||
"turn": "42",
|
||||
|
||||
@@ -243,6 +243,60 @@ func (h *UserGamesHandlers) Report() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// Battle handles GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}.
|
||||
// Forwards to the engine's `GET /api/v1/battle/:turn/:uuid`. Path
|
||||
// parameters are validated up-front to save a network hop. 404 from
|
||||
// the engine is forwarded as 404. The recipient race is resolved
|
||||
// from the runtime mapping but not forwarded — engine returns the
|
||||
// battle by id, visibility is enforced by the engine state.
|
||||
func (h *UserGamesHandlers) Battle() gin.HandlerFunc {
|
||||
if h == nil || h.runtime == nil || h.engine == nil {
|
||||
return handlers.NotImplemented("userGamesBattle")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
turnRaw := c.Param("turn")
|
||||
turn, err := strconv.Atoi(turnRaw)
|
||||
if err != nil || turn < 0 {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "turn must be a non-negative integer")
|
||||
return
|
||||
}
|
||||
battleID := c.Param("battle_id")
|
||||
if battleID == "" {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "battle id is required")
|
||||
return
|
||||
}
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "user id missing")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
if _, err := h.runtime.ResolvePlayerMapping(ctx, gameID, userID); err != nil {
|
||||
respondGameProxyError(c, h.logger, "user games battle", ctx, err)
|
||||
return
|
||||
}
|
||||
endpoint, err := h.runtime.EngineEndpoint(ctx, gameID)
|
||||
if err != nil {
|
||||
respondGameProxyError(c, h.logger, "user games battle", ctx, err)
|
||||
return
|
||||
}
|
||||
body, status, err := h.engine.FetchBattle(ctx, endpoint, turn, battleID)
|
||||
if err != nil {
|
||||
respondEngineProxyError(c, h.logger, "user games battle", ctx, body, err)
|
||||
return
|
||||
}
|
||||
if status == http.StatusNotFound {
|
||||
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "battle not found")
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json", body)
|
||||
}
|
||||
}
|
||||
|
||||
// rebindActor decodes a JSON object from raw, sets `actor` to
|
||||
// raceName, and re-encodes. Backend never trusts the actor field
|
||||
// supplied by the client (per ARCHITECTURE.md §9).
|
||||
|
||||
@@ -263,6 +263,7 @@ func registerUserRoutes(router *gin.Engine, instruments *metrics.Instruments, de
|
||||
userGames.POST("/:game_id/orders", deps.UserGames.Orders())
|
||||
userGames.GET("/:game_id/orders", deps.UserGames.GetOrders())
|
||||
userGames.GET("/:game_id/reports/:turn", deps.UserGames.Report())
|
||||
userGames.GET("/:game_id/battles/:turn/:battle_id", deps.UserGames.Battle())
|
||||
|
||||
userSessions := group.Group("/sessions")
|
||||
userSessions.GET("", deps.UserSessions.List())
|
||||
|
||||
@@ -1106,6 +1106,44 @@ paths:
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/user/games/{game_id}/battles/{turn}/{battle_id}:
|
||||
get:
|
||||
tags: [User]
|
||||
operationId: userGamesBattle
|
||||
summary: Read one engine battle report
|
||||
description: |
|
||||
Forwards to the engine's `GET /api/v1/battle/:turn/:uuid`. The
|
||||
engine response body is passed through verbatim. `404 Not Found`
|
||||
is returned when the battle does not exist for the supplied
|
||||
`turn` / `battle_id` pair.
|
||||
security:
|
||||
- UserHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/XUserID"
|
||||
- $ref: "#/components/parameters/GameID"
|
||||
- $ref: "#/components/parameters/Turn"
|
||||
- name: battle_id
|
||||
in: path
|
||||
required: true
|
||||
description: Battle identifier (RFC 4122 UUID).
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
responses:
|
||||
"200":
|
||||
description: Engine battle report passed through.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PassthroughObject"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFoundError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/user/sessions:
|
||||
get:
|
||||
tags: [User]
|
||||
|
||||
Reference in New Issue
Block a user