ui: plan 01-27 done #1

Merged
developer merged 120 commits from ai/ui-client into main 2026-05-13 18:55:14 +00:00
81 changed files with 2911 additions and 230 deletions
Showing only changes of commit 969c0480ba - Show all commits
+36
View File
@@ -26,6 +26,7 @@ const (
pathPlayerCommand = "/api/v1/command" pathPlayerCommand = "/api/v1/command"
pathPlayerOrder = "/api/v1/order" pathPlayerOrder = "/api/v1/order"
pathPlayerReport = "/api/v1/report" pathPlayerReport = "/api/v1/report"
pathPlayerBattle = "/api/v1/battle"
pathHealthz = "/healthz" pathHealthz = "/healthz"
) )
@@ -269,6 +270,41 @@ func (c *Client) GetReport(ctx context.Context, baseURL, raceName string, turn i
} }
} }
// FetchBattle calls `GET /api/v1/battle/<turn>/<battleID>` 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. // Healthz calls `GET /healthz`. Returns nil on 2xx.
func (c *Client) Healthz(ctx context.Context, baseURL string) error { func (c *Client) Healthz(ctx context.Context, baseURL string) error {
if err := validateBaseURL(baseURL); err != nil { if err := validateBaseURL(baseURL); err != nil {
@@ -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) { func TestClientHealthzSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != pathHealthz { if r.URL.Path != pathHealthz {
+1
View File
@@ -45,6 +45,7 @@ var pathParamStubs = map[string]string{
"delivery_id": "00000000-0000-0000-0000-000000000006", "delivery_id": "00000000-0000-0000-0000-000000000006",
"user_id": "00000000-0000-0000-0000-000000000007", "user_id": "00000000-0000-0000-0000-000000000007",
"device_session_id": "00000000-0000-0000-0000-000000000008", "device_session_id": "00000000-0000-0000-0000-000000000008",
"battle_id": "00000000-0000-0000-0000-000000000009",
"id": "1.2.3", "id": "1.2.3",
"username": "alice", "username": "alice",
"turn": "42", "turn": "42",
@@ -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 // rebindActor decodes a JSON object from raw, sets `actor` to
// raceName, and re-encodes. Backend never trusts the actor field // raceName, and re-encodes. Backend never trusts the actor field
// supplied by the client (per ARCHITECTURE.md §9). // supplied by the client (per ARCHITECTURE.md §9).
+1
View File
@@ -263,6 +263,7 @@ func registerUserRoutes(router *gin.Engine, instruments *metrics.Instruments, de
userGames.POST("/:game_id/orders", deps.UserGames.Orders()) userGames.POST("/:game_id/orders", deps.UserGames.Orders())
userGames.GET("/:game_id/orders", deps.UserGames.GetOrders()) userGames.GET("/:game_id/orders", deps.UserGames.GetOrders())
userGames.GET("/:game_id/reports/:turn", deps.UserGames.Report()) 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 := group.Group("/sessions")
userSessions.GET("", deps.UserSessions.List()) userSessions.GET("", deps.UserSessions.List())
+38
View File
@@ -1106,6 +1106,44 @@ paths:
$ref: "#/components/responses/NotImplementedError" $ref: "#/components/responses/NotImplementedError"
"500": "500":
$ref: "#/components/responses/InternalError" $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: /api/v1/user/sessions:
get: get:
tags: [User] tags: [User]
+49 -3
View File
@@ -657,7 +657,7 @@ in `runtime_records.turn_schedule`. The backend scheduler
- After a failed tick (`engine_unreachable` / - After a failed tick (`engine_unreachable` /
`generation_failed`): the lobby's `OnRuntimeSnapshot` flips the `generation_failed`): the lobby's `OnRuntimeSnapshot` flips the
game from `running` to `paused` and publishes a `game.paused` 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. + `code = game_paused` until an admin resume succeeds.
`force-next-turn` (admin) schedules a one-shot extra tick that `force-next-turn` (admin) schedules a one-shot extra tick that
@@ -686,7 +686,53 @@ are exposed in a sticky table of contents (a `<select>` on mobile)
and the scroll position is preserved across active-view switches and the scroll position is preserved across active-view switches
via SvelteKit's `Snapshot` API. via SvelteKit's `Snapshot` API.
### 6.5 Side effects The Bombings section is a flat read-only table — one row per
bombing event, columns for `attacker`, `attack_power`, `wiped`
state and the post-bombing resource snapshot. The Battles section
is a list of links into the Battle Viewer (see [§6.5](#65-battle-viewer)).
### 6.5 Battle viewer
The Battle Viewer is a dedicated view that replaces the map and
renders one battle at a time. Entry points:
- A row in the Reports view's Battles section (link with the
current turn pinned via `?turn=`).
- A battle marker on the map (yellow cross drawn through the
corners of the square that circumscribes the planet circle;
stroke width scales with the protocol length).
The viewer is a logically isolated component that consumes a
`BattleReport` (shape per `pkg/model/report/battle.go`). The page
loader (`ui/frontend/src/lib/active-view/battle.svelte`) fetches
the report through the backend gateway route
`GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`,
which forwards verbatim to the engine's
`GET /api/v1/battle/:turn/:uuid`.
Visual model is radial: the planet sits at the centre, races are
placed at equal angular spacing on an outer ring, and each race is
rendered as a horizontal cluster of small ship-class circles
labelled `<className>:<numLeft>`. Observer groups (`inBattle:
false`) are not drawn. Eliminated races drop out and the survivors
re-spread on the next frame.
Each frame is one protocol entry; the shot is drawn as a thin line
from attacker to defender, red on `destroyed`, green otherwise.
Continuous playback offers 1x / 2x / 4x speeds (400 / 200 / 100 ms
per frame), plus play/pause, step ±, and rewind. The accessibility
text protocol below the scene mirrors the same events line-by-line.
Bombings and battles are intentionally not mixed: bombings remain a
static table in the Reports view; the bombing marker on the map is
a thin stroke-only ring around the planet (yellow when damaged, red
when wiped) and a click scrolls the corresponding row into view.
The current report wire carries a `battle: [{ id, planet, shots }]`
summary per battle so the map markers know where to anchor without
fetching every full `BattleReport`.
### 6.6 Side effects
A successful turn generation publishes a runtime snapshot into the A successful turn generation publishes a runtime snapshot into the
lobby module, which updates the denormalised view (current turn, lobby module, which updates the denormalised view (current turn,
@@ -719,7 +765,7 @@ producer; adding one is purely additive (register the kind in the
catalog, extend the migration `CHECK` constraint, and call catalog, extend the migration `CHECK` constraint, and call
`notification.Submit` from the appropriate domain module). `notification.Submit` from the appropriate domain module).
### 6.6 Cross-references ### 6.7 Cross-references
- Backend ↔ engine wire contract (`pkg/model/{order,report,rest}`): - Backend ↔ engine wire contract (`pkg/model/{order,report,rest}`):
[ARCHITECTURE.md §9](ARCHITECTURE.md#9-backend--game-engine-communication). [ARCHITECTURE.md §9](ARCHITECTURE.md#9-backend--game-engine-communication).
+49 -3
View File
@@ -675,7 +675,7 @@ engine `/admin/turn` двумя `runtime_status`-флипами:
- После провалившегося тика (`engine_unreachable` / - После провалившегося тика (`engine_unreachable` /
`generation_failed`): `lobby.OnRuntimeSnapshot` переводит игру `generation_failed`): `lobby.OnRuntimeSnapshot` переводит игру
`running → paused` и публикует push-эвент `game.paused` `running → paused` и публикует push-эвент `game.paused`
(см. §6.5). Order-handler'ы отклоняют запросы с HTTP 409 + (см. §6.6). Order-handler'ы отклоняют запросы с HTTP 409 +
`code = game_paused`, пока админ не выполнит resume. `code = game_paused`, пока админ не выполнит resume.
`force-next-turn` (admin) планирует one-shot-доп-тик, который `force-next-turn` (admin) планирует one-shot-доп-тик, который
@@ -704,7 +704,53 @@ empty-state. Якоря секций отображены в sticky-TOC (на м
`<select>`); позиция скролла сохраняется при переключении активного `<select>`); позиция скролла сохраняется при переключении активного
представления через SvelteKit `Snapshot` API. представления через 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`.
Визуальная модель — радиальная: планета в центре, расы по внешней
окружности на равных угловых интервалах, внутри расы — горизонтальный
кластер маленьких кружков по классам кораблей с подписями
`<className>:<numLeft>` под каждым. Наблюдатели (`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-snapshot в lobby-модуль,
который обновляет денормализованное вью (текущий ход, runtime- который обновляет денормализованное вью (текущий ход, runtime-
@@ -740,7 +786,7 @@ Directory-промоушен ([Раздел 5](#5-реестр-названий-
каталоге, расширить `CHECK`-констрейнт миграции и вызвать каталоге, расширить `CHECK`-констрейнт миграции и вызвать
`notification.Submit` из подходящего доменного модуля). `notification.Submit` из подходящего доменного модуля).
### 6.6 Перекрёстные ссылки ### 6.7 Перекрёстные ссылки
- Backend ↔ engine wire-контракт (`pkg/model/{order,report,rest}`): - Backend ↔ engine wire-контракт (`pkg/model/{order,report,rest}`):
[ARCHITECTURE.md §9](ARCHITECTURE.md#9-backend--game-engine-communication). [ARCHITECTURE.md §9](ARCHITECTURE.md#9-backend--game-engine-communication).
+6 -2
View File
@@ -37,7 +37,7 @@ func (c *Cache) InitReport(t uint) *mr.Report {
OtherScience: make([]mr.OtherScience, 0, 10), OtherScience: make([]mr.OtherScience, 0, 10),
LocalShipClass: make([]mr.ShipClass, 0, 20), LocalShipClass: make([]mr.ShipClass, 0, 20),
OtherShipClass: make([]mr.OthersShipClass, 0, 50), OtherShipClass: make([]mr.OthersShipClass, 0, 50),
Battle: make([]uuid.UUID, 0, 10), Battle: make([]mr.BattleSummary, 0, 10),
Bombing: make([]*mr.Bombing, 0, 10), Bombing: make([]*mr.Bombing, 0, 10),
IncomingGroup: make([]mr.IncomingGroup, 0, 10), IncomingGroup: make([]mr.IncomingGroup, 0, 10),
OnPlanetGroupCache: make(map[uint][]int), 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) 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++ i++
} }
} }
+26 -3
View File
@@ -584,10 +584,9 @@ components:
$ref: "#/components/schemas/OtherShipClass" $ref: "#/components/schemas/OtherShipClass"
battle: battle:
type: array type: array
description: UUIDs of battle reports relevant to this turn. description: Battle summaries relevant to this turn.
items: items:
type: string $ref: "#/components/schemas/BattleSummary"
format: uuid
bombing: bombing:
type: array type: array
description: Bombing events that occurred during this turn. description: Bombing events that occurred during this turn.
@@ -831,6 +830,30 @@ components:
wiped: wiped:
type: boolean type: boolean
description: True when all population was eliminated by the bombing. 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: BattleReport:
type: object type: object
description: | description: |
+16
View File
@@ -327,6 +327,22 @@ func TestGameOpenAPISpecFreezesBattleReport(t *testing.T) {
assertSchemaRef(t, shipsSchema.Value.AdditionalProperties.Schema, "#/components/schemas/BattleReportGroup", "BattleReport.ships additionalProperties schema") 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) { func TestGameOpenAPISpecHealthzStatusEnum(t *testing.T) {
t.Parallel() t.Parallel()
+10
View File
@@ -6,6 +6,16 @@ import (
"github.com/google/uuid" "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 { type BattleReport struct {
// Battle unique ID // Battle unique ID
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
+1 -1
View File
@@ -33,7 +33,7 @@ type Report struct {
OtherScience []OtherScience `json:"otherScience,omitempty"` OtherScience []OtherScience `json:"otherScience,omitempty"`
LocalShipClass []ShipClass `json:"localShipClass,omitempty"` LocalShipClass []ShipClass `json:"localShipClass,omitempty"`
OtherShipClass []OthersShipClass `json:"otherShipClass,omitempty"` OtherShipClass []OthersShipClass `json:"otherShipClass,omitempty"`
Battle []uuid.UUID `json:"battle,omitempty"` Battle []BattleSummary `json:"battle,omitempty"`
Bombing []*Bombing `json:"bombing,omitempty"` Bombing []*Bombing `json:"bombing,omitempty"`
IncomingGroup []IncomingGroup `json:"incomingGroup,omitempty"` IncomingGroup []IncomingGroup `json:"incomingGroup,omitempty"`
LocalPlanet []LocalPlanet `json:"localPlanet,omitempty"` LocalPlanet []LocalPlanet `json:"localPlanet,omitempty"`
+11 -1
View File
@@ -196,6 +196,16 @@ table LocalFleet {
state:string; 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 { table Report {
version:uint64; version:uint64;
turn:uint64; turn:uint64;
@@ -210,7 +220,7 @@ table Report {
other_science:[OtherScience]; other_science:[OtherScience];
local_ship_class:[ShipClass]; local_ship_class:[ShipClass];
other_ship_class:[OthersShipClass]; other_ship_class:[OthersShipClass];
battle:[common.UUID]; battle:[BattleSummary];
bombing:[Bombing]; bombing:[Bombing];
incoming_group:[IncomingGroup]; incoming_group:[IncomingGroup];
local_planet:[LocalPlanet]; local_planet:[LocalPlanet];
+97
View File
@@ -0,0 +1,97 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package report
import (
flatbuffers "github.com/google/flatbuffers/go"
common "galaxy/schema/fbs/common"
)
type BattleSummary struct {
_tab flatbuffers.Table
}
func GetRootAsBattleSummary(buf []byte, offset flatbuffers.UOffsetT) *BattleSummary {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &BattleSummary{}
x.Init(buf, n+offset)
return x
}
func FinishBattleSummaryBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsBattleSummary(buf []byte, offset flatbuffers.UOffsetT) *BattleSummary {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &BattleSummary{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedBattleSummaryBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *BattleSummary) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *BattleSummary) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *BattleSummary) Id(obj *common.UUID) *common.UUID {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
x := o + rcv._tab.Pos
if obj == nil {
obj = new(common.UUID)
}
obj.Init(rcv._tab.Bytes, x)
return obj
}
return nil
}
func (rcv *BattleSummary) Planet() uint64 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.GetUint64(o + rcv._tab.Pos)
}
return 0
}
func (rcv *BattleSummary) MutatePlanet(n uint64) bool {
return rcv._tab.MutateUint64Slot(6, n)
}
func (rcv *BattleSummary) Shots() uint64 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
return rcv._tab.GetUint64(o + rcv._tab.Pos)
}
return 0
}
func (rcv *BattleSummary) MutateShots(n uint64) bool {
return rcv._tab.MutateUint64Slot(8, n)
}
func BattleSummaryStart(builder *flatbuffers.Builder) {
builder.StartObject(3)
}
func BattleSummaryAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) {
builder.PrependStructSlot(0, flatbuffers.UOffsetT(id), 0)
}
func BattleSummaryAddPlanet(builder *flatbuffers.Builder, planet uint64) {
builder.PrependUint64Slot(1, planet, 0)
}
func BattleSummaryAddShots(builder *flatbuffers.Builder, shots uint64) {
builder.PrependUint64Slot(2, shots, 0)
}
func BattleSummaryEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+4 -5
View File
@@ -4,8 +4,6 @@ package report
import ( import (
flatbuffers "github.com/google/flatbuffers/go" flatbuffers "github.com/google/flatbuffers/go"
common "galaxy/schema/fbs/common"
) )
type Report struct { type Report struct {
@@ -231,11 +229,12 @@ func (rcv *Report) OtherShipClassLength() int {
return 0 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)) o := flatbuffers.UOffsetT(rcv._tab.Offset(30))
if o != 0 { if o != 0 {
x := rcv._tab.Vector(o) 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) obj.Init(rcv._tab.Bytes, x)
return true return true
} }
@@ -551,7 +550,7 @@ func ReportAddBattle(builder *flatbuffers.Builder, battle flatbuffers.UOffsetT)
builder.PrependUOffsetTSlot(13, flatbuffers.UOffsetT(battle), 0) builder.PrependUOffsetTSlot(13, flatbuffers.UOffsetT(battle), 0)
} }
func ReportStartBattleVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { 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) { func ReportAddBombing(builder *flatbuffers.Builder, bombing flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(14, flatbuffers.UOffsetT(bombing), 0) builder.PrependUOffsetTSlot(14, flatbuffers.UOffsetT(bombing), 0)
+36 -12
View File
@@ -10,7 +10,6 @@ import (
fbs "galaxy/schema/fbs/report" fbs "galaxy/schema/fbs/report"
flatbuffers "github.com/google/flatbuffers/go" flatbuffers "github.com/google/flatbuffers/go"
"github.com/google/uuid"
) )
// ReportToPayload converts model.Report from the internal representation to // 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) otherScienceVector := encodeReportOffsetVector(builder, len(otherScienceOffsets), fbs.ReportStartOtherScienceVector, otherScienceOffsets)
localShipClassVector := encodeReportOffsetVector(builder, len(localShipClassOffsets), fbs.ReportStartLocalShipClassVector, localShipClassOffsets) localShipClassVector := encodeReportOffsetVector(builder, len(localShipClassOffsets), fbs.ReportStartLocalShipClassVector, localShipClassOffsets)
otherShipClassVector := encodeReportOffsetVector(builder, len(otherShipClassOffsets), fbs.ReportStartOtherShipClassVector, otherShipClassOffsets) 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) bombingVector := encodeReportOffsetVector(builder, len(bombingOffsets), fbs.ReportStartBombingVector, bombingOffsets)
incomingGroupVector := encodeReportOffsetVector(builder, len(incomingGroupOffsets), fbs.ReportStartIncomingGroupVector, incomingGroupOffsets) incomingGroupVector := encodeReportOffsetVector(builder, len(incomingGroupOffsets), fbs.ReportStartIncomingGroupVector, incomingGroupOffsets)
localPlanetVector := encodeReportOffsetVector(builder, len(localPlanetOffsets), fbs.ReportStartLocalPlanetVector, localPlanetOffsets) localPlanetVector := encodeReportOffsetVector(builder, len(localPlanetOffsets), fbs.ReportStartLocalPlanetVector, localPlanetOffsets)
@@ -734,13 +733,29 @@ func decodeReportBattleVector(flatReport *fbs.Report, result *model.Report) erro
return nil return nil
} }
result.Battle = make([]uuid.UUID, length) result.Battle = make([]model.BattleSummary, length)
item := new(commonfbs.UUID) item := new(fbs.BattleSummary)
idHolder := new(commonfbs.UUID)
for i := 0; i < length; i++ { for i := 0; i < length; i++ {
if !flatReport.Battle(item, 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) 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 return nil
@@ -1299,17 +1314,26 @@ func encodeReportOffsetVector(
return builder.EndVector(length) return builder.EndVector(length)
} }
func encodeReportUUIDVector(builder *flatbuffers.Builder, ids []uuid.UUID) flatbuffers.UOffsetT { func encodeReportBattleSummaries(builder *flatbuffers.Builder, summaries []model.BattleSummary) flatbuffers.UOffsetT {
if len(ids) == 0 { if len(summaries) == 0 {
return 0 return 0
} }
fbs.ReportStartBattleVector(builder, len(ids)) offsets := make([]flatbuffers.UOffsetT, len(summaries))
for i := len(ids) - 1; i >= 0; i-- { for i := range summaries {
hi, lo := uuidToHiLo(ids[i]) hi, lo := uuidToHiLo(summaries[i].ID)
commonfbs.CreateUUID(builder, hi, lo) 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 { func encodeReportRouteEntryVector(builder *flatbuffers.Builder, route map[uint]string) flatbuffers.UOffsetT {
+11 -3
View File
@@ -255,9 +255,17 @@ func sampleReport() *model.Report {
OtherShipClass: []model.OthersShipClass{ 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)}}, {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{ Battle: []model.BattleSummary{
uuid.MustParse("11111111-1111-1111-1111-111111111111"), {
uuid.MustParse("22222222-2222-2222-2222-222222222222"), 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{ Bombing: []*model.Bombing{
{ {
+123 -27
View File
@@ -2949,45 +2949,126 @@ Targeted tests:
Status: pending. Status: pending.
Goal: render battles as a dedicated view with playback controls Goal: ship a dedicated Battle Viewer rendering radial scenes from
(play, pause, step forward, step backward, rewind), driven by the `BattleReport` data (planet centred, races on the outer ring, per
server-side combat log; render battle and bombing markers on the map. 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: Artifacts:
- `ui/frontend/src/map/battle-markers.ts` renders markers on the map - engine: `game/internal/router/handler/battle.go` for
for current-turn battles and bombings within visibility, clickable `GET /api/v1/battle/:turn/:uuid` (handler pre-existed; Phase 27
to open the battle viewer added the tests + openapi schemas)
- `ui/frontend/src/routes/games/[id]/battle/[battleId]/+page.svelte` - engine wire: `pkg/model/report/battle.go` ships a new
view with the combatant list, the round-by-round log, and a player `BattleSummary{id, planet, shots}`; `Report.battle` carries a
control bar slice of these summaries so the map can place markers without
- `ui/frontend/src/lib/battle-player/` round timeline, current-round fetching every full report
highlight, per-shot animation - backend: `backend/internal/engineclient/client.go.FetchBattle`
- entry points to the viewer: marker on map, row in the report's and `backend/internal/server/handlers_user_games.go.Battle`
battles section, push-event toast when a battle this turn involved expose `GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`
the player - UI viewer: `ui/frontend/src/lib/battle-player/`
- topic doc `ui/docs/battle-viewer-ux.md` covering playback (`radial-layout.ts`, `timeline.ts`, `battle-scene.svelte`,
semantics, accessibility (the combat log must be readable as text `playback-controls.svelte`, `battle-viewer.svelte`); SVG-based,
for users who skip animations) one frame per protocol entry, full controls (play/pause + step
back + step forward + rewind + 1x/2x/4x speed switch)
- UI route + page wrapper:
`ui/frontend/src/routes/games/[id]/battle/[[battleId]]/+page.svelte`
feeds `gameId` / `turn` / `battleId` into
`ui/frontend/src/lib/active-view/battle.svelte`, which loads the
report via `api/battle-fetch.ts` (synthetic-fixture path + real
engine fetch through the backend gateway)
- UI report link: `lib/active-view/report/section-battles.svelte`
now links every battle UUID into
`/games/{id}/battle/{uuid}?turn={turn}`
- UI map markers: `ui/frontend/src/map/battle-markers.ts` emits a
yellow X cross per battle (two `LinePrim` through the planet's
bounding-square diagonals; stroke width scales 1px..5px with
protocol length) plus a stroke-only ring per bombing (yellow when
damaged, red when wiped). Wired into `state-binding.ts`; the map
click handler dispatches battle clicks to the viewer and bombing
clicks to a scroll-into-view of the matching row in Reports.
- topic doc `ui/docs/battle-viewer-ux.md` covers playback
semantics, accessibility (the always-visible `<ol>` log), the
radial layout, and the marker click behaviour
- docs/FUNCTIONAL.md §6.5 (Battle viewer) + mirror in
docs/FUNCTIONAL_ru.md
Dependencies: Phase 23. Dependencies: Phase 23.
Acceptance criteria: Acceptance criteria:
- battle and bombing markers render on the map for the seeded - battle and bombing markers render on the map for the seeded
current-turn report and are clickable to open the viewer; current-turn report and are clickable: battle → Battle Viewer for
- the viewer plays back any battle in the seeded report including the corresponding UUID, bombing → scroll to its row in Reports;
multi-round and one-sided battles; - the Battle Viewer plays back any `BattleReport` end-to-end with
- step controls allow precise inspection; step back / step forward / rewind / 1x-2x-4x speeds; observers
- the same data is accessible as a static text log for accessibility. (`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: Targeted tests:
- Vitest unit tests for round-state transitions; - Vitest unit: radial layout (1/2/3 races) and timeline frame-
- Vitest unit tests for marker rendering on torus and no-wrap builder (initial state, shot decrement, race-elimination drop-out)
fixtures; in `tests/battle-player.test.ts`
- Playwright e2e: click a battle marker on the map, play through, - Vitest unit: marker primitives + stroke-width formula
step backward, return to the report. (1→1, 50→2.98, 100→5, 200→5) in `tests/battle-markers.test.ts`
- Go unit: engine HTTP handler validations (400 / 404 / 500) in
`game/internal/router/battle_test.go`
- Go contract: openapi freezes for the new endpoint and schemas in
`game/openapi_contract_test.go`
- Playwright e2e: click battle marker → viewer; play / step back;
click battle UUID in Reports → viewer; click bombing marker →
Reports bombings row scrolled into view.
Decisions during stage:
1. **Bombings stay a static table.** `section-bombings.svelte`
already covers the "who bombed, with what power, wiped or not"
requirement; nothing in Phase 27 touches it. Bombings explicitly
do not open the Battle Viewer.
2. **Wire change.** `Report.Battle` switched from `[]uuid.UUID` to
`[]BattleSummary{id, planet, shots}` so the map renderer can
place markers without N extra fetches and so the cross-marker
stroke can scale with protocol length.
3. **Battle marker = yellow X cross** drawn as 2 `LinePrim` through
the corners of the planet's circumscribed square; stroke width
`clamp(1 + (shots - 1) * 4 / 99, 1, 5)` px.
4. **Bombing marker = stroke-only ring** slightly larger than the
planet circle. Yellow when damaged, red when wiped. Click =
scroll to the matching row in Reports (not the viewer).
5. **Viewer URL** `/games/[id]/battle/[battleId]?turn=N`. Turn is a
query param so the same route works in history mode.
6. **SVG, not PixiJS** for the radial scene — isolated component,
no need for WebGL; PixiJS stays as the map renderer.
7. **Playback controls full set**: play / pause + step back + step
forward + rewind + 1x / 2x / 4x switch. 1x = 400 ms per frame.
8. **Observer groups (`inBattle: false`)** are filtered out of both
the scene and the text log.
9. **Cluster aggregation by `(race, className)`** so a race with
multiple groups of the same class collapses to one labelled
circle. Stable target for shot-line endpoints.
10. **Page loader switches on `synthetic-` gameId prefix** —
synthetic mode uses `api/synthetic-battle.ts` fixtures; live
games hit `GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`.
BattleViewer component itself is a logically isolated prop sink.
11. **Always-visible `<ol>` text protocol** under the scene satisfies
the accessibility requirement without a separate "skip
animation" toggle.
TODO carried into Phase 27 deferred items
(see Phase 27 of this PLAN's deferred-followups list, near the
bottom):
- push event `game.battle.new` + toast deep-link;
- richer ship-class visuals derived from class characteristics;
- animated transitions when survivors re-distribute after an
elimination (currently hard-jumps).
## Phase 28. Diplomatic Mail View ## 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 exercises a unary Connect call and a server-streaming Connect call
through `testenv.Bootstrap`. (Phase 7+, fold into the phase that through `testenv.Bootstrap`. (Phase 7+, fold into the phase that
needs it.) needs it.)
- **Battle viewer — push event `game.battle.new`** — when a battle
involving the current player lands, emit a backend notification
intent (idempotency `battle-new:<game_id>:<turn>:<battle_id>`,
payload `{game_id, turn, battle_id}`) so the in-game shell
surfaces a toast with a deep link into the Battle Viewer.
(Phase 27 deferred; needs an engine emit-side change.)
- **Battle viewer — richer ship-class visuals** — current MVP draws
one small circle plus `<class>:<numLeft>` label per `(race,
className)` pair. Future work derives shape / scale from mass,
armament, shields, and the number of ships in the group.
(Phase 27 deferred.)
- **Battle viewer — animated re-distribution on elimination** —
current implementation hard-jumps to the new spacing on the next
frame; replace with an easing so the survivors visibly slide
along the outer ring. (Phase 27 deferred.)
+136
View File
@@ -0,0 +1,136 @@
# Battle Viewer UX
Phase 27 ships a dedicated viewer for battles (`/games/<id>/battle/<battleId>`).
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 `<className>:<numLeft>` 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 `<ol>` 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/<id>/battle/<battleId>?turn=<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/<id>/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
`<class>:<numLeft>` label.
- Animated transitions when a race drops out and the survivors
re-distribute. Current implementation hard-jumps on the next
frame.
+88
View File
@@ -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<string, string>;
ships: Record<string, BattleReportGroup>;
protocol: BattleActionReport[];
}
export interface BattleReportGroup {
race: string;
className: string;
tech: Record<string, number>;
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<BattleReport> {
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;
}
+38 -12
View File
@@ -382,6 +382,18 @@ export interface ReportBombing {
* mirrors the producing planet's free industry. Stable order: sorted * mirrors the producing planet's free industry. Stable order: sorted
* by `(planetNumber, class)`. * 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 { export interface ReportShipProduction {
planetNumber: number; planetNumber: number;
class: string; class: string;
@@ -524,11 +536,17 @@ export interface GameReport {
*/ */
otherShipClass: ReportOtherShipClass[]; otherShipClass: ReportOtherShipClass[];
/** /**
* battleIds is the list of battle UUIDs the engine recorded for * battles is the list of battle summaries the engine recorded for
* the current turn. Phase 23 renders them as inactive * the current turn. Each entry carries the battle UUID, the planet
* monospace identifiers; Phase 27 will turn them into navigation * it happened on, and the number of shots exchanged. The Reports
* targets once the battle viewer lands. Empty when no battles * View uses `id` to link into the Battle Viewer; the map renderer
* occurred last turn. * 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[]; battleIds: string[];
/** /**
@@ -700,7 +718,8 @@ function decodeReport(report: Report): GameReport {
const localFleets = decodeLocalFleets(report); const localFleets = decodeLocalFleets(report);
const otherScience = decodeOtherScience(report); const otherScience = decodeOtherScience(report);
const otherShipClass = decodeOtherShipClass(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 bombings = decodeBombings(report);
const shipProductions = decodeShipProductions(report); const shipProductions = decodeShipProductions(report);
@@ -730,6 +749,7 @@ function decodeReport(report: Report): GameReport {
players, players,
otherScience, otherScience,
otherShipClass, otherShipClass,
battles,
battleIds, battleIds,
bombings, bombings,
shipProductions, shipProductions,
@@ -1153,13 +1173,18 @@ function decodeOtherShipClass(report: Report): ReportOtherShipClass[] {
return out; return out;
} }
function decodeBattleIds(report: Report): string[] { function decodeBattles(report: Report): ReportBattle[] {
const out: string[] = []; const out: ReportBattle[] = [];
for (let i = 0; i < report.battleLength(); i++) { for (let i = 0; i < report.battleLength(); i++) {
const uuid = report.battle(i); const summary = report.battle(i);
const value = uuidStringFromFB(uuid); if (summary === null) continue;
if (value === null) continue; const id = uuidStringFromFB(summary.id());
out.push(value); if (id === null) continue;
out.push({
id,
planet: Number(summary.planet()),
shots: Number(summary.shots()),
});
} }
return out; return out;
} }
@@ -1439,6 +1464,7 @@ export function applyOrderOverlay(
players: report.players ?? [], players: report.players ?? [],
otherScience: report.otherScience ?? [], otherScience: report.otherScience ?? [],
otherShipClass: report.otherShipClass ?? [], otherShipClass: report.otherShipClass ?? [],
battles: report.battles ?? [],
battleIds: report.battleIds ?? [], battleIds: report.battleIds ?? [],
bombings: report.bombings ?? [], bombings: report.bombings ?? [],
shipProductions: report.shipProductions ?? [], shipProductions: report.shipProductions ?? [],
+37
View File
@@ -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<string, BattleReport>();
/**
* 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();
}
+19 -4
View File
@@ -173,6 +173,12 @@ interface SyntheticOtherShipClass extends SyntheticShipClass {
mass?: number; mass?: number;
} }
interface SyntheticBattle {
id?: string;
planet?: number;
shots?: number;
}
interface SyntheticBombing { interface SyntheticBombing {
planet?: number; // wire field "number" planet?: number; // wire field "number"
planetName?: string; // wire field "planetName" planetName?: string; // wire field "planetName"
@@ -219,7 +225,7 @@ interface SyntheticReportRoot {
incomingGroup?: SyntheticIncomingGroup[]; incomingGroup?: SyntheticIncomingGroup[];
unidentifiedGroup?: SyntheticUnidentifiedGroup[]; unidentifiedGroup?: SyntheticUnidentifiedGroup[];
localFleet?: SyntheticLocalFleet[]; localFleet?: SyntheticLocalFleet[];
battle?: string[]; battle?: SyntheticBattle[];
bombing?: SyntheticBombing[]; bombing?: SyntheticBombing[];
shipProduction?: SyntheticShipProductionRow[]; shipProduction?: SyntheticShipProductionRow[];
} }
@@ -357,9 +363,17 @@ function decodeSyntheticReport(json: unknown): GameReport {
return a.name.localeCompare(b.name); return a.name.localeCompare(b.name);
}); });
const battleIds: string[] = (root.battle ?? []).filter( const battles = (root.battle ?? [])
(v): v is string => typeof v === "string" && v !== "", .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) => ({ const bombings: ReportBombing[] = (root.bombing ?? []).map((b) => ({
planetNumber: numOr0(b.planet), planetNumber: numOr0(b.planet),
@@ -419,6 +433,7 @@ function decodeSyntheticReport(json: unknown): GameReport {
players: collectPlayersFromSynthetic(root, race), players: collectPlayersFromSynthetic(root, race),
otherScience, otherScience,
otherShipClass, otherShipClass,
battles,
battleIds, battleIds,
bombings, bombings,
shipProductions, shipProductions,
+117 -13
View File
@@ -1,30 +1,134 @@
<!-- <!--
Phase 10 stub for the battle-log active view. Phase 27 wires the real Phase 27 — active-view wrapper around the BattleViewer. Loads the
battle viewer. BattleReport for the supplied `gameId`/`turn`/`battleId` and either
shows the radial playback (BattleViewer), a loading skeleton, or a
not-found state. The viewer itself is a logically isolated
component that takes a `BattleReport` prop — this wrapper owns
loading and routing concerns.
--> -->
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation";
import {
BattleFetchError,
fetchBattle,
type BattleReport,
} from "../../api/battle-fetch";
import { i18n } from "$lib/i18n/index.svelte"; import { i18n } from "$lib/i18n/index.svelte";
type Props = { battleId: string }; import BattleViewer from "../battle-player/battle-viewer.svelte";
let { battleId }: Props = $props();
let {
gameId,
turn,
battleId,
}: {
gameId: string;
turn: number;
battleId: string;
} = $props();
let state = $state<
| { kind: "loading" }
| { kind: "ready"; report: BattleReport }
| { kind: "not_found" }
| { kind: "error"; message: string }
>({ kind: "loading" });
$effect(() => {
if (!battleId) {
state = { kind: "not_found" };
return;
}
state = { kind: "loading" };
fetchBattle(gameId, turn, battleId)
.then((report) => {
state = { kind: "ready", report };
})
.catch((err: unknown) => {
if (err instanceof BattleFetchError && err.status === 404) {
state = { kind: "not_found" };
} else {
state = {
kind: "error",
message: err instanceof Error ? err.message : String(err),
};
}
});
});
function backToReport() {
goto(`/games/${gameId}/report`);
}
function backToMap() {
goto(`/games/${gameId}/map`);
}
</script> </script>
<section class="active-view" data-testid="active-view-battle" data-battle-id={battleId}> <section class="active-view" data-testid="active-view-battle" data-battle-id={battleId}>
<h2>{i18n.t("game.view.battle")}</h2> <nav class="back-row">
<p>{i18n.t("game.shell.coming_soon")}</p> <button
type="button"
class="back-btn"
onclick={backToMap}
data-testid="battle-back-to-map"
>{i18n.t("game.battle.back_to_map")}</button>
<button
type="button"
class="back-btn"
onclick={backToReport}
data-testid="battle-back-to-report"
>{i18n.t("game.battle.back_to_report")}</button>
</nav>
{#if state.kind === "loading"}
<p class="status" data-testid="battle-loading">
{i18n.t("game.battle.loading")}
</p>
{:else if state.kind === "ready"}
<BattleViewer report={state.report} />
{:else if state.kind === "not_found"}
<p class="status" data-testid="battle-not-found">
{i18n.t("game.battle.not_found")}
</p>
{:else}
<p class="status error" data-testid="battle-error">{state.message}</p>
{/if}
</section> </section>
<style> <style>
.active-view { .active-view {
padding: 1.5rem; padding: 1rem;
font-family: system-ui, sans-serif; font-family: system-ui, sans-serif;
color: #d6dcf2;
} }
.active-view h2 { .back-row {
margin: 0 0 0.5rem; display: flex;
font-size: 1.1rem; gap: 0.5rem;
max-width: 880px;
margin: 0 auto 1rem;
} }
.active-view p { .back-btn {
margin: 0; appearance: none;
color: #555; background: #1f2748;
color: #d6dcf2;
border: 1px solid #2c3568;
padding: 0.35rem 0.7rem;
border-radius: 3px;
cursor: pointer;
font-size: 0.85rem;
}
.back-btn:hover {
background: #2a3463;
}
.status {
margin: 2rem auto;
max-width: 880px;
color: #93a0d0;
font-size: 0.95rem;
text-align: center;
}
.status.error {
color: #e08585;
} }
</style> </style>
+34 -5
View File
@@ -21,6 +21,8 @@ preference the store already manages.
--> -->
<script lang="ts"> <script lang="ts">
import { getContext, onDestroy, onMount, untrack } from "svelte"; import { getContext, onDestroy, onMount, untrack } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { i18n } from "$lib/i18n/index.svelte"; import { i18n } from "$lib/i18n/index.svelte";
import { import {
createRenderer, createRenderer,
@@ -402,13 +404,40 @@ preference the store already manages.
if (selection === undefined) return; if (selection === undefined) return;
const hit = handle.hitAt(cursorPx); const hit = handle.hitAt(cursorPx);
if (hit === null) return; if (hit === null) return;
if (hit.primitive.kind !== "point") return;
const target = hitLookup.get(hit.primitive.id); const target = hitLookup.get(hit.primitive.id);
if (target === undefined) return; if (target === undefined) return;
if (target.kind === "planet") { switch (target.kind) {
selection.selectPlanet(target.number); case "planet":
} else { if (hit.primitive.kind !== "point") return;
selection.selectShipGroup(target.ref); selection.selectPlanet(target.number);
break;
case "shipGroup":
if (hit.primitive.kind !== "point") return;
selection.selectShipGroup(target.ref);
break;
case "battle": {
const gameId = page.params.id ?? "";
const turn = store?.report?.turn ?? 0;
void goto(
`/games/${gameId}/battle/${target.battleId}?turn=${turn}`,
);
break;
}
case "bombing": {
const gameId = page.params.id ?? "";
void goto(
`/games/${gameId}/report#report-bombings`,
).then(() => {
if (typeof document === "undefined") return;
const row = document.querySelector(
`[data-testid="report-bombing-row"][data-planet="${target.planet}"]`,
);
if (row && row.scrollIntoView) {
row.scrollIntoView({ behavior: "smooth", block: "center" });
}
});
break;
}
} }
} }
@@ -1,13 +1,14 @@
<!-- <!--
Phase 23 Report View — battles section. The wire only carries Phase 27 Report View — battles section. Each row is a link into the
battle UUIDs (the full battle report is fetched lazily by Phase 27), Battle Viewer at `/games/<id>/battle/<uuid>?turn=<turn>` where
so each row is a monospace, non-interactive `<span>` of the battle `turn` follows the current report's turn so history-mode views land
identifier. Phase 27 will turn each row into a link to on the right battle. Phase 23 rendered the same rows as inactive
`/games/<id>/battle/<uuid>`; until then dead links are worse than monospace `<span>`; the rewire here is the one-liner the Phase 23
plain text. decision log called out.
--> -->
<script lang="ts"> <script lang="ts">
import { getContext } from "svelte"; import { getContext } from "svelte";
import { page } from "$app/state";
import { i18n } from "$lib/i18n/index.svelte"; import { i18n } from "$lib/i18n/index.svelte";
import { import {
@@ -19,7 +20,9 @@ plain text.
RENDERED_REPORT_CONTEXT_KEY, RENDERED_REPORT_CONTEXT_KEY,
); );
const report = $derived(rendered?.report ?? null); const report = $derived(rendered?.report ?? null);
const ids = $derived(report?.battleIds ?? []); const battles = $derived(report?.battles ?? []);
const gameId = $derived(page.params.id ?? "");
const turn = $derived(report?.turn ?? 0);
</script> </script>
<section <section
@@ -31,22 +34,23 @@ plain text.
{#if report === null} {#if report === null}
<p class="status">{i18n.t("game.report.loading")}</p> <p class="status">{i18n.t("game.report.loading")}</p>
{:else if ids.length === 0} {:else if battles.length === 0}
<p class="status" data-testid="battles-empty"> <p class="status" data-testid="battles-empty">
{i18n.t("game.report.section.battles.empty")} {i18n.t("game.report.section.battles.empty")}
</p> </p>
{:else} {:else}
<ul class="ids" data-testid="battles-list"> <ul class="ids" data-testid="battles-list">
{#each ids as id (id)} {#each battles as b (b.id)}
<li> <li>
<span class="label"> <span class="label">
{i18n.t("game.report.section.battles.id_label")} {i18n.t("game.report.section.battles.id_label")}
</span> </span>
<span <a
class="uuid" class="uuid"
href={`/games/${gameId}/battle/${b.id}?turn=${turn}`}
data-testid="report-battle-row" data-testid="report-battle-row"
data-id={id} data-id={b.id}
>{id}</span> >{b.id}</a>
</li> </li>
{/each} {/each}
</ul> </ul>
@@ -87,5 +91,10 @@ plain text.
.uuid { .uuid {
color: #cfd7ff; color: #cfd7ff;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
text-decoration: underline;
text-underline-offset: 2px;
}
.uuid:hover {
color: #ffffff;
} }
</style> </style>
@@ -0,0 +1,223 @@
<!--
BattleScene — radial SVG visualisation of one battle frame.
Layout: planet at the centre, race anchors equally spaced on an
outer ring, each race rendered as a cluster of small class circles
labelled `<className>:<numLeft>` underneath. The shot line for the
current frame's `lastAction` is drawn from attacker group to
defender group; red when the shot destroyed the defender, green
otherwise. Observer groups (`inBattle === false`) are filtered out
by `buildFrames`, so they never appear here.
-->
<script lang="ts">
import type { BattleReport } from "../../api/battle-fetch";
import { layoutRaces } from "./radial-layout";
import {
buildGroupRaceMap,
normaliseGroups,
type Frame,
} from "./timeline";
let {
report,
frame,
}: {
report: BattleReport;
frame: Frame;
} = $props();
const VIEW_BOX = 800;
const CENTER = { x: VIEW_BOX / 2, y: VIEW_BOX / 2 };
const PLANET_RADIUS = 60;
const RACE_RING_RADIUS = 280;
const CLASS_CIRCLE_RADIUS = 24;
const CLASS_SPACING = 64;
const groupRace = $derived(buildGroupRaceMap(report.protocol));
const allGroups = $derived(normaliseGroups(report));
type ClusterEntry = {
key: number;
className: string;
numLeft: number;
};
const clustersByRace = $derived.by(() => {
const out = new Map<number, ClusterEntry[]>();
for (const g of allGroups) {
const numLeft = frame.remaining.get(g.key) ?? 0;
const list = out.get(g.raceId) ?? [];
list.push({
key: g.key,
className: g.group.className,
numLeft,
});
out.set(g.raceId, list);
}
// Stable cluster order: by classname then key.
for (const list of out.values()) {
list.sort((a, b) => {
const byName = a.className.localeCompare(b.className);
if (byName !== 0) return byName;
return a.key - b.key;
});
}
return out;
});
const raceLayout = $derived(
layoutRaces(frame.activeRaceIds, {
center: CENTER,
radius: RACE_RING_RADIUS,
}),
);
function classCircleX(index: number, count: number): number {
const span = (count - 1) * CLASS_SPACING;
return -span / 2 + index * CLASS_SPACING;
}
function findClassCircleCenter(groupKey: number) {
const raceId = groupRace.get(groupKey);
if (raceId === undefined) return null;
const anchor = raceLayout.find((a) => a.raceId === raceId);
if (!anchor) return null;
const cluster = clustersByRace.get(raceId) ?? [];
const idx = cluster.findIndex((c) => c.key === groupKey);
if (idx === -1) return null;
return {
x: anchor.x + classCircleX(idx, cluster.length),
y: anchor.y,
};
}
const shotLine = $derived.by(() => {
const action = frame.lastAction;
if (!action) return null;
const from = findClassCircleCenter(action.sa);
const to = findClassCircleCenter(action.sd);
if (!from || !to) return null;
return { from, to, destroyed: action.x };
});
const raceLabelById = $derived.by(() => {
const out = new Map<number, string>();
for (const g of allGroups) {
out.set(g.raceId, g.group.race);
}
return out;
});
</script>
<svg
class="battle-scene"
viewBox="0 0 {VIEW_BOX} {VIEW_BOX}"
role="img"
aria-label="battle scene"
data-testid="battle-scene"
>
<circle
cx={CENTER.x}
cy={CENTER.y}
r={PLANET_RADIUS}
class="planet"
data-testid="battle-scene-planet"
/>
<text
x={CENTER.x}
y={CENTER.y + PLANET_RADIUS + 24}
text-anchor="middle"
class="planet-label"
>{report.planetName} (#{report.planet})</text>
{#each raceLayout as anchor (anchor.raceId)}
{@const cluster = clustersByRace.get(anchor.raceId) ?? []}
<g
class="race-cluster"
data-testid="battle-race-cluster"
data-race-id={anchor.raceId}
>
<text
x={anchor.x}
y={anchor.y - CLASS_CIRCLE_RADIUS - 12}
text-anchor="middle"
class="race-label"
>{raceLabelById.get(anchor.raceId) ?? `race ${anchor.raceId}`}</text>
{#each cluster as entry, i (entry.key)}
{@const cx = anchor.x + classCircleX(i, cluster.length)}
<g
class="class-marker"
data-testid="battle-class-marker"
data-group-key={entry.key}
>
<circle
cx={cx}
cy={anchor.y}
r={CLASS_CIRCLE_RADIUS}
/>
<text
x={cx}
y={anchor.y + CLASS_CIRCLE_RADIUS + 16}
text-anchor="middle"
class="class-label"
>{entry.className}:{entry.numLeft}</text>
</g>
{/each}
</g>
{/each}
{#if shotLine}
<line
x1={shotLine.from.x}
y1={shotLine.from.y}
x2={shotLine.to.x}
y2={shotLine.to.y}
class="shot"
class:destroyed={shotLine.destroyed}
data-testid="battle-shot"
data-destroyed={shotLine.destroyed ? "true" : "false"}
/>
{/if}
</svg>
<style>
.battle-scene {
width: 100%;
height: auto;
background: #0a0d1a;
display: block;
}
.planet {
fill: #2a345f;
stroke: #5b6aa3;
stroke-width: 2;
}
.planet-label {
fill: #c4caea;
font-size: 18px;
font-family: ui-sans-serif, system-ui, sans-serif;
}
.race-label {
fill: #e2e6ff;
font-size: 16px;
font-weight: 600;
font-family: ui-sans-serif, system-ui, sans-serif;
}
.class-marker circle {
fill: #1a2042;
stroke: #6d7bb5;
stroke-width: 1.5;
}
.class-label {
fill: #b8c0e6;
font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.shot {
stroke: #44dd66;
stroke-width: 2;
}
.shot.destroyed {
stroke: #ee3344;
}
</style>
@@ -0,0 +1,167 @@
<!--
BattleViewer — orchestrates the radial battle scene, the playback
controls, and the accessibility text log for one BattleReport. Owns
the playback state (`frameIndex`, `playing`, `speed`). The component
is logically isolated: feed it any `BattleReport` matching
`pkg/model/report/battle.go` and it plays back.
-->
<script lang="ts">
import { onDestroy } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import type { BattleReport } from "../../api/battle-fetch";
import BattleScene from "./battle-scene.svelte";
import PlaybackControls from "./playback-controls.svelte";
import { buildFrames } from "./timeline";
let { report }: { report: BattleReport } = $props();
const frames = $derived(buildFrames(report));
let frameIndex = $state(0);
let playing = $state(false);
let speed = $state<1 | 2 | 4>(1);
const frame = $derived(frames[Math.min(frameIndex, frames.length - 1)]);
// 1x = 400 ms per frame, 2x = 200 ms, 4x = 100 ms. The timer is
// rescheduled whenever `speed` or `playing` flips.
let timer: ReturnType<typeof setInterval> | null = null;
$effect(() => {
if (timer !== null) {
clearInterval(timer);
timer = null;
}
if (!playing) return;
const intervalMs = 400 / speed;
timer = setInterval(() => {
if (frameIndex >= frames.length - 1) {
playing = false;
return;
}
frameIndex = frameIndex + 1;
}, intervalMs);
});
onDestroy(() => {
if (timer !== null) {
clearInterval(timer);
timer = null;
}
});
function describeAction(index: number): string {
const action = report.protocol[index];
const attackerGroup = report.ships[String(action.sa)];
const defenderGroup = report.ships[String(action.sd)];
const attackerRace = attackerGroup?.race ?? `race ${action.a}`;
const attackerClass = attackerGroup?.className ?? `class ${action.sa}`;
const defenderRace = defenderGroup?.race ?? `race ${action.d}`;
const defenderClass = defenderGroup?.className ?? `class ${action.sd}`;
const key = action.x
? "game.battle.log.destroyed"
: "game.battle.log.shielded";
return i18n.t(key, {
attacker_race: attackerRace,
attacker_class: attackerClass,
defender_race: defenderRace,
defender_class: defenderClass,
});
}
</script>
<div class="viewer" data-testid="battle-viewer">
<header class="header">
<h2 data-testid="battle-viewer-title">
{i18n.t("game.battle.title")}
</h2>
<span class="progress" data-testid="battle-frame-index">
{frame.shotIndex} / {report.protocol.length}
</span>
</header>
<div class="scene">
<BattleScene {report} {frame} />
</div>
<PlaybackControls
bind:playing
bind:frameIndex
bind:speed
frameCount={frames.length}
/>
<section
class="log"
aria-label={i18n.t("game.battle.accessibility.protocol_heading")}
>
<h3>{i18n.t("game.battle.accessibility.protocol_heading")}</h3>
<ol data-testid="battle-protocol-log">
{#each report.protocol as _action, i (i)}
<li
data-testid="battle-protocol-log-item"
data-current={i + 1 === frame.shotIndex ? "true" : "false"}
>{describeAction(i)}</li>
{/each}
</ol>
</section>
</div>
<style>
.viewer {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 880px;
margin: 0 auto;
padding: 1rem;
color: #d6dcf2;
font-family: inherit;
}
.header {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.header h2 {
margin: 0;
font-size: 1.1rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.progress {
color: #93a0d0;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.scene {
background: #0a0d1a;
border: 1px solid #1e264a;
border-radius: 4px;
overflow: hidden;
}
.log h3 {
margin: 0 0 0.4rem;
color: #93a0d0;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.log ol {
list-style: decimal inside;
margin: 0;
padding: 0;
font-size: 0.85rem;
max-height: 14rem;
overflow-y: auto;
color: #c6cdf0;
}
.log li {
padding: 0.15rem 0;
border-bottom: 1px solid #1c2240;
}
.log li[data-current="true"] {
color: #ffe27a;
font-weight: 600;
}
</style>
@@ -0,0 +1,145 @@
<!--
PlaybackControls — rewind / step-back / play-pause / step-forward
plus a 1x/2x/4x speed switch. Owns no playback state; bind `playing`,
`frameIndex`, and `speed` from the orchestrator. Disables step/rewind
when there's nowhere to go and disables forward when the timeline is
already at its end.
-->
<script lang="ts">
import { i18n } from "$lib/i18n/index.svelte";
let {
playing = $bindable(),
frameIndex = $bindable(),
speed = $bindable(),
frameCount,
}: {
playing: boolean;
frameIndex: number;
speed: 1 | 2 | 4;
frameCount: number;
} = $props();
function rewind() {
playing = false;
frameIndex = 0;
}
function stepBack() {
playing = false;
if (frameIndex > 0) frameIndex = frameIndex - 1;
}
function togglePlay() {
if (frameIndex >= frameCount - 1) {
frameIndex = 0;
}
playing = !playing;
}
function stepForward() {
playing = false;
if (frameIndex < frameCount - 1) frameIndex = frameIndex + 1;
}
function setSpeed(value: 1 | 2 | 4) {
speed = value;
}
</script>
<div class="controls" data-testid="battle-controls">
<button
type="button"
onclick={rewind}
disabled={frameIndex === 0}
aria-label={i18n.t("game.battle.controls.rewind")}
data-testid="battle-control-rewind"
></button>
<button
type="button"
onclick={stepBack}
disabled={frameIndex === 0}
aria-label={i18n.t("game.battle.controls.step_backward")}
data-testid="battle-control-step-back"
>◀︎</button>
<button
type="button"
onclick={togglePlay}
aria-label={playing
? i18n.t("game.battle.controls.pause")
: i18n.t("game.battle.controls.play")}
data-testid="battle-control-play"
data-playing={playing ? "true" : "false"}
>{playing ? "⏸" : "▶︎"}</button>
<button
type="button"
onclick={stepForward}
disabled={frameIndex >= frameCount - 1}
aria-label={i18n.t("game.battle.controls.step_forward")}
data-testid="battle-control-step-forward"
>▶︎▶︎</button>
<div class="spacer" aria-hidden="true"></div>
<span class="speed-label">{i18n.t("game.battle.controls.speed_label")}</span>
<button
type="button"
class:active={speed === 1}
onclick={() => setSpeed(1)}
data-testid="battle-control-speed-1x"
>{i18n.t("game.battle.controls.speed_1x")}</button>
<button
type="button"
class:active={speed === 2}
onclick={() => setSpeed(2)}
data-testid="battle-control-speed-2x"
>{i18n.t("game.battle.controls.speed_2x")}</button>
<button
type="button"
class:active={speed === 4}
onclick={() => setSpeed(4)}
data-testid="battle-control-speed-4x"
>{i18n.t("game.battle.controls.speed_4x")}</button>
</div>
<style>
.controls {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.75rem;
background: #131934;
border: 1px solid #1e264a;
border-radius: 4px;
}
.spacer {
flex: 1 1 auto;
}
button {
appearance: none;
background: #1f2748;
color: #d6dcf2;
border: 1px solid #2c3568;
padding: 0.35rem 0.7rem;
border-radius: 3px;
cursor: pointer;
font-size: 0.9rem;
font-family: inherit;
min-width: 2.5rem;
}
button:hover:not(:disabled) {
background: #2a3463;
}
button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
button.active {
background: #3a4585;
border-color: #5d6cb8;
color: #ffffff;
}
.speed-label {
color: #93a0d0;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-right: 0.2rem;
}
</style>
@@ -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;
}
@@ -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<number, number>;
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<number, number> {
const out = new Map<number, number>();
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<number, number>();
const raceTotals = new Map<number, number>();
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<number, number>();
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, number>): number[] {
const out: number[] = [];
for (const [raceId, total] of totals.entries()) {
if (total > 0) out.push(raceId);
}
return out.sort((a, b) => a - b);
}
+17
View File
@@ -483,6 +483,23 @@ const en = {
"game.report.section.battles.title": "battles", "game.report.section.battles.title": "battles",
"game.report.section.battles.empty": "no battles last turn", "game.report.section.battles.empty": "no battles last turn",
"game.report.section.battles.id_label": "battle", "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.title": "bombings",
"game.report.section.bombings.empty": "no bombings last turn", "game.report.section.bombings.empty": "no bombings last turn",
"game.report.section.bombings.column.planet": "planet", "game.report.section.bombings.column.planet": "planet",
+17
View File
@@ -484,6 +484,23 @@ const ru: Record<keyof typeof en, string> = {
"game.report.section.battles.title": "сражения", "game.report.section.battles.title": "сражения",
"game.report.section.battles.empty": "сражений в этом ходу не было", "game.report.section.battles.empty": "сражений в этом ходу не было",
"game.report.section.battles.id_label": "сражение", "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.title": "бомбардировки",
"game.report.section.bombings.empty": "бомбардировок в этом ходу не было", "game.report.section.bombings.empty": "бомбардировок в этом ходу не было",
"game.report.section.bombings.column.planet": "планета", "game.report.section.bombings.column.planet": "планета",
+168
View File
@@ -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<PrimitiveID, MarkerTarget>;
}
/**
* 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<number, ReportPlanet>();
for (const planet of report.planets) {
planetByNumber.set(planet.number, planet);
}
const primitives: Primitive[] = [];
const lookup = new Map<PrimitiveID, MarkerTarget>();
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 };
}
+12 -1
View File
@@ -15,6 +15,7 @@
import type { GameReport, ReportPlanet } from "../api/game-state"; import type { GameReport, ReportPlanet } from "../api/game-state";
import type { ShipGroupRef } from "../lib/selection.svelte"; import type { ShipGroupRef } from "../lib/selection.svelte";
import { buildBattleAndBombingMarkers } from "./battle-markers";
import { shipGroupsToPrimitives } from "./ship-groups"; import { shipGroupsToPrimitives } from "./ship-groups";
import { World, type Primitive, type PrimitiveID, type Style } from "./world"; import { World, type Primitive, type PrimitiveID, type Style } from "./world";
@@ -83,7 +84,9 @@ function priorityFor(kind: ReportPlanet["kind"]): number {
*/ */
export type HitTarget = export type HitTarget =
| { kind: "planet"; number: number } | { 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 { export interface ReportToWorldResult {
world: World; world: World;
@@ -127,6 +130,14 @@ export function reportToWorld(report: GameReport): ReportToWorldResult {
hitLookup.set(primId, { kind: "shipGroup", ref }); 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 width = report.mapWidth > 0 ? report.mapWidth : 1;
const height = report.mapHeight > 0 ? report.mapHeight : 1; const height = report.mapHeight > 0 ? report.mapHeight : 1;
return { world: new World(width, height, primitives), hitLookup }; return { world: new World(width, height, primitives), hitLookup };
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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<ApplicationSubmitResponseT> { export class ApplicationSubmitResponse implements flatbuffers.IUnpackableObject<ApplicationSubmitResponseT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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<ErrorResponseT> { export class ErrorResponse implements flatbuffers.IUnpackableObject<ErrorResponseT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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<GameCreateResponseT> { export class GameCreateResponse implements flatbuffers.IUnpackableObject<GameCreateResponseT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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<InviteDeclineResponseT> { export class InviteDeclineResponse implements flatbuffers.IUnpackableObject<InviteDeclineResponseT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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<InviteRedeemResponseT> { export class InviteRedeemResponse implements flatbuffers.IUnpackableObject<InviteRedeemResponseT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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<MyApplicationsListResponseT> { export class MyApplicationsListResponse implements flatbuffers.IUnpackableObject<MyApplicationsListResponseT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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<MyGamesListResponseT> { export class MyGamesListResponse implements flatbuffers.IUnpackableObject<MyGamesListResponseT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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<MyInvitesListResponseT> { export class MyInvitesListResponse implements flatbuffers.IUnpackableObject<MyInvitesListResponseT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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<PublicGamesListResponseT> { export class PublicGamesListResponse implements flatbuffers.IUnpackableObject<PublicGamesListResponseT> {
@@ -4,30 +4,30 @@
import * as flatbuffers from 'flatbuffers'; import * as flatbuffers from 'flatbuffers';
import { CommandFleetMerge, CommandFleetMergeT } from './command-fleet-merge.js'; import { CommandFleetMerge, CommandFleetMergeT } from '../order/command-fleet-merge.js';
import { CommandFleetSend, CommandFleetSendT } from './command-fleet-send.js'; import { CommandFleetSend, CommandFleetSendT } from '../order/command-fleet-send.js';
import { CommandPayload, unionToCommandPayload, unionListToCommandPayload } from './command-payload.js'; import { CommandPayload, unionToCommandPayload, unionListToCommandPayload } from '../order/command-payload.js';
import { CommandPlanetProduce, CommandPlanetProduceT } from './command-planet-produce.js'; import { CommandPlanetProduce, CommandPlanetProduceT } from '../order/command-planet-produce.js';
import { CommandPlanetRename, CommandPlanetRenameT } from './command-planet-rename.js'; import { CommandPlanetRename, CommandPlanetRenameT } from '../order/command-planet-rename.js';
import { CommandPlanetRouteRemove, CommandPlanetRouteRemoveT } from './command-planet-route-remove.js'; import { CommandPlanetRouteRemove, CommandPlanetRouteRemoveT } from '../order/command-planet-route-remove.js';
import { CommandPlanetRouteSet, CommandPlanetRouteSetT } from './command-planet-route-set.js'; import { CommandPlanetRouteSet, CommandPlanetRouteSetT } from '../order/command-planet-route-set.js';
import { CommandRaceQuit, CommandRaceQuitT } from './command-race-quit.js'; import { CommandRaceQuit, CommandRaceQuitT } from '../order/command-race-quit.js';
import { CommandRaceRelation, CommandRaceRelationT } from './command-race-relation.js'; import { CommandRaceRelation, CommandRaceRelationT } from '../order/command-race-relation.js';
import { CommandRaceVote, CommandRaceVoteT } from './command-race-vote.js'; import { CommandRaceVote, CommandRaceVoteT } from '../order/command-race-vote.js';
import { CommandScienceCreate, CommandScienceCreateT } from './command-science-create.js'; import { CommandScienceCreate, CommandScienceCreateT } from '../order/command-science-create.js';
import { CommandScienceRemove, CommandScienceRemoveT } from './command-science-remove.js'; import { CommandScienceRemove, CommandScienceRemoveT } from '../order/command-science-remove.js';
import { CommandShipClassCreate, CommandShipClassCreateT } from './command-ship-class-create.js'; import { CommandShipClassCreate, CommandShipClassCreateT } from '../order/command-ship-class-create.js';
import { CommandShipClassMerge, CommandShipClassMergeT } from './command-ship-class-merge.js'; import { CommandShipClassMerge, CommandShipClassMergeT } from '../order/command-ship-class-merge.js';
import { CommandShipClassRemove, CommandShipClassRemoveT } from './command-ship-class-remove.js'; import { CommandShipClassRemove, CommandShipClassRemoveT } from '../order/command-ship-class-remove.js';
import { CommandShipGroupBreak, CommandShipGroupBreakT } from './command-ship-group-break.js'; import { CommandShipGroupBreak, CommandShipGroupBreakT } from '../order/command-ship-group-break.js';
import { CommandShipGroupDismantle, CommandShipGroupDismantleT } from './command-ship-group-dismantle.js'; import { CommandShipGroupDismantle, CommandShipGroupDismantleT } from '../order/command-ship-group-dismantle.js';
import { CommandShipGroupJoinFleet, CommandShipGroupJoinFleetT } from './command-ship-group-join-fleet.js'; import { CommandShipGroupJoinFleet, CommandShipGroupJoinFleetT } from '../order/command-ship-group-join-fleet.js';
import { CommandShipGroupLoad, CommandShipGroupLoadT } from './command-ship-group-load.js'; import { CommandShipGroupLoad, CommandShipGroupLoadT } from '../order/command-ship-group-load.js';
import { CommandShipGroupMerge, CommandShipGroupMergeT } from './command-ship-group-merge.js'; import { CommandShipGroupMerge, CommandShipGroupMergeT } from '../order/command-ship-group-merge.js';
import { CommandShipGroupSend, CommandShipGroupSendT } from './command-ship-group-send.js'; import { CommandShipGroupSend, CommandShipGroupSendT } from '../order/command-ship-group-send.js';
import { CommandShipGroupTransfer, CommandShipGroupTransferT } from './command-ship-group-transfer.js'; import { CommandShipGroupTransfer, CommandShipGroupTransferT } from '../order/command-ship-group-transfer.js';
import { CommandShipGroupUnload, CommandShipGroupUnloadT } from './command-ship-group-unload.js'; import { CommandShipGroupUnload, CommandShipGroupUnloadT } from '../order/command-ship-group-unload.js';
import { CommandShipGroupUpgrade, CommandShipGroupUpgradeT } from './command-ship-group-upgrade.js'; import { CommandShipGroupUpgrade, CommandShipGroupUpgradeT } from '../order/command-ship-group-upgrade.js';
export class CommandItem implements flatbuffers.IUnpackableObject<CommandItemT> { export class CommandItem implements flatbuffers.IUnpackableObject<CommandItemT> {
@@ -2,29 +2,29 @@
/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ /* 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 { CommandFleetMerge, CommandFleetMergeT } from '../order/command-fleet-merge.js';
import { CommandFleetSend, CommandFleetSendT } from './command-fleet-send.js'; import { CommandFleetSend, CommandFleetSendT } from '../order/command-fleet-send.js';
import { CommandPlanetProduce, CommandPlanetProduceT } from './command-planet-produce.js'; import { CommandPlanetProduce, CommandPlanetProduceT } from '../order/command-planet-produce.js';
import { CommandPlanetRename, CommandPlanetRenameT } from './command-planet-rename.js'; import { CommandPlanetRename, CommandPlanetRenameT } from '../order/command-planet-rename.js';
import { CommandPlanetRouteRemove, CommandPlanetRouteRemoveT } from './command-planet-route-remove.js'; import { CommandPlanetRouteRemove, CommandPlanetRouteRemoveT } from '../order/command-planet-route-remove.js';
import { CommandPlanetRouteSet, CommandPlanetRouteSetT } from './command-planet-route-set.js'; import { CommandPlanetRouteSet, CommandPlanetRouteSetT } from '../order/command-planet-route-set.js';
import { CommandRaceQuit, CommandRaceQuitT } from './command-race-quit.js'; import { CommandRaceQuit, CommandRaceQuitT } from '../order/command-race-quit.js';
import { CommandRaceRelation, CommandRaceRelationT } from './command-race-relation.js'; import { CommandRaceRelation, CommandRaceRelationT } from '../order/command-race-relation.js';
import { CommandRaceVote, CommandRaceVoteT } from './command-race-vote.js'; import { CommandRaceVote, CommandRaceVoteT } from '../order/command-race-vote.js';
import { CommandScienceCreate, CommandScienceCreateT } from './command-science-create.js'; import { CommandScienceCreate, CommandScienceCreateT } from '../order/command-science-create.js';
import { CommandScienceRemove, CommandScienceRemoveT } from './command-science-remove.js'; import { CommandScienceRemove, CommandScienceRemoveT } from '../order/command-science-remove.js';
import { CommandShipClassCreate, CommandShipClassCreateT } from './command-ship-class-create.js'; import { CommandShipClassCreate, CommandShipClassCreateT } from '../order/command-ship-class-create.js';
import { CommandShipClassMerge, CommandShipClassMergeT } from './command-ship-class-merge.js'; import { CommandShipClassMerge, CommandShipClassMergeT } from '../order/command-ship-class-merge.js';
import { CommandShipClassRemove, CommandShipClassRemoveT } from './command-ship-class-remove.js'; import { CommandShipClassRemove, CommandShipClassRemoveT } from '../order/command-ship-class-remove.js';
import { CommandShipGroupBreak, CommandShipGroupBreakT } from './command-ship-group-break.js'; import { CommandShipGroupBreak, CommandShipGroupBreakT } from '../order/command-ship-group-break.js';
import { CommandShipGroupDismantle, CommandShipGroupDismantleT } from './command-ship-group-dismantle.js'; import { CommandShipGroupDismantle, CommandShipGroupDismantleT } from '../order/command-ship-group-dismantle.js';
import { CommandShipGroupJoinFleet, CommandShipGroupJoinFleetT } from './command-ship-group-join-fleet.js'; import { CommandShipGroupJoinFleet, CommandShipGroupJoinFleetT } from '../order/command-ship-group-join-fleet.js';
import { CommandShipGroupLoad, CommandShipGroupLoadT } from './command-ship-group-load.js'; import { CommandShipGroupLoad, CommandShipGroupLoadT } from '../order/command-ship-group-load.js';
import { CommandShipGroupMerge, CommandShipGroupMergeT } from './command-ship-group-merge.js'; import { CommandShipGroupMerge, CommandShipGroupMergeT } from '../order/command-ship-group-merge.js';
import { CommandShipGroupSend, CommandShipGroupSendT } from './command-ship-group-send.js'; import { CommandShipGroupSend, CommandShipGroupSendT } from '../order/command-ship-group-send.js';
import { CommandShipGroupTransfer, CommandShipGroupTransferT } from './command-ship-group-transfer.js'; import { CommandShipGroupTransfer, CommandShipGroupTransferT } from '../order/command-ship-group-transfer.js';
import { CommandShipGroupUnload, CommandShipGroupUnloadT } from './command-ship-group-unload.js'; import { CommandShipGroupUnload, CommandShipGroupUnloadT } from '../order/command-ship-group-unload.js';
import { CommandShipGroupUpgrade, CommandShipGroupUpgradeT } from './command-ship-group-upgrade.js'; import { CommandShipGroupUpgrade, CommandShipGroupUpgradeT } from '../order/command-ship-group-upgrade.js';
export enum CommandPayload { export enum CommandPayload {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; import * as flatbuffers from 'flatbuffers';
import { PlanetProduction } from './planet-production.js'; import { PlanetProduction } from '../order/planet-production.js';
export class CommandPlanetProduce implements flatbuffers.IUnpackableObject<CommandPlanetProduceT> { export class CommandPlanetProduce implements flatbuffers.IUnpackableObject<CommandPlanetProduceT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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<CommandPlanetRouteRemoveT> { export class CommandPlanetRouteRemove implements flatbuffers.IUnpackableObject<CommandPlanetRouteRemoveT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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<CommandPlanetRouteSetT> { export class CommandPlanetRouteSet implements flatbuffers.IUnpackableObject<CommandPlanetRouteSetT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; import * as flatbuffers from 'flatbuffers';
import { Relation } from './relation.js'; import { Relation } from '../order/relation.js';
export class CommandRaceRelation implements flatbuffers.IUnpackableObject<CommandRaceRelationT> { export class CommandRaceRelation implements flatbuffers.IUnpackableObject<CommandRaceRelationT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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<CommandShipGroupLoadT> { export class CommandShipGroupLoad implements flatbuffers.IUnpackableObject<CommandShipGroupLoadT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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<CommandShipGroupUpgradeT> { export class CommandShipGroupUpgrade implements flatbuffers.IUnpackableObject<CommandShipGroupUpgradeT> {
@@ -5,7 +5,7 @@
import * as flatbuffers from 'flatbuffers'; import * as flatbuffers from 'flatbuffers';
import { UUID, UUIDT } from '../common/uuid.js'; 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<UserGamesCommandT> { export class UserGamesCommand implements flatbuffers.IUnpackableObject<UserGamesCommandT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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<UserGamesOrderGetResponseT> { export class UserGamesOrderGetResponse implements flatbuffers.IUnpackableObject<UserGamesOrderGetResponseT> {
@@ -5,7 +5,7 @@
import * as flatbuffers from 'flatbuffers'; import * as flatbuffers from 'flatbuffers';
import { UUID, UUIDT } from '../common/uuid.js'; 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<UserGamesOrderResponseT> { export class UserGamesOrderResponse implements flatbuffers.IUnpackableObject<UserGamesOrderResponseT> {
@@ -5,7 +5,7 @@
import * as flatbuffers from 'flatbuffers'; import * as flatbuffers from 'flatbuffers';
import { UUID, UUIDT } from '../common/uuid.js'; 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<UserGamesOrderT> { export class UserGamesOrder implements flatbuffers.IUnpackableObject<UserGamesOrderT> {
@@ -2,6 +2,7 @@
/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */ /* 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 { Bombing, BombingT } from './report/bombing.js';
export { GameReportRequest, GameReportRequestT } from './report/game-report-request.js'; export { GameReportRequest, GameReportRequestT } from './report/game-report-request.js';
export { IncomingGroup, IncomingGroupT } from './report/incoming-group.js'; export { IncomingGroup, IncomingGroupT } from './report/incoming-group.js';
@@ -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<BattleSummaryT> {
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
);
}
}
@@ -5,7 +5,7 @@
import * as flatbuffers from 'flatbuffers'; import * as flatbuffers from 'flatbuffers';
import { UUID, UUIDT } from '../common/uuid.js'; 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<LocalGroupT> { export class LocalGroup implements flatbuffers.IUnpackableObject<LocalGroupT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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<OtherGroupT> { export class OtherGroup implements flatbuffers.IUnpackableObject<OtherGroupT> {
@@ -4,24 +4,24 @@
import * as flatbuffers from 'flatbuffers'; import * as flatbuffers from 'flatbuffers';
import { UUID, UUIDT } from '../common/uuid.js'; import { BattleSummary, BattleSummaryT } from '../report/battle-summary.js';
import { Bombing, BombingT } from './bombing.js'; import { Bombing, BombingT } from '../report/bombing.js';
import { IncomingGroup, IncomingGroupT } from './incoming-group.js'; import { IncomingGroup, IncomingGroupT } from '../report/incoming-group.js';
import { LocalFleet, LocalFleetT } from './local-fleet.js'; import { LocalFleet, LocalFleetT } from '../report/local-fleet.js';
import { LocalGroup, LocalGroupT } from './local-group.js'; import { LocalGroup, LocalGroupT } from '../report/local-group.js';
import { LocalPlanet, LocalPlanetT } from './local-planet.js'; import { LocalPlanet, LocalPlanetT } from '../report/local-planet.js';
import { OtherGroup, OtherGroupT } from './other-group.js'; import { OtherGroup, OtherGroupT } from '../report/other-group.js';
import { OtherPlanet, OtherPlanetT } from './other-planet.js'; import { OtherPlanet, OtherPlanetT } from '../report/other-planet.js';
import { OtherScience, OtherScienceT } from './other-science.js'; import { OtherScience, OtherScienceT } from '../report/other-science.js';
import { OthersShipClass, OthersShipClassT } from './others-ship-class.js'; import { OthersShipClass, OthersShipClassT } from '../report/others-ship-class.js';
import { Player, PlayerT } from './player.js'; import { Player, PlayerT } from '../report/player.js';
import { Route, RouteT } from './route.js'; import { Route, RouteT } from '../report/route.js';
import { Science, ScienceT } from './science.js'; import { Science, ScienceT } from '../report/science.js';
import { ShipClass, ShipClassT } from './ship-class.js'; import { ShipClass, ShipClassT } from '../report/ship-class.js';
import { ShipProduction, ShipProductionT } from './ship-production.js'; import { ShipProduction, ShipProductionT } from '../report/ship-production.js';
import { UnidentifiedGroup, UnidentifiedGroupT } from './unidentified-group.js'; import { UnidentifiedGroup, UnidentifiedGroupT } from '../report/unidentified-group.js';
import { UnidentifiedPlanet, UnidentifiedPlanetT } from './unidentified-planet.js'; import { UnidentifiedPlanet, UnidentifiedPlanetT } from '../report/unidentified-planet.js';
import { UninhabitedPlanet, UninhabitedPlanetT } from './uninhabited-planet.js'; import { UninhabitedPlanet, UninhabitedPlanetT } from '../report/uninhabited-planet.js';
export class Report implements flatbuffers.IUnpackableObject<ReportT> { export class Report implements flatbuffers.IUnpackableObject<ReportT> {
@@ -136,9 +136,9 @@ otherShipClassLength():number {
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; 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); 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 { battleLength():number {
@@ -386,8 +386,16 @@ static addBattle(builder:flatbuffers.Builder, battleOffset:flatbuffers.Offset) {
builder.addFieldOffset(13, battleOffset, 0); 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) { 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) { static addBombing(builder:flatbuffers.Builder, bombingOffset:flatbuffers.Offset) {
@@ -641,7 +649,7 @@ unpack(): ReportT {
this.bb!.createObjList<OtherScience, OtherScienceT>(this.otherScience.bind(this), this.otherScienceLength()), this.bb!.createObjList<OtherScience, OtherScienceT>(this.otherScience.bind(this), this.otherScienceLength()),
this.bb!.createObjList<ShipClass, ShipClassT>(this.localShipClass.bind(this), this.localShipClassLength()), this.bb!.createObjList<ShipClass, ShipClassT>(this.localShipClass.bind(this), this.localShipClassLength()),
this.bb!.createObjList<OthersShipClass, OthersShipClassT>(this.otherShipClass.bind(this), this.otherShipClassLength()), this.bb!.createObjList<OthersShipClass, OthersShipClassT>(this.otherShipClass.bind(this), this.otherShipClassLength()),
this.bb!.createObjList<UUID, UUIDT>(this.battle.bind(this), this.battleLength()), this.bb!.createObjList<BattleSummary, BattleSummaryT>(this.battle.bind(this), this.battleLength()),
this.bb!.createObjList<Bombing, BombingT>(this.bombing.bind(this), this.bombingLength()), this.bb!.createObjList<Bombing, BombingT>(this.bombing.bind(this), this.bombingLength()),
this.bb!.createObjList<IncomingGroup, IncomingGroupT>(this.incomingGroup.bind(this), this.incomingGroupLength()), this.bb!.createObjList<IncomingGroup, IncomingGroupT>(this.incomingGroup.bind(this), this.incomingGroupLength()),
this.bb!.createObjList<LocalPlanet, LocalPlanetT>(this.localPlanet.bind(this), this.localPlanetLength()), this.bb!.createObjList<LocalPlanet, LocalPlanetT>(this.localPlanet.bind(this), this.localPlanetLength()),
@@ -672,7 +680,7 @@ unpackTo(_o: ReportT): void {
_o.otherScience = this.bb!.createObjList<OtherScience, OtherScienceT>(this.otherScience.bind(this), this.otherScienceLength()); _o.otherScience = this.bb!.createObjList<OtherScience, OtherScienceT>(this.otherScience.bind(this), this.otherScienceLength());
_o.localShipClass = this.bb!.createObjList<ShipClass, ShipClassT>(this.localShipClass.bind(this), this.localShipClassLength()); _o.localShipClass = this.bb!.createObjList<ShipClass, ShipClassT>(this.localShipClass.bind(this), this.localShipClassLength());
_o.otherShipClass = this.bb!.createObjList<OthersShipClass, OthersShipClassT>(this.otherShipClass.bind(this), this.otherShipClassLength()); _o.otherShipClass = this.bb!.createObjList<OthersShipClass, OthersShipClassT>(this.otherShipClass.bind(this), this.otherShipClassLength());
_o.battle = this.bb!.createObjList<UUID, UUIDT>(this.battle.bind(this), this.battleLength()); _o.battle = this.bb!.createObjList<BattleSummary, BattleSummaryT>(this.battle.bind(this), this.battleLength());
_o.bombing = this.bb!.createObjList<Bombing, BombingT>(this.bombing.bind(this), this.bombingLength()); _o.bombing = this.bb!.createObjList<Bombing, BombingT>(this.bombing.bind(this), this.bombingLength());
_o.incomingGroup = this.bb!.createObjList<IncomingGroup, IncomingGroupT>(this.incomingGroup.bind(this), this.incomingGroupLength()); _o.incomingGroup = this.bb!.createObjList<IncomingGroup, IncomingGroupT>(this.incomingGroup.bind(this), this.incomingGroupLength());
_o.localPlanet = this.bb!.createObjList<LocalPlanet, LocalPlanetT>(this.localPlanet.bind(this), this.localPlanetLength()); _o.localPlanet = this.bb!.createObjList<LocalPlanet, LocalPlanetT>(this.localPlanet.bind(this), this.localPlanetLength());
@@ -703,7 +711,7 @@ constructor(
public otherScience: (OtherScienceT)[] = [], public otherScience: (OtherScienceT)[] = [],
public localShipClass: (ShipClassT)[] = [], public localShipClass: (ShipClassT)[] = [],
public otherShipClass: (OthersShipClassT)[] = [], public otherShipClass: (OthersShipClassT)[] = [],
public battle: (UUIDT)[] = [], public battle: (BattleSummaryT)[] = [],
public bombing: (BombingT)[] = [], public bombing: (BombingT)[] = [],
public incomingGroup: (IncomingGroupT)[] = [], public incomingGroup: (IncomingGroupT)[] = [],
public localPlanet: (LocalPlanetT)[] = [], public localPlanet: (LocalPlanetT)[] = [],
@@ -727,7 +735,7 @@ pack(builder:flatbuffers.Builder): flatbuffers.Offset {
const otherScience = Report.createOtherScienceVector(builder, builder.createObjectOffsetList(this.otherScience)); const otherScience = Report.createOtherScienceVector(builder, builder.createObjectOffsetList(this.otherScience));
const localShipClass = Report.createLocalShipClassVector(builder, builder.createObjectOffsetList(this.localShipClass)); const localShipClass = Report.createLocalShipClassVector(builder, builder.createObjectOffsetList(this.localShipClass));
const otherShipClass = Report.createOtherShipClassVector(builder, builder.createObjectOffsetList(this.otherShipClass)); 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 bombing = Report.createBombingVector(builder, builder.createObjectOffsetList(this.bombing));
const incomingGroup = Report.createIncomingGroupVector(builder, builder.createObjectOffsetList(this.incomingGroup)); const incomingGroup = Report.createIncomingGroupVector(builder, builder.createObjectOffsetList(this.incomingGroup));
const localPlanet = Report.createLocalPlanetVector(builder, builder.createObjectOffsetList(this.localPlanet)); const localPlanet = Report.createLocalPlanetVector(builder, builder.createObjectOffsetList(this.localPlanet));
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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<RouteT> { export class Route implements flatbuffers.IUnpackableObject<RouteT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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<AccountResponseT> { export class AccountResponse implements flatbuffers.IUnpackableObject<AccountResponseT> {
@@ -4,9 +4,9 @@
import * as flatbuffers from 'flatbuffers'; import * as flatbuffers from 'flatbuffers';
import { ActiveLimit, ActiveLimitT } from './active-limit.js'; import { ActiveLimit, ActiveLimitT } from '../user/active-limit.js';
import { ActiveSanction, ActiveSanctionT } from './active-sanction.js'; import { ActiveSanction, ActiveSanctionT } from '../user/active-sanction.js';
import { EntitlementSnapshot, EntitlementSnapshotT } from './entitlement-snapshot.js'; import { EntitlementSnapshot, EntitlementSnapshotT } from '../user/entitlement-snapshot.js';
export class AccountView implements flatbuffers.IUnpackableObject<AccountViewT> { export class AccountView implements flatbuffers.IUnpackableObject<AccountViewT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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<ActiveLimitT> { export class ActiveLimit implements flatbuffers.IUnpackableObject<ActiveLimitT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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<ActiveSanctionT> { export class ActiveSanction implements flatbuffers.IUnpackableObject<ActiveSanctionT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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<EntitlementSnapshotT> { export class EntitlementSnapshot implements flatbuffers.IUnpackableObject<EntitlementSnapshotT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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<ErrorResponseT> { export class ErrorResponse implements flatbuffers.IUnpackableObject<ErrorResponseT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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<ListMySessionsResponseT> { export class ListMySessionsResponse implements flatbuffers.IUnpackableObject<ListMySessionsResponseT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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<RevokeAllMySessionsResponseT> { export class RevokeAllMySessionsResponse implements flatbuffers.IUnpackableObject<RevokeAllMySessionsResponseT> {
@@ -4,7 +4,7 @@
import * as flatbuffers from 'flatbuffers'; 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<RevokeMySessionResponseT> { export class RevokeMySessionResponse implements flatbuffers.IUnpackableObject<RevokeMySessionResponseT> {
@@ -1,6 +1,16 @@
<script lang="ts"> <script lang="ts">
import { page } from "$app/state"; import { page } from "$app/state";
import BattleView from "$lib/active-view/battle.svelte"; import BattleView from "$lib/active-view/battle.svelte";
const turn = $derived.by(() => {
const raw = page.url.searchParams.get("turn");
const n = raw === null ? NaN : Number(raw);
return Number.isFinite(n) && n >= 0 ? Math.trunc(n) : 0;
});
</script> </script>
<BattleView battleId={page.params.battleId ?? ""} /> <BattleView
gameId={page.params.id ?? ""}
{turn}
battleId={page.params.battleId ?? ""}
/>
+190
View File
@@ -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>): 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<typeof lines[number] & { x1: number; y1: number; x2: number; y2: number }>;
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);
});
});
+146
View File
@@ -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]);
});
});
+252
View File
@@ -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<void> {
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<void> {
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();
});
});
+23 -11
View File
@@ -19,6 +19,7 @@ import { Builder } from "flatbuffers";
import { UUID } from "../../../src/proto/galaxy/fbs/common"; import { UUID } from "../../../src/proto/galaxy/fbs/common";
import { import {
BattleSummary,
Bombing, Bombing,
LocalPlanet, LocalPlanet,
OtherPlanet, OtherPlanet,
@@ -108,6 +109,12 @@ export interface OtherShipClassFixture extends ShipClassFixture {
mass?: number; mass?: number;
} }
export interface BattleSummaryFixture {
id: string;
planet: number;
shots: number;
}
export interface BombingFixture { export interface BombingFixture {
planetNumber: number; planetNumber: number;
planet: string; planet: string;
@@ -149,7 +156,7 @@ export interface ReportFixture {
myVoteFor?: string; myVoteFor?: string;
otherScience?: OtherScienceFixture[]; otherScience?: OtherScienceFixture[];
otherShipClass?: OtherShipClassFixture[]; otherShipClass?: OtherShipClassFixture[];
battles?: string[]; battles?: BattleSummaryFixture[];
bombings?: BombingFixture[]; bombings?: BombingFixture[];
shipProductions?: ShipProductionFixture[]; shipProductions?: ShipProductionFixture[];
} }
@@ -397,17 +404,22 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
shipProductionOffsets.length === 0 shipProductionOffsets.length === 0
? null ? null
: Report.createShipProductionVector(builder, shipProductionOffsets); : Report.createShipProductionVector(builder, shipProductionOffsets);
// `battle` is a struct vector (16 bytes per UUID, alignment 8), so // Phase 27 — `battle` carries `BattleSummary` tables, each with
// it uses the start/inline-write/end pattern rather than a typical // an inline `id:UUID` struct plus `planet` and `shots` slots.
// offset-list helper. Iterating in reverse matches the FlatBuffers
// convention that the vector is built end-to-start.
const battleVec = (() => { const battleVec = (() => {
const ids = fixture.battles ?? []; const summaries = fixture.battles ?? [];
if (ids.length === 0) return null; if (summaries.length === 0) return null;
Report.startBattleVector(builder, ids.length); const offsets = summaries.map((s) => {
for (let i = ids.length - 1; i >= 0; i--) { const [hi, lo] = uuidToHiLo(s.id);
const [hi, lo] = uuidToHiLo(ids[i]!); BattleSummary.startBattleSummary(builder);
UUID.createUUID(builder, hi, lo); 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(); return builder.endVector();
})(); })();
@@ -151,7 +151,7 @@ async function mockGateway(page: Page): Promise<void> {
{ race: "Andori", name: "Spear", drive: 8, armament: 4, weapons: 6, shields: 3, cargo: 1, mass: 90 }, { 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 }, { 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: [ 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: 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 }, { 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 },
+17 -5
View File
@@ -76,18 +76,30 @@ describe("active-view stubs", () => {
); );
}); });
test("battle stub stamps the battleId on the host element", () => { test("battle view stamps the battleId and renders the back-to-map link", () => {
const ui = render(BattleView, { props: { battleId: "b-42" } }); // 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"); const node = ui.getByTestId("active-view-battle");
expect(node).toHaveAttribute("data-battle-id", "b-42"); 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", () => { test("battle view surfaces the not-found state for an empty battleId", () => {
const ui = render(BattleView, { props: { battleId: "" } }); const ui = render(BattleView, {
props: { gameId: "synthetic-test", turn: 0, battleId: "" },
});
expect(ui.getByTestId("active-view-battle")).toHaveAttribute( expect(ui.getByTestId("active-view-battle")).toHaveAttribute(
"data-battle-id", "data-battle-id",
"", "",
); );
expect(ui.getByTestId("battle-not-found")).toBeInTheDocument();
}); });
}); });
@@ -8,6 +8,7 @@
// every spec to enumerate the full GameReport surface. // every spec to enumerate the full GameReport surface.
import type { import type {
ReportBattle,
ReportBombing, ReportBombing,
ReportIncomingShipGroup, ReportIncomingShipGroup,
ReportLocalFleet, ReportLocalFleet,
@@ -36,6 +37,7 @@ export const EMPTY_SHIP_GROUPS: {
players: ReportPlayer[]; players: ReportPlayer[];
otherScience: ReportOtherScience[]; otherScience: ReportOtherScience[];
otherShipClass: ReportOtherShipClass[]; otherShipClass: ReportOtherShipClass[];
battles: ReportBattle[];
battleIds: string[]; battleIds: string[];
bombings: ReportBombing[]; bombings: ReportBombing[];
shipProductions: ReportShipProduction[]; shipProductions: ReportShipProduction[];
@@ -53,6 +55,7 @@ export const EMPTY_SHIP_GROUPS: {
players: [], players: [],
otherScience: [], otherScience: [],
otherShipClass: [], otherShipClass: [],
battles: [],
battleIds: [], battleIds: [],
bombings: [], bombings: [],
shipProductions: [], shipProductions: [],
@@ -75,6 +75,7 @@ function makeReport(
players: [], players: [],
otherScience: [], otherScience: [],
otherShipClass: [], otherShipClass: [],
battles: [],
battleIds: [], battleIds: [],
bombings: [], bombings: [],
shipProductions: [], shipProductions: [],