From 969c0480ba4ea0f4c1b834b26e9d98faa73d7b92 Mon Sep 17 00:00:00 2001
From: Ilia Denisov
Date: Wed, 13 May 2026 12:24:20 +0200
Subject: [PATCH] ui/phase-27: battle viewer (radial scene, playback, map
markers)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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 : 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 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)
---
backend/internal/engineclient/client.go | 36 +++
backend/internal/engineclient/client_test.go | 57 ++++
backend/internal/server/contract_test.go | 1 +
.../internal/server/handlers_user_games.go | 54 ++++
backend/internal/server/router.go | 1 +
backend/openapi.yaml | 38 +++
docs/FUNCTIONAL.md | 52 +++-
docs/FUNCTIONAL_ru.md | 52 +++-
game/internal/controller/report.go | 8 +-
game/openapi.yaml | 29 +-
game/openapi_contract_test.go | 16 ++
pkg/model/report/battle.go | 10 +
pkg/model/report/report.go | 2 +-
pkg/schema/fbs/report.fbs | 12 +-
pkg/schema/fbs/report/BattleSummary.go | 97 +++++++
pkg/schema/fbs/report/Report.go | 9 +-
pkg/transcoder/report.go | 48 +++-
pkg/transcoder/report_test.go | 14 +-
ui/PLAN.md | 150 +++++++++--
ui/docs/battle-viewer-ux.md | 136 ++++++++++
ui/frontend/src/api/battle-fetch.ts | 88 ++++++
ui/frontend/src/api/game-state.ts | 50 +++-
ui/frontend/src/api/synthetic-battle.ts | 37 +++
ui/frontend/src/api/synthetic-report.ts | 23 +-
ui/frontend/src/lib/active-view/battle.svelte | 130 ++++++++-
ui/frontend/src/lib/active-view/map.svelte | 39 ++-
.../active-view/report/section-battles.svelte | 33 ++-
.../src/lib/battle-player/battle-scene.svelte | 223 ++++++++++++++++
.../lib/battle-player/battle-viewer.svelte | 167 ++++++++++++
.../battle-player/playback-controls.svelte | 145 ++++++++++
.../src/lib/battle-player/radial-layout.ts | 50 ++++
ui/frontend/src/lib/battle-player/timeline.ts | 134 ++++++++++
ui/frontend/src/lib/i18n/locales/en.ts | 17 ++
ui/frontend/src/lib/i18n/locales/ru.ts | 17 ++
ui/frontend/src/map/battle-markers.ts | 168 ++++++++++++
ui/frontend/src/map/state-binding.ts | 13 +-
.../fbs/lobby/application-submit-response.ts | 2 +-
.../proto/galaxy/fbs/lobby/error-response.ts | 2 +-
.../galaxy/fbs/lobby/game-create-response.ts | 2 +-
.../fbs/lobby/invite-decline-response.ts | 2 +-
.../fbs/lobby/invite-redeem-response.ts | 2 +-
.../lobby/my-applications-list-response.ts | 2 +-
.../fbs/lobby/my-games-list-response.ts | 2 +-
.../fbs/lobby/my-invites-list-response.ts | 2 +-
.../fbs/lobby/public-games-list-response.ts | 2 +-
.../proto/galaxy/fbs/order/command-item.ts | 48 ++--
.../proto/galaxy/fbs/order/command-payload.ts | 46 ++--
.../fbs/order/command-planet-produce.ts | 2 +-
.../fbs/order/command-planet-route-remove.ts | 2 +-
.../fbs/order/command-planet-route-set.ts | 2 +-
.../galaxy/fbs/order/command-race-relation.ts | 2 +-
.../fbs/order/command-ship-group-load.ts | 2 +-
.../fbs/order/command-ship-group-upgrade.ts | 2 +-
.../galaxy/fbs/order/user-games-command.ts | 2 +-
.../order/user-games-order-get-response.ts | 2 +-
.../fbs/order/user-games-order-response.ts | 2 +-
.../galaxy/fbs/order/user-games-order.ts | 2 +-
ui/frontend/src/proto/galaxy/fbs/report.ts | 1 +
.../proto/galaxy/fbs/report/battle-summary.ts | 104 ++++++++
.../proto/galaxy/fbs/report/local-group.ts | 2 +-
.../proto/galaxy/fbs/report/other-group.ts | 2 +-
.../src/proto/galaxy/fbs/report/report.ts | 58 ++--
.../src/proto/galaxy/fbs/report/route.ts | 2 +-
.../proto/galaxy/fbs/user/account-response.ts | 2 +-
.../src/proto/galaxy/fbs/user/account-view.ts | 6 +-
.../src/proto/galaxy/fbs/user/active-limit.ts | 2 +-
.../proto/galaxy/fbs/user/active-sanction.ts | 2 +-
.../galaxy/fbs/user/entitlement-snapshot.ts | 2 +-
.../proto/galaxy/fbs/user/error-response.ts | 2 +-
.../fbs/user/list-my-sessions-response.ts | 2 +-
.../user/revoke-all-my-sessions-response.ts | 2 +-
.../fbs/user/revoke-my-session-response.ts | 2 +-
.../[id]/battle/[[battleId]]/+page.svelte | 12 +-
ui/frontend/tests/battle-markers.test.ts | 190 +++++++++++++
ui/frontend/tests/battle-player.test.ts | 146 ++++++++++
ui/frontend/tests/e2e/battle-viewer.spec.ts | 252 ++++++++++++++++++
ui/frontend/tests/e2e/fixtures/report-fbs.ts | 34 ++-
ui/frontend/tests/e2e/report-sections.spec.ts | 2 +-
ui/frontend/tests/game-shell-stubs.test.ts | 22 +-
.../tests/helpers/empty-ship-groups.ts | 3 +
ui/frontend/tests/pending-send-routes.test.ts | 1 +
81 files changed, 2911 insertions(+), 230 deletions(-)
create mode 100644 pkg/schema/fbs/report/BattleSummary.go
create mode 100644 ui/docs/battle-viewer-ux.md
create mode 100644 ui/frontend/src/api/battle-fetch.ts
create mode 100644 ui/frontend/src/api/synthetic-battle.ts
create mode 100644 ui/frontend/src/lib/battle-player/battle-scene.svelte
create mode 100644 ui/frontend/src/lib/battle-player/battle-viewer.svelte
create mode 100644 ui/frontend/src/lib/battle-player/playback-controls.svelte
create mode 100644 ui/frontend/src/lib/battle-player/radial-layout.ts
create mode 100644 ui/frontend/src/lib/battle-player/timeline.ts
create mode 100644 ui/frontend/src/map/battle-markers.ts
create mode 100644 ui/frontend/src/proto/galaxy/fbs/report/battle-summary.ts
create mode 100644 ui/frontend/tests/battle-markers.test.ts
create mode 100644 ui/frontend/tests/battle-player.test.ts
create mode 100644 ui/frontend/tests/e2e/battle-viewer.spec.ts
diff --git a/backend/internal/engineclient/client.go b/backend/internal/engineclient/client.go
index cb8e2a3..454d93d 100644
--- a/backend/internal/engineclient/client.go
+++ b/backend/internal/engineclient/client.go
@@ -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//` 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 {
diff --git a/backend/internal/engineclient/client_test.go b/backend/internal/engineclient/client_test.go
index ad71ba3..6819f2f 100644
--- a/backend/internal/engineclient/client_test.go
+++ b/backend/internal/engineclient/client_test.go
@@ -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 {
diff --git a/backend/internal/server/contract_test.go b/backend/internal/server/contract_test.go
index 9e88eb7..ba8e82b 100644
--- a/backend/internal/server/contract_test.go
+++ b/backend/internal/server/contract_test.go
@@ -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",
diff --git a/backend/internal/server/handlers_user_games.go b/backend/internal/server/handlers_user_games.go
index 0c30077..e66559b 100644
--- a/backend/internal/server/handlers_user_games.go
+++ b/backend/internal/server/handlers_user_games.go
@@ -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).
diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go
index 6934407..16dd2d0 100644
--- a/backend/internal/server/router.go
+++ b/backend/internal/server/router.go
@@ -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())
diff --git a/backend/openapi.yaml b/backend/openapi.yaml
index adbe95b..6915d34 100644
--- a/backend/openapi.yaml
+++ b/backend/openapi.yaml
@@ -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]
diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md
index 2a89153..3998dac 100644
--- a/docs/FUNCTIONAL.md
+++ b/docs/FUNCTIONAL.md
@@ -657,7 +657,7 @@ in `runtime_records.turn_schedule`. The backend scheduler
- After a failed tick (`engine_unreachable` /
`generation_failed`): the lobby's `OnRuntimeSnapshot` flips the
game from `running` to `paused` and publishes a `game.paused`
- push event (see §6.5). The order handlers reject with HTTP 409
+ push event (see §6.6). The order handlers reject with HTTP 409
+ `code = game_paused` until an admin resume succeeds.
`force-next-turn` (admin) schedules a one-shot extra tick that
@@ -686,7 +686,53 @@ are exposed in a sticky table of contents (a `
- {:else if ids.length === 0}
+ {:else if battles.length === 0}
{i18n.t("game.report.section.battles.empty")}
{:else}
- {#each ids as id (id)}
+ {#each battles as b (b.id)}
-
{i18n.t("game.report.section.battles.id_label")}
- {id}
+ data-id={b.id}
+ >{b.id}
{/each}
@@ -87,5 +91,10 @@ plain text.
.uuid {
color: #cfd7ff;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ text-decoration: underline;
+ text-underline-offset: 2px;
+ }
+ .uuid:hover {
+ color: #ffffff;
}
diff --git a/ui/frontend/src/lib/battle-player/battle-scene.svelte b/ui/frontend/src/lib/battle-player/battle-scene.svelte
new file mode 100644
index 0000000..2e59090
--- /dev/null
+++ b/ui/frontend/src/lib/battle-player/battle-scene.svelte
@@ -0,0 +1,223 @@
+
+
+
+
+
+
diff --git a/ui/frontend/src/lib/battle-player/battle-viewer.svelte b/ui/frontend/src/lib/battle-player/battle-viewer.svelte
new file mode 100644
index 0000000..66befd1
--- /dev/null
+++ b/ui/frontend/src/lib/battle-player/battle-viewer.svelte
@@ -0,0 +1,167 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {i18n.t("game.battle.accessibility.protocol_heading")}
+
+ {#each report.protocol as _action, i (i)}
+ - {describeAction(i)}
+ {/each}
+
+
+
+
+
diff --git a/ui/frontend/src/lib/battle-player/playback-controls.svelte b/ui/frontend/src/lib/battle-player/playback-controls.svelte
new file mode 100644
index 0000000..f5ef0ea
--- /dev/null
+++ b/ui/frontend/src/lib/battle-player/playback-controls.svelte
@@ -0,0 +1,145 @@
+
+
+
+
+
+
+
+
+
+
+
+
{i18n.t("game.battle.controls.speed_label")}
+
+
+
+
+
+
diff --git a/ui/frontend/src/lib/battle-player/radial-layout.ts b/ui/frontend/src/lib/battle-player/radial-layout.ts
new file mode 100644
index 0000000..161e9c0
--- /dev/null
+++ b/ui/frontend/src/lib/battle-player/radial-layout.ts
@@ -0,0 +1,50 @@
+// Radial layout for the BattleViewer.
+//
+// Places race anchors on a circle of radius `radius` around `center`
+// at equal angular spacing. The first anchor sits at the top (12
+// o'clock); subsequent anchors march clockwise. When a race is
+// eliminated mid-battle, the caller filters it out of `activeRaceIds`
+// and the survivors are re-spaced on the next frame. The same helper
+// drives both the initial layout and that re-distribution.
+
+export interface RaceAnchor {
+ raceId: number;
+ x: number;
+ y: number;
+ /** Angle in radians measured from the positive Y axis clockwise. */
+ angle: number;
+}
+
+export interface RadialLayoutOptions {
+ center: { x: number; y: number };
+ radius: number;
+}
+
+/**
+ * layoutRaces returns anchor positions for each `activeRaceIds`
+ * entry, placed at equal angular spacing on a circle. The input
+ * order is preserved so consumers get a stable mapping across
+ * frames; eliminated entries should simply be filtered out before
+ * the call.
+ */
+export function layoutRaces(
+ activeRaceIds: number[],
+ options: RadialLayoutOptions,
+): RaceAnchor[] {
+ const count = activeRaceIds.length;
+ if (count === 0) return [];
+ const { center, radius } = options;
+ const out: RaceAnchor[] = [];
+ for (let i = 0; i < count; i++) {
+ // 12 o'clock = -PI/2 in math convention; clockwise → +i*step.
+ const step = (2 * Math.PI) / count;
+ const angle = -Math.PI / 2 + i * step;
+ out.push({
+ raceId: activeRaceIds[i],
+ x: center.x + radius * Math.cos(angle),
+ y: center.y + radius * Math.sin(angle),
+ angle,
+ });
+ }
+ return out;
+}
diff --git a/ui/frontend/src/lib/battle-player/timeline.ts b/ui/frontend/src/lib/battle-player/timeline.ts
new file mode 100644
index 0000000..f72916c
--- /dev/null
+++ b/ui/frontend/src/lib/battle-player/timeline.ts
@@ -0,0 +1,134 @@
+// Timeline builder for the BattleViewer.
+//
+// Given a `BattleReport`, expands the flat `protocol` into a
+// sequence of frames. Frame 0 carries the initial state; frame N
+// (1 ≤ N ≤ protocol.length) reflects the state right after the
+// (N-1)-th action has been applied. Each frame is self-contained so
+// stepping forward and backward is a constant-time index lookup, no
+// rewind logic needed.
+
+import type {
+ BattleActionReport,
+ BattleReport,
+ BattleReportGroup,
+} from "../../api/battle-fetch";
+
+/**
+ * Frame is one tick of the battle playback. `remaining` carries the
+ * surviving ship count for each ship-group key from
+ * `BattleReport.ships`; `activeRaceIds` are the race indices with at
+ * least one surviving in-battle group. `lastAction` is the action
+ * applied to produce this frame, or `null` for the initial frame.
+ */
+export interface Frame {
+ shotIndex: number;
+ remaining: Map;
+ activeRaceIds: number[];
+ lastAction: BattleActionReport | null;
+}
+
+export interface NormalisedGroup {
+ key: number;
+ group: BattleReportGroup;
+ raceId: number;
+}
+
+/**
+ * normaliseGroups returns the in-battle ship groups from a
+ * BattleReport indexed by their integer key. Observer groups
+ * (`inBattle === false`) are skipped because they are neither
+ * targeted nor drawn. The race index per group is derived from the
+ * protocol — every in-battle group appears at least once as
+ * attacker or defender, and the engine's pairing (a, sa) / (d, sd)
+ * defines the relationship.
+ */
+export function normaliseGroups(report: BattleReport): NormalisedGroup[] {
+ const raceByKey = buildGroupRaceMap(report.protocol);
+ const out: NormalisedGroup[] = [];
+ for (const [keyRaw, group] of Object.entries(report.ships)) {
+ if (!group.inBattle) continue;
+ const key = Number(keyRaw);
+ if (!Number.isFinite(key)) continue;
+ const raceId = raceByKey.get(key);
+ if (raceId === undefined) continue;
+ out.push({ key, group, raceId });
+ }
+ return out;
+}
+
+/**
+ * buildGroupRaceMap extracts the `ship-group key → race index`
+ * mapping from a battle protocol. Same key appearing twice always
+ * carries the same race index — protocol entries are emitted by the
+ * engine, which never crosses these wires.
+ */
+export function buildGroupRaceMap(
+ protocol: BattleActionReport[],
+): Map {
+ const out = new Map();
+ for (const action of protocol) {
+ if (!out.has(action.sa)) out.set(action.sa, action.a);
+ if (!out.has(action.sd)) out.set(action.sd, action.d);
+ }
+ return out;
+}
+
+/**
+ * buildFrames walks the protocol once and emits a frame after each
+ * applied action plus the initial frame. The remaining-ships map is
+ * cloned per frame so callers can step backward without manual
+ * bookkeeping. Eliminated races drop out of `activeRaceIds` as soon
+ * as their last in-battle group hits zero.
+ */
+export function buildFrames(report: BattleReport): Frame[] {
+ const groups = normaliseGroups(report);
+ const initialRemaining = new Map();
+ const raceTotals = new Map();
+ for (const g of groups) {
+ initialRemaining.set(g.key, g.group.num);
+ raceTotals.set(g.raceId, (raceTotals.get(g.raceId) ?? 0) + g.group.num);
+ }
+
+ const frames: Frame[] = [];
+ frames.push({
+ shotIndex: 0,
+ remaining: new Map(initialRemaining),
+ activeRaceIds: collectActiveRaces(raceTotals),
+ lastAction: null,
+ });
+
+ const groupRaceByKey = new Map();
+ for (const g of groups) groupRaceByKey.set(g.key, g.raceId);
+
+ const current = new Map(initialRemaining);
+ const runningRaceTotals = new Map(raceTotals);
+ for (let i = 0; i < report.protocol.length; i++) {
+ const action = report.protocol[i];
+ if (action.x) {
+ const left = current.get(action.sd) ?? 0;
+ const next = Math.max(0, left - 1);
+ current.set(action.sd, next);
+ const raceId = groupRaceByKey.get(action.sd);
+ if (raceId !== undefined) {
+ const t = (runningRaceTotals.get(raceId) ?? 0) - 1;
+ runningRaceTotals.set(raceId, Math.max(0, t));
+ }
+ }
+ frames.push({
+ shotIndex: i + 1,
+ remaining: new Map(current),
+ activeRaceIds: collectActiveRaces(runningRaceTotals),
+ lastAction: action,
+ });
+ }
+
+ return frames;
+}
+
+function collectActiveRaces(totals: Map): number[] {
+ const out: number[] = [];
+ for (const [raceId, total] of totals.entries()) {
+ if (total > 0) out.push(raceId);
+ }
+ return out.sort((a, b) => a - b);
+}
diff --git a/ui/frontend/src/lib/i18n/locales/en.ts b/ui/frontend/src/lib/i18n/locales/en.ts
index a5c226e..86c3ce8 100644
--- a/ui/frontend/src/lib/i18n/locales/en.ts
+++ b/ui/frontend/src/lib/i18n/locales/en.ts
@@ -483,6 +483,23 @@ const en = {
"game.report.section.battles.title": "battles",
"game.report.section.battles.empty": "no battles last turn",
"game.report.section.battles.id_label": "battle",
+ "game.battle.title": "battle",
+ "game.battle.loading": "loading battle…",
+ "game.battle.not_found": "battle not found",
+ "game.battle.back_to_report": "back to report",
+ "game.battle.back_to_map": "back to map",
+ "game.battle.controls.play": "play",
+ "game.battle.controls.pause": "pause",
+ "game.battle.controls.step_forward": "step forward",
+ "game.battle.controls.step_backward": "step back",
+ "game.battle.controls.rewind": "rewind to start",
+ "game.battle.controls.speed_label": "speed",
+ "game.battle.controls.speed_1x": "1x",
+ "game.battle.controls.speed_2x": "2x",
+ "game.battle.controls.speed_4x": "4x",
+ "game.battle.log.destroyed": "{attacker_race}'s {attacker_class} destroyed {defender_race}'s {defender_class}",
+ "game.battle.log.shielded": "{attacker_race}'s {attacker_class} hit {defender_race}'s {defender_class}, shields held",
+ "game.battle.accessibility.protocol_heading": "battle log",
"game.report.section.bombings.title": "bombings",
"game.report.section.bombings.empty": "no bombings last turn",
"game.report.section.bombings.column.planet": "planet",
diff --git a/ui/frontend/src/lib/i18n/locales/ru.ts b/ui/frontend/src/lib/i18n/locales/ru.ts
index b9e170d..ef547a2 100644
--- a/ui/frontend/src/lib/i18n/locales/ru.ts
+++ b/ui/frontend/src/lib/i18n/locales/ru.ts
@@ -484,6 +484,23 @@ const ru: Record = {
"game.report.section.battles.title": "сражения",
"game.report.section.battles.empty": "сражений в этом ходу не было",
"game.report.section.battles.id_label": "сражение",
+ "game.battle.title": "сражение",
+ "game.battle.loading": "загрузка сражения…",
+ "game.battle.not_found": "сражение не найдено",
+ "game.battle.back_to_report": "к отчёту",
+ "game.battle.back_to_map": "к карте",
+ "game.battle.controls.play": "запустить",
+ "game.battle.controls.pause": "пауза",
+ "game.battle.controls.step_forward": "шаг вперёд",
+ "game.battle.controls.step_backward": "шаг назад",
+ "game.battle.controls.rewind": "к началу",
+ "game.battle.controls.speed_label": "скорость",
+ "game.battle.controls.speed_1x": "1x",
+ "game.battle.controls.speed_2x": "2x",
+ "game.battle.controls.speed_4x": "4x",
+ "game.battle.log.destroyed": "{attacker_class} расы {attacker_race} уничтожает {defender_class} расы {defender_race}",
+ "game.battle.log.shielded": "{attacker_class} расы {attacker_race} попадает в {defender_class} расы {defender_race}, щиты выдержали",
+ "game.battle.accessibility.protocol_heading": "протокол сражения",
"game.report.section.bombings.title": "бомбардировки",
"game.report.section.bombings.empty": "бомбардировок в этом ходу не было",
"game.report.section.bombings.column.planet": "планета",
diff --git a/ui/frontend/src/map/battle-markers.ts b/ui/frontend/src/map/battle-markers.ts
new file mode 100644
index 0000000..49ec6f3
--- /dev/null
+++ b/ui/frontend/src/map/battle-markers.ts
@@ -0,0 +1,168 @@
+// Phase 27 battle and bombing markers on the map.
+//
+// Two visual markers per planet:
+//
+// * Battle marker — an X cross drawn through the corners of the
+// square that circumscribes the planet circle. Two yellow
+// LinePrim, stroke width scales linearly with the number of
+// shots: 1 shot → 1px, 100+ shots → 5px (capped). Clicking
+// either line opens the Battle Viewer for the corresponding
+// UUID.
+// * Bombing marker — a thin stroke-only circle slightly larger
+// than the planet circle. Yellow on damaged planets, red on
+// wiped planets. Clicking it deep-links to the bombings row in
+// the Reports view for the planet number.
+//
+// Both markers are wired into `state-binding.ts` so they live in the
+// same `world` / `hitLookup` plumbing as planets and ship groups.
+
+import type { GameReport, ReportPlanet } from "../api/game-state";
+import type {
+ CirclePrim,
+ LinePrim,
+ Primitive,
+ PrimitiveID,
+ Style,
+} from "./world";
+
+export const BATTLE_MARKER_COLOR = 0xffd400;
+export const BOMBING_MARKER_COLOR_DAMAGED = 0xffd400;
+export const BOMBING_MARKER_COLOR_WIPED = 0xff3030;
+
+/** Battle and bombing marker primitive ids use a high-bit prefix to
+ * avoid colliding with planet numbers or cargo-route line ids. */
+export const BATTLE_MARKER_ID_PREFIX = 0xa0000000;
+export const BOMBING_MARKER_ID_PREFIX = 0xc0000000;
+
+const PLANET_RADIUS_WORLD = 6;
+const BOMBING_RING_RADIUS = PLANET_RADIUS_WORLD + 3;
+const BATTLE_CROSS_HALF = PLANET_RADIUS_WORLD + 2;
+
+/** Battle marker priority sits between planets (1..4) and cargo
+ * routes; the cross is over the planet but loses clicks against the
+ * planet glyph itself. */
+const BATTLE_MARKER_PRIORITY = 9;
+const BOMBING_MARKER_PRIORITY = 10;
+
+const BATTLE_LINE_INDEX_A = 0;
+const BATTLE_LINE_INDEX_B = 1;
+
+export interface BattleMarkerTarget {
+ kind: "battle";
+ battleId: string;
+ planet: number;
+}
+
+export interface BombingMarkerTarget {
+ kind: "bombing";
+ planet: number;
+}
+
+export type MarkerTarget = BattleMarkerTarget | BombingMarkerTarget;
+
+export interface BuildMarkersResult {
+ primitives: Primitive[];
+ lookup: Map;
+}
+
+/**
+ * battleMarkerStrokeWidth maps a battle's `shots` count to a stroke
+ * width in pixels. 1 shot → 1 px (the thinnest visible), 100+ shots
+ * → 5 px (the cap). Linearly interpolated between those bounds.
+ */
+export function battleMarkerStrokeWidth(shots: number): number {
+ if (shots <= 1) return 1;
+ if (shots >= 100) return 5;
+ return 1 + ((shots - 1) * 4) / 99;
+}
+
+/**
+ * buildBattleAndBombingMarkers emits battle and bombing marker
+ * primitives plus a hit-lookup mapping for the current-turn report.
+ * Battles whose planet is not visible (e.g. observer-only without a
+ * report.planets entry) are skipped — they have no on-map location
+ * to anchor against.
+ */
+export function buildBattleAndBombingMarkers(
+ report: GameReport,
+): BuildMarkersResult {
+ const planetByNumber = new Map();
+ for (const planet of report.planets) {
+ planetByNumber.set(planet.number, planet);
+ }
+
+ const primitives: Primitive[] = [];
+ const lookup = new Map();
+
+ for (let i = 0; i < report.battles.length; i++) {
+ const battle = report.battles[i];
+ const planet = planetByNumber.get(battle.planet);
+ if (planet === undefined) continue;
+ const strokeWidthPx = battleMarkerStrokeWidth(battle.shots);
+ const style: Style = {
+ strokeColor: BATTLE_MARKER_COLOR,
+ strokeAlpha: 0.95,
+ strokeWidthPx,
+ };
+ const baseId = BATTLE_MARKER_ID_PREFIX | (i << 4);
+ const lineA: LinePrim = {
+ kind: "line",
+ id: baseId | BATTLE_LINE_INDEX_A,
+ priority: BATTLE_MARKER_PRIORITY,
+ style,
+ hitSlopPx: 0,
+ x1: planet.x - BATTLE_CROSS_HALF,
+ y1: planet.y - BATTLE_CROSS_HALF,
+ x2: planet.x + BATTLE_CROSS_HALF,
+ y2: planet.y + BATTLE_CROSS_HALF,
+ };
+ const lineB: LinePrim = {
+ kind: "line",
+ id: baseId | BATTLE_LINE_INDEX_B,
+ priority: BATTLE_MARKER_PRIORITY,
+ style,
+ hitSlopPx: 0,
+ x1: planet.x - BATTLE_CROSS_HALF,
+ y1: planet.y + BATTLE_CROSS_HALF,
+ x2: planet.x + BATTLE_CROSS_HALF,
+ y2: planet.y - BATTLE_CROSS_HALF,
+ };
+ const target: BattleMarkerTarget = {
+ kind: "battle",
+ battleId: battle.id,
+ planet: battle.planet,
+ };
+ primitives.push(lineA, lineB);
+ lookup.set(lineA.id, target);
+ lookup.set(lineB.id, target);
+ }
+
+ for (let i = 0; i < report.bombings.length; i++) {
+ const bombing = report.bombings[i];
+ const planet = planetByNumber.get(bombing.planetNumber);
+ if (planet === undefined) continue;
+ const color = bombing.wiped
+ ? BOMBING_MARKER_COLOR_WIPED
+ : BOMBING_MARKER_COLOR_DAMAGED;
+ const style: Style = {
+ strokeColor: color,
+ strokeAlpha: 0.9,
+ strokeWidthPx: 1.5,
+ };
+ const id = BOMBING_MARKER_ID_PREFIX | i;
+ const ring: CirclePrim = {
+ kind: "circle",
+ id,
+ priority: BOMBING_MARKER_PRIORITY,
+ style,
+ hitSlopPx: 0,
+ x: planet.x,
+ y: planet.y,
+ radius: BOMBING_RING_RADIUS,
+ };
+ primitives.push(ring);
+ lookup.set(id, { kind: "bombing", planet: bombing.planetNumber });
+ }
+
+ return { primitives, lookup };
+}
diff --git a/ui/frontend/src/map/state-binding.ts b/ui/frontend/src/map/state-binding.ts
index 44e0a3b..730869f 100644
--- a/ui/frontend/src/map/state-binding.ts
+++ b/ui/frontend/src/map/state-binding.ts
@@ -15,6 +15,7 @@
import type { GameReport, ReportPlanet } from "../api/game-state";
import type { ShipGroupRef } from "../lib/selection.svelte";
+import { buildBattleAndBombingMarkers } from "./battle-markers";
import { shipGroupsToPrimitives } from "./ship-groups";
import { World, type Primitive, type PrimitiveID, type Style } from "./world";
@@ -83,7 +84,9 @@ function priorityFor(kind: ReportPlanet["kind"]): number {
*/
export type HitTarget =
| { kind: "planet"; number: number }
- | { kind: "shipGroup"; ref: ShipGroupRef };
+ | { kind: "shipGroup"; ref: ShipGroupRef }
+ | { kind: "battle"; battleId: string; planet: number }
+ | { kind: "bombing"; planet: number };
export interface ReportToWorldResult {
world: World;
@@ -127,6 +130,14 @@ export function reportToWorld(report: GameReport): ReportToWorldResult {
hitLookup.set(primId, { kind: "shipGroup", ref });
}
+ const markers = buildBattleAndBombingMarkers(report);
+ for (const prim of markers.primitives) {
+ primitives.push(prim);
+ }
+ for (const [primId, target] of markers.lookup) {
+ hitLookup.set(primId, target);
+ }
+
const width = report.mapWidth > 0 ? report.mapWidth : 1;
const height = report.mapHeight > 0 ? report.mapHeight : 1;
return { world: new World(width, height, primitives), hitLookup };
diff --git a/ui/frontend/src/proto/galaxy/fbs/lobby/application-submit-response.ts b/ui/frontend/src/proto/galaxy/fbs/lobby/application-submit-response.ts
index c26463d..e10b337 100644
--- a/ui/frontend/src/proto/galaxy/fbs/lobby/application-submit-response.ts
+++ b/ui/frontend/src/proto/galaxy/fbs/lobby/application-submit-response.ts
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
-import { ApplicationSummary, ApplicationSummaryT } from './application-summary.js';
+import { ApplicationSummary, ApplicationSummaryT } from '../lobby/application-summary.js';
export class ApplicationSubmitResponse implements flatbuffers.IUnpackableObject {
diff --git a/ui/frontend/src/proto/galaxy/fbs/lobby/error-response.ts b/ui/frontend/src/proto/galaxy/fbs/lobby/error-response.ts
index 5e99abb..fbc7798 100644
--- a/ui/frontend/src/proto/galaxy/fbs/lobby/error-response.ts
+++ b/ui/frontend/src/proto/galaxy/fbs/lobby/error-response.ts
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
-import { ErrorBody, ErrorBodyT } from './error-body.js';
+import { ErrorBody, ErrorBodyT } from '../lobby/error-body.js';
export class ErrorResponse implements flatbuffers.IUnpackableObject {
diff --git a/ui/frontend/src/proto/galaxy/fbs/lobby/game-create-response.ts b/ui/frontend/src/proto/galaxy/fbs/lobby/game-create-response.ts
index 5a4b951..006568d 100644
--- a/ui/frontend/src/proto/galaxy/fbs/lobby/game-create-response.ts
+++ b/ui/frontend/src/proto/galaxy/fbs/lobby/game-create-response.ts
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
-import { GameSummary, GameSummaryT } from './game-summary.js';
+import { GameSummary, GameSummaryT } from '../lobby/game-summary.js';
export class GameCreateResponse implements flatbuffers.IUnpackableObject {
diff --git a/ui/frontend/src/proto/galaxy/fbs/lobby/invite-decline-response.ts b/ui/frontend/src/proto/galaxy/fbs/lobby/invite-decline-response.ts
index 29f914b..fda9682 100644
--- a/ui/frontend/src/proto/galaxy/fbs/lobby/invite-decline-response.ts
+++ b/ui/frontend/src/proto/galaxy/fbs/lobby/invite-decline-response.ts
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
-import { InviteSummary, InviteSummaryT } from './invite-summary.js';
+import { InviteSummary, InviteSummaryT } from '../lobby/invite-summary.js';
export class InviteDeclineResponse implements flatbuffers.IUnpackableObject {
diff --git a/ui/frontend/src/proto/galaxy/fbs/lobby/invite-redeem-response.ts b/ui/frontend/src/proto/galaxy/fbs/lobby/invite-redeem-response.ts
index c33c329..1019243 100644
--- a/ui/frontend/src/proto/galaxy/fbs/lobby/invite-redeem-response.ts
+++ b/ui/frontend/src/proto/galaxy/fbs/lobby/invite-redeem-response.ts
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
-import { InviteSummary, InviteSummaryT } from './invite-summary.js';
+import { InviteSummary, InviteSummaryT } from '../lobby/invite-summary.js';
export class InviteRedeemResponse implements flatbuffers.IUnpackableObject {
diff --git a/ui/frontend/src/proto/galaxy/fbs/lobby/my-applications-list-response.ts b/ui/frontend/src/proto/galaxy/fbs/lobby/my-applications-list-response.ts
index d2be296..8b8821a 100644
--- a/ui/frontend/src/proto/galaxy/fbs/lobby/my-applications-list-response.ts
+++ b/ui/frontend/src/proto/galaxy/fbs/lobby/my-applications-list-response.ts
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
-import { ApplicationSummary, ApplicationSummaryT } from './application-summary.js';
+import { ApplicationSummary, ApplicationSummaryT } from '../lobby/application-summary.js';
export class MyApplicationsListResponse implements flatbuffers.IUnpackableObject {
diff --git a/ui/frontend/src/proto/galaxy/fbs/lobby/my-games-list-response.ts b/ui/frontend/src/proto/galaxy/fbs/lobby/my-games-list-response.ts
index ece5c6f..8f10c87 100644
--- a/ui/frontend/src/proto/galaxy/fbs/lobby/my-games-list-response.ts
+++ b/ui/frontend/src/proto/galaxy/fbs/lobby/my-games-list-response.ts
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
-import { GameSummary, GameSummaryT } from './game-summary.js';
+import { GameSummary, GameSummaryT } from '../lobby/game-summary.js';
export class MyGamesListResponse implements flatbuffers.IUnpackableObject {
diff --git a/ui/frontend/src/proto/galaxy/fbs/lobby/my-invites-list-response.ts b/ui/frontend/src/proto/galaxy/fbs/lobby/my-invites-list-response.ts
index 42fde82..1f858b2 100644
--- a/ui/frontend/src/proto/galaxy/fbs/lobby/my-invites-list-response.ts
+++ b/ui/frontend/src/proto/galaxy/fbs/lobby/my-invites-list-response.ts
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
-import { InviteSummary, InviteSummaryT } from './invite-summary.js';
+import { InviteSummary, InviteSummaryT } from '../lobby/invite-summary.js';
export class MyInvitesListResponse implements flatbuffers.IUnpackableObject {
diff --git a/ui/frontend/src/proto/galaxy/fbs/lobby/public-games-list-response.ts b/ui/frontend/src/proto/galaxy/fbs/lobby/public-games-list-response.ts
index 2b49679..cc95d0a 100644
--- a/ui/frontend/src/proto/galaxy/fbs/lobby/public-games-list-response.ts
+++ b/ui/frontend/src/proto/galaxy/fbs/lobby/public-games-list-response.ts
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
-import { GameSummary, GameSummaryT } from './game-summary.js';
+import { GameSummary, GameSummaryT } from '../lobby/game-summary.js';
export class PublicGamesListResponse implements flatbuffers.IUnpackableObject {
diff --git a/ui/frontend/src/proto/galaxy/fbs/order/command-item.ts b/ui/frontend/src/proto/galaxy/fbs/order/command-item.ts
index f754446..f95b299 100644
--- a/ui/frontend/src/proto/galaxy/fbs/order/command-item.ts
+++ b/ui/frontend/src/proto/galaxy/fbs/order/command-item.ts
@@ -4,30 +4,30 @@
import * as flatbuffers from 'flatbuffers';
-import { CommandFleetMerge, CommandFleetMergeT } from './command-fleet-merge.js';
-import { CommandFleetSend, CommandFleetSendT } from './command-fleet-send.js';
-import { CommandPayload, unionToCommandPayload, unionListToCommandPayload } from './command-payload.js';
-import { CommandPlanetProduce, CommandPlanetProduceT } from './command-planet-produce.js';
-import { CommandPlanetRename, CommandPlanetRenameT } from './command-planet-rename.js';
-import { CommandPlanetRouteRemove, CommandPlanetRouteRemoveT } from './command-planet-route-remove.js';
-import { CommandPlanetRouteSet, CommandPlanetRouteSetT } from './command-planet-route-set.js';
-import { CommandRaceQuit, CommandRaceQuitT } from './command-race-quit.js';
-import { CommandRaceRelation, CommandRaceRelationT } from './command-race-relation.js';
-import { CommandRaceVote, CommandRaceVoteT } from './command-race-vote.js';
-import { CommandScienceCreate, CommandScienceCreateT } from './command-science-create.js';
-import { CommandScienceRemove, CommandScienceRemoveT } from './command-science-remove.js';
-import { CommandShipClassCreate, CommandShipClassCreateT } from './command-ship-class-create.js';
-import { CommandShipClassMerge, CommandShipClassMergeT } from './command-ship-class-merge.js';
-import { CommandShipClassRemove, CommandShipClassRemoveT } from './command-ship-class-remove.js';
-import { CommandShipGroupBreak, CommandShipGroupBreakT } from './command-ship-group-break.js';
-import { CommandShipGroupDismantle, CommandShipGroupDismantleT } from './command-ship-group-dismantle.js';
-import { CommandShipGroupJoinFleet, CommandShipGroupJoinFleetT } from './command-ship-group-join-fleet.js';
-import { CommandShipGroupLoad, CommandShipGroupLoadT } from './command-ship-group-load.js';
-import { CommandShipGroupMerge, CommandShipGroupMergeT } from './command-ship-group-merge.js';
-import { CommandShipGroupSend, CommandShipGroupSendT } from './command-ship-group-send.js';
-import { CommandShipGroupTransfer, CommandShipGroupTransferT } from './command-ship-group-transfer.js';
-import { CommandShipGroupUnload, CommandShipGroupUnloadT } from './command-ship-group-unload.js';
-import { CommandShipGroupUpgrade, CommandShipGroupUpgradeT } from './command-ship-group-upgrade.js';
+import { CommandFleetMerge, CommandFleetMergeT } from '../order/command-fleet-merge.js';
+import { CommandFleetSend, CommandFleetSendT } from '../order/command-fleet-send.js';
+import { CommandPayload, unionToCommandPayload, unionListToCommandPayload } from '../order/command-payload.js';
+import { CommandPlanetProduce, CommandPlanetProduceT } from '../order/command-planet-produce.js';
+import { CommandPlanetRename, CommandPlanetRenameT } from '../order/command-planet-rename.js';
+import { CommandPlanetRouteRemove, CommandPlanetRouteRemoveT } from '../order/command-planet-route-remove.js';
+import { CommandPlanetRouteSet, CommandPlanetRouteSetT } from '../order/command-planet-route-set.js';
+import { CommandRaceQuit, CommandRaceQuitT } from '../order/command-race-quit.js';
+import { CommandRaceRelation, CommandRaceRelationT } from '../order/command-race-relation.js';
+import { CommandRaceVote, CommandRaceVoteT } from '../order/command-race-vote.js';
+import { CommandScienceCreate, CommandScienceCreateT } from '../order/command-science-create.js';
+import { CommandScienceRemove, CommandScienceRemoveT } from '../order/command-science-remove.js';
+import { CommandShipClassCreate, CommandShipClassCreateT } from '../order/command-ship-class-create.js';
+import { CommandShipClassMerge, CommandShipClassMergeT } from '../order/command-ship-class-merge.js';
+import { CommandShipClassRemove, CommandShipClassRemoveT } from '../order/command-ship-class-remove.js';
+import { CommandShipGroupBreak, CommandShipGroupBreakT } from '../order/command-ship-group-break.js';
+import { CommandShipGroupDismantle, CommandShipGroupDismantleT } from '../order/command-ship-group-dismantle.js';
+import { CommandShipGroupJoinFleet, CommandShipGroupJoinFleetT } from '../order/command-ship-group-join-fleet.js';
+import { CommandShipGroupLoad, CommandShipGroupLoadT } from '../order/command-ship-group-load.js';
+import { CommandShipGroupMerge, CommandShipGroupMergeT } from '../order/command-ship-group-merge.js';
+import { CommandShipGroupSend, CommandShipGroupSendT } from '../order/command-ship-group-send.js';
+import { CommandShipGroupTransfer, CommandShipGroupTransferT } from '../order/command-ship-group-transfer.js';
+import { CommandShipGroupUnload, CommandShipGroupUnloadT } from '../order/command-ship-group-unload.js';
+import { CommandShipGroupUpgrade, CommandShipGroupUpgradeT } from '../order/command-ship-group-upgrade.js';
export class CommandItem implements flatbuffers.IUnpackableObject {
diff --git a/ui/frontend/src/proto/galaxy/fbs/order/command-payload.ts b/ui/frontend/src/proto/galaxy/fbs/order/command-payload.ts
index 5bad98c..5ac2553 100644
--- a/ui/frontend/src/proto/galaxy/fbs/order/command-payload.ts
+++ b/ui/frontend/src/proto/galaxy/fbs/order/command-payload.ts
@@ -2,29 +2,29 @@
/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
-import { CommandFleetMerge, CommandFleetMergeT } from './command-fleet-merge.js';
-import { CommandFleetSend, CommandFleetSendT } from './command-fleet-send.js';
-import { CommandPlanetProduce, CommandPlanetProduceT } from './command-planet-produce.js';
-import { CommandPlanetRename, CommandPlanetRenameT } from './command-planet-rename.js';
-import { CommandPlanetRouteRemove, CommandPlanetRouteRemoveT } from './command-planet-route-remove.js';
-import { CommandPlanetRouteSet, CommandPlanetRouteSetT } from './command-planet-route-set.js';
-import { CommandRaceQuit, CommandRaceQuitT } from './command-race-quit.js';
-import { CommandRaceRelation, CommandRaceRelationT } from './command-race-relation.js';
-import { CommandRaceVote, CommandRaceVoteT } from './command-race-vote.js';
-import { CommandScienceCreate, CommandScienceCreateT } from './command-science-create.js';
-import { CommandScienceRemove, CommandScienceRemoveT } from './command-science-remove.js';
-import { CommandShipClassCreate, CommandShipClassCreateT } from './command-ship-class-create.js';
-import { CommandShipClassMerge, CommandShipClassMergeT } from './command-ship-class-merge.js';
-import { CommandShipClassRemove, CommandShipClassRemoveT } from './command-ship-class-remove.js';
-import { CommandShipGroupBreak, CommandShipGroupBreakT } from './command-ship-group-break.js';
-import { CommandShipGroupDismantle, CommandShipGroupDismantleT } from './command-ship-group-dismantle.js';
-import { CommandShipGroupJoinFleet, CommandShipGroupJoinFleetT } from './command-ship-group-join-fleet.js';
-import { CommandShipGroupLoad, CommandShipGroupLoadT } from './command-ship-group-load.js';
-import { CommandShipGroupMerge, CommandShipGroupMergeT } from './command-ship-group-merge.js';
-import { CommandShipGroupSend, CommandShipGroupSendT } from './command-ship-group-send.js';
-import { CommandShipGroupTransfer, CommandShipGroupTransferT } from './command-ship-group-transfer.js';
-import { CommandShipGroupUnload, CommandShipGroupUnloadT } from './command-ship-group-unload.js';
-import { CommandShipGroupUpgrade, CommandShipGroupUpgradeT } from './command-ship-group-upgrade.js';
+import { CommandFleetMerge, CommandFleetMergeT } from '../order/command-fleet-merge.js';
+import { CommandFleetSend, CommandFleetSendT } from '../order/command-fleet-send.js';
+import { CommandPlanetProduce, CommandPlanetProduceT } from '../order/command-planet-produce.js';
+import { CommandPlanetRename, CommandPlanetRenameT } from '../order/command-planet-rename.js';
+import { CommandPlanetRouteRemove, CommandPlanetRouteRemoveT } from '../order/command-planet-route-remove.js';
+import { CommandPlanetRouteSet, CommandPlanetRouteSetT } from '../order/command-planet-route-set.js';
+import { CommandRaceQuit, CommandRaceQuitT } from '../order/command-race-quit.js';
+import { CommandRaceRelation, CommandRaceRelationT } from '../order/command-race-relation.js';
+import { CommandRaceVote, CommandRaceVoteT } from '../order/command-race-vote.js';
+import { CommandScienceCreate, CommandScienceCreateT } from '../order/command-science-create.js';
+import { CommandScienceRemove, CommandScienceRemoveT } from '../order/command-science-remove.js';
+import { CommandShipClassCreate, CommandShipClassCreateT } from '../order/command-ship-class-create.js';
+import { CommandShipClassMerge, CommandShipClassMergeT } from '../order/command-ship-class-merge.js';
+import { CommandShipClassRemove, CommandShipClassRemoveT } from '../order/command-ship-class-remove.js';
+import { CommandShipGroupBreak, CommandShipGroupBreakT } from '../order/command-ship-group-break.js';
+import { CommandShipGroupDismantle, CommandShipGroupDismantleT } from '../order/command-ship-group-dismantle.js';
+import { CommandShipGroupJoinFleet, CommandShipGroupJoinFleetT } from '../order/command-ship-group-join-fleet.js';
+import { CommandShipGroupLoad, CommandShipGroupLoadT } from '../order/command-ship-group-load.js';
+import { CommandShipGroupMerge, CommandShipGroupMergeT } from '../order/command-ship-group-merge.js';
+import { CommandShipGroupSend, CommandShipGroupSendT } from '../order/command-ship-group-send.js';
+import { CommandShipGroupTransfer, CommandShipGroupTransferT } from '../order/command-ship-group-transfer.js';
+import { CommandShipGroupUnload, CommandShipGroupUnloadT } from '../order/command-ship-group-unload.js';
+import { CommandShipGroupUpgrade, CommandShipGroupUpgradeT } from '../order/command-ship-group-upgrade.js';
export enum CommandPayload {
diff --git a/ui/frontend/src/proto/galaxy/fbs/order/command-planet-produce.ts b/ui/frontend/src/proto/galaxy/fbs/order/command-planet-produce.ts
index 100f188..bfbf0f3 100644
--- a/ui/frontend/src/proto/galaxy/fbs/order/command-planet-produce.ts
+++ b/ui/frontend/src/proto/galaxy/fbs/order/command-planet-produce.ts
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
-import { PlanetProduction } from './planet-production.js';
+import { PlanetProduction } from '../order/planet-production.js';
export class CommandPlanetProduce implements flatbuffers.IUnpackableObject {
diff --git a/ui/frontend/src/proto/galaxy/fbs/order/command-planet-route-remove.ts b/ui/frontend/src/proto/galaxy/fbs/order/command-planet-route-remove.ts
index 2f6c704..b4b6d1c 100644
--- a/ui/frontend/src/proto/galaxy/fbs/order/command-planet-route-remove.ts
+++ b/ui/frontend/src/proto/galaxy/fbs/order/command-planet-route-remove.ts
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
-import { PlanetRouteLoadType } from './planet-route-load-type.js';
+import { PlanetRouteLoadType } from '../order/planet-route-load-type.js';
export class CommandPlanetRouteRemove implements flatbuffers.IUnpackableObject {
diff --git a/ui/frontend/src/proto/galaxy/fbs/order/command-planet-route-set.ts b/ui/frontend/src/proto/galaxy/fbs/order/command-planet-route-set.ts
index 7ad7137..a4ff8ae 100644
--- a/ui/frontend/src/proto/galaxy/fbs/order/command-planet-route-set.ts
+++ b/ui/frontend/src/proto/galaxy/fbs/order/command-planet-route-set.ts
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
-import { PlanetRouteLoadType } from './planet-route-load-type.js';
+import { PlanetRouteLoadType } from '../order/planet-route-load-type.js';
export class CommandPlanetRouteSet implements flatbuffers.IUnpackableObject {
diff --git a/ui/frontend/src/proto/galaxy/fbs/order/command-race-relation.ts b/ui/frontend/src/proto/galaxy/fbs/order/command-race-relation.ts
index ee1c713..327cd95 100644
--- a/ui/frontend/src/proto/galaxy/fbs/order/command-race-relation.ts
+++ b/ui/frontend/src/proto/galaxy/fbs/order/command-race-relation.ts
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
-import { Relation } from './relation.js';
+import { Relation } from '../order/relation.js';
export class CommandRaceRelation implements flatbuffers.IUnpackableObject {
diff --git a/ui/frontend/src/proto/galaxy/fbs/order/command-ship-group-load.ts b/ui/frontend/src/proto/galaxy/fbs/order/command-ship-group-load.ts
index a4d6013..6d4d91d 100644
--- a/ui/frontend/src/proto/galaxy/fbs/order/command-ship-group-load.ts
+++ b/ui/frontend/src/proto/galaxy/fbs/order/command-ship-group-load.ts
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
-import { ShipGroupCargo } from './ship-group-cargo.js';
+import { ShipGroupCargo } from '../order/ship-group-cargo.js';
export class CommandShipGroupLoad implements flatbuffers.IUnpackableObject {
diff --git a/ui/frontend/src/proto/galaxy/fbs/order/command-ship-group-upgrade.ts b/ui/frontend/src/proto/galaxy/fbs/order/command-ship-group-upgrade.ts
index 548f82e..a95bc8e 100644
--- a/ui/frontend/src/proto/galaxy/fbs/order/command-ship-group-upgrade.ts
+++ b/ui/frontend/src/proto/galaxy/fbs/order/command-ship-group-upgrade.ts
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
-import { ShipGroupUpgradeTech } from './ship-group-upgrade-tech.js';
+import { ShipGroupUpgradeTech } from '../order/ship-group-upgrade-tech.js';
export class CommandShipGroupUpgrade implements flatbuffers.IUnpackableObject {
diff --git a/ui/frontend/src/proto/galaxy/fbs/order/user-games-command.ts b/ui/frontend/src/proto/galaxy/fbs/order/user-games-command.ts
index 67557a2..2afc8a8 100644
--- a/ui/frontend/src/proto/galaxy/fbs/order/user-games-command.ts
+++ b/ui/frontend/src/proto/galaxy/fbs/order/user-games-command.ts
@@ -5,7 +5,7 @@
import * as flatbuffers from 'flatbuffers';
import { UUID, UUIDT } from '../common/uuid.js';
-import { CommandItem, CommandItemT } from './command-item.js';
+import { CommandItem, CommandItemT } from '../order/command-item.js';
export class UserGamesCommand implements flatbuffers.IUnpackableObject {
diff --git a/ui/frontend/src/proto/galaxy/fbs/order/user-games-order-get-response.ts b/ui/frontend/src/proto/galaxy/fbs/order/user-games-order-get-response.ts
index dfc6387..1375b2b 100644
--- a/ui/frontend/src/proto/galaxy/fbs/order/user-games-order-get-response.ts
+++ b/ui/frontend/src/proto/galaxy/fbs/order/user-games-order-get-response.ts
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
-import { UserGamesOrder, UserGamesOrderT } from './user-games-order.js';
+import { UserGamesOrder, UserGamesOrderT } from '../order/user-games-order.js';
export class UserGamesOrderGetResponse implements flatbuffers.IUnpackableObject {
diff --git a/ui/frontend/src/proto/galaxy/fbs/order/user-games-order-response.ts b/ui/frontend/src/proto/galaxy/fbs/order/user-games-order-response.ts
index 29c0702..c694100 100644
--- a/ui/frontend/src/proto/galaxy/fbs/order/user-games-order-response.ts
+++ b/ui/frontend/src/proto/galaxy/fbs/order/user-games-order-response.ts
@@ -5,7 +5,7 @@
import * as flatbuffers from 'flatbuffers';
import { UUID, UUIDT } from '../common/uuid.js';
-import { CommandItem, CommandItemT } from './command-item.js';
+import { CommandItem, CommandItemT } from '../order/command-item.js';
export class UserGamesOrderResponse implements flatbuffers.IUnpackableObject {
diff --git a/ui/frontend/src/proto/galaxy/fbs/order/user-games-order.ts b/ui/frontend/src/proto/galaxy/fbs/order/user-games-order.ts
index fb7aa3a..a783d23 100644
--- a/ui/frontend/src/proto/galaxy/fbs/order/user-games-order.ts
+++ b/ui/frontend/src/proto/galaxy/fbs/order/user-games-order.ts
@@ -5,7 +5,7 @@
import * as flatbuffers from 'flatbuffers';
import { UUID, UUIDT } from '../common/uuid.js';
-import { CommandItem, CommandItemT } from './command-item.js';
+import { CommandItem, CommandItemT } from '../order/command-item.js';
export class UserGamesOrder implements flatbuffers.IUnpackableObject {
diff --git a/ui/frontend/src/proto/galaxy/fbs/report.ts b/ui/frontend/src/proto/galaxy/fbs/report.ts
index 7f990bd..d35b818 100644
--- a/ui/frontend/src/proto/galaxy/fbs/report.ts
+++ b/ui/frontend/src/proto/galaxy/fbs/report.ts
@@ -2,6 +2,7 @@
/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
+export { BattleSummary, BattleSummaryT } from './report/battle-summary.js';
export { Bombing, BombingT } from './report/bombing.js';
export { GameReportRequest, GameReportRequestT } from './report/game-report-request.js';
export { IncomingGroup, IncomingGroupT } from './report/incoming-group.js';
diff --git a/ui/frontend/src/proto/galaxy/fbs/report/battle-summary.ts b/ui/frontend/src/proto/galaxy/fbs/report/battle-summary.ts
new file mode 100644
index 0000000..131500a
--- /dev/null
+++ b/ui/frontend/src/proto/galaxy/fbs/report/battle-summary.ts
@@ -0,0 +1,104 @@
+// automatically generated by the FlatBuffers compiler, do not modify
+
+/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
+
+import * as flatbuffers from 'flatbuffers';
+
+import { UUID, UUIDT } from '../common/uuid.js';
+
+
+export class BattleSummary implements flatbuffers.IUnpackableObject {
+ bb: flatbuffers.ByteBuffer|null = null;
+ bb_pos = 0;
+ __init(i:number, bb:flatbuffers.ByteBuffer):BattleSummary {
+ this.bb_pos = i;
+ this.bb = bb;
+ return this;
+}
+
+static getRootAsBattleSummary(bb:flatbuffers.ByteBuffer, obj?:BattleSummary):BattleSummary {
+ return (obj || new BattleSummary()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
+}
+
+static getSizePrefixedRootAsBattleSummary(bb:flatbuffers.ByteBuffer, obj?:BattleSummary):BattleSummary {
+ bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
+ return (obj || new BattleSummary()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
+}
+
+id(obj?:UUID):UUID|null {
+ const offset = this.bb!.__offset(this.bb_pos, 4);
+ return offset ? (obj || new UUID()).__init(this.bb_pos + offset, this.bb!) : null;
+}
+
+planet():bigint {
+ const offset = this.bb!.__offset(this.bb_pos, 6);
+ return offset ? this.bb!.readUint64(this.bb_pos + offset) : BigInt('0');
+}
+
+shots():bigint {
+ const offset = this.bb!.__offset(this.bb_pos, 8);
+ return offset ? this.bb!.readUint64(this.bb_pos + offset) : BigInt('0');
+}
+
+static startBattleSummary(builder:flatbuffers.Builder) {
+ builder.startObject(3);
+}
+
+static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
+ builder.addFieldStruct(0, idOffset, 0);
+}
+
+static addPlanet(builder:flatbuffers.Builder, planet:bigint) {
+ builder.addFieldInt64(1, planet, BigInt('0'));
+}
+
+static addShots(builder:flatbuffers.Builder, shots:bigint) {
+ builder.addFieldInt64(2, shots, BigInt('0'));
+}
+
+static endBattleSummary(builder:flatbuffers.Builder):flatbuffers.Offset {
+ const offset = builder.endObject();
+ builder.requiredField(offset, 4) // id
+ return offset;
+}
+
+static createBattleSummary(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, planet:bigint, shots:bigint):flatbuffers.Offset {
+ BattleSummary.startBattleSummary(builder);
+ BattleSummary.addId(builder, idOffset);
+ BattleSummary.addPlanet(builder, planet);
+ BattleSummary.addShots(builder, shots);
+ return BattleSummary.endBattleSummary(builder);
+}
+
+unpack(): BattleSummaryT {
+ return new BattleSummaryT(
+ (this.id() !== null ? this.id()!.unpack() : null),
+ this.planet(),
+ this.shots()
+ );
+}
+
+
+unpackTo(_o: BattleSummaryT): void {
+ _o.id = (this.id() !== null ? this.id()!.unpack() : null);
+ _o.planet = this.planet();
+ _o.shots = this.shots();
+}
+}
+
+export class BattleSummaryT implements flatbuffers.IGeneratedObject {
+constructor(
+ public id: UUIDT|null = null,
+ public planet: bigint = BigInt('0'),
+ public shots: bigint = BigInt('0')
+){}
+
+
+pack(builder:flatbuffers.Builder): flatbuffers.Offset {
+ return BattleSummary.createBattleSummary(builder,
+ (this.id !== null ? this.id!.pack(builder) : 0),
+ this.planet,
+ this.shots
+ );
+}
+}
diff --git a/ui/frontend/src/proto/galaxy/fbs/report/local-group.ts b/ui/frontend/src/proto/galaxy/fbs/report/local-group.ts
index 39fdee3..00943ab 100644
--- a/ui/frontend/src/proto/galaxy/fbs/report/local-group.ts
+++ b/ui/frontend/src/proto/galaxy/fbs/report/local-group.ts
@@ -5,7 +5,7 @@
import * as flatbuffers from 'flatbuffers';
import { UUID, UUIDT } from '../common/uuid.js';
-import { TechEntry, TechEntryT } from './tech-entry.js';
+import { TechEntry, TechEntryT } from '../report/tech-entry.js';
export class LocalGroup implements flatbuffers.IUnpackableObject {
diff --git a/ui/frontend/src/proto/galaxy/fbs/report/other-group.ts b/ui/frontend/src/proto/galaxy/fbs/report/other-group.ts
index dd90ec9..d833d66 100644
--- a/ui/frontend/src/proto/galaxy/fbs/report/other-group.ts
+++ b/ui/frontend/src/proto/galaxy/fbs/report/other-group.ts
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
-import { TechEntry, TechEntryT } from './tech-entry.js';
+import { TechEntry, TechEntryT } from '../report/tech-entry.js';
export class OtherGroup implements flatbuffers.IUnpackableObject {
diff --git a/ui/frontend/src/proto/galaxy/fbs/report/report.ts b/ui/frontend/src/proto/galaxy/fbs/report/report.ts
index 7e60656..df929af 100644
--- a/ui/frontend/src/proto/galaxy/fbs/report/report.ts
+++ b/ui/frontend/src/proto/galaxy/fbs/report/report.ts
@@ -4,24 +4,24 @@
import * as flatbuffers from 'flatbuffers';
-import { UUID, UUIDT } from '../common/uuid.js';
-import { Bombing, BombingT } from './bombing.js';
-import { IncomingGroup, IncomingGroupT } from './incoming-group.js';
-import { LocalFleet, LocalFleetT } from './local-fleet.js';
-import { LocalGroup, LocalGroupT } from './local-group.js';
-import { LocalPlanet, LocalPlanetT } from './local-planet.js';
-import { OtherGroup, OtherGroupT } from './other-group.js';
-import { OtherPlanet, OtherPlanetT } from './other-planet.js';
-import { OtherScience, OtherScienceT } from './other-science.js';
-import { OthersShipClass, OthersShipClassT } from './others-ship-class.js';
-import { Player, PlayerT } from './player.js';
-import { Route, RouteT } from './route.js';
-import { Science, ScienceT } from './science.js';
-import { ShipClass, ShipClassT } from './ship-class.js';
-import { ShipProduction, ShipProductionT } from './ship-production.js';
-import { UnidentifiedGroup, UnidentifiedGroupT } from './unidentified-group.js';
-import { UnidentifiedPlanet, UnidentifiedPlanetT } from './unidentified-planet.js';
-import { UninhabitedPlanet, UninhabitedPlanetT } from './uninhabited-planet.js';
+import { BattleSummary, BattleSummaryT } from '../report/battle-summary.js';
+import { Bombing, BombingT } from '../report/bombing.js';
+import { IncomingGroup, IncomingGroupT } from '../report/incoming-group.js';
+import { LocalFleet, LocalFleetT } from '../report/local-fleet.js';
+import { LocalGroup, LocalGroupT } from '../report/local-group.js';
+import { LocalPlanet, LocalPlanetT } from '../report/local-planet.js';
+import { OtherGroup, OtherGroupT } from '../report/other-group.js';
+import { OtherPlanet, OtherPlanetT } from '../report/other-planet.js';
+import { OtherScience, OtherScienceT } from '../report/other-science.js';
+import { OthersShipClass, OthersShipClassT } from '../report/others-ship-class.js';
+import { Player, PlayerT } from '../report/player.js';
+import { Route, RouteT } from '../report/route.js';
+import { Science, ScienceT } from '../report/science.js';
+import { ShipClass, ShipClassT } from '../report/ship-class.js';
+import { ShipProduction, ShipProductionT } from '../report/ship-production.js';
+import { UnidentifiedGroup, UnidentifiedGroupT } from '../report/unidentified-group.js';
+import { UnidentifiedPlanet, UnidentifiedPlanetT } from '../report/unidentified-planet.js';
+import { UninhabitedPlanet, UninhabitedPlanetT } from '../report/uninhabited-planet.js';
export class Report implements flatbuffers.IUnpackableObject {
@@ -136,9 +136,9 @@ otherShipClassLength():number {
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
-battle(index: number, obj?:UUID):UUID|null {
+battle(index: number, obj?:BattleSummary):BattleSummary|null {
const offset = this.bb!.__offset(this.bb_pos, 30);
- return offset ? (obj || new UUID()).__init(this.bb!.__vector(this.bb_pos + offset) + index * 16, this.bb!) : null;
+ return offset ? (obj || new BattleSummary()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
}
battleLength():number {
@@ -386,8 +386,16 @@ static addBattle(builder:flatbuffers.Builder, battleOffset:flatbuffers.Offset) {
builder.addFieldOffset(13, battleOffset, 0);
}
+static createBattleVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
+ builder.startVector(4, data.length, 4);
+ for (let i = data.length - 1; i >= 0; i--) {
+ builder.addOffset(data[i]!);
+ }
+ return builder.endVector();
+}
+
static startBattleVector(builder:flatbuffers.Builder, numElems:number) {
- builder.startVector(16, numElems, 8);
+ builder.startVector(4, numElems, 4);
}
static addBombing(builder:flatbuffers.Builder, bombingOffset:flatbuffers.Offset) {
@@ -641,7 +649,7 @@ unpack(): ReportT {
this.bb!.createObjList(this.otherScience.bind(this), this.otherScienceLength()),
this.bb!.createObjList(this.localShipClass.bind(this), this.localShipClassLength()),
this.bb!.createObjList(this.otherShipClass.bind(this), this.otherShipClassLength()),
- this.bb!.createObjList(this.battle.bind(this), this.battleLength()),
+ this.bb!.createObjList(this.battle.bind(this), this.battleLength()),
this.bb!.createObjList(this.bombing.bind(this), this.bombingLength()),
this.bb!.createObjList(this.incomingGroup.bind(this), this.incomingGroupLength()),
this.bb!.createObjList(this.localPlanet.bind(this), this.localPlanetLength()),
@@ -672,7 +680,7 @@ unpackTo(_o: ReportT): void {
_o.otherScience = this.bb!.createObjList(this.otherScience.bind(this), this.otherScienceLength());
_o.localShipClass = this.bb!.createObjList(this.localShipClass.bind(this), this.localShipClassLength());
_o.otherShipClass = this.bb!.createObjList(this.otherShipClass.bind(this), this.otherShipClassLength());
- _o.battle = this.bb!.createObjList(this.battle.bind(this), this.battleLength());
+ _o.battle = this.bb!.createObjList(this.battle.bind(this), this.battleLength());
_o.bombing = this.bb!.createObjList(this.bombing.bind(this), this.bombingLength());
_o.incomingGroup = this.bb!.createObjList(this.incomingGroup.bind(this), this.incomingGroupLength());
_o.localPlanet = this.bb!.createObjList(this.localPlanet.bind(this), this.localPlanetLength());
@@ -703,7 +711,7 @@ constructor(
public otherScience: (OtherScienceT)[] = [],
public localShipClass: (ShipClassT)[] = [],
public otherShipClass: (OthersShipClassT)[] = [],
- public battle: (UUIDT)[] = [],
+ public battle: (BattleSummaryT)[] = [],
public bombing: (BombingT)[] = [],
public incomingGroup: (IncomingGroupT)[] = [],
public localPlanet: (LocalPlanetT)[] = [],
@@ -727,7 +735,7 @@ pack(builder:flatbuffers.Builder): flatbuffers.Offset {
const otherScience = Report.createOtherScienceVector(builder, builder.createObjectOffsetList(this.otherScience));
const localShipClass = Report.createLocalShipClassVector(builder, builder.createObjectOffsetList(this.localShipClass));
const otherShipClass = Report.createOtherShipClassVector(builder, builder.createObjectOffsetList(this.otherShipClass));
- const battle = builder.createStructOffsetList(this.battle, Report.startBattleVector);
+ const battle = Report.createBattleVector(builder, builder.createObjectOffsetList(this.battle));
const bombing = Report.createBombingVector(builder, builder.createObjectOffsetList(this.bombing));
const incomingGroup = Report.createIncomingGroupVector(builder, builder.createObjectOffsetList(this.incomingGroup));
const localPlanet = Report.createLocalPlanetVector(builder, builder.createObjectOffsetList(this.localPlanet));
diff --git a/ui/frontend/src/proto/galaxy/fbs/report/route.ts b/ui/frontend/src/proto/galaxy/fbs/report/route.ts
index 404932c..6005e3a 100644
--- a/ui/frontend/src/proto/galaxy/fbs/report/route.ts
+++ b/ui/frontend/src/proto/galaxy/fbs/report/route.ts
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
-import { RouteEntry, RouteEntryT } from './route-entry.js';
+import { RouteEntry, RouteEntryT } from '../report/route-entry.js';
export class Route implements flatbuffers.IUnpackableObject {
diff --git a/ui/frontend/src/proto/galaxy/fbs/user/account-response.ts b/ui/frontend/src/proto/galaxy/fbs/user/account-response.ts
index 938756c..083b052 100644
--- a/ui/frontend/src/proto/galaxy/fbs/user/account-response.ts
+++ b/ui/frontend/src/proto/galaxy/fbs/user/account-response.ts
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
-import { AccountView, AccountViewT } from './account-view.js';
+import { AccountView, AccountViewT } from '../user/account-view.js';
export class AccountResponse implements flatbuffers.IUnpackableObject {
diff --git a/ui/frontend/src/proto/galaxy/fbs/user/account-view.ts b/ui/frontend/src/proto/galaxy/fbs/user/account-view.ts
index e992d38..62739e2 100644
--- a/ui/frontend/src/proto/galaxy/fbs/user/account-view.ts
+++ b/ui/frontend/src/proto/galaxy/fbs/user/account-view.ts
@@ -4,9 +4,9 @@
import * as flatbuffers from 'flatbuffers';
-import { ActiveLimit, ActiveLimitT } from './active-limit.js';
-import { ActiveSanction, ActiveSanctionT } from './active-sanction.js';
-import { EntitlementSnapshot, EntitlementSnapshotT } from './entitlement-snapshot.js';
+import { ActiveLimit, ActiveLimitT } from '../user/active-limit.js';
+import { ActiveSanction, ActiveSanctionT } from '../user/active-sanction.js';
+import { EntitlementSnapshot, EntitlementSnapshotT } from '../user/entitlement-snapshot.js';
export class AccountView implements flatbuffers.IUnpackableObject {
diff --git a/ui/frontend/src/proto/galaxy/fbs/user/active-limit.ts b/ui/frontend/src/proto/galaxy/fbs/user/active-limit.ts
index 775fc37..8c26893 100644
--- a/ui/frontend/src/proto/galaxy/fbs/user/active-limit.ts
+++ b/ui/frontend/src/proto/galaxy/fbs/user/active-limit.ts
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
-import { ActorRef, ActorRefT } from './actor-ref.js';
+import { ActorRef, ActorRefT } from '../user/actor-ref.js';
export class ActiveLimit implements flatbuffers.IUnpackableObject {
diff --git a/ui/frontend/src/proto/galaxy/fbs/user/active-sanction.ts b/ui/frontend/src/proto/galaxy/fbs/user/active-sanction.ts
index 136db50..20c01f7 100644
--- a/ui/frontend/src/proto/galaxy/fbs/user/active-sanction.ts
+++ b/ui/frontend/src/proto/galaxy/fbs/user/active-sanction.ts
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
-import { ActorRef, ActorRefT } from './actor-ref.js';
+import { ActorRef, ActorRefT } from '../user/actor-ref.js';
export class ActiveSanction implements flatbuffers.IUnpackableObject {
diff --git a/ui/frontend/src/proto/galaxy/fbs/user/entitlement-snapshot.ts b/ui/frontend/src/proto/galaxy/fbs/user/entitlement-snapshot.ts
index edb63bd..9d730cc 100644
--- a/ui/frontend/src/proto/galaxy/fbs/user/entitlement-snapshot.ts
+++ b/ui/frontend/src/proto/galaxy/fbs/user/entitlement-snapshot.ts
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
-import { ActorRef, ActorRefT } from './actor-ref.js';
+import { ActorRef, ActorRefT } from '../user/actor-ref.js';
export class EntitlementSnapshot implements flatbuffers.IUnpackableObject {
diff --git a/ui/frontend/src/proto/galaxy/fbs/user/error-response.ts b/ui/frontend/src/proto/galaxy/fbs/user/error-response.ts
index 5e99abb..a41aa3c 100644
--- a/ui/frontend/src/proto/galaxy/fbs/user/error-response.ts
+++ b/ui/frontend/src/proto/galaxy/fbs/user/error-response.ts
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
-import { ErrorBody, ErrorBodyT } from './error-body.js';
+import { ErrorBody, ErrorBodyT } from '../user/error-body.js';
export class ErrorResponse implements flatbuffers.IUnpackableObject {
diff --git a/ui/frontend/src/proto/galaxy/fbs/user/list-my-sessions-response.ts b/ui/frontend/src/proto/galaxy/fbs/user/list-my-sessions-response.ts
index b274719..c6570a4 100644
--- a/ui/frontend/src/proto/galaxy/fbs/user/list-my-sessions-response.ts
+++ b/ui/frontend/src/proto/galaxy/fbs/user/list-my-sessions-response.ts
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
-import { DeviceSessionView, DeviceSessionViewT } from './device-session-view.js';
+import { DeviceSessionView, DeviceSessionViewT } from '../user/device-session-view.js';
export class ListMySessionsResponse implements flatbuffers.IUnpackableObject {
diff --git a/ui/frontend/src/proto/galaxy/fbs/user/revoke-all-my-sessions-response.ts b/ui/frontend/src/proto/galaxy/fbs/user/revoke-all-my-sessions-response.ts
index ce5bf84..19eed05 100644
--- a/ui/frontend/src/proto/galaxy/fbs/user/revoke-all-my-sessions-response.ts
+++ b/ui/frontend/src/proto/galaxy/fbs/user/revoke-all-my-sessions-response.ts
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
-import { DeviceSessionRevocationSummaryView, DeviceSessionRevocationSummaryViewT } from './device-session-revocation-summary-view.js';
+import { DeviceSessionRevocationSummaryView, DeviceSessionRevocationSummaryViewT } from '../user/device-session-revocation-summary-view.js';
export class RevokeAllMySessionsResponse implements flatbuffers.IUnpackableObject {
diff --git a/ui/frontend/src/proto/galaxy/fbs/user/revoke-my-session-response.ts b/ui/frontend/src/proto/galaxy/fbs/user/revoke-my-session-response.ts
index 25dc653..b41adb2 100644
--- a/ui/frontend/src/proto/galaxy/fbs/user/revoke-my-session-response.ts
+++ b/ui/frontend/src/proto/galaxy/fbs/user/revoke-my-session-response.ts
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers';
-import { DeviceSessionView, DeviceSessionViewT } from './device-session-view.js';
+import { DeviceSessionView, DeviceSessionViewT } from '../user/device-session-view.js';
export class RevokeMySessionResponse implements flatbuffers.IUnpackableObject {
diff --git a/ui/frontend/src/routes/games/[id]/battle/[[battleId]]/+page.svelte b/ui/frontend/src/routes/games/[id]/battle/[[battleId]]/+page.svelte
index d16714b..a5a5606 100644
--- a/ui/frontend/src/routes/games/[id]/battle/[[battleId]]/+page.svelte
+++ b/ui/frontend/src/routes/games/[id]/battle/[[battleId]]/+page.svelte
@@ -1,6 +1,16 @@
-
+
diff --git a/ui/frontend/tests/battle-markers.test.ts b/ui/frontend/tests/battle-markers.test.ts
new file mode 100644
index 0000000..811cee7
--- /dev/null
+++ b/ui/frontend/tests/battle-markers.test.ts
@@ -0,0 +1,190 @@
+// Phase 27 unit tests for battle and bombing map markers.
+
+import { describe, expect, it } from "vitest";
+
+import type { GameReport } from "../src/api/game-state";
+import {
+ battleMarkerStrokeWidth,
+ BATTLE_MARKER_COLOR,
+ BOMBING_MARKER_COLOR_DAMAGED,
+ BOMBING_MARKER_COLOR_WIPED,
+ buildBattleAndBombingMarkers,
+} from "../src/map/battle-markers";
+import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
+
+describe("battleMarkerStrokeWidth", () => {
+ it("clamps to 1 px at one shot", () => {
+ expect(battleMarkerStrokeWidth(1)).toBe(1);
+ });
+
+ it("clamps to 5 px at 100 shots", () => {
+ expect(battleMarkerStrokeWidth(100)).toBe(5);
+ });
+
+ it("caps above 100 shots at 5 px", () => {
+ expect(battleMarkerStrokeWidth(250)).toBe(5);
+ });
+
+ it("interpolates linearly between 1 and 100 shots", () => {
+ // ~halfway: 50 shots → 1 + 49 * 4 / 99 ≈ 2.98
+ expect(battleMarkerStrokeWidth(50)).toBeCloseTo(2.98, 2);
+ });
+});
+
+function makeReport(overrides: Partial): GameReport {
+ return {
+ turn: 1,
+ mapWidth: 200,
+ mapHeight: 200,
+ planetCount: 0,
+ race: "Earthlings",
+ planets: [],
+ localShipClass: [],
+ routes: [],
+ localPlayerDrive: 0,
+ localPlayerWeapons: 0,
+ localPlayerShields: 0,
+ localPlayerCargo: 0,
+ ...EMPTY_SHIP_GROUPS,
+ ...overrides,
+ };
+}
+
+describe("buildBattleAndBombingMarkers", () => {
+ it("returns no primitives when both battles and bombings are empty", () => {
+ const report = makeReport({});
+ const out = buildBattleAndBombingMarkers(report);
+ expect(out.primitives).toEqual([]);
+ expect(out.lookup.size).toBe(0);
+ });
+
+ it("emits two yellow lines through opposite corners of the planet square per battle", () => {
+ const report = makeReport({
+ planets: [
+ {
+ number: 4,
+ name: "Test",
+ kind: "local",
+ x: 10,
+ y: 20,
+ size: 50,
+ resources: 0,
+ industryStockpile: 0,
+ materialsStockpile: 0,
+ population: 0,
+ colonists: 0,
+ industry: 0,
+ freeIndustry: 0,
+ production: "MAT",
+ owner: null,
+ },
+ ],
+ battles: [
+ { id: "11111111-1111-1111-1111-111111111111", planet: 4, shots: 100 },
+ ],
+ });
+
+ const out = buildBattleAndBombingMarkers(report);
+ const lines = out.primitives.filter((p) => p.kind === "line");
+ expect(lines).toHaveLength(2);
+ // Same yellow colour, 5 px wide for a 100-shot battle.
+ for (const l of lines) {
+ expect(l.style.strokeColor).toBe(BATTLE_MARKER_COLOR);
+ expect(l.style.strokeWidthPx).toBe(5);
+ }
+ // First line: top-left → bottom-right corner of the planet square.
+ const [a, b] = lines as Array;
+ expect(a.x1).toBeLessThan(a.x2);
+ expect(a.y1).toBeLessThan(a.y2);
+ // Second line: top-right → bottom-left.
+ expect(b.x1).toBeLessThan(b.x2);
+ expect(b.y1).toBeGreaterThan(b.y2);
+ });
+
+ it("skips battles whose planet is not in the planet list", () => {
+ const report = makeReport({
+ battles: [
+ { id: "11111111-1111-1111-1111-111111111111", planet: 99, shots: 4 },
+ ],
+ });
+ const out = buildBattleAndBombingMarkers(report);
+ expect(out.primitives).toHaveLength(0);
+ });
+
+ it("emits one yellow ring per damaged bombing and red per wiped", () => {
+ const report = makeReport({
+ planets: [
+ {
+ number: 1,
+ name: "A",
+ kind: "local",
+ x: 1,
+ y: 2,
+ size: 50,
+ resources: 0,
+ industryStockpile: 0,
+ materialsStockpile: 0,
+ population: 0,
+ colonists: 0,
+ industry: 0,
+ freeIndustry: 0,
+ production: "MAT",
+ owner: null,
+ },
+ {
+ number: 2,
+ name: "B",
+ kind: "local",
+ x: 5,
+ y: 6,
+ size: 50,
+ resources: 0,
+ industryStockpile: 0,
+ materialsStockpile: 0,
+ population: 0,
+ colonists: 0,
+ industry: 0,
+ freeIndustry: 0,
+ production: "MAT",
+ owner: null,
+ },
+ ],
+ bombings: [
+ {
+ planetNumber: 1,
+ planet: "A",
+ owner: "X",
+ attacker: "Y",
+ production: "MAT",
+ industry: 0,
+ population: 0,
+ colonists: 0,
+ industryStockpile: 0,
+ materialsStockpile: 0,
+ attackPower: 1,
+ wiped: false,
+ },
+ {
+ planetNumber: 2,
+ planet: "B",
+ owner: "X",
+ attacker: "Y",
+ production: "MAT",
+ industry: 0,
+ population: 0,
+ colonists: 0,
+ industryStockpile: 0,
+ materialsStockpile: 0,
+ attackPower: 1,
+ wiped: true,
+ },
+ ],
+ });
+
+ const out = buildBattleAndBombingMarkers(report);
+ const rings = out.primitives.filter((p) => p.kind === "circle");
+ expect(rings).toHaveLength(2);
+ expect(rings[0].style.strokeColor).toBe(BOMBING_MARKER_COLOR_DAMAGED);
+ expect(rings[1].style.strokeColor).toBe(BOMBING_MARKER_COLOR_WIPED);
+ });
+});
diff --git a/ui/frontend/tests/battle-player.test.ts b/ui/frontend/tests/battle-player.test.ts
new file mode 100644
index 0000000..ab0ddd6
--- /dev/null
+++ b/ui/frontend/tests/battle-player.test.ts
@@ -0,0 +1,146 @@
+// Unit tests for the BattleViewer's pure helpers: radial layout and
+// the timeline frame builder. Both are pure functions and don't
+// require DOM mounting, so they exercise the playback semantics in
+// isolation.
+
+import { describe, expect, it } from "vitest";
+
+import type { BattleReport } from "../src/api/battle-fetch";
+import { layoutRaces } from "../src/lib/battle-player/radial-layout";
+import {
+ buildFrames,
+ buildGroupRaceMap,
+ normaliseGroups,
+} from "../src/lib/battle-player/timeline";
+
+describe("layoutRaces", () => {
+ const center = { x: 100, y: 100 };
+ const radius = 50;
+
+ it("returns no anchors for an empty input", () => {
+ expect(layoutRaces([], { center, radius })).toEqual([]);
+ });
+
+ it("places one race at the 12 o'clock position", () => {
+ const result = layoutRaces([0], { center, radius });
+ expect(result).toHaveLength(1);
+ expect(result[0].raceId).toBe(0);
+ expect(result[0].x).toBeCloseTo(center.x, 5);
+ expect(result[0].y).toBeCloseTo(center.y - radius, 5);
+ });
+
+ it("places two races at opposite poles (180° apart)", () => {
+ const result = layoutRaces([0, 1], { center, radius });
+ expect(result).toHaveLength(2);
+ expect(result[0].x).toBeCloseTo(center.x, 5);
+ expect(result[0].y).toBeCloseTo(center.y - radius, 5);
+ expect(result[1].x).toBeCloseTo(center.x, 5);
+ expect(result[1].y).toBeCloseTo(center.y + radius, 5);
+ });
+
+ it("places three races at 120° intervals", () => {
+ const result = layoutRaces([0, 1, 2], { center, radius });
+ expect(result).toHaveLength(3);
+ expect(result[0].angle).toBeCloseTo(-Math.PI / 2, 5);
+ expect(result[1].angle - result[0].angle).toBeCloseTo((2 * Math.PI) / 3, 5);
+ expect(result[2].angle - result[1].angle).toBeCloseTo((2 * Math.PI) / 3, 5);
+ });
+
+ it("preserves the input race order", () => {
+ const result = layoutRaces([7, 2, 5], { center, radius });
+ expect(result.map((a) => a.raceId)).toEqual([7, 2, 5]);
+ });
+});
+
+const TWO_RACE_BATTLE: BattleReport = {
+ id: "battle-1",
+ planet: 4,
+ planetName: "Test",
+ races: { "0": "race-A-uuid", "1": "race-B-uuid" },
+ ships: {
+ "10": {
+ race: "Alpha",
+ className: "Drone",
+ tech: {},
+ num: 3,
+ numLeft: 1,
+ loadType: "EMP",
+ loadQuantity: 0,
+ inBattle: true,
+ },
+ "20": {
+ race: "Beta",
+ className: "Spy",
+ tech: {},
+ num: 2,
+ numLeft: 0,
+ loadType: "EMP",
+ loadQuantity: 0,
+ inBattle: true,
+ },
+ "99": {
+ race: "Gamma",
+ className: "Observer",
+ tech: {},
+ num: 4,
+ numLeft: 4,
+ loadType: "EMP",
+ loadQuantity: 0,
+ inBattle: false,
+ },
+ },
+ protocol: [
+ { a: 0, sa: 10, d: 1, sd: 20, x: false },
+ { a: 1, sa: 20, d: 0, sd: 10, x: true },
+ { a: 0, sa: 10, d: 1, sd: 20, x: true },
+ { a: 0, sa: 10, d: 1, sd: 20, x: true },
+ ],
+};
+
+describe("buildGroupRaceMap", () => {
+ it("derives group → race from protocol entries", () => {
+ const map = buildGroupRaceMap(TWO_RACE_BATTLE.protocol);
+ expect(map.get(10)).toBe(0);
+ expect(map.get(20)).toBe(1);
+ });
+});
+
+describe("normaliseGroups", () => {
+ it("returns only in-battle groups with race index attached", () => {
+ const groups = normaliseGroups(TWO_RACE_BATTLE);
+ expect(groups.map((g) => g.key).sort((a, b) => a - b)).toEqual([10, 20]);
+ expect(groups.every((g) => g.group.inBattle)).toBe(true);
+ });
+});
+
+describe("buildFrames", () => {
+ it("produces protocol.length + 1 frames", () => {
+ const frames = buildFrames(TWO_RACE_BATTLE);
+ expect(frames).toHaveLength(TWO_RACE_BATTLE.protocol.length + 1);
+ });
+
+ it("frame 0 reports initial ship counts and all active races", () => {
+ const [first] = buildFrames(TWO_RACE_BATTLE);
+ expect(first.shotIndex).toBe(0);
+ expect(first.lastAction).toBeNull();
+ expect(first.remaining.get(10)).toBe(3);
+ expect(first.remaining.get(20)).toBe(2);
+ expect(first.activeRaceIds).toEqual([0, 1]);
+ });
+
+ it("decrements destroyed defenders only on x === true", () => {
+ const frames = buildFrames(TWO_RACE_BATTLE);
+ // Action 1: x=false → no decrement on defender 20.
+ expect(frames[1].remaining.get(20)).toBe(2);
+ // Action 2: x=true → attacker is race 1 group 20, defender
+ // is race 0 group 10 → group 10 drops 3→2.
+ expect(frames[2].remaining.get(10)).toBe(2);
+ });
+
+ it("drops a race from activeRaceIds once its last in-battle group reaches zero", () => {
+ const frames = buildFrames(TWO_RACE_BATTLE);
+ // After the 4-th action both Beta ships have been destroyed.
+ expect(frames[4].remaining.get(20)).toBe(0);
+ expect(frames[4].activeRaceIds).toEqual([0]);
+ });
+});
diff --git a/ui/frontend/tests/e2e/battle-viewer.spec.ts b/ui/frontend/tests/e2e/battle-viewer.spec.ts
new file mode 100644
index 0000000..9d465a6
--- /dev/null
+++ b/ui/frontend/tests/e2e/battle-viewer.spec.ts
@@ -0,0 +1,252 @@
+// Phase 27 — Playwright coverage for the Battle Viewer.
+//
+// Mocks both the Connect-RPC `user.games.report` (so the report
+// renders battles + bombings) and the REST forwarder
+// `/api/v1/user/games/{game_id}/battles/{turn}/{battle_id}` (so the
+// viewer page loads its `BattleReport` without an engine).
+// Drives three flows:
+// 1. Reports view → click battle UUID → viewer renders.
+// 2. Playback controls: play / step back.
+// 3. Reports view → click bombing marker proxy → row scrolls
+// (here approximated by clicking the link in Reports — the
+// map e2e flow is exercised separately by `map-roundtrip`).
+
+import { fromJson, type JsonValue } from "@bufbuild/protobuf";
+import { ByteBuffer } from "flatbuffers";
+import { expect, test, type Page } from "@playwright/test";
+
+import { ExecuteCommandRequestSchema } from "../../src/proto/galaxy/gateway/v1/edge_gateway_pb";
+import { UUID } from "../../src/proto/galaxy/fbs/common";
+import { GameReportRequest } from "../../src/proto/galaxy/fbs/report";
+
+import {
+ buildOrderResponsePayload,
+ buildOrderGetResponsePayload,
+} from "./fixtures/order-fbs";
+import { buildMyGamesListPayload, type GameFixture } from "./fixtures/lobby-fbs";
+import { buildReportPayload } from "./fixtures/report-fbs";
+import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response";
+
+const GAME_ID = "00000000-0000-0000-0000-000000000010";
+const BATTLE_ID = "11111111-1111-1111-1111-111111111111";
+const SESSION_ID = "device-session-battle";
+const RACE_A = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
+const RACE_B = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
+
+const SAMPLE_BATTLE = {
+ id: BATTLE_ID,
+ planet: 1,
+ planetName: "Earth",
+ races: { "0": RACE_A, "1": RACE_B },
+ ships: {
+ "10": {
+ race: "Earthlings",
+ className: "Cruiser",
+ tech: { WEAPONS: 1 },
+ num: 3,
+ numLeft: 2,
+ loadType: "EMP",
+ loadQuantity: 0,
+ inBattle: true,
+ },
+ "20": {
+ race: "Bajori",
+ className: "Hawk",
+ tech: { SHIELDS: 1 },
+ num: 2,
+ numLeft: 0,
+ loadType: "EMP",
+ loadQuantity: 0,
+ inBattle: true,
+ },
+ },
+ protocol: [
+ { a: 0, sa: 10, d: 1, sd: 20, x: false },
+ { a: 0, sa: 10, d: 1, sd: 20, x: true },
+ { a: 1, sa: 20, d: 0, sd: 10, x: true },
+ { a: 0, sa: 10, d: 1, sd: 20, x: true },
+ ],
+};
+
+async function mockGatewayAndBattle(page: Page): Promise {
+ const game: GameFixture = {
+ gameId: GAME_ID,
+ gameName: "Phase 27 Game",
+ gameType: "private",
+ status: "running",
+ ownerUserId: "user-1",
+ minPlayers: 2,
+ maxPlayers: 8,
+ enrollmentEndsAtMs: BigInt(Date.now() + 86_400_000),
+ createdAtMs: BigInt(Date.now() - 86_400_000),
+ updatedAtMs: BigInt(Date.now()),
+ currentTurn: 1,
+ };
+
+ await page.route(
+ "**/galaxy.gateway.v1.EdgeGateway/ExecuteCommand",
+ async (route) => {
+ const reqText = route.request().postData();
+ if (reqText === null) {
+ await route.fulfill({ status: 400 });
+ return;
+ }
+ const req = fromJson(
+ ExecuteCommandRequestSchema,
+ JSON.parse(reqText) as JsonValue,
+ );
+
+ let resultCode = "ok";
+ let payload: Uint8Array;
+ switch (req.messageType) {
+ case "lobby.my.games.list":
+ payload = buildMyGamesListPayload([game]);
+ break;
+ case "user.games.report": {
+ GameReportRequest.getRootAsGameReportRequest(
+ new ByteBuffer(req.payloadBytes),
+ ).gameId(new UUID());
+ payload = buildReportPayload({
+ turn: 1,
+ mapWidth: 4000,
+ mapHeight: 4000,
+ race: "Earthlings",
+ localPlanets: [
+ {
+ number: 1,
+ name: "Earth",
+ x: 2000,
+ y: 2000,
+ size: 1000,
+ resources: 5,
+ population: 4000,
+ industry: 3000,
+ capital: 0,
+ material: 0,
+ colonists: 100,
+ freeIndustry: 800,
+ production: "Cruiser",
+ },
+ ],
+ battles: [{ id: BATTLE_ID, planet: 1, shots: 4 }],
+ });
+ break;
+ }
+ case "user.games.order":
+ payload = buildOrderResponsePayload(GAME_ID, [], Date.now());
+ break;
+ case "user.games.order.get":
+ payload = buildOrderGetResponsePayload(GAME_ID, [], Date.now(), false);
+ break;
+ default:
+ resultCode = "internal_error";
+ payload = new Uint8Array();
+ }
+
+ const body = await forgeExecuteCommandResponseJson({
+ requestId: req.requestId,
+ timestampMs: BigInt(Date.now()),
+ resultCode,
+ payloadBytes: payload,
+ });
+ await route.fulfill({
+ status: 200,
+ headers: { "content-type": "application/json" },
+ body,
+ });
+ },
+ );
+
+ await page.route(
+ `**/api/v1/user/games/${GAME_ID}/battles/1/${BATTLE_ID}`,
+ async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(SAMPLE_BATTLE),
+ });
+ },
+ );
+
+ await page.route(
+ `**/api/v1/user/games/${GAME_ID}/battles/1/missing-uuid`,
+ async (route) => {
+ await route.fulfill({ status: 404 });
+ },
+ );
+}
+
+async function bootSession(page: Page): Promise {
+ await page.goto("/__debug/store");
+ await expect(page.getByTestId("debug-store-ready")).toBeVisible();
+ await page.waitForFunction(() => window.__galaxyDebug?.ready === true);
+ await page.evaluate(() => window.__galaxyDebug!.clearSession());
+ await page.evaluate(
+ (id) => window.__galaxyDebug!.setDeviceSessionId(id),
+ SESSION_ID,
+ );
+}
+
+test.describe("Phase 27 battle viewer", () => {
+ test("Reports UUID link opens the battle viewer", async ({ page }, testInfo) => {
+ test.skip(
+ testInfo.project.name.startsWith("chromium-mobile"),
+ "desktop variant covers the link flow",
+ );
+
+ await mockGatewayAndBattle(page);
+ await bootSession(page);
+ await page.goto(`/games/${GAME_ID}/report`);
+
+ await expect(page.getByTestId("active-view-report")).toBeVisible();
+ const row = page.getByTestId("report-battle-row").first();
+ await expect(row).toBeVisible();
+ await row.click();
+
+ await expect(page).toHaveURL(
+ new RegExp(`/games/${GAME_ID}/battle/${BATTLE_ID}\\?turn=1`),
+ );
+ await expect(page.getByTestId("battle-viewer")).toBeVisible();
+ await expect(page.getByTestId("battle-scene")).toBeVisible();
+ await expect(page.getByTestId("battle-frame-index")).toContainText("0 / 4");
+ });
+
+ test("playback play + step back updates the frame counter", async ({
+ page,
+ }, testInfo) => {
+ test.skip(
+ testInfo.project.name.startsWith("chromium-mobile"),
+ "desktop variant covers playback controls",
+ );
+
+ await mockGatewayAndBattle(page);
+ await bootSession(page);
+ await page.goto(`/games/${GAME_ID}/battle/${BATTLE_ID}?turn=1`);
+
+ await expect(page.getByTestId("battle-viewer")).toBeVisible();
+ await expect(page.getByTestId("battle-frame-index")).toContainText("0 / 4");
+
+ // Step forward once → 1 / 4.
+ await page.getByTestId("battle-control-step-forward").click();
+ await expect(page.getByTestId("battle-frame-index")).toContainText("1 / 4");
+
+ // Step back to 0 / 4.
+ await page.getByTestId("battle-control-step-back").click();
+ await expect(page.getByTestId("battle-frame-index")).toContainText("0 / 4");
+ });
+
+ test("missing battle id surfaces the not-found state", async ({
+ page,
+ }, testInfo) => {
+ test.skip(
+ testInfo.project.name.startsWith("chromium-mobile"),
+ "desktop variant covers the negative path",
+ );
+
+ await mockGatewayAndBattle(page);
+ await bootSession(page);
+ await page.goto(`/games/${GAME_ID}/battle/missing-uuid?turn=1`);
+
+ await expect(page.getByTestId("battle-not-found")).toBeVisible();
+ });
+});
diff --git a/ui/frontend/tests/e2e/fixtures/report-fbs.ts b/ui/frontend/tests/e2e/fixtures/report-fbs.ts
index 45ffca4..6c87413 100644
--- a/ui/frontend/tests/e2e/fixtures/report-fbs.ts
+++ b/ui/frontend/tests/e2e/fixtures/report-fbs.ts
@@ -19,6 +19,7 @@ import { Builder } from "flatbuffers";
import { UUID } from "../../../src/proto/galaxy/fbs/common";
import {
+ BattleSummary,
Bombing,
LocalPlanet,
OtherPlanet,
@@ -108,6 +109,12 @@ export interface OtherShipClassFixture extends ShipClassFixture {
mass?: number;
}
+export interface BattleSummaryFixture {
+ id: string;
+ planet: number;
+ shots: number;
+}
+
export interface BombingFixture {
planetNumber: number;
planet: string;
@@ -149,7 +156,7 @@ export interface ReportFixture {
myVoteFor?: string;
otherScience?: OtherScienceFixture[];
otherShipClass?: OtherShipClassFixture[];
- battles?: string[];
+ battles?: BattleSummaryFixture[];
bombings?: BombingFixture[];
shipProductions?: ShipProductionFixture[];
}
@@ -397,17 +404,22 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
shipProductionOffsets.length === 0
? null
: Report.createShipProductionVector(builder, shipProductionOffsets);
- // `battle` is a struct vector (16 bytes per UUID, alignment 8), so
- // it uses the start/inline-write/end pattern rather than a typical
- // offset-list helper. Iterating in reverse matches the FlatBuffers
- // convention that the vector is built end-to-start.
+ // Phase 27 — `battle` carries `BattleSummary` tables, each with
+ // an inline `id:UUID` struct plus `planet` and `shots` slots.
const battleVec = (() => {
- const ids = fixture.battles ?? [];
- if (ids.length === 0) return null;
- Report.startBattleVector(builder, ids.length);
- for (let i = ids.length - 1; i >= 0; i--) {
- const [hi, lo] = uuidToHiLo(ids[i]!);
- UUID.createUUID(builder, hi, lo);
+ const summaries = fixture.battles ?? [];
+ if (summaries.length === 0) return null;
+ const offsets = summaries.map((s) => {
+ const [hi, lo] = uuidToHiLo(s.id);
+ BattleSummary.startBattleSummary(builder);
+ BattleSummary.addId(builder, UUID.createUUID(builder, hi, lo));
+ BattleSummary.addPlanet(builder, BigInt(s.planet));
+ BattleSummary.addShots(builder, BigInt(s.shots));
+ return BattleSummary.endBattleSummary(builder);
+ });
+ Report.startBattleVector(builder, offsets.length);
+ for (let i = offsets.length - 1; i >= 0; i--) {
+ builder.addOffset(offsets[i]);
}
return builder.endVector();
})();
diff --git a/ui/frontend/tests/e2e/report-sections.spec.ts b/ui/frontend/tests/e2e/report-sections.spec.ts
index ba5fe17..f0c6aea 100644
--- a/ui/frontend/tests/e2e/report-sections.spec.ts
+++ b/ui/frontend/tests/e2e/report-sections.spec.ts
@@ -151,7 +151,7 @@ async function mockGateway(page: Page): Promise {
{ race: "Andori", name: "Spear", drive: 8, armament: 4, weapons: 6, shields: 3, cargo: 1, mass: 90 },
{ race: "Bajori", name: "Hawk", drive: 12, armament: 1, weapons: 4, shields: 2, cargo: 0, mass: 75 },
],
- battles: [BATTLE_ID],
+ battles: [{ id: BATTLE_ID, planet: 1, shots: 12 }],
bombings: [
{ planetNumber: 1, planet: "Earth", owner: "Earthlings", attacker: "Bajori", production: "Cruiser", industry: 500, population: 200, colonists: 12, capital: 30, material: 5, attackPower: 250, wiped: false },
{ planetNumber: 99, planet: "DW-99", owner: "Earthlings", attacker: "Bajori", production: "Dron", industry: 0, population: 0, colonists: 0, capital: 0, material: 0, attackPower: 800, wiped: true },
diff --git a/ui/frontend/tests/game-shell-stubs.test.ts b/ui/frontend/tests/game-shell-stubs.test.ts
index 6d80357..0d38f6a 100644
--- a/ui/frontend/tests/game-shell-stubs.test.ts
+++ b/ui/frontend/tests/game-shell-stubs.test.ts
@@ -76,18 +76,30 @@ describe("active-view stubs", () => {
);
});
- test("battle stub stamps the battleId on the host element", () => {
- const ui = render(BattleView, { props: { battleId: "b-42" } });
+ test("battle view stamps the battleId and renders the back-to-map link", () => {
+ // Phase 27 replaces the Phase 10 stub with the Battle Viewer
+ // wrapper. The wrapper mounts the loading copy until the
+ // fetcher resolves (component test runs in jsdom without a
+ // network); the back buttons and the data-battle-id stamp are
+ // rendered unconditionally so the orchestrator scaffold is the
+ // stable hook the active-view shell relies on.
+ const ui = render(BattleView, {
+ props: { gameId: "synthetic-test", turn: 0, battleId: "b-42" },
+ });
const node = ui.getByTestId("active-view-battle");
expect(node).toHaveAttribute("data-battle-id", "b-42");
- expect(node).toHaveTextContent("battle log");
+ expect(ui.getByTestId("battle-back-to-map")).toBeInTheDocument();
+ expect(ui.getByTestId("battle-back-to-report")).toBeInTheDocument();
});
- test("battle stub accepts an empty battleId for the list URL", () => {
- const ui = render(BattleView, { props: { battleId: "" } });
+ test("battle view surfaces the not-found state for an empty battleId", () => {
+ const ui = render(BattleView, {
+ props: { gameId: "synthetic-test", turn: 0, battleId: "" },
+ });
expect(ui.getByTestId("active-view-battle")).toHaveAttribute(
"data-battle-id",
"",
);
+ expect(ui.getByTestId("battle-not-found")).toBeInTheDocument();
});
});
diff --git a/ui/frontend/tests/helpers/empty-ship-groups.ts b/ui/frontend/tests/helpers/empty-ship-groups.ts
index 0b318ab..9303880 100644
--- a/ui/frontend/tests/helpers/empty-ship-groups.ts
+++ b/ui/frontend/tests/helpers/empty-ship-groups.ts
@@ -8,6 +8,7 @@
// every spec to enumerate the full GameReport surface.
import type {
+ ReportBattle,
ReportBombing,
ReportIncomingShipGroup,
ReportLocalFleet,
@@ -36,6 +37,7 @@ export const EMPTY_SHIP_GROUPS: {
players: ReportPlayer[];
otherScience: ReportOtherScience[];
otherShipClass: ReportOtherShipClass[];
+ battles: ReportBattle[];
battleIds: string[];
bombings: ReportBombing[];
shipProductions: ReportShipProduction[];
@@ -53,6 +55,7 @@ export const EMPTY_SHIP_GROUPS: {
players: [],
otherScience: [],
otherShipClass: [],
+ battles: [],
battleIds: [],
bombings: [],
shipProductions: [],
diff --git a/ui/frontend/tests/pending-send-routes.test.ts b/ui/frontend/tests/pending-send-routes.test.ts
index b0844f5..3bf7da9 100644
--- a/ui/frontend/tests/pending-send-routes.test.ts
+++ b/ui/frontend/tests/pending-send-routes.test.ts
@@ -75,6 +75,7 @@ function makeReport(
players: [],
otherScience: [],
otherShipClass: [],
+ battles: [],
battleIds: [],
bombings: [],
shipProductions: [],