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
+36 -12
View File
@@ -10,7 +10,6 @@ import (
fbs "galaxy/schema/fbs/report"
flatbuffers "github.com/google/flatbuffers/go"
"github.com/google/uuid"
)
// ReportToPayload converts model.Report from the internal representation to
@@ -120,7 +119,7 @@ func ReportToPayload(report *model.Report) ([]byte, error) {
otherScienceVector := encodeReportOffsetVector(builder, len(otherScienceOffsets), fbs.ReportStartOtherScienceVector, otherScienceOffsets)
localShipClassVector := encodeReportOffsetVector(builder, len(localShipClassOffsets), fbs.ReportStartLocalShipClassVector, localShipClassOffsets)
otherShipClassVector := encodeReportOffsetVector(builder, len(otherShipClassOffsets), fbs.ReportStartOtherShipClassVector, otherShipClassOffsets)
battleVector := encodeReportUUIDVector(builder, report.Battle)
battleVector := encodeReportBattleSummaries(builder, report.Battle)
bombingVector := encodeReportOffsetVector(builder, len(bombingOffsets), fbs.ReportStartBombingVector, bombingOffsets)
incomingGroupVector := encodeReportOffsetVector(builder, len(incomingGroupOffsets), fbs.ReportStartIncomingGroupVector, incomingGroupOffsets)
localPlanetVector := encodeReportOffsetVector(builder, len(localPlanetOffsets), fbs.ReportStartLocalPlanetVector, localPlanetOffsets)
@@ -734,13 +733,29 @@ func decodeReportBattleVector(flatReport *fbs.Report, result *model.Report) erro
return nil
}
result.Battle = make([]uuid.UUID, length)
item := new(commonfbs.UUID)
result.Battle = make([]model.BattleSummary, length)
item := new(fbs.BattleSummary)
idHolder := new(commonfbs.UUID)
for i := 0; i < length; i++ {
if !flatReport.Battle(item, i) {
return fmt.Errorf("decode report battle %d: battle is missing", i)
}
if item.Id(idHolder) == nil {
return fmt.Errorf("decode report battle %d: battle id is missing", i)
}
result.Battle[i] = uuidFromHiLo(item.Hi(), item.Lo())
planet, err := uint64ToUint(item.Planet(), "planet")
if err != nil {
return fmt.Errorf("decode report battle %d: %w", i, err)
}
shots, err := uint64ToUint(item.Shots(), "shots")
if err != nil {
return fmt.Errorf("decode report battle %d: %w", i, err)
}
result.Battle[i] = model.BattleSummary{
ID: uuidFromHiLo(idHolder.Hi(), idHolder.Lo()),
Planet: planet,
Shots: shots,
}
}
return nil
@@ -1299,17 +1314,26 @@ func encodeReportOffsetVector(
return builder.EndVector(length)
}
func encodeReportUUIDVector(builder *flatbuffers.Builder, ids []uuid.UUID) flatbuffers.UOffsetT {
if len(ids) == 0 {
func encodeReportBattleSummaries(builder *flatbuffers.Builder, summaries []model.BattleSummary) flatbuffers.UOffsetT {
if len(summaries) == 0 {
return 0
}
fbs.ReportStartBattleVector(builder, len(ids))
for i := len(ids) - 1; i >= 0; i-- {
hi, lo := uuidToHiLo(ids[i])
commonfbs.CreateUUID(builder, hi, lo)
offsets := make([]flatbuffers.UOffsetT, len(summaries))
for i := range summaries {
hi, lo := uuidToHiLo(summaries[i].ID)
fbs.BattleSummaryStart(builder)
fbs.BattleSummaryAddId(builder, commonfbs.CreateUUID(builder, hi, lo))
fbs.BattleSummaryAddPlanet(builder, uint64(summaries[i].Planet))
fbs.BattleSummaryAddShots(builder, uint64(summaries[i].Shots))
offsets[i] = fbs.BattleSummaryEnd(builder)
}
return builder.EndVector(len(ids))
fbs.ReportStartBattleVector(builder, len(offsets))
for i := len(offsets) - 1; i >= 0; i-- {
builder.PrependUOffsetT(offsets[i])
}
return builder.EndVector(len(offsets))
}
func encodeReportRouteEntryVector(builder *flatbuffers.Builder, route map[uint]string) flatbuffers.UOffsetT {