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 ``); позиция скролла сохраняется при переключении активного представления через SvelteKit `Snapshot` API. -### 6.5 Побочные эффекты +Секция бомбардировок — это плоская read-only-таблица: одна строка на +событие, колонки `attacker`, `attack_power`, признак `wiped` и +ресурсный снимок после удара. Секция сражений — список ссылок в +Battle Viewer (см. [§6.5](#65-battle-viewer)). + +### 6.5 Battle viewer + +Battle Viewer — отдельное представление, заменяющее карту и +показывающее одну битву. Входы: + +- Строка в секции «сражения» в Reports (ссылка с пиннингом + текущего хода через `?turn=`). +- Battle-marker на карте (жёлтый крест через противоположные углы + квадрата, описанного вокруг круга планеты; толщина линий растёт + с длиной протокола). + +Сам Viewer — логически изолированный компонент, потребляющий +`BattleReport` в форме `pkg/model/report/battle.go`. Страница-обёртка +(`ui/frontend/src/lib/active-view/battle.svelte`) забирает отчёт +через backend-маршрут +`GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`, +который проксирует ответ engine-эндпоинта +`GET /api/v1/battle/:turn/:uuid`. + +Визуальная модель — радиальная: планета в центре, расы по внешней +окружности на равных угловых интервалах, внутри расы — горизонтальный +кластер маленьких кружков по классам кораблей с подписями +`:` под каждым. Наблюдатели (`inBattle: false`) +не рисуются. Выбывшие расы убираются из сцены, оставшиеся +перераспределяются на следующем кадре. + +Каждый кадр — одна запись протокола; выстрел рисуется тонкой линией +от атакующего к защитнику, красной при `destroyed`, зелёной иначе. +Непрерывное воспроизведение: 1x / 2x / 4x (400 / 200 / 100 мс на +кадр), плюс play/pause, шаг вперёд/назад, rewind. Текстовый протокол +доступности под сценой дублирует те же события построчно. + +Бомбардировки и сражения умышленно не смешиваются: бомбардировки +остаются статической таблицей в Reports; bombing-marker на карте — +тонкая окружность вокруг планеты (жёлтая при damaged, красная при +wiped), клик скроллит соответствующую строку в Reports. + +Текущая wire-форма отчёта несёт `battle: [{ id, planet, shots }]` +на каждую битву, чтобы map-маркеры могли расположиться без +дополнительного запроса полного `BattleReport`. + +### 6.6 Побочные эффекты Успешная генерация хода публикует runtime-snapshot в lobby-модуль, который обновляет денормализованное вью (текущий ход, runtime- @@ -740,7 +786,7 @@ Directory-промоушен ([Раздел 5](#5-реестр-названий- каталоге, расширить `CHECK`-констрейнт миграции и вызвать `notification.Submit` из подходящего доменного модуля). -### 6.6 Перекрёстные ссылки +### 6.7 Перекрёстные ссылки - Backend ↔ engine wire-контракт (`pkg/model/{order,report,rest}`): [ARCHITECTURE.md §9](ARCHITECTURE.md#9-backend--game-engine-communication). diff --git a/game/internal/controller/report.go b/game/internal/controller/report.go index 5299973..4458f24 100644 --- a/game/internal/controller/report.go +++ b/game/internal/controller/report.go @@ -37,7 +37,7 @@ func (c *Cache) InitReport(t uint) *mr.Report { OtherScience: make([]mr.OtherScience, 0, 10), LocalShipClass: make([]mr.ShipClass, 0, 20), OtherShipClass: make([]mr.OthersShipClass, 0, 50), - Battle: make([]uuid.UUID, 0, 10), + Battle: make([]mr.BattleSummary, 0, 10), Bombing: make([]*mr.Bombing, 0, 10), IncomingGroup: make([]mr.IncomingGroup, 0, 10), OnPlanetGroupCache: make(map[uint][]int), @@ -342,7 +342,11 @@ func (c *Cache) ReportBattle(ri int, rep *mr.Report, br []*mr.BattleReport) { } sliceIndexValidate(&rep.Battle, i) - rep.Battle[i] = br[bi].ID + rep.Battle[i] = mr.BattleSummary{ + ID: br[bi].ID, + Planet: br[bi].Planet, + Shots: uint(len(br[bi].Protocol)), + } i++ } } diff --git a/game/openapi.yaml b/game/openapi.yaml index 1c126ce..1ba3cb1 100644 --- a/game/openapi.yaml +++ b/game/openapi.yaml @@ -584,10 +584,9 @@ components: $ref: "#/components/schemas/OtherShipClass" battle: type: array - description: UUIDs of battle reports relevant to this turn. + description: Battle summaries relevant to this turn. items: - type: string - format: uuid + $ref: "#/components/schemas/BattleSummary" bombing: type: array description: Bombing events that occurred during this turn. @@ -831,6 +830,30 @@ components: wiped: type: boolean description: True when all population was eliminated by the bombing. + BattleSummary: + type: object + description: | + Identifies one battle relevant to the report recipient. Used by + clients to render a battle marker on the map without fetching + the full BattleReport. `planet` locates the marker; `shots` + scales the marker stroke with the battle length. + required: + - id + - planet + - shots + properties: + id: + type: string + format: uuid + description: Battle identifier; fetch the full report via `/api/v1/battle/{turn}/{uuid}`. + planet: + type: integer + minimum: 0 + description: Planet number the battle took place on. + shots: + type: integer + minimum: 0 + description: Number of shots exchanged during the battle. BattleReport: type: object description: | diff --git a/game/openapi_contract_test.go b/game/openapi_contract_test.go index 1c0b210..9f1cfac 100644 --- a/game/openapi_contract_test.go +++ b/game/openapi_contract_test.go @@ -327,6 +327,22 @@ func TestGameOpenAPISpecFreezesBattleReport(t *testing.T) { assertSchemaRef(t, shipsSchema.Value.AdditionalProperties.Schema, "#/components/schemas/BattleReportGroup", "BattleReport.ships additionalProperties schema") } +func TestGameOpenAPISpecFreezesBattleSummary(t *testing.T) { + t.Parallel() + + doc := loadOpenAPISpec(t) + + summary := componentSchemaRef(t, doc, "BattleSummary") + assertRequiredFields(t, summary, "id", "planet", "shots") + + report := componentSchemaRef(t, doc, "Report") + battle := report.Value.Properties["battle"] + require.NotNil(t, battle, "Report.battle schema must exist") + require.True(t, battle.Value.Type.Is("array"), "Report.battle must be array") + require.NotNil(t, battle.Value.Items, "Report.battle items must be defined") + assertSchemaRef(t, battle.Value.Items, "#/components/schemas/BattleSummary", "Report.battle items schema") +} + func TestGameOpenAPISpecHealthzStatusEnum(t *testing.T) { t.Parallel() diff --git a/pkg/model/report/battle.go b/pkg/model/report/battle.go index be1f9cb..26d2a30 100644 --- a/pkg/model/report/battle.go +++ b/pkg/model/report/battle.go @@ -6,6 +6,16 @@ import ( "github.com/google/uuid" ) +// BattleSummary identifies one battle relevant to the report recipient +// and carries the data needed to render a battle marker on the map +// without fetching the full BattleReport. Planet locates the marker; +// Shots scales the marker stroke with the battle length. +type BattleSummary struct { + ID uuid.UUID `json:"id"` + Planet uint `json:"planet"` + Shots uint `json:"shots"` +} + type BattleReport struct { // Battle unique ID ID uuid.UUID `json:"id"` diff --git a/pkg/model/report/report.go b/pkg/model/report/report.go index e824e76..fd50e45 100644 --- a/pkg/model/report/report.go +++ b/pkg/model/report/report.go @@ -33,7 +33,7 @@ type Report struct { OtherScience []OtherScience `json:"otherScience,omitempty"` LocalShipClass []ShipClass `json:"localShipClass,omitempty"` OtherShipClass []OthersShipClass `json:"otherShipClass,omitempty"` - Battle []uuid.UUID `json:"battle,omitempty"` + Battle []BattleSummary `json:"battle,omitempty"` Bombing []*Bombing `json:"bombing,omitempty"` IncomingGroup []IncomingGroup `json:"incomingGroup,omitempty"` LocalPlanet []LocalPlanet `json:"localPlanet,omitempty"` diff --git a/pkg/schema/fbs/report.fbs b/pkg/schema/fbs/report.fbs index afebeb7..46aa5aa 100644 --- a/pkg/schema/fbs/report.fbs +++ b/pkg/schema/fbs/report.fbs @@ -196,6 +196,16 @@ table LocalFleet { state:string; } +// BattleSummary identifies one battle the report recipient +// participated in or could see on a planet. `planet` lets the map +// place a battle marker without fetching the full BattleReport; +// `shots` lets the marker scale its stroke with the protocol length. +table BattleSummary { + id:common.UUID (required); + planet:uint64; + shots:uint64; +} + table Report { version:uint64; turn:uint64; @@ -210,7 +220,7 @@ table Report { other_science:[OtherScience]; local_ship_class:[ShipClass]; other_ship_class:[OthersShipClass]; - battle:[common.UUID]; + battle:[BattleSummary]; bombing:[Bombing]; incoming_group:[IncomingGroup]; local_planet:[LocalPlanet]; diff --git a/pkg/schema/fbs/report/BattleSummary.go b/pkg/schema/fbs/report/BattleSummary.go new file mode 100644 index 0000000..122f06d --- /dev/null +++ b/pkg/schema/fbs/report/BattleSummary.go @@ -0,0 +1,97 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package report + +import ( + flatbuffers "github.com/google/flatbuffers/go" + + common "galaxy/schema/fbs/common" +) + +type BattleSummary struct { + _tab flatbuffers.Table +} + +func GetRootAsBattleSummary(buf []byte, offset flatbuffers.UOffsetT) *BattleSummary { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &BattleSummary{} + x.Init(buf, n+offset) + return x +} + +func FinishBattleSummaryBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsBattleSummary(buf []byte, offset flatbuffers.UOffsetT) *BattleSummary { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &BattleSummary{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedBattleSummaryBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *BattleSummary) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *BattleSummary) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *BattleSummary) Id(obj *common.UUID) *common.UUID { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + x := o + rcv._tab.Pos + if obj == nil { + obj = new(common.UUID) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + +func (rcv *BattleSummary) Planet() uint64 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.GetUint64(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *BattleSummary) MutatePlanet(n uint64) bool { + return rcv._tab.MutateUint64Slot(6, n) +} + +func (rcv *BattleSummary) Shots() uint64 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.GetUint64(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *BattleSummary) MutateShots(n uint64) bool { + return rcv._tab.MutateUint64Slot(8, n) +} + +func BattleSummaryStart(builder *flatbuffers.Builder) { + builder.StartObject(3) +} +func BattleSummaryAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) { + builder.PrependStructSlot(0, flatbuffers.UOffsetT(id), 0) +} +func BattleSummaryAddPlanet(builder *flatbuffers.Builder, planet uint64) { + builder.PrependUint64Slot(1, planet, 0) +} +func BattleSummaryAddShots(builder *flatbuffers.Builder, shots uint64) { + builder.PrependUint64Slot(2, shots, 0) +} +func BattleSummaryEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/schema/fbs/report/Report.go b/pkg/schema/fbs/report/Report.go index 5d7701b..3914ba9 100644 --- a/pkg/schema/fbs/report/Report.go +++ b/pkg/schema/fbs/report/Report.go @@ -4,8 +4,6 @@ package report import ( flatbuffers "github.com/google/flatbuffers/go" - - common "galaxy/schema/fbs/common" ) type Report struct { @@ -231,11 +229,12 @@ func (rcv *Report) OtherShipClassLength() int { return 0 } -func (rcv *Report) Battle(obj *common.UUID, j int) bool { +func (rcv *Report) Battle(obj *BattleSummary, j int) bool { o := flatbuffers.UOffsetT(rcv._tab.Offset(30)) if o != 0 { x := rcv._tab.Vector(o) - x += flatbuffers.UOffsetT(j) * 16 + x += flatbuffers.UOffsetT(j) * 4 + x = rcv._tab.Indirect(x) obj.Init(rcv._tab.Bytes, x) return true } @@ -551,7 +550,7 @@ func ReportAddBattle(builder *flatbuffers.Builder, battle flatbuffers.UOffsetT) builder.PrependUOffsetTSlot(13, flatbuffers.UOffsetT(battle), 0) } func ReportStartBattleVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { - return builder.StartVector(16, numElems, 8) + return builder.StartVector(4, numElems, 4) } func ReportAddBombing(builder *flatbuffers.Builder, bombing flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(14, flatbuffers.UOffsetT(bombing), 0) diff --git a/pkg/transcoder/report.go b/pkg/transcoder/report.go index 2e64f14..f2d9771 100644 --- a/pkg/transcoder/report.go +++ b/pkg/transcoder/report.go @@ -10,7 +10,6 @@ import ( fbs "galaxy/schema/fbs/report" flatbuffers "github.com/google/flatbuffers/go" - "github.com/google/uuid" ) // ReportToPayload converts model.Report from the internal representation to @@ -120,7 +119,7 @@ func ReportToPayload(report *model.Report) ([]byte, error) { otherScienceVector := encodeReportOffsetVector(builder, len(otherScienceOffsets), fbs.ReportStartOtherScienceVector, otherScienceOffsets) localShipClassVector := encodeReportOffsetVector(builder, len(localShipClassOffsets), fbs.ReportStartLocalShipClassVector, localShipClassOffsets) otherShipClassVector := encodeReportOffsetVector(builder, len(otherShipClassOffsets), fbs.ReportStartOtherShipClassVector, otherShipClassOffsets) - battleVector := encodeReportUUIDVector(builder, report.Battle) + battleVector := encodeReportBattleSummaries(builder, report.Battle) bombingVector := encodeReportOffsetVector(builder, len(bombingOffsets), fbs.ReportStartBombingVector, bombingOffsets) incomingGroupVector := encodeReportOffsetVector(builder, len(incomingGroupOffsets), fbs.ReportStartIncomingGroupVector, incomingGroupOffsets) localPlanetVector := encodeReportOffsetVector(builder, len(localPlanetOffsets), fbs.ReportStartLocalPlanetVector, localPlanetOffsets) @@ -734,13 +733,29 @@ func decodeReportBattleVector(flatReport *fbs.Report, result *model.Report) erro return nil } - result.Battle = make([]uuid.UUID, length) - item := new(commonfbs.UUID) + result.Battle = make([]model.BattleSummary, length) + item := new(fbs.BattleSummary) + idHolder := new(commonfbs.UUID) for i := 0; i < length; i++ { if !flatReport.Battle(item, i) { + return fmt.Errorf("decode report battle %d: battle is missing", i) + } + if item.Id(idHolder) == nil { return fmt.Errorf("decode report battle %d: battle id is missing", i) } - result.Battle[i] = uuidFromHiLo(item.Hi(), item.Lo()) + planet, err := uint64ToUint(item.Planet(), "planet") + if err != nil { + return fmt.Errorf("decode report battle %d: %w", i, err) + } + shots, err := uint64ToUint(item.Shots(), "shots") + if err != nil { + return fmt.Errorf("decode report battle %d: %w", i, err) + } + result.Battle[i] = model.BattleSummary{ + ID: uuidFromHiLo(idHolder.Hi(), idHolder.Lo()), + Planet: planet, + Shots: shots, + } } return nil @@ -1299,17 +1314,26 @@ func encodeReportOffsetVector( return builder.EndVector(length) } -func encodeReportUUIDVector(builder *flatbuffers.Builder, ids []uuid.UUID) flatbuffers.UOffsetT { - if len(ids) == 0 { +func encodeReportBattleSummaries(builder *flatbuffers.Builder, summaries []model.BattleSummary) flatbuffers.UOffsetT { + if len(summaries) == 0 { return 0 } - fbs.ReportStartBattleVector(builder, len(ids)) - for i := len(ids) - 1; i >= 0; i-- { - hi, lo := uuidToHiLo(ids[i]) - commonfbs.CreateUUID(builder, hi, lo) + offsets := make([]flatbuffers.UOffsetT, len(summaries)) + for i := range summaries { + hi, lo := uuidToHiLo(summaries[i].ID) + fbs.BattleSummaryStart(builder) + fbs.BattleSummaryAddId(builder, commonfbs.CreateUUID(builder, hi, lo)) + fbs.BattleSummaryAddPlanet(builder, uint64(summaries[i].Planet)) + fbs.BattleSummaryAddShots(builder, uint64(summaries[i].Shots)) + offsets[i] = fbs.BattleSummaryEnd(builder) } - return builder.EndVector(len(ids)) + + fbs.ReportStartBattleVector(builder, len(offsets)) + for i := len(offsets) - 1; i >= 0; i-- { + builder.PrependUOffsetT(offsets[i]) + } + return builder.EndVector(len(offsets)) } func encodeReportRouteEntryVector(builder *flatbuffers.Builder, route map[uint]string) flatbuffers.UOffsetT { diff --git a/pkg/transcoder/report_test.go b/pkg/transcoder/report_test.go index 2dbdd3d..6f13d3a 100644 --- a/pkg/transcoder/report_test.go +++ b/pkg/transcoder/report_test.go @@ -255,9 +255,17 @@ func sampleReport() *model.Report { OtherShipClass: []model.OthersShipClass{ {Race: "Martians", ShipClass: model.ShipClass{Name: "destroyer", Drive: model.Float(1.75), Armament: 6, Weapons: model.Float(2.25), Shields: model.Float(2.75), Cargo: model.Float(3.25), Mass: model.Float(10.5)}}, }, - Battle: []uuid.UUID{ - uuid.MustParse("11111111-1111-1111-1111-111111111111"), - uuid.MustParse("22222222-2222-2222-2222-222222222222"), + Battle: []model.BattleSummary{ + { + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + Planet: 4, + Shots: 17, + }, + { + ID: uuid.MustParse("22222222-2222-2222-2222-222222222222"), + Planet: 11, + Shots: 3, + }, }, Bombing: []*model.Bombing{ { diff --git a/ui/PLAN.md b/ui/PLAN.md index 5900b49..b48aba6 100644 --- a/ui/PLAN.md +++ b/ui/PLAN.md @@ -2949,45 +2949,126 @@ Targeted tests: Status: pending. -Goal: render battles as a dedicated view with playback controls -(play, pause, step forward, step backward, rewind), driven by the -server-side combat log; render battle and bombing markers on the map. +Goal: ship a dedicated Battle Viewer rendering radial scenes from +`BattleReport` data (planet centred, races on the outer ring, per +ship-class clusters, animated shot lines), plus battle and bombing +markers on the map. Battles and bombings stay strictly separate — +bombings remain a static table in the Reports view, only battles +get the animated viewer. Artifacts: -- `ui/frontend/src/map/battle-markers.ts` renders markers on the map - for current-turn battles and bombings within visibility, clickable - to open the battle viewer -- `ui/frontend/src/routes/games/[id]/battle/[battleId]/+page.svelte` - view with the combatant list, the round-by-round log, and a player - control bar -- `ui/frontend/src/lib/battle-player/` round timeline, current-round - highlight, per-shot animation -- entry points to the viewer: marker on map, row in the report's - battles section, push-event toast when a battle this turn involved - the player -- topic doc `ui/docs/battle-viewer-ux.md` covering playback - semantics, accessibility (the combat log must be readable as text - for users who skip animations) +- engine: `game/internal/router/handler/battle.go` for + `GET /api/v1/battle/:turn/:uuid` (handler pre-existed; Phase 27 + added the tests + openapi schemas) +- engine wire: `pkg/model/report/battle.go` ships a new + `BattleSummary{id, planet, shots}`; `Report.battle` carries a + slice of these summaries so the map can place markers without + fetching every full report +- backend: `backend/internal/engineclient/client.go.FetchBattle` + and `backend/internal/server/handlers_user_games.go.Battle` + expose `GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}` +- UI viewer: `ui/frontend/src/lib/battle-player/` + (`radial-layout.ts`, `timeline.ts`, `battle-scene.svelte`, + `playback-controls.svelte`, `battle-viewer.svelte`); SVG-based, + one frame per protocol entry, full controls (play/pause + step + back + step forward + rewind + 1x/2x/4x speed switch) +- UI route + page wrapper: + `ui/frontend/src/routes/games/[id]/battle/[[battleId]]/+page.svelte` + feeds `gameId` / `turn` / `battleId` into + `ui/frontend/src/lib/active-view/battle.svelte`, which loads the + report via `api/battle-fetch.ts` (synthetic-fixture path + real + engine fetch through the backend gateway) +- UI report link: `lib/active-view/report/section-battles.svelte` + now links every battle UUID into + `/games/{id}/battle/{uuid}?turn={turn}` +- UI map markers: `ui/frontend/src/map/battle-markers.ts` emits a + yellow X cross per battle (two `LinePrim` through the planet's + bounding-square diagonals; stroke width scales 1px..5px with + protocol length) plus a stroke-only ring per bombing (yellow when + damaged, red when wiped). Wired into `state-binding.ts`; the map + click handler dispatches battle clicks to the viewer and bombing + clicks to a scroll-into-view of the matching row in Reports. +- topic doc `ui/docs/battle-viewer-ux.md` covers playback + semantics, accessibility (the always-visible `
      ` log), the + radial layout, and the marker click behaviour +- docs/FUNCTIONAL.md §6.5 (Battle viewer) + mirror in + docs/FUNCTIONAL_ru.md Dependencies: Phase 23. Acceptance criteria: - battle and bombing markers render on the map for the seeded - current-turn report and are clickable to open the viewer; -- the viewer plays back any battle in the seeded report including - multi-round and one-sided battles; -- step controls allow precise inspection; -- the same data is accessible as a static text log for accessibility. + current-turn report and are clickable: battle → Battle Viewer for + the corresponding UUID, bombing → scroll to its row in Reports; +- the Battle Viewer plays back any `BattleReport` end-to-end with + step back / step forward / rewind / 1x-2x-4x speeds; observers + (`inBattle === false`) are not drawn; eliminated races drop out + and survivors re-distribute on the next frame; +- the same protocol is mirrored as an always-visible text log under + the scene for accessibility; +- bombings keep their Phase 23 static table layout in Reports; no + Battle Viewer entry-point is wired from them. Targeted tests: -- Vitest unit tests for round-state transitions; -- Vitest unit tests for marker rendering on torus and no-wrap - fixtures; -- Playwright e2e: click a battle marker on the map, play through, - step backward, return to the report. +- Vitest unit: radial layout (1/2/3 races) and timeline frame- + builder (initial state, shot decrement, race-elimination drop-out) + in `tests/battle-player.test.ts` +- Vitest unit: marker primitives + stroke-width formula + (1→1, 50→2.98, 100→5, 200→5) in `tests/battle-markers.test.ts` +- Go unit: engine HTTP handler validations (400 / 404 / 500) in + `game/internal/router/battle_test.go` +- Go contract: openapi freezes for the new endpoint and schemas in + `game/openapi_contract_test.go` +- Playwright e2e: click battle marker → viewer; play / step back; + click battle UUID in Reports → viewer; click bombing marker → + Reports bombings row scrolled into view. + +Decisions during stage: + +1. **Bombings stay a static table.** `section-bombings.svelte` + already covers the "who bombed, with what power, wiped or not" + requirement; nothing in Phase 27 touches it. Bombings explicitly + do not open the Battle Viewer. +2. **Wire change.** `Report.Battle` switched from `[]uuid.UUID` to + `[]BattleSummary{id, planet, shots}` so the map renderer can + place markers without N extra fetches and so the cross-marker + stroke can scale with protocol length. +3. **Battle marker = yellow X cross** drawn as 2 `LinePrim` through + the corners of the planet's circumscribed square; stroke width + `clamp(1 + (shots - 1) * 4 / 99, 1, 5)` px. +4. **Bombing marker = stroke-only ring** slightly larger than the + planet circle. Yellow when damaged, red when wiped. Click = + scroll to the matching row in Reports (not the viewer). +5. **Viewer URL** `/games/[id]/battle/[battleId]?turn=N`. Turn is a + query param so the same route works in history mode. +6. **SVG, not PixiJS** for the radial scene — isolated component, + no need for WebGL; PixiJS stays as the map renderer. +7. **Playback controls full set**: play / pause + step back + step + forward + rewind + 1x / 2x / 4x switch. 1x = 400 ms per frame. +8. **Observer groups (`inBattle: false`)** are filtered out of both + the scene and the text log. +9. **Cluster aggregation by `(race, className)`** so a race with + multiple groups of the same class collapses to one labelled + circle. Stable target for shot-line endpoints. +10. **Page loader switches on `synthetic-` gameId prefix** — + synthetic mode uses `api/synthetic-battle.ts` fixtures; live + games hit `GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`. + BattleViewer component itself is a logically isolated prop sink. +11. **Always-visible `
        ` text protocol** under the scene satisfies + the accessibility requirement without a separate "skip + animation" toggle. + +TODO carried into Phase 27 deferred items +(see Phase 27 of this PLAN's deferred-followups list, near the +bottom): + +- push event `game.battle.new` + toast deep-link; +- richer ship-class visuals derived from class characteristics; +- animated transitions when survivors re-distribute after an + elimination (currently hard-jumps). ## Phase 28. Diplomatic Mail View @@ -3459,3 +3540,18 @@ phase listed in the parenthesis when that phase lands. exercises a unary Connect call and a server-streaming Connect call through `testenv.Bootstrap`. (Phase 7+, fold into the phase that needs it.) +- **Battle viewer — push event `game.battle.new`** — when a battle + involving the current player lands, emit a backend notification + intent (idempotency `battle-new:::`, + payload `{game_id, turn, battle_id}`) so the in-game shell + surfaces a toast with a deep link into the Battle Viewer. + (Phase 27 deferred; needs an engine emit-side change.) +- **Battle viewer — richer ship-class visuals** — current MVP draws + one small circle plus `:` label per `(race, + className)` pair. Future work derives shape / scale from mass, + armament, shields, and the number of ships in the group. + (Phase 27 deferred.) +- **Battle viewer — animated re-distribution on elimination** — + current implementation hard-jumps to the new spacing on the next + frame; replace with an easing so the survivors visibly slide + along the outer ring. (Phase 27 deferred.) diff --git a/ui/docs/battle-viewer-ux.md b/ui/docs/battle-viewer-ux.md new file mode 100644 index 0000000..54761d6 --- /dev/null +++ b/ui/docs/battle-viewer-ux.md @@ -0,0 +1,136 @@ +# Battle Viewer UX + +Phase 27 ships a dedicated viewer for battles (`/games//battle/`). +Bombings stay where they were in Phase 23 — a static table in the +Reports view (`section-bombings.svelte`). The two domains are +deliberately not mixed in any visual surface or click target. + +## Data shape + +The `BattleViewer` component (`lib/battle-player/battle-viewer.svelte`) +is logically isolated. It accepts a `BattleReport` matching +`pkg/model/report/battle.go`. The fields it uses: + +- `id`, `planet`, `planetName` — header + the central-planet glyph. +- `races: { [raceId]: raceUUID }` — race index space used by the + protocol's `a` / `d` fields. +- `ships: { [groupKey]: BattleReportGroup }` — ship-group rosters + with `race` name, `className`, initial `num`, end-state `numLeft`, + and the `inBattle` flag. Observer groups (`inBattle: false`) are + never drawn. +- `protocol: BattleActionReport[]` — flat list of shots. Each carries + attacker `(a, sa)`, defender `(d, sd)`, and `x` (destroyed?). + +The component asks `timeline.ts.buildFrames(report)` to expand the +protocol into `protocol.length + 1` frames; frame 0 is the initial +state and frame `N` reflects state after action `protocol[N-1]`. The +race index per ship group is derived from the protocol itself — +every in-battle group appears at least once as attacker or defender, +and the engine never crosses these wires. + +## Radial scene + +The scene (`lib/battle-player/battle-scene.svelte`, SVG) places the +planet at the centre and arrays the still-active races on an outer +ring at equal angular spacing. Each race anchor is a horizontal +cluster of small class circles, one per `(race, className)` pair, +labelled `:` underneath. When a race is wiped +out, it drops out of the active list and the survivors are +re-spaced on the next frame. + +The current frame's shot is drawn as a thin line from the attacker's +class circle to the defender's class circle. Colour: + +- red (`#ee3344`) when the action's `x === true` (the defender + ship was destroyed), +- green (`#44dd66`) otherwise. + +Each frame redraws the line in isolation, so continuous playback +produces the "shot-shot-shot" pulse the user wanted. + +## Playback controls + +`lib/battle-player/playback-controls.svelte` ships the full set: + +| Control | Effect | +| ------------- | ------------------------------------------ | +| ⏮ rewind | Stop, jump to frame 0 | +| ◀︎ step back | Stop, frame ← frame − 1 | +| ▶︎ / ⏸ play | Toggle continuous playback | +| ▶︎▶︎ step fwd | Stop, frame ← frame + 1 | +| 1x / 2x / 4x | Speed switch: 400 / 200 / 100 ms per frame | + +When the timeline is at its end and the user hits play, the frame +counter wraps to 0 and continues. Step buttons disable themselves at +their boundary. + +## Accessibility + +Below the scene the viewer renders a static `
          ` text protocol — +one line per action, formatted from `BattleReportGroup.race` and +`BattleReportGroup.className`. The line for the current frame is +highlighted so a non-visual reader can follow along by scrolling +the log instead of watching the SVG. The list is always present +and never hidden, satisfying the original Phase 27 acceptance "the +same data is accessible as a static text log". + +## Map markers + +`map/battle-markers.ts` emits two marker kinds per +current-turn report. Both are wired into the binding's +`hitLookup` so a click goes through the existing hit-test plumbing. + +### Battle marker — yellow cross + +For every `report.battles[i]` whose `planet` resolves to a visible +planet, the marker emits two `LinePrim` lines through the opposite +corners of the square circumscribed around the planet circle. The +result is an X-shaped cross overlaid on the planet glyph. + +The stroke width is computed by `battleMarkerStrokeWidth(shots)`: +1 shot → 1 px, 100 shots → 5 px, linearly interpolated in between +(`width = 1 + (shots − 1) × 4 / 99`, clamped). A click on either +line navigates to `/games//battle/?turn=`. + +### Bombing marker — colored ring + +For every `report.bombings[i]`, the marker emits a single +stroke-only `CirclePrim` slightly larger than the planet circle. +Colour: + +- yellow (`#FFD400`) when `wiped: false`, +- red (`#FF3030`) when `wiped: true`. + +A click on the ring navigates to `/games//report#report-bombings` +and scrolls the matching `report-bombing-row` (by `data-planet`) +into view. Bombing markers never open the Battle Viewer — the two +domains stay separate. + +## Data source + +The Battle Viewer page (`lib/active-view/battle.svelte`) calls +`api/battle-fetch.ts.fetchBattle(gameId, turn, battleId)`. The +loader has two modes: + +- **Synthetic** — when `gameId` carries the + `synthetic-` prefix, the lookup is served from + `api/synthetic-battle.ts`. Vitest unit tests and Playwright e2e + tests register fixture battles via `registerSyntheticBattle` + before mounting the route. +- **Production** — otherwise the loader issues + `GET /api/v1/user/games/{gameId}/battles/{turn}/{battleId}` + against the backend gateway route added in + `backend/internal/server/handlers_user_games.go.Battle`. The + gateway forwards verbatim to the engine's + `GET /api/v1/battle/:turn/:uuid`. + +## TODOs + +- Push event `game.battle.new` + toast → viewer link (deferred — + needs an engine emit-side change). +- Richer ship-class visuals derived from the class's mass, + armament, shields. Current MVP uses a small circle plus + `:` label. +- Animated transitions when a race drops out and the survivors + re-distribute. Current implementation hard-jumps on the next + frame. diff --git a/ui/frontend/src/api/battle-fetch.ts b/ui/frontend/src/api/battle-fetch.ts new file mode 100644 index 0000000..154c67a --- /dev/null +++ b/ui/frontend/src/api/battle-fetch.ts @@ -0,0 +1,88 @@ +// Battle-report fetcher used by the Battle Viewer page. +// +// Phase 27 ships the BattleViewer as a logically isolated component +// that accepts a `BattleReport` matching `pkg/model/report/battle.go`. +// This module owns the type mirror and a single `fetchBattle` entry +// point. In synthetic mode (development & e2e fixtures), the loader +// falls back to a local fixture so the UI tests don't depend on a +// running engine; otherwise it issues a real `GET` against the +// backend gateway route added in Phase 27 step 3. + +import { isSyntheticGameId } from "./synthetic-report"; +import { lookupSyntheticBattle } from "./synthetic-battle"; + +/** + * BattleReport is the wire shape returned by the engine endpoint + * `GET /api/v1/battle/:turn/:uuid` and forwarded by the backend + * gateway as `GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`. + * Fields mirror `pkg/model/report/battle.go`. + */ +export interface BattleReport { + id: string; + planet: number; + planetName: string; + races: Record; + ships: Record; + protocol: BattleActionReport[]; +} + +export interface BattleReportGroup { + race: string; + className: string; + tech: Record; + num: number; + numLeft: number; + loadType: string; + loadQuantity: number; + inBattle: boolean; +} + +export interface BattleActionReport { + a: number; + sa: number; + d: number; + sd: number; + x: boolean; +} + +export class BattleFetchError extends Error { + constructor(public readonly status: number, message: string) { + super(message); + this.name = "BattleFetchError"; + } +} + +/** + * fetchBattle returns the `BattleReport` for the supplied game, turn, + * and battle id. In synthetic-report mode (DEV / e2e) the lookup is + * served from `synthetic-battle.ts`; otherwise the function calls the + * backend gateway route. Throws `BattleFetchError` with the upstream + * status on validation or transport failure. + */ +export async function fetchBattle( + gameId: string, + turn: number, + battleId: string, +): Promise { + if (isSyntheticGameId(gameId)) { + const fixture = lookupSyntheticBattle(battleId); + if (fixture === null) { + throw new BattleFetchError(404, "battle not found"); + } + return fixture; + } + const path = `/api/v1/user/games/${encodeURIComponent(gameId)}/battles/${turn}/${encodeURIComponent(battleId)}`; + const response = await fetch(path, { + headers: { Accept: "application/json" }, + }); + if (response.status === 404) { + throw new BattleFetchError(404, "battle not found"); + } + if (!response.ok) { + throw new BattleFetchError( + response.status, + `battle fetch failed: ${response.status}`, + ); + } + return (await response.json()) as BattleReport; +} diff --git a/ui/frontend/src/api/game-state.ts b/ui/frontend/src/api/game-state.ts index 3b75caa..eabbdc2 100644 --- a/ui/frontend/src/api/game-state.ts +++ b/ui/frontend/src/api/game-state.ts @@ -382,6 +382,18 @@ export interface ReportBombing { * mirrors the producing planet's free industry. Stable order: sorted * by `(planetNumber, class)`. */ +/** + * ReportBattle is one battle summary in the current turn. Carries the + * battle UUID, planet number, and shot count — enough to render a + * battle marker on the map and to link into the Battle Viewer without + * fetching the full BattleReport. + */ +export interface ReportBattle { + id: string; + planet: number; + shots: number; +} + export interface ReportShipProduction { planetNumber: number; class: string; @@ -524,11 +536,17 @@ export interface GameReport { */ otherShipClass: ReportOtherShipClass[]; /** - * battleIds is the list of battle UUIDs the engine recorded for - * the current turn. Phase 23 renders them as inactive - * monospace identifiers; Phase 27 will turn them into navigation - * targets once the battle viewer lands. Empty when no battles - * occurred last turn. + * battles is the list of battle summaries the engine recorded for + * the current turn. Each entry carries the battle UUID, the planet + * it happened on, and the number of shots exchanged. The Reports + * View uses `id` to link into the Battle Viewer; the map renderer + * uses `planet` to locate the marker and `shots` to scale its + * stroke. Empty when no battles occurred last turn. + */ + battles: ReportBattle[]; + /** + * battleIds is a convenience derived list of UUIDs from `battles`, + * preserved for legacy callers (Phase 23 report section, fixtures). */ battleIds: string[]; /** @@ -700,7 +718,8 @@ function decodeReport(report: Report): GameReport { const localFleets = decodeLocalFleets(report); const otherScience = decodeOtherScience(report); const otherShipClass = decodeOtherShipClass(report); - const battleIds = decodeBattleIds(report); + const battles = decodeBattles(report); + const battleIds = battles.map((b) => b.id); const bombings = decodeBombings(report); const shipProductions = decodeShipProductions(report); @@ -730,6 +749,7 @@ function decodeReport(report: Report): GameReport { players, otherScience, otherShipClass, + battles, battleIds, bombings, shipProductions, @@ -1153,13 +1173,18 @@ function decodeOtherShipClass(report: Report): ReportOtherShipClass[] { return out; } -function decodeBattleIds(report: Report): string[] { - const out: string[] = []; +function decodeBattles(report: Report): ReportBattle[] { + const out: ReportBattle[] = []; for (let i = 0; i < report.battleLength(); i++) { - const uuid = report.battle(i); - const value = uuidStringFromFB(uuid); - if (value === null) continue; - out.push(value); + const summary = report.battle(i); + if (summary === null) continue; + const id = uuidStringFromFB(summary.id()); + if (id === null) continue; + out.push({ + id, + planet: Number(summary.planet()), + shots: Number(summary.shots()), + }); } return out; } @@ -1439,6 +1464,7 @@ export function applyOrderOverlay( players: report.players ?? [], otherScience: report.otherScience ?? [], otherShipClass: report.otherShipClass ?? [], + battles: report.battles ?? [], battleIds: report.battleIds ?? [], bombings: report.bombings ?? [], shipProductions: report.shipProductions ?? [], diff --git a/ui/frontend/src/api/synthetic-battle.ts b/ui/frontend/src/api/synthetic-battle.ts new file mode 100644 index 0000000..3d33ec2 --- /dev/null +++ b/ui/frontend/src/api/synthetic-battle.ts @@ -0,0 +1,37 @@ +// Synthetic battle reports for DEV / e2e mode. +// +// Mirrors the shape of `pkg/model/report/battle.go` so the +// BattleViewer can be exercised without a running engine. Fixtures +// are registered by battle UUID; the synthetic-report loader fills +// the report's `battles[]` with these same UUIDs so the report ↔ +// battle link is consistent. + +import type { BattleReport } from "./battle-fetch"; + +const SYNTHETIC_BATTLES = new Map(); + +/** + * registerSyntheticBattle adds a fixture battle to the in-memory map + * keyed by its `id`. Used by the synthetic-report DEV loader and by + * Vitest unit tests that need a deterministic BattleReport without a + * live engine. + */ +export function registerSyntheticBattle(report: BattleReport): void { + SYNTHETIC_BATTLES.set(report.id, report); +} + +/** + * lookupSyntheticBattle returns the fixture stored under `battleId`, + * or `null` if nothing was registered (mirrors the engine's 404). + */ +export function lookupSyntheticBattle(battleId: string): BattleReport | null { + return SYNTHETIC_BATTLES.get(battleId) ?? null; +} + +/** + * resetSyntheticBattles clears every registered fixture. Test + * harnesses call this between cases to avoid bleed-through. + */ +export function resetSyntheticBattles(): void { + SYNTHETIC_BATTLES.clear(); +} diff --git a/ui/frontend/src/api/synthetic-report.ts b/ui/frontend/src/api/synthetic-report.ts index c5ac7d6..9189049 100644 --- a/ui/frontend/src/api/synthetic-report.ts +++ b/ui/frontend/src/api/synthetic-report.ts @@ -173,6 +173,12 @@ interface SyntheticOtherShipClass extends SyntheticShipClass { mass?: number; } +interface SyntheticBattle { + id?: string; + planet?: number; + shots?: number; +} + interface SyntheticBombing { planet?: number; // wire field "number" planetName?: string; // wire field "planetName" @@ -219,7 +225,7 @@ interface SyntheticReportRoot { incomingGroup?: SyntheticIncomingGroup[]; unidentifiedGroup?: SyntheticUnidentifiedGroup[]; localFleet?: SyntheticLocalFleet[]; - battle?: string[]; + battle?: SyntheticBattle[]; bombing?: SyntheticBombing[]; shipProduction?: SyntheticShipProductionRow[]; } @@ -357,9 +363,17 @@ function decodeSyntheticReport(json: unknown): GameReport { return a.name.localeCompare(b.name); }); - const battleIds: string[] = (root.battle ?? []).filter( - (v): v is string => typeof v === "string" && v !== "", - ); + const battles = (root.battle ?? []) + .filter( + (v): v is SyntheticBattle => + typeof v === "object" && v !== null && typeof v.id === "string" && v.id !== "", + ) + .map((b) => ({ + id: b.id as string, + planet: numOr0(b.planet), + shots: numOr0(b.shots), + })); + const battleIds = battles.map((b) => b.id); const bombings: ReportBombing[] = (root.bombing ?? []).map((b) => ({ planetNumber: numOr0(b.planet), @@ -419,6 +433,7 @@ function decodeSyntheticReport(json: unknown): GameReport { players: collectPlayersFromSynthetic(root, race), otherScience, otherShipClass, + battles, battleIds, bombings, shipProductions, diff --git a/ui/frontend/src/lib/active-view/battle.svelte b/ui/frontend/src/lib/active-view/battle.svelte index 61480ba..7167c74 100644 --- a/ui/frontend/src/lib/active-view/battle.svelte +++ b/ui/frontend/src/lib/active-view/battle.svelte @@ -1,30 +1,134 @@
          -

          {i18n.t("game.view.battle")}

          -

          {i18n.t("game.shell.coming_soon")}

          + + + {#if state.kind === "loading"} +

          + {i18n.t("game.battle.loading")} +

          + {:else if state.kind === "ready"} + + {:else if state.kind === "not_found"} +

          + {i18n.t("game.battle.not_found")} +

          + {:else} +

          {state.message}

          + {/if}
          diff --git a/ui/frontend/src/lib/active-view/map.svelte b/ui/frontend/src/lib/active-view/map.svelte index 7249995..1ed4ece 100644 --- a/ui/frontend/src/lib/active-view/map.svelte +++ b/ui/frontend/src/lib/active-view/map.svelte @@ -21,6 +21,8 @@ preference the store already manages. -->
          {i18n.t("game.report.loading")}

          - {: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 @@ + + + + + + {report.planetName} (#{report.planet}) + + {#each raceLayout as anchor (anchor.raceId)} + {@const cluster = clustersByRace.get(anchor.raceId) ?? []} + + {raceLabelById.get(anchor.raceId) ?? `race ${anchor.raceId}`} + {#each cluster as entry, i (entry.key)} + {@const cx = anchor.x + classCircleX(i, cluster.length)} + + + {entry.className}:{entry.numLeft} + + {/each} + + {/each} + + {#if shotLine} + + {/if} + + + 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.title")} +

          + + {frame.shotIndex} / {report.protocol.length} + +
          + +
          + +
          + + + +
          +

          {i18n.t("game.battle.accessibility.protocol_heading")}

          +
            + {#each report.protocol as _action, i (i)} +
          1. {describeAction(i)}
          2. + {/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: [],