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
+11 -1
View File
@@ -196,6 +196,16 @@ table LocalFleet {
state:string;
}
// BattleSummary identifies one battle the report recipient
// participated in or could see on a planet. `planet` lets the map
// place a battle marker without fetching the full BattleReport;
// `shots` lets the marker scale its stroke with the protocol length.
table BattleSummary {
id:common.UUID (required);
planet:uint64;
shots:uint64;
}
table Report {
version:uint64;
turn:uint64;
@@ -210,7 +220,7 @@ table Report {
other_science:[OtherScience];
local_ship_class:[ShipClass];
other_ship_class:[OthersShipClass];
battle:[common.UUID];
battle:[BattleSummary];
bombing:[Bombing];
incoming_group:[IncomingGroup];
local_planet:[LocalPlanet];
+97
View File
@@ -0,0 +1,97 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package report
import (
flatbuffers "github.com/google/flatbuffers/go"
common "galaxy/schema/fbs/common"
)
type BattleSummary struct {
_tab flatbuffers.Table
}
func GetRootAsBattleSummary(buf []byte, offset flatbuffers.UOffsetT) *BattleSummary {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &BattleSummary{}
x.Init(buf, n+offset)
return x
}
func FinishBattleSummaryBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsBattleSummary(buf []byte, offset flatbuffers.UOffsetT) *BattleSummary {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &BattleSummary{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedBattleSummaryBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *BattleSummary) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *BattleSummary) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *BattleSummary) Id(obj *common.UUID) *common.UUID {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
x := o + rcv._tab.Pos
if obj == nil {
obj = new(common.UUID)
}
obj.Init(rcv._tab.Bytes, x)
return obj
}
return nil
}
func (rcv *BattleSummary) Planet() uint64 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.GetUint64(o + rcv._tab.Pos)
}
return 0
}
func (rcv *BattleSummary) MutatePlanet(n uint64) bool {
return rcv._tab.MutateUint64Slot(6, n)
}
func (rcv *BattleSummary) Shots() uint64 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
return rcv._tab.GetUint64(o + rcv._tab.Pos)
}
return 0
}
func (rcv *BattleSummary) MutateShots(n uint64) bool {
return rcv._tab.MutateUint64Slot(8, n)
}
func BattleSummaryStart(builder *flatbuffers.Builder) {
builder.StartObject(3)
}
func BattleSummaryAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) {
builder.PrependStructSlot(0, flatbuffers.UOffsetT(id), 0)
}
func BattleSummaryAddPlanet(builder *flatbuffers.Builder, planet uint64) {
builder.PrependUint64Slot(1, planet, 0)
}
func BattleSummaryAddShots(builder *flatbuffers.Builder, shots uint64) {
builder.PrependUint64Slot(2, shots, 0)
}
func BattleSummaryEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+4 -5
View File
@@ -4,8 +4,6 @@ package report
import (
flatbuffers "github.com/google/flatbuffers/go"
common "galaxy/schema/fbs/common"
)
type Report struct {
@@ -231,11 +229,12 @@ func (rcv *Report) OtherShipClassLength() int {
return 0
}
func (rcv *Report) Battle(obj *common.UUID, j int) bool {
func (rcv *Report) Battle(obj *BattleSummary, j int) bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(30))
if o != 0 {
x := rcv._tab.Vector(o)
x += flatbuffers.UOffsetT(j) * 16
x += flatbuffers.UOffsetT(j) * 4
x = rcv._tab.Indirect(x)
obj.Init(rcv._tab.Bytes, x)
return true
}
@@ -551,7 +550,7 @@ func ReportAddBattle(builder *flatbuffers.Builder, battle flatbuffers.UOffsetT)
builder.PrependUOffsetTSlot(13, flatbuffers.UOffsetT(battle), 0)
}
func ReportStartBattleVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(16, numElems, 8)
return builder.StartVector(4, numElems, 4)
}
func ReportAddBombing(builder *flatbuffers.Builder, bombing flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(14, flatbuffers.UOffsetT(bombing), 0)