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:
Ilia Denisov
2026-05-13 12:24:20 +02:00
parent 4ffcac00d0
commit 969c0480ba
81 changed files with 2911 additions and 230 deletions
+123 -27
View File
@@ -2949,45 +2949,126 @@ Targeted tests:
Status: pending.
Goal: render battles as a dedicated view with playback controls
(play, pause, step forward, step backward, rewind), driven by the
server-side combat log; render battle and bombing markers on the map.
Goal: ship a dedicated Battle Viewer rendering radial scenes from
`BattleReport` data (planet centred, races on the outer ring, per
ship-class clusters, animated shot lines), plus battle and bombing
markers on the map. Battles and bombings stay strictly separate —
bombings remain a static table in the Reports view, only battles
get the animated viewer.
Artifacts:
- `ui/frontend/src/map/battle-markers.ts` renders markers on the map
for current-turn battles and bombings within visibility, clickable
to open the battle viewer
- `ui/frontend/src/routes/games/[id]/battle/[battleId]/+page.svelte`
view with the combatant list, the round-by-round log, and a player
control bar
- `ui/frontend/src/lib/battle-player/` round timeline, current-round
highlight, per-shot animation
- entry points to the viewer: marker on map, row in the report's
battles section, push-event toast when a battle this turn involved
the player
- topic doc `ui/docs/battle-viewer-ux.md` covering playback
semantics, accessibility (the combat log must be readable as text
for users who skip animations)
- engine: `game/internal/router/handler/battle.go` for
`GET /api/v1/battle/:turn/:uuid` (handler pre-existed; Phase 27
added the tests + openapi schemas)
- engine wire: `pkg/model/report/battle.go` ships a new
`BattleSummary{id, planet, shots}`; `Report.battle` carries a
slice of these summaries so the map can place markers without
fetching every full report
- backend: `backend/internal/engineclient/client.go.FetchBattle`
and `backend/internal/server/handlers_user_games.go.Battle`
expose `GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`
- UI viewer: `ui/frontend/src/lib/battle-player/`
(`radial-layout.ts`, `timeline.ts`, `battle-scene.svelte`,
`playback-controls.svelte`, `battle-viewer.svelte`); SVG-based,
one frame per protocol entry, full controls (play/pause + step
back + step forward + rewind + 1x/2x/4x speed switch)
- UI route + page wrapper:
`ui/frontend/src/routes/games/[id]/battle/[[battleId]]/+page.svelte`
feeds `gameId` / `turn` / `battleId` into
`ui/frontend/src/lib/active-view/battle.svelte`, which loads the
report via `api/battle-fetch.ts` (synthetic-fixture path + real
engine fetch through the backend gateway)
- UI report link: `lib/active-view/report/section-battles.svelte`
now links every battle UUID into
`/games/{id}/battle/{uuid}?turn={turn}`
- UI map markers: `ui/frontend/src/map/battle-markers.ts` emits a
yellow X cross per battle (two `LinePrim` through the planet's
bounding-square diagonals; stroke width scales 1px..5px with
protocol length) plus a stroke-only ring per bombing (yellow when
damaged, red when wiped). Wired into `state-binding.ts`; the map
click handler dispatches battle clicks to the viewer and bombing
clicks to a scroll-into-view of the matching row in Reports.
- topic doc `ui/docs/battle-viewer-ux.md` covers playback
semantics, accessibility (the always-visible `<ol>` log), the
radial layout, and the marker click behaviour
- docs/FUNCTIONAL.md §6.5 (Battle viewer) + mirror in
docs/FUNCTIONAL_ru.md
Dependencies: Phase 23.
Acceptance criteria:
- battle and bombing markers render on the map for the seeded
current-turn report and are clickable to open the viewer;
- the viewer plays back any battle in the seeded report including
multi-round and one-sided battles;
- step controls allow precise inspection;
- the same data is accessible as a static text log for accessibility.
current-turn report and are clickable: battle → Battle Viewer for
the corresponding UUID, bombing → scroll to its row in Reports;
- the Battle Viewer plays back any `BattleReport` end-to-end with
step back / step forward / rewind / 1x-2x-4x speeds; observers
(`inBattle === false`) are not drawn; eliminated races drop out
and survivors re-distribute on the next frame;
- the same protocol is mirrored as an always-visible text log under
the scene for accessibility;
- bombings keep their Phase 23 static table layout in Reports; no
Battle Viewer entry-point is wired from them.
Targeted tests:
- Vitest unit tests for round-state transitions;
- Vitest unit tests for marker rendering on torus and no-wrap
fixtures;
- Playwright e2e: click a battle marker on the map, play through,
step backward, return to the report.
- Vitest unit: radial layout (1/2/3 races) and timeline frame-
builder (initial state, shot decrement, race-elimination drop-out)
in `tests/battle-player.test.ts`
- Vitest unit: marker primitives + stroke-width formula
(1→1, 50→2.98, 100→5, 200→5) in `tests/battle-markers.test.ts`
- Go unit: engine HTTP handler validations (400 / 404 / 500) in
`game/internal/router/battle_test.go`
- Go contract: openapi freezes for the new endpoint and schemas in
`game/openapi_contract_test.go`
- Playwright e2e: click battle marker → viewer; play / step back;
click battle UUID in Reports → viewer; click bombing marker →
Reports bombings row scrolled into view.
Decisions during stage:
1. **Bombings stay a static table.** `section-bombings.svelte`
already covers the "who bombed, with what power, wiped or not"
requirement; nothing in Phase 27 touches it. Bombings explicitly
do not open the Battle Viewer.
2. **Wire change.** `Report.Battle` switched from `[]uuid.UUID` to
`[]BattleSummary{id, planet, shots}` so the map renderer can
place markers without N extra fetches and so the cross-marker
stroke can scale with protocol length.
3. **Battle marker = yellow X cross** drawn as 2 `LinePrim` through
the corners of the planet's circumscribed square; stroke width
`clamp(1 + (shots - 1) * 4 / 99, 1, 5)` px.
4. **Bombing marker = stroke-only ring** slightly larger than the
planet circle. Yellow when damaged, red when wiped. Click =
scroll to the matching row in Reports (not the viewer).
5. **Viewer URL** `/games/[id]/battle/[battleId]?turn=N`. Turn is a
query param so the same route works in history mode.
6. **SVG, not PixiJS** for the radial scene — isolated component,
no need for WebGL; PixiJS stays as the map renderer.
7. **Playback controls full set**: play / pause + step back + step
forward + rewind + 1x / 2x / 4x switch. 1x = 400 ms per frame.
8. **Observer groups (`inBattle: false`)** are filtered out of both
the scene and the text log.
9. **Cluster aggregation by `(race, className)`** so a race with
multiple groups of the same class collapses to one labelled
circle. Stable target for shot-line endpoints.
10. **Page loader switches on `synthetic-` gameId prefix** —
synthetic mode uses `api/synthetic-battle.ts` fixtures; live
games hit `GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`.
BattleViewer component itself is a logically isolated prop sink.
11. **Always-visible `<ol>` text protocol** under the scene satisfies
the accessibility requirement without a separate "skip
animation" toggle.
TODO carried into Phase 27 deferred items
(see Phase 27 of this PLAN's deferred-followups list, near the
bottom):
- push event `game.battle.new` + toast deep-link;
- richer ship-class visuals derived from class characteristics;
- animated transitions when survivors re-distribute after an
elimination (currently hard-jumps).
## Phase 28. Diplomatic Mail View
@@ -3459,3 +3540,18 @@ phase listed in the parenthesis when that phase lands.
exercises a unary Connect call and a server-streaming Connect call
through `testenv.Bootstrap`. (Phase 7+, fold into the phase that
needs it.)
- **Battle viewer — push event `game.battle.new`** — when a battle
involving the current player lands, emit a backend notification
intent (idempotency `battle-new:<game_id>:<turn>:<battle_id>`,
payload `{game_id, turn, battle_id}`) so the in-game shell
surfaces a toast with a deep link into the Battle Viewer.
(Phase 27 deferred; needs an engine emit-side change.)
- **Battle viewer — richer ship-class visuals** — current MVP draws
one small circle plus `<class>:<numLeft>` label per `(race,
className)` pair. Future work derives shape / scale from mass,
armament, shields, and the number of ships in the group.
(Phase 27 deferred.)
- **Battle viewer — animated re-distribution on elimination** —
current implementation hard-jumps to the new spacing on the next
frame; replace with an easing so the survivors visibly slide
along the outer ring. (Phase 27 deferred.)