Compare commits
11 Commits
4ffcac00d0
...
6921c70df7
| Author | SHA1 | Date | |
|---|---|---|---|
| 6921c70df7 | |||
| bd11cd80da | |||
| 2e7478f5ea | |||
| e2aba856b5 | |||
| 17a3afd5e9 | |||
| 8c260f8715 | |||
| b23649059f | |||
| 46996ebf31 | |||
| 37cf34a587 | |||
| 659ba00ebf | |||
| 969c0480ba |
@@ -26,6 +26,7 @@ const (
|
||||
pathPlayerCommand = "/api/v1/command"
|
||||
pathPlayerOrder = "/api/v1/order"
|
||||
pathPlayerReport = "/api/v1/report"
|
||||
pathPlayerBattle = "/api/v1/battle"
|
||||
pathHealthz = "/healthz"
|
||||
)
|
||||
|
||||
@@ -269,6 +270,41 @@ func (c *Client) GetReport(ctx context.Context, baseURL, raceName string, turn i
|
||||
}
|
||||
}
|
||||
|
||||
// FetchBattle calls `GET /api/v1/battle/<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.
|
||||
func (c *Client) Healthz(ctx context.Context, baseURL string) error {
|
||||
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) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != pathHealthz {
|
||||
|
||||
@@ -45,6 +45,7 @@ var pathParamStubs = map[string]string{
|
||||
"delivery_id": "00000000-0000-0000-0000-000000000006",
|
||||
"user_id": "00000000-0000-0000-0000-000000000007",
|
||||
"device_session_id": "00000000-0000-0000-0000-000000000008",
|
||||
"battle_id": "00000000-0000-0000-0000-000000000009",
|
||||
"id": "1.2.3",
|
||||
"username": "alice",
|
||||
"turn": "42",
|
||||
|
||||
@@ -243,6 +243,60 @@ func (h *UserGamesHandlers) Report() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// Battle handles GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}.
|
||||
// Forwards to the engine's `GET /api/v1/battle/:turn/:uuid`. Path
|
||||
// parameters are validated up-front to save a network hop. 404 from
|
||||
// the engine is forwarded as 404. The recipient race is resolved
|
||||
// from the runtime mapping but not forwarded — engine returns the
|
||||
// battle by id, visibility is enforced by the engine state.
|
||||
func (h *UserGamesHandlers) Battle() gin.HandlerFunc {
|
||||
if h == nil || h.runtime == nil || h.engine == nil {
|
||||
return handlers.NotImplemented("userGamesBattle")
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
turnRaw := c.Param("turn")
|
||||
turn, err := strconv.Atoi(turnRaw)
|
||||
if err != nil || turn < 0 {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "turn must be a non-negative integer")
|
||||
return
|
||||
}
|
||||
battleID := c.Param("battle_id")
|
||||
if battleID == "" {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "battle id is required")
|
||||
return
|
||||
}
|
||||
userID, ok := userid.FromContext(c.Request.Context())
|
||||
if !ok {
|
||||
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "user id missing")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
if _, err := h.runtime.ResolvePlayerMapping(ctx, gameID, userID); err != nil {
|
||||
respondGameProxyError(c, h.logger, "user games battle", ctx, err)
|
||||
return
|
||||
}
|
||||
endpoint, err := h.runtime.EngineEndpoint(ctx, gameID)
|
||||
if err != nil {
|
||||
respondGameProxyError(c, h.logger, "user games battle", ctx, err)
|
||||
return
|
||||
}
|
||||
body, status, err := h.engine.FetchBattle(ctx, endpoint, turn, battleID)
|
||||
if err != nil {
|
||||
respondEngineProxyError(c, h.logger, "user games battle", ctx, body, err)
|
||||
return
|
||||
}
|
||||
if status == http.StatusNotFound {
|
||||
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "battle not found")
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json", body)
|
||||
}
|
||||
}
|
||||
|
||||
// rebindActor decodes a JSON object from raw, sets `actor` to
|
||||
// raceName, and re-encodes. Backend never trusts the actor field
|
||||
// supplied by the client (per ARCHITECTURE.md §9).
|
||||
|
||||
@@ -263,6 +263,7 @@ func registerUserRoutes(router *gin.Engine, instruments *metrics.Instruments, de
|
||||
userGames.POST("/:game_id/orders", deps.UserGames.Orders())
|
||||
userGames.GET("/:game_id/orders", deps.UserGames.GetOrders())
|
||||
userGames.GET("/:game_id/reports/:turn", deps.UserGames.Report())
|
||||
userGames.GET("/:game_id/battles/:turn/:battle_id", deps.UserGames.Battle())
|
||||
|
||||
userSessions := group.Group("/sessions")
|
||||
userSessions.GET("", deps.UserSessions.List())
|
||||
|
||||
@@ -1106,6 +1106,44 @@ paths:
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/user/games/{game_id}/battles/{turn}/{battle_id}:
|
||||
get:
|
||||
tags: [User]
|
||||
operationId: userGamesBattle
|
||||
summary: Read one engine battle report
|
||||
description: |
|
||||
Forwards to the engine's `GET /api/v1/battle/:turn/:uuid`. The
|
||||
engine response body is passed through verbatim. `404 Not Found`
|
||||
is returned when the battle does not exist for the supplied
|
||||
`turn` / `battle_id` pair.
|
||||
security:
|
||||
- UserHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/XUserID"
|
||||
- $ref: "#/components/parameters/GameID"
|
||||
- $ref: "#/components/parameters/Turn"
|
||||
- name: battle_id
|
||||
in: path
|
||||
required: true
|
||||
description: Battle identifier (RFC 4122 UUID).
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
responses:
|
||||
"200":
|
||||
description: Engine battle report passed through.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PassthroughObject"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFoundError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/user/sessions:
|
||||
get:
|
||||
tags: [User]
|
||||
|
||||
+65
-3
@@ -657,7 +657,7 @@ in `runtime_records.turn_schedule`. The backend scheduler
|
||||
- After a failed tick (`engine_unreachable` /
|
||||
`generation_failed`): the lobby's `OnRuntimeSnapshot` flips the
|
||||
game from `running` to `paused` and publishes a `game.paused`
|
||||
push event (see §6.5). The order handlers reject with HTTP 409
|
||||
push event (see §6.6). The order handlers reject with HTTP 409
|
||||
+ `code = game_paused` until an admin resume succeeds.
|
||||
|
||||
`force-next-turn` (admin) schedules a one-shot extra tick that
|
||||
@@ -686,7 +686,69 @@ are exposed in a sticky table of contents (a `<select>` on mobile)
|
||||
and the scroll position is preserved across active-view switches
|
||||
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 cloud of ship-class circles arranged on a Vogel
|
||||
sunflower spiral biased toward the planet (the largest group by
|
||||
NumberLeft sits closest to the planet, lighter buckets fan behind).
|
||||
Tech-variants of the same `(race, className)` collapse into one
|
||||
visual bucket labelled `<className>:<numLeft>`; per-class detail
|
||||
stays available in the Reports view. Circle radius scales with
|
||||
per-ship FullMass (range `[6, 24] px`, per-battle normalisation)
|
||||
so heavy ships visually dominate. Observer groups (`inBattle:
|
||||
false`) are not drawn. Eliminated races drop out and the survivors
|
||||
re-spread on the next frame. The viewer is pinned to the viewport
|
||||
(scene grows, log scrolls internally) so no page-level scroll
|
||||
appears.
|
||||
|
||||
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`.
|
||||
|
||||
For DEV / e2e the legacy-report CLI
|
||||
(`tools/local-dev/legacy-report/cmd/legacy-report-to-json`) emits an
|
||||
envelope `{version: 1, report, battles}` where `battles` carries the
|
||||
full `BattleReport`-s parsed out of legacy `Battle at (#N)` blocks.
|
||||
The synthetic-report loader on the lobby unwraps the envelope and
|
||||
hands every battle to `registerSyntheticBattle`, so the Battle Viewer
|
||||
resolves any UUID without a network fetch.
|
||||
|
||||
### 6.6 Side effects
|
||||
|
||||
A successful turn generation publishes a runtime snapshot into the
|
||||
lobby module, which updates the denormalised view (current turn,
|
||||
@@ -719,7 +781,7 @@ producer; adding one is purely additive (register the kind in the
|
||||
catalog, extend the migration `CHECK` constraint, and call
|
||||
`notification.Submit` from the appropriate domain module).
|
||||
|
||||
### 6.6 Cross-references
|
||||
### 6.7 Cross-references
|
||||
|
||||
- Backend ↔ engine wire contract (`pkg/model/{order,report,rest}`):
|
||||
[ARCHITECTURE.md §9](ARCHITECTURE.md#9-backend--game-engine-communication).
|
||||
|
||||
+65
-3
@@ -675,7 +675,7 @@ engine `/admin/turn` двумя `runtime_status`-флипами:
|
||||
- После провалившегося тика (`engine_unreachable` /
|
||||
`generation_failed`): `lobby.OnRuntimeSnapshot` переводит игру
|
||||
`running → paused` и публикует push-эвент `game.paused`
|
||||
(см. §6.5). Order-handler'ы отклоняют запросы с HTTP 409 +
|
||||
(см. §6.6). Order-handler'ы отклоняют запросы с HTTP 409 +
|
||||
`code = game_paused`, пока админ не выполнит resume.
|
||||
|
||||
`force-next-turn` (admin) планирует one-shot-доп-тик, который
|
||||
@@ -704,7 +704,69 @@ empty-state. Якоря секций отображены в sticky-TOC (на м
|
||||
`<select>`); позиция скролла сохраняется при переключении активного
|
||||
представления через 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`.
|
||||
|
||||
Визуальная модель — радиальная: планета в центре, расы по внешней
|
||||
окружности на равных угловых интервалах, внутри расы — облако
|
||||
кружков по классам кораблей, выложенное Vogel-спиралью с биасом к
|
||||
планете (самая многочисленная группа по NumberLeft — ближе к
|
||||
планете, остальные раскручиваются спиралью позади). Tech-варианты
|
||||
одного `(race, className)` схлопываются в один визуальный нод
|
||||
`<className>:<numLeft>`; детали по тех-уровням остаются в Reports.
|
||||
Радиус кружка масштабируется по FullMass корабля (диапазон
|
||||
`[6, 24] px`, нормировка на самую тяжёлую группу в битве), так что
|
||||
тяжёлые корабли визуально доминируют. Наблюдатели (`inBattle:
|
||||
false`) не рисуются. Выбывшие расы убираются из сцены, оставшиеся
|
||||
перераспределяются на следующем кадре. Viewer закреплён по высоте
|
||||
viewport-а: сцена растягивается, лог скроллит внутри — никаких
|
||||
скроллов на уровне страницы.
|
||||
|
||||
Каждый кадр — одна запись протокола; выстрел рисуется тонкой линией
|
||||
от атакующего к защитнику, красной при `destroyed`, зелёной иначе.
|
||||
Непрерывное воспроизведение: 1x / 2x / 4x (400 / 200 / 100 мс на
|
||||
кадр), плюс play/pause, шаг вперёд/назад, rewind. Текстовый протокол
|
||||
доступности под сценой дублирует те же события построчно.
|
||||
|
||||
Бомбардировки и сражения умышленно не смешиваются: бомбардировки
|
||||
остаются статической таблицей в Reports; bombing-marker на карте —
|
||||
тонкая окружность вокруг планеты (жёлтая при damaged, красная при
|
||||
wiped), клик скроллит соответствующую строку в Reports.
|
||||
|
||||
Текущая wire-форма отчёта несёт `battle: [{ id, planet, shots }]`
|
||||
на каждую битву, чтобы map-маркеры могли расположиться без
|
||||
дополнительного запроса полного `BattleReport`.
|
||||
|
||||
Для DEV / e2e легаси-CLI
|
||||
(`tools/local-dev/legacy-report/cmd/legacy-report-to-json`) выдаёт
|
||||
envelope `{version: 1, report, battles}`, где `battles` несёт полные
|
||||
`BattleReport`-ы, распарсенные из `Battle at (#N)`-блоков. Synthetic-
|
||||
загрузчик в лобби разбирает envelope и регистрирует каждую битву
|
||||
через `registerSyntheticBattle`, так что Battle Viewer открывает
|
||||
любой UUID без сетевого запроса.
|
||||
|
||||
### 6.6 Побочные эффекты
|
||||
|
||||
Успешная генерация хода публикует runtime-snapshot в lobby-модуль,
|
||||
который обновляет денормализованное вью (текущий ход, runtime-
|
||||
@@ -740,7 +802,7 @@ Directory-промоушен ([Раздел 5](#5-реестр-названий-
|
||||
каталоге, расширить `CHECK`-констрейнт миграции и вызвать
|
||||
`notification.Submit` из подходящего доменного модуля).
|
||||
|
||||
### 6.6 Перекрёстные ссылки
|
||||
### 6.7 Перекрёстные ссылки
|
||||
|
||||
- Backend ↔ engine wire-контракт (`pkg/model/{order,report,rest}`):
|
||||
[ARCHITECTURE.md §9](ARCHITECTURE.md#9-backend--game-engine-communication).
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"galaxy/calc"
|
||||
"galaxy/game/internal/controller"
|
||||
"galaxy/game/internal/model/game"
|
||||
"galaxy/model/report"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -184,3 +185,89 @@ func TestProduceBattles(t *testing.T) {
|
||||
assert.Zero(t, c.ShipGroup(3).Number)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTransformBattleAggregatesSameShipClass guards against the
|
||||
// engine-side variant of the duplicate-class bug. Several ShipGroups
|
||||
// of the same ShipClass.ID can take part in the same battle (arrivals
|
||||
// from different planets, tech splits, etc.); they must collapse into
|
||||
// a single BattleReportGroup with summed Number and NumberLeft. The
|
||||
// pre-fix engine cached the first group's index and silently dropped
|
||||
// every subsequent group's initial / survivor counts, which manifested
|
||||
// downstream as more Destroyed shots in the protocol than the
|
||||
// recorded initial roster could account for.
|
||||
func TestTransformBattleAggregatesSameShipClass(t *testing.T) {
|
||||
c, g := newCache()
|
||||
|
||||
assert.NoError(t, g.RaceRelation(Race_0.Name, Race_1.Name, game.RelationWar.String()))
|
||||
assert.NoError(t, g.RaceRelation(Race_1.Name, Race_0.Name, game.RelationWar.String()))
|
||||
|
||||
// Two Race_0 groups of the SAME ship class (Race_0_Gunship) plus
|
||||
// one Race_1 group of Race_1_Gunship — all parked on Planet_0
|
||||
// (owned by Race_0; the Race_1 group lands there via the Unsafe
|
||||
// helper that bypasses the ownership check). Group indices land
|
||||
// at 0, 1, 2 in creation order.
|
||||
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 10))
|
||||
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 10))
|
||||
c.CreateShipsUnsafe_T(Race_1_idx, c.MustShipClass(Race_1_idx, Race_1_Gunship).ID, R0_Planet_0_num, 5)
|
||||
|
||||
// Simulate post-battle survivor counts: Group 0 ended the battle
|
||||
// with 8 ships, Group 1 with 6. The aggregated BattleReportGroup
|
||||
// must report NumberLeft = 8 + 6 = 14 (not just the last cached
|
||||
// group's 6 — that's the regression).
|
||||
c.ShipGroup(0).Number = 8
|
||||
c.ShipGroup(1).Number = 6
|
||||
|
||||
b := &controller.Battle{
|
||||
Planet: R0_Planet_0_num,
|
||||
ObserverGroups: map[int]bool{0: true, 1: true, 2: true},
|
||||
InitialNumbers: map[int]uint{0: 10, 1: 10, 2: 5},
|
||||
// Protocol must reference every in-battle group at least once
|
||||
// (otherwise TransformBattle won't register it through the
|
||||
// `ship()` path). Two shots from Race_1 against each Race_0
|
||||
// group hits both groupIds.
|
||||
Protocol: []controller.BattleAction{
|
||||
{Attacker: 2, Defender: 0, Destroyed: true},
|
||||
{Attacker: 2, Defender: 1, Destroyed: true},
|
||||
},
|
||||
}
|
||||
|
||||
r := controller.TransformBattle(c, b)
|
||||
|
||||
// Two BattleReportGroup entries total: one merged Race_0_Gunship
|
||||
// (groups 0 + 1) and one Race_1_Gunship. NOT three.
|
||||
if got, want := len(r.Ships), 2; got != want {
|
||||
t.Fatalf("len(r.Ships) = %d, want %d (duplicate ShipClass.ID must merge)", got, want)
|
||||
}
|
||||
|
||||
var gunship0, gunship1 *report.BattleReportGroup
|
||||
for i := range r.Ships {
|
||||
grp := r.Ships[i]
|
||||
switch grp.Race {
|
||||
case Race_0.Name:
|
||||
gunship0 = &grp
|
||||
case Race_1.Name:
|
||||
gunship1 = &grp
|
||||
}
|
||||
}
|
||||
if gunship0 == nil || gunship1 == nil {
|
||||
t.Fatalf("missing race entry: race0=%v race1=%v", gunship0, gunship1)
|
||||
}
|
||||
|
||||
if gunship0.ClassName != Race_0_Gunship {
|
||||
t.Errorf("race0.ClassName = %q, want %q", gunship0.ClassName, Race_0_Gunship)
|
||||
}
|
||||
if gunship0.Number != 20 {
|
||||
t.Errorf("race0.Number = %d, want 20 (10+10)", gunship0.Number)
|
||||
}
|
||||
if gunship0.NumberLeft != 14 {
|
||||
t.Errorf("race0.NumberLeft = %d, want 14 (8+6)", gunship0.NumberLeft)
|
||||
}
|
||||
if !gunship0.InBattle {
|
||||
t.Errorf("race0.InBattle = false, want true (both source groups were in-battle)")
|
||||
}
|
||||
|
||||
if gunship1.Number != 5 || gunship1.NumberLeft != 5 {
|
||||
t.Errorf("race1 = (Number=%d, NumberLeft=%d), want (5, 5)",
|
||||
gunship1.Number, gunship1.NumberLeft)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,35 @@ func TransformBattle(c *Cache, b *Battle) *report.BattleReport {
|
||||
|
||||
cacheShipClass := make(map[uuid.UUID]int)
|
||||
cacheRaceName := make(map[uuid.UUID]int)
|
||||
processedGroup := make(map[int]bool)
|
||||
|
||||
addShipGroup := func(groupId int, inBattle bool) int {
|
||||
shipClass := c.ShipGroupShipClass(groupId)
|
||||
sg := c.ShipGroup(groupId)
|
||||
// Several ship-groups of the same race/class can take part
|
||||
// in the same battle (different tech upgrades, arrivals from
|
||||
// different planets, …). They share a single
|
||||
// BattleReportGroup entry keyed by ShipClass.ID — when a
|
||||
// later group lands on a cached class we add its Number and
|
||||
// NumberLeft into the existing entry instead of dropping
|
||||
// them, so the protocol's per-class destroy counts reconcile
|
||||
// with the recorded totals. `processedGroup` guards against
|
||||
// double-counting a single groupId across multiple shots in
|
||||
// the protocol — `ship()` runs on every attacker and defender
|
||||
// reference, the merge must happen once per groupId.
|
||||
if existing, ok := cacheShipClass[shipClass.ID]; ok {
|
||||
if !processedGroup[groupId] {
|
||||
bg := r.Ships[existing]
|
||||
bg.Number += b.InitialNumbers[groupId]
|
||||
bg.NumberLeft += sg.Number
|
||||
if inBattle {
|
||||
bg.InBattle = true
|
||||
}
|
||||
r.Ships[existing] = bg
|
||||
processedGroup[groupId] = true
|
||||
}
|
||||
return existing
|
||||
}
|
||||
itemNumber := len(r.Ships)
|
||||
bg := &report.BattleReportGroup{
|
||||
Race: c.g.Race[c.RaceIndex(sg.OwnerID)].Name,
|
||||
@@ -31,23 +56,20 @@ func TransformBattle(c *Cache, b *Battle) *report.BattleReport {
|
||||
ClassName: shipClass.Name,
|
||||
LoadType: sg.CargoString(),
|
||||
LoadQuantity: report.F(sg.Load.F()),
|
||||
Tech: make(map[string]report.Float, len(sg.Tech)),
|
||||
}
|
||||
for t, v := range sg.Tech {
|
||||
bg.Tech[t.String()] = report.F(v.F())
|
||||
}
|
||||
r.Ships[itemNumber] = *bg
|
||||
cacheShipClass[shipClass.ID] = itemNumber
|
||||
processedGroup[groupId] = true
|
||||
return itemNumber
|
||||
}
|
||||
|
||||
ship := func(groupId int) int {
|
||||
shipClass := c.ShipGroupShipClass(groupId)
|
||||
if v, ok := cacheShipClass[shipClass.ID]; ok {
|
||||
return v
|
||||
} else {
|
||||
return addShipGroup(groupId, true)
|
||||
}
|
||||
}
|
||||
|
||||
race := func(groupId int) int {
|
||||
race := c.ShipGroupOwnerRace(groupId)
|
||||
|
||||
@@ -37,7 +37,7 @@ func (c *Cache) InitReport(t uint) *mr.Report {
|
||||
OtherScience: make([]mr.OtherScience, 0, 10),
|
||||
LocalShipClass: make([]mr.ShipClass, 0, 20),
|
||||
OtherShipClass: make([]mr.OthersShipClass, 0, 50),
|
||||
Battle: make([]uuid.UUID, 0, 10),
|
||||
Battle: make([]mr.BattleSummary, 0, 10),
|
||||
Bombing: make([]*mr.Bombing, 0, 10),
|
||||
IncomingGroup: make([]mr.IncomingGroup, 0, 10),
|
||||
OnPlanetGroupCache: make(map[uint][]int),
|
||||
@@ -342,7 +342,11 @@ func (c *Cache) ReportBattle(ri int, rep *mr.Report, br []*mr.BattleReport) {
|
||||
}
|
||||
|
||||
sliceIndexValidate(&rep.Battle, i)
|
||||
rep.Battle[i] = br[bi].ID
|
||||
rep.Battle[i] = mr.BattleSummary{
|
||||
ID: br[bi].ID,
|
||||
Planet: br[bi].Planet,
|
||||
Shots: uint(len(br[bi].Protocol)),
|
||||
}
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
+26
-3
@@ -584,10 +584,9 @@ components:
|
||||
$ref: "#/components/schemas/OtherShipClass"
|
||||
battle:
|
||||
type: array
|
||||
description: UUIDs of battle reports relevant to this turn.
|
||||
description: Battle summaries relevant to this turn.
|
||||
items:
|
||||
type: string
|
||||
format: uuid
|
||||
$ref: "#/components/schemas/BattleSummary"
|
||||
bombing:
|
||||
type: array
|
||||
description: Bombing events that occurred during this turn.
|
||||
@@ -831,6 +830,30 @@ components:
|
||||
wiped:
|
||||
type: boolean
|
||||
description: True when all population was eliminated by the bombing.
|
||||
BattleSummary:
|
||||
type: object
|
||||
description: |
|
||||
Identifies one battle relevant to the report recipient. Used by
|
||||
clients to render a battle marker on the map without fetching
|
||||
the full BattleReport. `planet` locates the marker; `shots`
|
||||
scales the marker stroke with the battle length.
|
||||
required:
|
||||
- id
|
||||
- planet
|
||||
- shots
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Battle identifier; fetch the full report via `/api/v1/battle/{turn}/{uuid}`.
|
||||
planet:
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: Planet number the battle took place on.
|
||||
shots:
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: Number of shots exchanged during the battle.
|
||||
BattleReport:
|
||||
type: object
|
||||
description: |
|
||||
|
||||
@@ -327,6 +327,22 @@ func TestGameOpenAPISpecFreezesBattleReport(t *testing.T) {
|
||||
assertSchemaRef(t, shipsSchema.Value.AdditionalProperties.Schema, "#/components/schemas/BattleReportGroup", "BattleReport.ships additionalProperties schema")
|
||||
}
|
||||
|
||||
func TestGameOpenAPISpecFreezesBattleSummary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := loadOpenAPISpec(t)
|
||||
|
||||
summary := componentSchemaRef(t, doc, "BattleSummary")
|
||||
assertRequiredFields(t, summary, "id", "planet", "shots")
|
||||
|
||||
report := componentSchemaRef(t, doc, "Report")
|
||||
battle := report.Value.Properties["battle"]
|
||||
require.NotNil(t, battle, "Report.battle schema must exist")
|
||||
require.True(t, battle.Value.Type.Is("array"), "Report.battle must be array")
|
||||
require.NotNil(t, battle.Value.Items, "Report.battle items must be defined")
|
||||
assertSchemaRef(t, battle.Value.Items, "#/components/schemas/BattleSummary", "Report.battle items schema")
|
||||
}
|
||||
|
||||
func TestGameOpenAPISpecHealthzStatusEnum(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -6,6 +6,16 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// BattleSummary identifies one battle relevant to the report recipient
|
||||
// and carries the data needed to render a battle marker on the map
|
||||
// without fetching the full BattleReport. Planet locates the marker;
|
||||
// Shots scales the marker stroke with the battle length.
|
||||
type BattleSummary struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Planet uint `json:"planet"`
|
||||
Shots uint `json:"shots"`
|
||||
}
|
||||
|
||||
type BattleReport struct {
|
||||
// Battle unique ID
|
||||
ID uuid.UUID `json:"id"`
|
||||
|
||||
@@ -33,7 +33,7 @@ type Report struct {
|
||||
OtherScience []OtherScience `json:"otherScience,omitempty"`
|
||||
LocalShipClass []ShipClass `json:"localShipClass,omitempty"`
|
||||
OtherShipClass []OthersShipClass `json:"otherShipClass,omitempty"`
|
||||
Battle []uuid.UUID `json:"battle,omitempty"`
|
||||
Battle []BattleSummary `json:"battle,omitempty"`
|
||||
Bombing []*Bombing `json:"bombing,omitempty"`
|
||||
IncomingGroup []IncomingGroup `json:"incomingGroup,omitempty"`
|
||||
LocalPlanet []LocalPlanet `json:"localPlanet,omitempty"`
|
||||
|
||||
@@ -196,6 +196,17 @@ table LocalFleet {
|
||||
state:string;
|
||||
}
|
||||
|
||||
// BattleSummary identifies one battle the report recipient
|
||||
// participated in or could see on a planet. `planet` lets the map
|
||||
// place a battle marker without fetching the full BattleReport;
|
||||
// `shots` lets the marker scale its stroke with the protocol length
|
||||
// (1 shot → thinnest cross, 100+ shots → maximum cross thickness).
|
||||
table BattleSummary {
|
||||
id:common.UUID (required);
|
||||
planet:uint64;
|
||||
shots:uint64;
|
||||
}
|
||||
|
||||
table Report {
|
||||
version:uint64;
|
||||
turn:uint64;
|
||||
@@ -210,7 +221,7 @@ table Report {
|
||||
other_science:[OtherScience];
|
||||
local_ship_class:[ShipClass];
|
||||
other_ship_class:[OthersShipClass];
|
||||
battle:[common.UUID];
|
||||
battle:[BattleSummary];
|
||||
bombing:[Bombing];
|
||||
incoming_group:[IncomingGroup];
|
||||
local_planet:[LocalPlanet];
|
||||
|
||||
@@ -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,8 +4,6 @@ package report
|
||||
|
||||
import (
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
|
||||
common "galaxy/schema/fbs/common"
|
||||
)
|
||||
|
||||
type Report struct {
|
||||
@@ -231,11 +229,12 @@ func (rcv *Report) OtherShipClassLength() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *Report) Battle(obj *common.UUID, j int) bool {
|
||||
func (rcv *Report) Battle(obj *BattleSummary, j int) bool {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(30))
|
||||
if o != 0 {
|
||||
x := rcv._tab.Vector(o)
|
||||
x += flatbuffers.UOffsetT(j) * 16
|
||||
x += flatbuffers.UOffsetT(j) * 4
|
||||
x = rcv._tab.Indirect(x)
|
||||
obj.Init(rcv._tab.Bytes, x)
|
||||
return true
|
||||
}
|
||||
@@ -551,7 +550,7 @@ func ReportAddBattle(builder *flatbuffers.Builder, battle flatbuffers.UOffsetT)
|
||||
builder.PrependUOffsetTSlot(13, flatbuffers.UOffsetT(battle), 0)
|
||||
}
|
||||
func ReportStartBattleVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||
return builder.StartVector(16, numElems, 8)
|
||||
return builder.StartVector(4, numElems, 4)
|
||||
}
|
||||
func ReportAddBombing(builder *flatbuffers.Builder, bombing flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(14, flatbuffers.UOffsetT(bombing), 0)
|
||||
|
||||
+36
-12
@@ -10,7 +10,6 @@ import (
|
||||
fbs "galaxy/schema/fbs/report"
|
||||
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ReportToPayload converts model.Report from the internal representation to
|
||||
@@ -120,7 +119,7 @@ func ReportToPayload(report *model.Report) ([]byte, error) {
|
||||
otherScienceVector := encodeReportOffsetVector(builder, len(otherScienceOffsets), fbs.ReportStartOtherScienceVector, otherScienceOffsets)
|
||||
localShipClassVector := encodeReportOffsetVector(builder, len(localShipClassOffsets), fbs.ReportStartLocalShipClassVector, localShipClassOffsets)
|
||||
otherShipClassVector := encodeReportOffsetVector(builder, len(otherShipClassOffsets), fbs.ReportStartOtherShipClassVector, otherShipClassOffsets)
|
||||
battleVector := encodeReportUUIDVector(builder, report.Battle)
|
||||
battleVector := encodeReportBattleSummaries(builder, report.Battle)
|
||||
bombingVector := encodeReportOffsetVector(builder, len(bombingOffsets), fbs.ReportStartBombingVector, bombingOffsets)
|
||||
incomingGroupVector := encodeReportOffsetVector(builder, len(incomingGroupOffsets), fbs.ReportStartIncomingGroupVector, incomingGroupOffsets)
|
||||
localPlanetVector := encodeReportOffsetVector(builder, len(localPlanetOffsets), fbs.ReportStartLocalPlanetVector, localPlanetOffsets)
|
||||
@@ -734,13 +733,29 @@ func decodeReportBattleVector(flatReport *fbs.Report, result *model.Report) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
result.Battle = make([]uuid.UUID, length)
|
||||
item := new(commonfbs.UUID)
|
||||
result.Battle = make([]model.BattleSummary, length)
|
||||
item := new(fbs.BattleSummary)
|
||||
idHolder := new(commonfbs.UUID)
|
||||
for i := 0; i < length; i++ {
|
||||
if !flatReport.Battle(item, i) {
|
||||
return fmt.Errorf("decode report battle %d: battle is missing", i)
|
||||
}
|
||||
if item.Id(idHolder) == nil {
|
||||
return fmt.Errorf("decode report battle %d: battle id is missing", i)
|
||||
}
|
||||
result.Battle[i] = uuidFromHiLo(item.Hi(), item.Lo())
|
||||
planet, err := uint64ToUint(item.Planet(), "planet")
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode report battle %d: %w", i, err)
|
||||
}
|
||||
shots, err := uint64ToUint(item.Shots(), "shots")
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode report battle %d: %w", i, err)
|
||||
}
|
||||
result.Battle[i] = model.BattleSummary{
|
||||
ID: uuidFromHiLo(idHolder.Hi(), idHolder.Lo()),
|
||||
Planet: planet,
|
||||
Shots: shots,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -1299,17 +1314,26 @@ func encodeReportOffsetVector(
|
||||
return builder.EndVector(length)
|
||||
}
|
||||
|
||||
func encodeReportUUIDVector(builder *flatbuffers.Builder, ids []uuid.UUID) flatbuffers.UOffsetT {
|
||||
if len(ids) == 0 {
|
||||
func encodeReportBattleSummaries(builder *flatbuffers.Builder, summaries []model.BattleSummary) flatbuffers.UOffsetT {
|
||||
if len(summaries) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
fbs.ReportStartBattleVector(builder, len(ids))
|
||||
for i := len(ids) - 1; i >= 0; i-- {
|
||||
hi, lo := uuidToHiLo(ids[i])
|
||||
commonfbs.CreateUUID(builder, hi, lo)
|
||||
offsets := make([]flatbuffers.UOffsetT, len(summaries))
|
||||
for i := range summaries {
|
||||
hi, lo := uuidToHiLo(summaries[i].ID)
|
||||
fbs.BattleSummaryStart(builder)
|
||||
fbs.BattleSummaryAddId(builder, commonfbs.CreateUUID(builder, hi, lo))
|
||||
fbs.BattleSummaryAddPlanet(builder, uint64(summaries[i].Planet))
|
||||
fbs.BattleSummaryAddShots(builder, uint64(summaries[i].Shots))
|
||||
offsets[i] = fbs.BattleSummaryEnd(builder)
|
||||
}
|
||||
return builder.EndVector(len(ids))
|
||||
|
||||
fbs.ReportStartBattleVector(builder, len(offsets))
|
||||
for i := len(offsets) - 1; i >= 0; i-- {
|
||||
builder.PrependUOffsetT(offsets[i])
|
||||
}
|
||||
return builder.EndVector(len(offsets))
|
||||
}
|
||||
|
||||
func encodeReportRouteEntryVector(builder *flatbuffers.Builder, route map[uint]string) flatbuffers.UOffsetT {
|
||||
|
||||
@@ -255,9 +255,17 @@ func sampleReport() *model.Report {
|
||||
OtherShipClass: []model.OthersShipClass{
|
||||
{Race: "Martians", ShipClass: model.ShipClass{Name: "destroyer", Drive: model.Float(1.75), Armament: 6, Weapons: model.Float(2.25), Shields: model.Float(2.75), Cargo: model.Float(3.25), Mass: model.Float(10.5)}},
|
||||
},
|
||||
Battle: []uuid.UUID{
|
||||
uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
uuid.MustParse("22222222-2222-2222-2222-222222222222"),
|
||||
Battle: []model.BattleSummary{
|
||||
{
|
||||
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||
Planet: 4,
|
||||
Shots: 17,
|
||||
},
|
||||
{
|
||||
ID: uuid.MustParse("22222222-2222-2222-2222-222222222222"),
|
||||
Planet: 11,
|
||||
Shots: 3,
|
||||
},
|
||||
},
|
||||
Bombing: []*model.Bombing{
|
||||
{
|
||||
|
||||
@@ -1,8 +1,25 @@
|
||||
# legacy-report-to-json
|
||||
|
||||
Converts legacy text-format Galaxy turn reports (the *dg* and *gplus*
|
||||
engines that lived under `tools/local-dev/reports/`) into the JSON
|
||||
shape of [`pkg/model/report.Report`](../../../pkg/model/report).
|
||||
engines that lived under `tools/local-dev/reports/`) into a JSON
|
||||
envelope around [`pkg/model/report.Report`](../../../pkg/model/report)
|
||||
plus full `BattleReport`s (Phase 27).
|
||||
|
||||
## Output envelope
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"version": 1,
|
||||
"report": { /* report.Report */ },
|
||||
"battles": { "<uuid>": { /* report.BattleReport */ }, ... }
|
||||
}
|
||||
```
|
||||
|
||||
`version: 1` lets the UI distinguish a current-format envelope from a
|
||||
bare `Report` JSON. The synthetic-report loader accepts both — pre-
|
||||
envelope synthetic JSON files still load, just without battle
|
||||
fixtures. `battles` is omitted when the legacy file has no combat
|
||||
events.
|
||||
|
||||
The output is consumed by the **DEV-only synthetic-report loader** on
|
||||
the UI client's lobby (`import.meta.env.DEV`). With it, the map view,
|
||||
@@ -17,8 +34,8 @@ The tool is part of the synthetic-report parity rule documented in
|
||||
```sh
|
||||
# from the repo root, with the Go workspace active
|
||||
go run ./tools/local-dev/legacy-report/cmd/legacy-report-to-json \
|
||||
--in tools/local-dev/reports/dg/KNNTS039.REP \
|
||||
--out tools/local-dev/reports/dg/KNNTS039.json
|
||||
--in tools/local-dev/reports/dg/KNNTS041.REP \
|
||||
--out tools/local-dev/reports/dg/KNNTS041.json
|
||||
```
|
||||
|
||||
`--in` reads `-` as stdin; `--out` defaults to stdout when empty or
|
||||
@@ -68,6 +85,21 @@ already decodes from server responses
|
||||
| `LocalGroup[]` | `Your Groups` (Phase 19) |
|
||||
| `LocalFleet[]` | `Your Fleets` (Phase 19) |
|
||||
| `IncomingGroup[]` | `Incoming Groups` (Phase 19) |
|
||||
| `Battle[]` (summary) | `Battle at (#N) Name` headers + `Battle Protocol` (Phase 27 follow-up) |
|
||||
|
||||
The envelope's `battles` map carries the full `BattleReport`-s parsed
|
||||
out of the same blocks: every roster row turns into a
|
||||
`BattleReportGroup` (`Number`/`Tech`/`LoadType`/`LoadQuantity`/
|
||||
`NumberLeft`/`InBattle`), every `... fires on ... : Destroyed|Shields`
|
||||
line turns into a `BattleActionReport`. UUIDs are synthesised
|
||||
deterministically — `syntheticBattleID(idx)` for the battle
|
||||
identifier (per-report 0-based index, SHA1 namespace
|
||||
`be01a000-0000-0000-0000-000000000002`) and
|
||||
`syntheticBattleRaceID(name)` for `BattleReport.Races` entries (SHA1
|
||||
namespace `be01a000-0000-0000-0000-000000000003`). Re-running the
|
||||
converter on the same input file yields byte-identical JSON, so
|
||||
synthetic-mode UI URLs (`/games/synthetic-…/battle/<uuid>?turn=N`)
|
||||
stay stable across regenerations.
|
||||
|
||||
Players whose name in the legacy file ends with `_RIP` are emitted with
|
||||
the suffix stripped and `Extinct: true`.
|
||||
@@ -103,15 +135,9 @@ These exist in legacy reports but cannot be derived from the legacy
|
||||
text format at all. Each could become in-scope if a strong enough
|
||||
reason arises (see "Adding a new field" below).
|
||||
|
||||
- Battles (`Battle at (#N) Name`, `Battle Protocol`) — the wire schema
|
||||
carries battle UUIDs (`Report.Battle: []uuid.UUID`); the legacy text
|
||||
carries per-battle rosters with stripped columns (no origin / range /
|
||||
destination) and no stable identifier. Synthesising UUIDs from the
|
||||
text would invent data that future Phase 27 work would have to drop;
|
||||
the synthetic JSON therefore emits `battle: []`.
|
||||
- `OtherGroup[]` — no top-level legacy section. Foreign groups appear
|
||||
only inside battle rosters (see above), with stripped columns; the
|
||||
synthetic JSON emits `otherGroup: []`.
|
||||
only inside battle rosters; the synthetic JSON emits
|
||||
`otherGroup: []`.
|
||||
- `UnidentifiedGroup[]` — no legacy section at all; synthetic JSON
|
||||
emits `unidentifiedGroup: []`.
|
||||
- Cargo routes — no dedicated section in the legacy text format; the
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
// Command legacy-report-to-json converts a legacy text-format Galaxy
|
||||
// turn report (the "dg" / "gplus" engines) into the JSON shape of
|
||||
// pkg/model/report.Report. The resulting file is what the UI client's
|
||||
// DEV-only synthetic-report loader on the lobby consumes.
|
||||
// turn report (the "dg" / "gplus" engines) into a JSON envelope
|
||||
// readable by the UI client's DEV-only synthetic-report loader:
|
||||
//
|
||||
// {
|
||||
// "version": 1,
|
||||
// "report": <Report JSON>,
|
||||
// "battles": { "<uuid>": <BattleReport JSON>, ... }
|
||||
// }
|
||||
//
|
||||
// Carrying the per-turn report and the full BattleReports in one
|
||||
// payload lets the synthetic loader register the battles up-front
|
||||
// so the Battle Viewer can render any battle without a network
|
||||
// fetch. The bare Report shape (no envelope) the lobby loader
|
||||
// historically accepted remains backward-compatible on the UI side.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -12,8 +23,18 @@ import (
|
||||
"os"
|
||||
|
||||
legacyreport "galaxy/legacy-report"
|
||||
"galaxy/model/report"
|
||||
)
|
||||
|
||||
// envelope is the on-disk shape emitted by this CLI. `Version` lets
|
||||
// the UI loader distinguish a v1 envelope from a bare Report; future
|
||||
// versions can bump it without breaking older synthetic JSON files.
|
||||
type envelope struct {
|
||||
Version int `json:"version"`
|
||||
Report report.Report `json:"report"`
|
||||
Battles map[string]report.BattleReport `json:"battles,omitempty"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
in := flag.String("in", "", "path to legacy .REP file (use - for stdin)")
|
||||
out := flag.String("out", "", "path to write JSON to (use - or empty for stdout)")
|
||||
@@ -31,7 +52,7 @@ func main() {
|
||||
}
|
||||
defer closeIn()
|
||||
|
||||
rep, err := legacyreport.Parse(r)
|
||||
rep, battles, err := legacyreport.Parse(r)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "parse: %v\n", err)
|
||||
os.Exit(1)
|
||||
@@ -44,9 +65,17 @@ func main() {
|
||||
}
|
||||
defer closeOut()
|
||||
|
||||
env := envelope{Version: 1, Report: rep}
|
||||
if len(battles) > 0 {
|
||||
env.Battles = make(map[string]report.BattleReport, len(battles))
|
||||
for i := range battles {
|
||||
env.Battles[battles[i].ID.String()] = battles[i]
|
||||
}
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(rep); err != nil {
|
||||
if err := enc.Encode(env); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "encode: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -26,22 +26,29 @@ import (
|
||||
)
|
||||
|
||||
// Parse reads a legacy text report and returns a [report.Report]
|
||||
// carrying the in-scope subset of fields. The Width and Height of the
|
||||
// returned report are both set to the legacy "Size" value (galaxies
|
||||
// are square in the legacy engines).
|
||||
func Parse(r io.Reader) (report.Report, error) {
|
||||
// carrying the in-scope subset of fields, plus the per-battle
|
||||
// [report.BattleReport] payloads parsed out of the "Battle at (#N)"
|
||||
// blocks. The Width and Height of the returned report are both set
|
||||
// to the legacy "Size" value (galaxies are square in the legacy
|
||||
// engines). The battle slice is empty when the legacy file carries
|
||||
// no combat events.
|
||||
func Parse(r io.Reader) (report.Report, []report.BattleReport, error) {
|
||||
p := newParser()
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, 1024*1024), 4*1024*1024)
|
||||
for sc.Scan() {
|
||||
if err := p.handle(sc.Text()); err != nil {
|
||||
return report.Report{}, err
|
||||
return report.Report{}, nil, err
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return report.Report{}, fmt.Errorf("legacyreport: scan: %w", err)
|
||||
return report.Report{}, nil, fmt.Errorf("legacyreport: scan: %w", err)
|
||||
}
|
||||
return p.finish()
|
||||
battles, err := p.finish()
|
||||
if err != nil {
|
||||
return report.Report{}, nil, err
|
||||
}
|
||||
return p.rep, battles, nil
|
||||
}
|
||||
|
||||
type section int
|
||||
@@ -63,6 +70,8 @@ const (
|
||||
sectionOtherShipTypes
|
||||
sectionBombings
|
||||
sectionShipsInProduction
|
||||
sectionBattle
|
||||
sectionBattleProtocol
|
||||
)
|
||||
|
||||
type parser struct {
|
||||
@@ -85,6 +94,40 @@ type parser struct {
|
||||
pendingFleets []pendingFleet
|
||||
pendingIncomings []pendingIncoming
|
||||
pendingShipProducts []pendingShipProduction
|
||||
|
||||
// Battle accumulator. `battles` collects every parsed BattleReport;
|
||||
// `pendingBattle` carries the in-flight battle until its block
|
||||
// ends (next "Battle at " header, a top-level section header, or
|
||||
// end-of-file). `battleIndex` is the per-report 0-based index used
|
||||
// to derive a stable synthetic UUID through `syntheticBattleID`.
|
||||
// `pendingBattleRace` holds the race name currently being
|
||||
// rostered, set by the "<Race> Groups" sub-header that opens each
|
||||
// race's roster table inside the battle block.
|
||||
battles []report.BattleReport
|
||||
pendingBattle *pendingBattle
|
||||
battleIndex uint
|
||||
pendingBattleRace string
|
||||
}
|
||||
|
||||
type pendingBattle struct {
|
||||
id uuid.UUID
|
||||
planet uint
|
||||
planetName string
|
||||
// Race name → race index used in Protocol.{a,d}. Indices are
|
||||
// 0-based and assigned in first-seen order across the battle.
|
||||
raceIndex map[string]int
|
||||
// (race name, class name) → ship-group index used in
|
||||
// Protocol.{sa,sd}. Indices are 0-based and assigned in
|
||||
// first-seen order across the battle, across all races.
|
||||
shipIndex map[shipKey]int
|
||||
races map[int]uuid.UUID
|
||||
ships map[int]report.BattleReportGroup
|
||||
protocol []report.BattleActionReport
|
||||
}
|
||||
|
||||
type shipKey struct {
|
||||
race string
|
||||
class string
|
||||
}
|
||||
|
||||
type pendingGroup struct {
|
||||
@@ -155,10 +198,62 @@ func (p *parser) handle(line string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Inside a battle block, "<Race> Groups" lines open a per-race
|
||||
// roster sub-table. The line matches singleTokenPrefix(_, " Groups")
|
||||
// and would otherwise be treated as a top-level section transition
|
||||
// by classifySection. Trap it here so the battle state stays open.
|
||||
if (p.sec == sectionBattle || p.sec == sectionBattleProtocol) && p.pendingBattle != nil {
|
||||
if race, ok := singleTokenPrefix(trimmed, " Groups"); ok {
|
||||
// New roster — the protocol block, if it had started,
|
||||
// cannot reopen; but the engine never emits "<Race> Groups"
|
||||
// after "Battle Protocol" inside the same battle.
|
||||
p.sec = sectionBattle
|
||||
p.pendingBattleRace = race
|
||||
p.skipHeader = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if newSec, owner, isHeader := classifySection(trimmed); isHeader {
|
||||
// Flush the previous battle on any header transition that
|
||||
// moves us out of the battle block. Sub-transitions
|
||||
// (sectionBattle → sectionBattleProtocol or vice-versa)
|
||||
// inside the same battle do not flush.
|
||||
switch {
|
||||
case newSec == sectionBattle:
|
||||
p.flushPendingBattle()
|
||||
planet, planetName, ok := parseBattleHeader(trimmed)
|
||||
if ok {
|
||||
p.pendingBattle = &pendingBattle{
|
||||
id: syntheticBattleID(p.battleIndex),
|
||||
planet: planet,
|
||||
planetName: planetName,
|
||||
raceIndex: make(map[string]int),
|
||||
shipIndex: make(map[shipKey]int),
|
||||
races: make(map[int]uuid.UUID),
|
||||
ships: make(map[int]report.BattleReportGroup),
|
||||
}
|
||||
p.battleIndex++
|
||||
}
|
||||
p.pendingBattleRace = ""
|
||||
case newSec == sectionBattleProtocol:
|
||||
// Stay in the same battle; the protocol header itself
|
||||
// has no column header to skip — `Battle Protocol` is
|
||||
// followed by the shot lines directly. Reset
|
||||
// pendingBattleRace because the roster phase ended.
|
||||
p.pendingBattleRace = ""
|
||||
default:
|
||||
// Any other section transition closes the battle.
|
||||
p.flushPendingBattle()
|
||||
}
|
||||
p.sec = newSec
|
||||
p.otherOwner = owner
|
||||
p.skipHeader = newSec != sectionNone
|
||||
// `Battle Protocol` has no column header to skip; ditto for
|
||||
// the per-race `<Race> Groups` sub-header trapped above (we
|
||||
// handle that branch separately). For sectionBattle the
|
||||
// header line is "Battle at (#N) Name" with no following
|
||||
// column row, so skipHeader stays false there as well.
|
||||
p.skipHeader = newSec != sectionNone && newSec != sectionBattle && newSec != sectionBattleProtocol
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -205,16 +300,21 @@ func (p *parser) handle(line string) error {
|
||||
p.parseBombing(fields)
|
||||
case sectionShipsInProduction:
|
||||
p.parseShipProductionRow(fields)
|
||||
case sectionBattle:
|
||||
p.parseBattleRosterRow(fields)
|
||||
case sectionBattleProtocol:
|
||||
p.parseBattleProtocolLine(fields)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *parser) finish() (report.Report, error) {
|
||||
func (p *parser) finish() ([]report.BattleReport, error) {
|
||||
if !p.sawHeader {
|
||||
return report.Report{}, errors.New("legacyreport: missing report header line")
|
||||
return nil, errors.New("legacyreport: missing report header line")
|
||||
}
|
||||
p.flushPendingBattle()
|
||||
p.resolvePending()
|
||||
return p.rep, nil
|
||||
return p.battles, nil
|
||||
}
|
||||
|
||||
// parseHeader extracts (race, turn) from
|
||||
@@ -294,15 +394,16 @@ func classifySection(line string) (sec section, owner string, isHeader bool) {
|
||||
case "Ships In Production":
|
||||
return sectionShipsInProduction, "", true
|
||||
case "Approaching Groups",
|
||||
"Broadcast Message",
|
||||
"Battle Protocol":
|
||||
"Broadcast Message":
|
||||
return sectionNone, "", true
|
||||
case "Battle Protocol":
|
||||
return sectionBattleProtocol, "", true
|
||||
}
|
||||
if strings.HasPrefix(line, "Status of Players") {
|
||||
return sectionStatusOfPlayers, "", true
|
||||
}
|
||||
if strings.HasPrefix(line, "Battle at ") {
|
||||
return sectionNone, "", true
|
||||
return sectionBattle, "", true
|
||||
}
|
||||
if strings.HasPrefix(line, "=== ATTENTION") {
|
||||
return sectionNone, "", true
|
||||
@@ -637,6 +738,227 @@ func (p *parser) parseBombing(fields []string) {
|
||||
})
|
||||
}
|
||||
|
||||
// parseBattleHeader extracts (planet, planetName) from a
|
||||
// "Battle at (#N) <PlanetName>" line. The planet number is the
|
||||
// integer between "(#" and ")"; the planet name is the rest of the
|
||||
// line after the closing parenthesis (trimmed).
|
||||
func parseBattleHeader(line string) (uint, string, bool) {
|
||||
const prefix = "Battle at "
|
||||
if !strings.HasPrefix(line, prefix) {
|
||||
return 0, "", false
|
||||
}
|
||||
rest := strings.TrimSpace(line[len(prefix):])
|
||||
if !strings.HasPrefix(rest, "(#") {
|
||||
return 0, "", false
|
||||
}
|
||||
closing := strings.IndexByte(rest, ')')
|
||||
if closing < 0 {
|
||||
return 0, "", false
|
||||
}
|
||||
num, err := strconv.ParseUint(rest[2:closing], 10, 32)
|
||||
if err != nil {
|
||||
return 0, "", false
|
||||
}
|
||||
name := strings.TrimSpace(rest[closing+1:])
|
||||
return uint(num), name, true
|
||||
}
|
||||
|
||||
// parseBattleRosterRow consumes one ship-group line from a battle
|
||||
// roster sub-table. Columns (10 tokens; the last is the per-group
|
||||
// state word):
|
||||
//
|
||||
// # T D W S C T Q L state
|
||||
// 1 Pistolet 1.6 1.00 1.00 0 - 0 1 In_Battle
|
||||
//
|
||||
// where column "L" carries the number of ships remaining after the
|
||||
// battle (confirmed against KNNTS fixtures). Rows are appended to
|
||||
// `pendingBattle.ships` under the race name currently held in
|
||||
// `pendingBattleRace`.
|
||||
func (p *parser) parseBattleRosterRow(fields []string) {
|
||||
if p.pendingBattle == nil || p.pendingBattleRace == "" {
|
||||
return
|
||||
}
|
||||
if len(fields) < 10 {
|
||||
return
|
||||
}
|
||||
number, err := strconv.ParseUint(fields[0], 10, 32)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
className := fields[1]
|
||||
drive, _ := parseFloat(fields[2])
|
||||
weapons, _ := parseFloat(fields[3])
|
||||
shields, _ := parseFloat(fields[4])
|
||||
cargo, _ := parseFloat(fields[5])
|
||||
loadQuantity, _ := parseFloat(fields[7])
|
||||
numLeft, err := strconv.ParseUint(fields[8], 10, 32)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
state := fields[9]
|
||||
tech := make(map[string]report.Float, 4)
|
||||
if drive != 0 {
|
||||
tech["DRIVE"] = report.F(drive)
|
||||
}
|
||||
if weapons != 0 {
|
||||
tech["WEAPONS"] = report.F(weapons)
|
||||
}
|
||||
if shields != 0 {
|
||||
tech["SHIELDS"] = report.F(shields)
|
||||
}
|
||||
if cargo != 0 {
|
||||
tech["CARGO"] = report.F(cargo)
|
||||
}
|
||||
|
||||
p.assignRaceIndex(p.pendingBattleRace)
|
||||
key := shipKey{race: p.pendingBattleRace, class: className}
|
||||
idx := p.assignShipIndex(key)
|
||||
|
||||
// Legacy battle rosters may list the same `(race, className)`
|
||||
// across multiple rows — different tech variants, ships pulled
|
||||
// from several stacks / planets, etc. We collapse those rows
|
||||
// into one BattleReportGroup keyed by `(race, className)` (the
|
||||
// viewer aggregates per class anyway) by SUMMING Number and
|
||||
// NumberLeft instead of overwriting; otherwise only the last
|
||||
// row's counts survive and the battle protocol's destroy count
|
||||
// would dwarf the recorded initial count (the original
|
||||
// motivation for the now-removed "phantom destroy" workaround).
|
||||
if existing, found := p.pendingBattle.ships[idx]; found {
|
||||
existing.Number += uint(number)
|
||||
existing.NumberLeft += uint(numLeft)
|
||||
// LoadQuantity is per-ship cargo — average is a fair fallback
|
||||
// when several stacks of the same class merge into one bucket.
|
||||
existing.LoadQuantity = report.F(
|
||||
(existing.LoadQuantity.F() + loadQuantity) / 2,
|
||||
)
|
||||
// Tech / LoadType / InBattle keep their first-seen values:
|
||||
// the viewer treats them as bucket-wide attributes and the
|
||||
// first row is normally the most representative tech variant.
|
||||
p.pendingBattle.ships[idx] = existing
|
||||
return
|
||||
}
|
||||
|
||||
p.pendingBattle.ships[idx] = report.BattleReportGroup{
|
||||
Race: p.pendingBattleRace,
|
||||
ClassName: className,
|
||||
Tech: tech,
|
||||
Number: uint(number),
|
||||
NumberLeft: uint(numLeft),
|
||||
LoadType: dashOrEmpty(fields[6]),
|
||||
LoadQuantity: report.F(loadQuantity),
|
||||
InBattle: state == "In_Battle",
|
||||
}
|
||||
}
|
||||
|
||||
// parseBattleProtocolLine consumes one shot line of the
|
||||
// "Battle Protocol" sub-block. Required shape (8 tokens):
|
||||
//
|
||||
// <atkRace> <atkClass> fires on <defRace> <defClass> : <Destroyed|Shields>
|
||||
//
|
||||
// Anything else (including the empty line separating the protocol
|
||||
// from the preceding rosters) is silently skipped — the engine never
|
||||
// emits other text inside this block.
|
||||
func (p *parser) parseBattleProtocolLine(fields []string) {
|
||||
if p.pendingBattle == nil {
|
||||
return
|
||||
}
|
||||
if len(fields) != 8 {
|
||||
return
|
||||
}
|
||||
if fields[2] != "fires" || fields[3] != "on" || fields[6] != ":" {
|
||||
return
|
||||
}
|
||||
atkRace, atkClass := fields[0], fields[1]
|
||||
defRace, defClass := fields[4], fields[5]
|
||||
destroyed := fields[7] == "Destroyed"
|
||||
|
||||
aRace := p.assignRaceIndex(atkRace)
|
||||
dRace := p.assignRaceIndex(defRace)
|
||||
sa := p.assignShipIndex(shipKey{race: atkRace, class: atkClass})
|
||||
sd := p.assignShipIndex(shipKey{race: defRace, class: defClass})
|
||||
|
||||
// Synthesise a minimal BattleReportGroup entry when the shot
|
||||
// references a (race, class) pair that the roster did not
|
||||
// declare. This happens when the legacy emitter trims a roster
|
||||
// row but the engine logged a shot for that group.
|
||||
if _, ok := p.pendingBattle.ships[sa]; !ok {
|
||||
p.pendingBattle.ships[sa] = report.BattleReportGroup{
|
||||
Race: atkRace, ClassName: atkClass, InBattle: true,
|
||||
Tech: map[string]report.Float{},
|
||||
}
|
||||
}
|
||||
if _, ok := p.pendingBattle.ships[sd]; !ok {
|
||||
p.pendingBattle.ships[sd] = report.BattleReportGroup{
|
||||
Race: defRace, ClassName: defClass, InBattle: true,
|
||||
Tech: map[string]report.Float{},
|
||||
}
|
||||
}
|
||||
|
||||
p.pendingBattle.protocol = append(p.pendingBattle.protocol, report.BattleActionReport{
|
||||
Attacker: aRace,
|
||||
AttackerShipClass: sa,
|
||||
Defender: dRace,
|
||||
DefenderShipClass: sd,
|
||||
Destroyed: destroyed,
|
||||
})
|
||||
}
|
||||
|
||||
// assignRaceIndex returns the in-battle race index for raceName,
|
||||
// creating a new entry on first sight. Race indices are 0-based and
|
||||
// monotonically increasing in first-seen order. The synthetic race
|
||||
// UUID is derived from the race name through
|
||||
// `syntheticBattleRaceNamespace`.
|
||||
func (p *parser) assignRaceIndex(raceName string) int {
|
||||
if idx, ok := p.pendingBattle.raceIndex[raceName]; ok {
|
||||
return idx
|
||||
}
|
||||
idx := len(p.pendingBattle.raceIndex)
|
||||
p.pendingBattle.raceIndex[raceName] = idx
|
||||
p.pendingBattle.races[idx] = syntheticBattleRaceID(raceName)
|
||||
return idx
|
||||
}
|
||||
|
||||
// assignShipIndex returns the in-battle ship-group index for
|
||||
// (race, class), creating a new entry on first sight. Indices are
|
||||
// 0-based and monotonically increasing in first-seen order across
|
||||
// all races.
|
||||
func (p *parser) assignShipIndex(key shipKey) int {
|
||||
if idx, ok := p.pendingBattle.shipIndex[key]; ok {
|
||||
return idx
|
||||
}
|
||||
idx := len(p.pendingBattle.shipIndex)
|
||||
p.pendingBattle.shipIndex[key] = idx
|
||||
return idx
|
||||
}
|
||||
|
||||
// flushPendingBattle finalises the in-flight battle: appends the
|
||||
// BattleReport to `p.battles` and a matching BattleSummary
|
||||
// (id/planet/shots) to `p.rep.Battle`. No-op when no battle is
|
||||
// pending. Idempotent — clears `pendingBattle` on completion.
|
||||
func (p *parser) flushPendingBattle() {
|
||||
if p.pendingBattle == nil {
|
||||
return
|
||||
}
|
||||
pb := p.pendingBattle
|
||||
p.pendingBattle = nil
|
||||
p.pendingBattleRace = ""
|
||||
|
||||
br := report.BattleReport{
|
||||
ID: pb.id,
|
||||
Planet: pb.planet,
|
||||
PlanetName: pb.planetName,
|
||||
Races: pb.races,
|
||||
Ships: pb.ships,
|
||||
Protocol: pb.protocol,
|
||||
}
|
||||
p.battles = append(p.battles, br)
|
||||
p.rep.Battle = append(p.rep.Battle, report.BattleSummary{
|
||||
ID: pb.id,
|
||||
Planet: pb.planet,
|
||||
Shots: uint(len(pb.protocol)),
|
||||
})
|
||||
}
|
||||
|
||||
// parseShipProductionRow buffers a "Ships In Production" row for
|
||||
// post-processing in [parser.finish]. Columns:
|
||||
//
|
||||
@@ -957,6 +1279,31 @@ func syntheticGroupID(g uint) uuid.UUID {
|
||||
return uuid.NewSHA1(syntheticGroupNamespace, fmt.Appendf(nil, "legacy-local-group-%d", g))
|
||||
}
|
||||
|
||||
// syntheticBattleNamespace seeds [uuid.NewSHA1] for the per-report
|
||||
// battle-index → UUID derivation used by `Report.Battle[i].ID` and
|
||||
// `BattleReport.ID`. Distinct from `syntheticGroupNamespace` so a
|
||||
// per-report battle index can never collide with a ship-group id.
|
||||
// Mirrors the rationale in `syntheticGroupNamespace`: arbitrary
|
||||
// value, stable across releases.
|
||||
var syntheticBattleNamespace = uuid.MustParse("be01a000-0000-0000-0000-000000000002")
|
||||
|
||||
// syntheticBattleRaceNamespace seeds [uuid.NewSHA1] for the
|
||||
// per-battle race name → race UUID derivation that fills
|
||||
// `BattleReport.Races`. Engine-side reports carry the real race
|
||||
// UUID; the legacy text only carries the race name, so we derive a
|
||||
// stable identifier from the name. The constant is independent of
|
||||
// `syntheticBattleNamespace` so race UUIDs can never collide with
|
||||
// battle UUIDs.
|
||||
var syntheticBattleRaceNamespace = uuid.MustParse("be01a000-0000-0000-0000-000000000003")
|
||||
|
||||
func syntheticBattleID(idx uint) uuid.UUID {
|
||||
return uuid.NewSHA1(syntheticBattleNamespace, fmt.Appendf(nil, "legacy-battle-%d", idx))
|
||||
}
|
||||
|
||||
func syntheticBattleRaceID(name string) uuid.UUID {
|
||||
return uuid.NewSHA1(syntheticBattleRaceNamespace, fmt.Appendf(nil, "legacy-battle-race-%s", name))
|
||||
}
|
||||
|
||||
func dashOrEmpty(s string) string {
|
||||
if s == "-" {
|
||||
return ""
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestParseHeaderAndSize(t *testing.T) {
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func TestParseStatusOfPlayers(t *testing.T) {
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func TestParseYourVote(t *testing.T) {
|
||||
"KnightErrants 16.02",
|
||||
"",
|
||||
}, "\n")
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
@@ -115,7 +115,7 @@ func TestParseLocalAndOtherPlanets(t *testing.T) {
|
||||
" 12 303.84 579.23 Skarabei 500.00 500.00 500.00 10.00 Capital 0.00 70.99 20.03 341.78",
|
||||
"",
|
||||
}, "\n")
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
@@ -160,7 +160,7 @@ func TestParseUninhabitedAndUnidentified(t *testing.T) {
|
||||
" 1 579.12 489.37",
|
||||
"",
|
||||
}, "\n")
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
@@ -196,7 +196,7 @@ func TestParseShipClasses(t *testing.T) {
|
||||
"Dragon 16.70 1 1.10 1.00 1 19.80",
|
||||
"",
|
||||
}, "\n")
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
@@ -241,7 +241,7 @@ func TestParseSciences(t *testing.T) {
|
||||
"_Drift 1 0 0 0",
|
||||
"",
|
||||
}, "\n")
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
@@ -280,7 +280,7 @@ func TestParseBombings(t *testing.T) {
|
||||
"Knights Ricksha 332 PEHKE 500.00 258.64 Dron 184.39 0.00 6.42 331.93 Damaged",
|
||||
"",
|
||||
}, "\n")
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
@@ -339,7 +339,7 @@ func TestParseShipsInProduction(t *testing.T) {
|
||||
" 17 Castle CombatFlame 990.10 0.07 1000.00",
|
||||
"",
|
||||
}, "\n")
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
@@ -381,7 +381,7 @@ func TestParseShipsInProductionDropsUnknownPlanet(t *testing.T) {
|
||||
" 99 Lost Frigate 100.00 0.05 500.00",
|
||||
"",
|
||||
}, "\n")
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
@@ -391,23 +391,57 @@ func TestParseShipsInProductionDropsUnknownPlanet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseSkipsBattles covers the only remaining legacy section the
|
||||
// parser ignores: "Battle at ..." headers and the following "Battle
|
||||
// Protocol" block. Bombings, Ships In Production, and the per-race
|
||||
// Sciences / Ship Types blocks now flow through real parsers; the
|
||||
// dedicated section tests below cover them.
|
||||
func TestParseSkipsBattles(t *testing.T) {
|
||||
// TestParseBattles exercises the battle-block parser end-to-end:
|
||||
// two battles with two races each, full rosters, and protocols. The
|
||||
// inline fixture mirrors the KNNTS-style layout (race-named roster
|
||||
// sub-headers, 10-column roster rows, 8-token shot lines) so any
|
||||
// drift from the real engine format breaks this test before a smoke
|
||||
// regression. Asserts:
|
||||
// - report.Battle carries one BattleSummary per "Battle at"
|
||||
// - BattleReport slice mirrors that with full Races/Ships/Protocol
|
||||
// - Battle Protocol "Foo fires on Bar : <Destroyed|Shields>" lines
|
||||
// map to BattleActionReport entries with the correct destroyed flag
|
||||
// - Roster column 8 (the "L" column) populates NumberLeft
|
||||
// - Top-level sections after a battle (Your Planets) still parse
|
||||
// — battle state must close cleanly without leaking rows.
|
||||
func TestParseBattles(t *testing.T) {
|
||||
in := strings.Join([]string{
|
||||
"Race Report for Galaxy PLUS Turn 1",
|
||||
"",
|
||||
"Battle at (#7) B-007",
|
||||
"",
|
||||
"Foo Groups",
|
||||
"",
|
||||
"# T D W S C T Q L",
|
||||
"1 PeaceShip 4 0 0 0 - 0 1 Out_Battle",
|
||||
"1 PeaceShip 4.0 0 0 0 - 0 1 In_Battle",
|
||||
"2 Drone 0.0 1 1 0 - 0 0 In_Battle",
|
||||
"",
|
||||
"Bar Groups",
|
||||
"",
|
||||
"# T D W S C T Q L",
|
||||
"1 Pistolet 1.0 1.0 0 0 - 0 1 In_Battle",
|
||||
"",
|
||||
"Battle Protocol",
|
||||
"",
|
||||
"Foo fires on Bar : Destroyed",
|
||||
"Foo PeaceShip fires on Bar Pistolet : Shields",
|
||||
"Bar Pistolet fires on Foo Drone : Destroyed",
|
||||
"Bar Pistolet fires on Foo Drone : Destroyed",
|
||||
"",
|
||||
"Battle at (#11) X-011",
|
||||
"",
|
||||
"Foo Groups",
|
||||
"",
|
||||
"# T D W S C T Q L",
|
||||
"1 Scout 1.0 0 0 0 - 0 1 In_Battle",
|
||||
"",
|
||||
"Bar Groups",
|
||||
"",
|
||||
"# T D W S C T Q L",
|
||||
"1 Sniper 2.0 1 0 0 - 0 0 In_Battle",
|
||||
"",
|
||||
"Battle Protocol",
|
||||
"",
|
||||
"Foo Scout fires on Bar Sniper : Destroyed",
|
||||
"",
|
||||
"Your Planets",
|
||||
"",
|
||||
@@ -415,15 +449,187 @@ func TestParseSkipsBattles(t *testing.T) {
|
||||
" 17 171.05 700.24 Castle 1000.00 1000.00 1000.00 10.00 Capital 0.00 0.68 88.78 1000.00",
|
||||
"",
|
||||
}, "\n")
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, battles, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
|
||||
// The trailing Your Planets section must still parse — battle
|
||||
// state must close before the next top-level header.
|
||||
if got, want := len(rep.LocalPlanet), 1; got != want {
|
||||
t.Fatalf("len(LocalPlanet) = %d, want %d (battle rows must not leak in)", got, want)
|
||||
t.Fatalf("len(LocalPlanet) = %d, want %d (battle state did not close)", got, want)
|
||||
}
|
||||
if got, want := len(rep.Battle), 0; got != want {
|
||||
t.Errorf("len(Battle) = %d, want %d (legacy parser does not synthesise battle UUIDs)", got, want)
|
||||
|
||||
if got, want := len(rep.Battle), 2; got != want {
|
||||
t.Fatalf("len(rep.Battle) = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := len(battles), 2; got != want {
|
||||
t.Fatalf("len(battles) = %d, want %d", got, want)
|
||||
}
|
||||
|
||||
// First battle: planet 7, 3 shots; protocol shape with one
|
||||
// shielded shot and two destroyed shots.
|
||||
b0 := battles[0]
|
||||
if b0.Planet != 7 || b0.PlanetName != "B-007" {
|
||||
t.Errorf("battle[0] = (planet=%d, name=%q), want (7, %q)",
|
||||
b0.Planet, b0.PlanetName, "B-007")
|
||||
}
|
||||
if got, want := len(b0.Protocol), 3; got != want {
|
||||
t.Fatalf("battle[0].Protocol = %d shots, want %d", got, want)
|
||||
}
|
||||
if b0.Protocol[0].Destroyed {
|
||||
t.Errorf("battle[0].Protocol[0].Destroyed = true (Shields hit), want false")
|
||||
}
|
||||
if !b0.Protocol[1].Destroyed || !b0.Protocol[2].Destroyed {
|
||||
t.Errorf("battle[0].Protocol[1..2].Destroyed must be true (Destroyed hits)")
|
||||
}
|
||||
|
||||
// First battle: roster size and NumberLeft mapping.
|
||||
if got, want := len(b0.Ships), 3; got != want {
|
||||
t.Fatalf("battle[0].Ships = %d groups, want %d", got, want)
|
||||
}
|
||||
// 'Drone' has NumberLeft=0 in the roster (column 8 = 0). The
|
||||
// protocol corroborates: Pistolet destroyed Drone twice.
|
||||
dronePresent := false
|
||||
for _, ship := range b0.Ships {
|
||||
if ship.ClassName == "Drone" {
|
||||
dronePresent = true
|
||||
if ship.NumberLeft != 0 {
|
||||
t.Errorf("Drone.NumberLeft = %d, want 0", ship.NumberLeft)
|
||||
}
|
||||
if ship.Number != 2 {
|
||||
t.Errorf("Drone.Number = %d, want 2", ship.Number)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !dronePresent {
|
||||
t.Errorf("Drone roster row not parsed into battle[0].Ships")
|
||||
}
|
||||
|
||||
// Summary mirrors the BattleReport ID and shot count.
|
||||
if rep.Battle[0].ID != b0.ID {
|
||||
t.Errorf("rep.Battle[0].ID = %s, want %s", rep.Battle[0].ID, b0.ID)
|
||||
}
|
||||
if rep.Battle[0].Shots != 3 {
|
||||
t.Errorf("rep.Battle[0].Shots = %d, want 3", rep.Battle[0].Shots)
|
||||
}
|
||||
if rep.Battle[0].Planet != 7 {
|
||||
t.Errorf("rep.Battle[0].Planet = %d, want 7", rep.Battle[0].Planet)
|
||||
}
|
||||
|
||||
// Second battle: planet 11, 1 shot.
|
||||
if rep.Battle[1].Planet != 11 || rep.Battle[1].Shots != 1 {
|
||||
t.Errorf("rep.Battle[1] = (planet=%d, shots=%d), want (11, 1)",
|
||||
rep.Battle[1].Planet, rep.Battle[1].Shots)
|
||||
}
|
||||
|
||||
// Battle IDs are stable across re-parses.
|
||||
rep2, battles2, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse (second pass): %v", err)
|
||||
}
|
||||
if rep.Battle[0].ID != rep2.Battle[0].ID || battles[0].ID != battles2[0].ID {
|
||||
t.Errorf("battle id must be deterministic across re-parses")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseBattleAggregatesDuplicateClasses guards against the
|
||||
// regression that produced the original "phantom destroys" symptom:
|
||||
// the same `(race, className)` pair appearing on multiple roster
|
||||
// rows must collapse into a single BattleReportGroup whose `Number`
|
||||
// (the "#" column, initial ship count) and `NumberLeft` (the "L"
|
||||
// column, survivors) are the sums across rows. Without the
|
||||
// aggregation only the last row's counts survived and the protocol's
|
||||
// destroy count dwarfed the recorded initial count (e.g. KNNTS041
|
||||
// turn-41 planet #7 lists `pup` seven separate times: 99 + 105 + 291 +
|
||||
// 287 + 166 + 132 + 88 = 1168 ships, 86 survivors, 1082 destroys).
|
||||
func TestParseBattleAggregatesDuplicateClasses(t *testing.T) {
|
||||
in := strings.Join([]string{
|
||||
"Race Report for Galaxy PLUS Turn 1",
|
||||
"",
|
||||
"Battle at (#7) B-007",
|
||||
"",
|
||||
"Foo Groups",
|
||||
"",
|
||||
" # T D W S C T Q L",
|
||||
" 3 Drone 1.0 1.0 0 0 - 0 1 In_Battle",
|
||||
" 4 Drone 1.2 1.0 0 0 - 0 2 In_Battle",
|
||||
"10 Cruiser 3.0 2.0 0 0 - 0 9 In_Battle",
|
||||
"",
|
||||
"Bar Groups",
|
||||
"",
|
||||
"# T D W S C T Q L",
|
||||
"5 Pistolet 1.0 1.0 0 0 - 0 3 In_Battle",
|
||||
"",
|
||||
"Battle Protocol",
|
||||
"",
|
||||
"Bar Pistolet fires on Foo Drone : Destroyed",
|
||||
"Bar Pistolet fires on Foo Drone : Destroyed",
|
||||
"Bar Pistolet fires on Foo Drone : Destroyed",
|
||||
"Bar Pistolet fires on Foo Drone : Destroyed",
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
rep, battles, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if got, want := len(battles), 1; got != want {
|
||||
t.Fatalf("len(battles) = %d, want %d", got, want)
|
||||
}
|
||||
b := battles[0]
|
||||
|
||||
// The three Foo roster rows collapse into two BattleReportGroup
|
||||
// entries: one Foo:Drone (rows 1+2) and one Foo:Cruiser (row 3),
|
||||
// plus one Bar:Pistolet. Total 3 groups, NOT 4.
|
||||
if got, want := len(b.Ships), 3; got != want {
|
||||
t.Fatalf("battle.Ships = %d groups, want %d (duplicate class rows must merge)", got, want)
|
||||
}
|
||||
|
||||
var drone, cruiser, pistolet *report.BattleReportGroup
|
||||
for i := range b.Ships {
|
||||
g := b.Ships[i]
|
||||
switch g.ClassName {
|
||||
case "Drone":
|
||||
drone = &g
|
||||
case "Cruiser":
|
||||
cruiser = &g
|
||||
case "Pistolet":
|
||||
pistolet = &g
|
||||
}
|
||||
}
|
||||
if drone == nil || cruiser == nil || pistolet == nil {
|
||||
t.Fatalf("missing class: drone=%v cruiser=%v pistolet=%v", drone, cruiser, pistolet)
|
||||
}
|
||||
|
||||
// Drone rows sum to Number = 3 + 4 = 7 and NumberLeft = 1 + 2 = 3.
|
||||
// Protocol corroborates: four Destroyed shots against Drone, so
|
||||
// 7 - 3 = 4 — the protocol's destroy count reconciles with the
|
||||
// recorded delta only when both rows are summed.
|
||||
if drone.Number != 7 {
|
||||
t.Errorf("Drone.Number = %d, want 7 (3+4)", drone.Number)
|
||||
}
|
||||
if drone.NumberLeft != 3 {
|
||||
t.Errorf("Drone.NumberLeft = %d, want 3 (1+2)", drone.NumberLeft)
|
||||
}
|
||||
// Cruiser and Pistolet are single-row classes — counts must match
|
||||
// the file verbatim with no spurious merging across classes.
|
||||
if cruiser.Number != 10 || cruiser.NumberLeft != 9 {
|
||||
t.Errorf("Cruiser = (Number=%d, NumberLeft=%d), want (10, 9)",
|
||||
cruiser.Number, cruiser.NumberLeft)
|
||||
}
|
||||
if pistolet.Number != 5 || pistolet.NumberLeft != 3 {
|
||||
t.Errorf("Pistolet = (Number=%d, NumberLeft=%d), want (5, 3)",
|
||||
pistolet.Number, pistolet.NumberLeft)
|
||||
}
|
||||
|
||||
// rep-level summary must reflect the merged shape: 4 shots, one
|
||||
// battle, no crash or spurious extra battles.
|
||||
if got, want := len(rep.Battle), 1; got != want {
|
||||
t.Fatalf("len(rep.Battle) = %d, want %d", got, want)
|
||||
}
|
||||
if rep.Battle[0].Shots != 4 {
|
||||
t.Errorf("rep.Battle[0].Shots = %d, want 4", rep.Battle[0].Shots)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,7 +657,7 @@ func TestParseYourGroups(t *testing.T) {
|
||||
" 2 1 Tormoz 11.19 0.00 0.00 1.0 CAP 4 North Castle 7.5 60.66 49.50 - In_Space",
|
||||
"",
|
||||
}, "\n")
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
@@ -515,7 +721,7 @@ func TestParseYourFleets(t *testing.T) {
|
||||
" 1 Far 2 North Castle 4.50 20 In_Space",
|
||||
"",
|
||||
}, "\n")
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
@@ -564,7 +770,7 @@ func TestParseIncomingGroups(t *testing.T) {
|
||||
" 87 169.59 694.49 North 500.00 500.00 500.00 10.00 Capital 0.00 0.52 35.76 500.00",
|
||||
"",
|
||||
}, "\n")
|
||||
rep, err := Parse(strings.NewReader(in))
|
||||
rep, _, err := Parse(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
@@ -597,11 +803,12 @@ type smokeWant struct {
|
||||
localGroups, localFleets, incomingGroups int
|
||||
localScience, otherScience, otherShipClass int
|
||||
bombings, shipProductions int
|
||||
battles int
|
||||
}
|
||||
|
||||
func runSmoke(t *testing.T, path string, want smokeWant) {
|
||||
t.Helper()
|
||||
rep, err := parseFile(t, path)
|
||||
rep, battles, err := parseFile(t, path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
t.Skipf("legacy report fixture missing: %s", path)
|
||||
@@ -647,12 +854,31 @@ func runSmoke(t *testing.T, path string, want smokeWant) {
|
||||
{"OtherShipClass", len(rep.OtherShipClass), want.otherShipClass},
|
||||
{"Bombing", len(rep.Bombing), want.bombings},
|
||||
{"ShipProduction", len(rep.ShipProduction), want.shipProductions},
|
||||
{"Battle (summary)", len(rep.Battle), want.battles},
|
||||
{"BattleReport", len(battles), want.battles},
|
||||
}
|
||||
for _, c := range checks {
|
||||
if c.got != c.want {
|
||||
t.Errorf("%s = %d, want %d", c.name, c.got, c.want)
|
||||
}
|
||||
}
|
||||
for i, summary := range rep.Battle {
|
||||
if i >= len(battles) {
|
||||
break
|
||||
}
|
||||
if summary.ID != battles[i].ID {
|
||||
t.Errorf("battle[%d].ID summary=%s vs report=%s",
|
||||
i, summary.ID, battles[i].ID)
|
||||
}
|
||||
if summary.Shots != uint(len(battles[i].Protocol)) {
|
||||
t.Errorf("battle[%d].Shots = %d, want %d (len(Protocol))",
|
||||
i, summary.Shots, len(battles[i].Protocol))
|
||||
}
|
||||
if summary.Planet != battles[i].Planet {
|
||||
t.Errorf("battle[%d].Planet summary=%d vs report=%d",
|
||||
i, summary.Planet, battles[i].Planet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseDgKNNTS039 is a smoke test: the parser must produce
|
||||
@@ -676,6 +902,7 @@ func TestParseDgKNNTS039(t *testing.T) {
|
||||
otherShipClass: 170,
|
||||
bombings: 16,
|
||||
shipProductions: 6,
|
||||
battles: 28,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -694,6 +921,7 @@ func TestParseDgKNNTS040(t *testing.T) {
|
||||
otherShipClass: 160,
|
||||
bombings: 24,
|
||||
shipProductions: 16,
|
||||
battles: 79,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -715,6 +943,7 @@ func TestParseDgKNNTS041(t *testing.T) {
|
||||
otherShipClass: 218,
|
||||
bombings: 12,
|
||||
shipProductions: 22,
|
||||
battles: 56,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -736,6 +965,7 @@ func TestParseGplus40(t *testing.T) {
|
||||
otherShipClass: 183,
|
||||
bombings: 4,
|
||||
shipProductions: 8,
|
||||
battles: 30,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -757,6 +987,7 @@ func TestParseDgKiller031(t *testing.T) {
|
||||
otherShipClass: 161,
|
||||
bombings: 18,
|
||||
shipProductions: 0,
|
||||
battles: 83,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -779,18 +1010,19 @@ func TestParseDgTancordia037(t *testing.T) {
|
||||
otherShipClass: 123,
|
||||
bombings: 22,
|
||||
shipProductions: 20,
|
||||
battles: 57,
|
||||
})
|
||||
}
|
||||
|
||||
func parseFile(t *testing.T, rel string) (report.Report, error) {
|
||||
func parseFile(t *testing.T, rel string) (report.Report, []report.BattleReport, error) {
|
||||
t.Helper()
|
||||
abs, err := filepath.Abs(rel)
|
||||
if err != nil {
|
||||
return report.Report{}, err
|
||||
return report.Report{}, nil, err
|
||||
}
|
||||
f, err := os.Open(abs)
|
||||
if err != nil {
|
||||
return report.Report{}, err
|
||||
return report.Report{}, nil, err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
return Parse(f)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+125
-29
@@ -2945,49 +2945,130 @@ Targeted tests:
|
||||
dropdown, return via banner action, confirm the order draft
|
||||
survives the round-trip.
|
||||
|
||||
## Phase 27. Battle Viewer
|
||||
## ~~Phase 27. Battle Viewer~~
|
||||
|
||||
Status: pending.
|
||||
Status: done (local-ci run 14).
|
||||
|
||||
Goal: render battles as a dedicated view with playback controls
|
||||
(play, pause, step forward, step backward, rewind), driven by the
|
||||
server-side combat log; render battle and bombing markers on the map.
|
||||
Goal: ship a dedicated Battle Viewer rendering radial scenes from
|
||||
`BattleReport` data (planet centred, races on the outer ring, per
|
||||
ship-class clusters, animated shot lines), plus battle and bombing
|
||||
markers on the map. Battles and bombings stay strictly separate —
|
||||
bombings remain a static table in the Reports view, only battles
|
||||
get the animated viewer.
|
||||
|
||||
Artifacts:
|
||||
|
||||
- `ui/frontend/src/map/battle-markers.ts` renders markers on the map
|
||||
for current-turn battles and bombings within visibility, clickable
|
||||
to open the battle viewer
|
||||
- `ui/frontend/src/routes/games/[id]/battle/[battleId]/+page.svelte`
|
||||
view with the combatant list, the round-by-round log, and a player
|
||||
control bar
|
||||
- `ui/frontend/src/lib/battle-player/` round timeline, current-round
|
||||
highlight, per-shot animation
|
||||
- entry points to the viewer: marker on map, row in the report's
|
||||
battles section, push-event toast when a battle this turn involved
|
||||
the player
|
||||
- topic doc `ui/docs/battle-viewer-ux.md` covering playback
|
||||
semantics, accessibility (the combat log must be readable as text
|
||||
for users who skip animations)
|
||||
- engine: `game/internal/router/handler/battle.go` for
|
||||
`GET /api/v1/battle/:turn/:uuid` (handler pre-existed; Phase 27
|
||||
added the tests + openapi schemas)
|
||||
- engine wire: `pkg/model/report/battle.go` ships a new
|
||||
`BattleSummary{id, planet, shots}`; `Report.battle` carries a
|
||||
slice of these summaries so the map can place markers without
|
||||
fetching every full report
|
||||
- backend: `backend/internal/engineclient/client.go.FetchBattle`
|
||||
and `backend/internal/server/handlers_user_games.go.Battle`
|
||||
expose `GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`
|
||||
- UI viewer: `ui/frontend/src/lib/battle-player/`
|
||||
(`radial-layout.ts`, `timeline.ts`, `battle-scene.svelte`,
|
||||
`playback-controls.svelte`, `battle-viewer.svelte`); SVG-based,
|
||||
one frame per protocol entry, full controls (play/pause + step
|
||||
back + step forward + rewind + 1x/2x/4x speed switch)
|
||||
- UI route + page wrapper:
|
||||
`ui/frontend/src/routes/games/[id]/battle/[[battleId]]/+page.svelte`
|
||||
feeds `gameId` / `turn` / `battleId` into
|
||||
`ui/frontend/src/lib/active-view/battle.svelte`, which loads the
|
||||
report via `api/battle-fetch.ts` (synthetic-fixture path + real
|
||||
engine fetch through the backend gateway)
|
||||
- UI report link: `lib/active-view/report/section-battles.svelte`
|
||||
now links every battle UUID into
|
||||
`/games/{id}/battle/{uuid}?turn={turn}`
|
||||
- UI map markers: `ui/frontend/src/map/battle-markers.ts` emits a
|
||||
yellow X cross per battle (two `LinePrim` through the planet's
|
||||
bounding-square diagonals; stroke width scales 1px..5px with
|
||||
protocol length) plus a stroke-only ring per bombing (yellow when
|
||||
damaged, red when wiped). Wired into `state-binding.ts`; the map
|
||||
click handler dispatches battle clicks to the viewer and bombing
|
||||
clicks to a scroll-into-view of the matching row in Reports.
|
||||
- topic doc `ui/docs/battle-viewer-ux.md` covers playback
|
||||
semantics, accessibility (the always-visible `<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.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- battle and bombing markers render on the map for the seeded
|
||||
current-turn report and are clickable to open the viewer;
|
||||
- the viewer plays back any battle in the seeded report including
|
||||
multi-round and one-sided battles;
|
||||
- step controls allow precise inspection;
|
||||
- the same data is accessible as a static text log for accessibility.
|
||||
current-turn report and are clickable: battle → Battle Viewer for
|
||||
the corresponding UUID, bombing → scroll to its row in Reports;
|
||||
- the Battle Viewer plays back any `BattleReport` end-to-end with
|
||||
step back / step forward / rewind / 1x-2x-4x speeds; observers
|
||||
(`inBattle === false`) are not drawn; eliminated races drop out
|
||||
and survivors re-distribute on the next frame;
|
||||
- the same protocol is mirrored as an always-visible text log under
|
||||
the scene for accessibility;
|
||||
- bombings keep their Phase 23 static table layout in Reports; no
|
||||
Battle Viewer entry-point is wired from them.
|
||||
|
||||
Targeted tests:
|
||||
|
||||
- Vitest unit tests for round-state transitions;
|
||||
- Vitest unit tests for marker rendering on torus and no-wrap
|
||||
fixtures;
|
||||
- Playwright e2e: click a battle marker on the map, play through,
|
||||
step backward, return to the report.
|
||||
- Vitest unit: radial layout (1/2/3 races) and timeline frame-
|
||||
builder (initial state, shot decrement, race-elimination drop-out)
|
||||
in `tests/battle-player.test.ts`
|
||||
- Vitest unit: marker primitives + stroke-width formula
|
||||
(1→1, 50→2.98, 100→5, 200→5) in `tests/battle-markers.test.ts`
|
||||
- Go unit: engine HTTP handler validations (400 / 404 / 500) in
|
||||
`game/internal/router/battle_test.go`
|
||||
- Go contract: openapi freezes for the new endpoint and schemas in
|
||||
`game/openapi_contract_test.go`
|
||||
- Playwright e2e: click battle marker → viewer; play / step back;
|
||||
click battle UUID in Reports → viewer; click bombing marker →
|
||||
Reports bombings row scrolled into view.
|
||||
|
||||
Decisions during stage:
|
||||
|
||||
1. **Bombings stay a static table.** `section-bombings.svelte`
|
||||
already covers the "who bombed, with what power, wiped or not"
|
||||
requirement; nothing in Phase 27 touches it. Bombings explicitly
|
||||
do not open the Battle Viewer.
|
||||
2. **Wire change.** `Report.Battle` switched from `[]uuid.UUID` to
|
||||
`[]BattleSummary{id, planet, shots}` so the map renderer can
|
||||
place markers without N extra fetches and so the cross-marker
|
||||
stroke can scale with protocol length.
|
||||
3. **Battle marker = yellow X cross** drawn as 2 `LinePrim` through
|
||||
the corners of the planet's circumscribed square; stroke width
|
||||
`clamp(1 + (shots - 1) * 4 / 99, 1, 5)` px.
|
||||
4. **Bombing marker = stroke-only ring** slightly larger than the
|
||||
planet circle. Yellow when damaged, red when wiped. Click =
|
||||
scroll to the matching row in Reports (not the viewer).
|
||||
5. **Viewer URL** `/games/[id]/battle/[battleId]?turn=N`. Turn is a
|
||||
query param so the same route works in history mode.
|
||||
6. **SVG, not PixiJS** for the radial scene — isolated component,
|
||||
no need for WebGL; PixiJS stays as the map renderer.
|
||||
7. **Playback controls full set**: play / pause + step back + step
|
||||
forward + rewind + 1x / 2x / 4x switch. 1x = 400 ms per frame.
|
||||
8. **Observer groups (`inBattle: false`)** are filtered out of both
|
||||
the scene and the text log.
|
||||
9. **Cluster aggregation by `(race, className)`** so a race with
|
||||
multiple groups of the same class collapses to one labelled
|
||||
circle. Stable target for shot-line endpoints.
|
||||
10. **Page loader switches on `synthetic-` gameId prefix** —
|
||||
synthetic mode uses `api/synthetic-battle.ts` fixtures; live
|
||||
games hit `GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`.
|
||||
BattleViewer component itself is a logically isolated prop sink.
|
||||
11. **Always-visible `<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
|
||||
|
||||
@@ -3459,3 +3540,18 @@ phase listed in the parenthesis when that phase lands.
|
||||
exercises a unary Connect call and a server-streaming Connect call
|
||||
through `testenv.Bootstrap`. (Phase 7+, fold into the phase that
|
||||
needs it.)
|
||||
- **Battle viewer — push event `game.battle.new`** — when a battle
|
||||
involving the current player lands, emit a backend notification
|
||||
intent (idempotency `battle-new:<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.)
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
# 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 hosts a *cloud* of
|
||||
class circles arranged on a Vogel sunflower spiral biased toward the
|
||||
planet (the cluster anchor is pushed inward by a quarter step so the
|
||||
rank-0 node — the heaviest group by NumberLeft — sits closest to the
|
||||
planet, and the spiral fans the rest behind it). When a race is
|
||||
wiped out, it drops out of the active list and the survivors are
|
||||
re-spaced on the next frame.
|
||||
|
||||
Each class circle is one *bucket* keyed by `(race, className)`:
|
||||
tech-variants of the same class collapse into one node so the scene
|
||||
stays readable when a race fields a dozen tech levels of the same
|
||||
hull. The per-bucket label `<className>:<numLeft>` sums NumberLeft
|
||||
across the underlying groups; per-tech detail is available in the
|
||||
Reports view (Foreign Ship Classes / My Ship Types).
|
||||
|
||||
Bucket order inside a cluster is **locked at battle start** by the
|
||||
initial ship count (`num` summed across tech variants, descending),
|
||||
together with mass, radius and local position. The static layout
|
||||
lives in `staticBucketsByRace`; the per-frame derivation
|
||||
`renderedByRace` overlays the live `NumberLeft` and drops buckets
|
||||
once they hit zero. The remaining buckets keep their slots in the
|
||||
cloud, so the cluster does not reshuffle when a class empties — the
|
||||
empty bucket simply disappears.
|
||||
|
||||
Vogel positions are reassigned per rank by their inward distance
|
||||
toward the planet, so the rank-0 bucket (the largest by initial
|
||||
ship count) always sits at the most-inward spiral slot.
|
||||
|
||||
When two races remain in battle the radial layout switches to the
|
||||
horizontal duel: race 0 at 9 o'clock, race 1 at 3 o'clock. This
|
||||
keeps both race labels clear of the SVG top edge and reads as the
|
||||
two sides facing off naturally.
|
||||
|
||||
Circle radius scales with per-ship FullMass (Empty + Carrying via
|
||||
the per-ship `LoadQuantity`). The viewer resolves a
|
||||
`(race, className) → ShipClassRef` lookup from the surrounding
|
||||
`GameReport.localShipClass` + `otherShipClass` tables and runs it
|
||||
through the existing wasm bridge to `pkg/calc/ship.go`
|
||||
(`emptyMass` + `carryingMass` + `fullMass`). The radius is then
|
||||
`MIN_RADIUS + (MAX_RADIUS − MIN_RADIUS) × sqrt(mass / maxMassInBattle)`
|
||||
clamped to `[6, 24]` pixels — per-battle normalisation, so the
|
||||
heaviest ship in any given battle renders at the cap. Unknown class
|
||||
or invalid params fall back to MAX_RADIUS so the bucket stays
|
||||
visible.
|
||||
|
||||
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:
|
||||
|
||||
| Control | Effect |
|
||||
| ------------------ | ------------------------------------------------------- |
|
||||
| ⏮ rewind | Stop, jump to frame 0 |
|
||||
| ◀︎◀︎ step back | Stop, frame ← frame − 1 |
|
||||
| ▶︎ / ⏸ play | Toggle continuous playback |
|
||||
| ▶︎▶︎ step forward | Stop, frame ← frame + 1 |
|
||||
| `Nx` cycle speed | Single button, cycles 1x → 2x → 4x → 6x → 1x; the label shows the current speed (400 / 200 / 100 / 67 ms per frame) |
|
||||
| `Log ▲▼` toggle | Collapses / expands the always-visible text protocol so the user can give the scene the full viewer height |
|
||||
|
||||
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.
|
||||
|
||||
A drag-seek slider sits between the scene and the controls. Dragging
|
||||
pauses playback and lands `frameIndex` on the chosen shot — handy
|
||||
for jumping to the moment a particular race started losing ground.
|
||||
|
||||
## 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".
|
||||
|
||||
Each log row is also a `<button>`: a click or Enter/Space jumps
|
||||
playback to that shot (pauses and seeks). The list auto-scrolls
|
||||
the current row into view as the timeline advances, so the user
|
||||
does not have to chase the highlight on long battles.
|
||||
|
||||
## Playback details
|
||||
|
||||
On play, the shot line + the defender circle's colour flash gate
|
||||
on a per-frame timer that blinks them off during the last 10 % of
|
||||
the frame's duration. Two consecutive shots from the same attacker
|
||||
on the same defender therefore look like two distinct pulses
|
||||
rather than one continuous line. On pause the line and flash
|
||||
stay drawn so the user can study the current shot.
|
||||
|
||||
## Aggregated ship-class buckets
|
||||
|
||||
Real legacy reports list the same `(race, className)` pair across
|
||||
several roster rows — different tech variants, ships pulled from
|
||||
multiple stacks or planets. The legacy-report parser
|
||||
([parser.go](../../tools/local-dev/legacy-report/parser.go))
|
||||
collapses those rows into a single `BattleReportGroup` keyed by
|
||||
`(race, className)` by SUMMING `Number` and `NumberLeft`; the
|
||||
engine's `TransformBattle`
|
||||
([battle_transform.go](../../game/internal/controller/battle_transform.go))
|
||||
applies the same merge keyed by `ShipClass.ID`, guarded by a
|
||||
processed-group set so the same source `groupId` is not summed
|
||||
twice across multiple protocol references.
|
||||
|
||||
Without this aggregation only the last roster row's counts
|
||||
survived, and the protocol's destroy count against the class
|
||||
would dwarf the recorded initial count — KNNTS041 turn 41 planet
|
||||
\#7 had 7 separate `Nails:pup` rows totalling 1168 ships; the
|
||||
buggy parser stored only the last row's 88, so the 1082 destroys
|
||||
in the protocol looked like phantom hits past the empty bucket.
|
||||
After the fix both sides reconcile: 1168 initial − 86 survivors =
|
||||
1082 destroys.
|
||||
|
||||
`buildFrames`
|
||||
([timeline.ts](../src/lib/battle-player/timeline.ts)) keeps a
|
||||
defence-in-depth clamp `if (left > 0)` on the destroy decrement so
|
||||
a malformed protocol never pushes a race below zero; in normal
|
||||
operation the clamp is a no-op because parser + engine already
|
||||
folded duplicate rows together.
|
||||
|
||||
## Final-frame freeze
|
||||
|
||||
When the last protocol action eliminates a race, the surviving
|
||||
side would otherwise reflow alone to the planet ring at the very
|
||||
last shot — visually jarring and uninformative. `displayFrame`
|
||||
freezes the layout-determining state (`remaining` and
|
||||
`activeRaceIds`) at the penultimate frame's values while keeping
|
||||
the final frame's `shotIndex` and `lastAction`, so the killing
|
||||
shot still renders as a line + flash against the picture the user
|
||||
saw a moment earlier.
|
||||
|
||||
## Header + layout
|
||||
|
||||
The viewer header carries three rows of chrome in a single line:
|
||||
the back-navigation buttons (`back to map` / `back to report`) on
|
||||
the left, a centred title — `Battle on planet <name> (<#number>)`,
|
||||
i18n key `game.battle.header_title` — and the frame counter on the
|
||||
right. Pulling navigation into the header frees the entire viewer
|
||||
area for the scene; the `.viewer` container has no `max-width` cap,
|
||||
so on wide monitors the scene scales up while the log keeps its
|
||||
internal 30 dvh scroll.
|
||||
|
||||
## Height fit
|
||||
|
||||
The viewer is pinned to the viewport: `.active-view` uses
|
||||
`calc(100dvh − 80px)` so the in-game-shell header + optional
|
||||
HistoryBanner do not push the scene below the fold. Inside the
|
||||
viewer, the scene grows (`flex: 1`), the scrubber + controls hold
|
||||
their natural height, and the log (when expanded) shrinks to a
|
||||
30 dvh ceiling with its own internal scroll, so the page itself
|
||||
never scrolls vertically. The 80 px allowance maps to the current
|
||||
Header + HistoryBanner total on desktop; mobile breakpoints reuse
|
||||
the same calc because dvh tracks the dynamic viewport.
|
||||
|
||||
## 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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -382,6 +382,18 @@ export interface ReportBombing {
|
||||
* mirrors the producing planet's free industry. Stable order: sorted
|
||||
* by `(planetNumber, class)`.
|
||||
*/
|
||||
/**
|
||||
* ReportBattle is one battle summary in the current turn. Carries the
|
||||
* battle UUID, planet number, and shot count — enough to render a
|
||||
* battle marker on the map and to link into the Battle Viewer without
|
||||
* fetching the full BattleReport.
|
||||
*/
|
||||
export interface ReportBattle {
|
||||
id: string;
|
||||
planet: number;
|
||||
shots: number;
|
||||
}
|
||||
|
||||
export interface ReportShipProduction {
|
||||
planetNumber: number;
|
||||
class: string;
|
||||
@@ -524,11 +536,17 @@ export interface GameReport {
|
||||
*/
|
||||
otherShipClass: ReportOtherShipClass[];
|
||||
/**
|
||||
* battleIds is the list of battle UUIDs the engine recorded for
|
||||
* the current turn. Phase 23 renders them as inactive
|
||||
* monospace identifiers; Phase 27 will turn them into navigation
|
||||
* targets once the battle viewer lands. Empty when no battles
|
||||
* occurred last turn.
|
||||
* battles is the list of battle summaries the engine recorded for
|
||||
* the current turn. Each entry carries the battle UUID, the planet
|
||||
* it happened on, and the number of shots exchanged. The Reports
|
||||
* View uses `id` to link into the Battle Viewer; the map renderer
|
||||
* uses `planet` to locate the marker and `shots` to scale its
|
||||
* stroke. Empty when no battles occurred last turn.
|
||||
*/
|
||||
battles: ReportBattle[];
|
||||
/**
|
||||
* battleIds is a convenience derived list of UUIDs from `battles`,
|
||||
* preserved for legacy callers (Phase 23 report section, fixtures).
|
||||
*/
|
||||
battleIds: string[];
|
||||
/**
|
||||
@@ -700,7 +718,8 @@ function decodeReport(report: Report): GameReport {
|
||||
const localFleets = decodeLocalFleets(report);
|
||||
const otherScience = decodeOtherScience(report);
|
||||
const otherShipClass = decodeOtherShipClass(report);
|
||||
const battleIds = decodeBattleIds(report);
|
||||
const battles = decodeBattles(report);
|
||||
const battleIds = battles.map((b) => b.id);
|
||||
const bombings = decodeBombings(report);
|
||||
const shipProductions = decodeShipProductions(report);
|
||||
|
||||
@@ -730,6 +749,7 @@ function decodeReport(report: Report): GameReport {
|
||||
players,
|
||||
otherScience,
|
||||
otherShipClass,
|
||||
battles,
|
||||
battleIds,
|
||||
bombings,
|
||||
shipProductions,
|
||||
@@ -1153,13 +1173,18 @@ function decodeOtherShipClass(report: Report): ReportOtherShipClass[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
function decodeBattleIds(report: Report): string[] {
|
||||
const out: string[] = [];
|
||||
function decodeBattles(report: Report): ReportBattle[] {
|
||||
const out: ReportBattle[] = [];
|
||||
for (let i = 0; i < report.battleLength(); i++) {
|
||||
const uuid = report.battle(i);
|
||||
const value = uuidStringFromFB(uuid);
|
||||
if (value === null) continue;
|
||||
out.push(value);
|
||||
const summary = report.battle(i);
|
||||
if (summary === null) continue;
|
||||
const id = uuidStringFromFB(summary.id());
|
||||
if (id === null) continue;
|
||||
out.push({
|
||||
id,
|
||||
planet: Number(summary.planet()),
|
||||
shots: Number(summary.shots()),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1439,6 +1464,7 @@ export function applyOrderOverlay(
|
||||
players: report.players ?? [],
|
||||
otherScience: report.otherScience ?? [],
|
||||
otherShipClass: report.otherShipClass ?? [],
|
||||
battles: report.battles ?? [],
|
||||
battleIds: report.battleIds ?? [],
|
||||
bombings: report.bombings ?? [],
|
||||
shipProductions: report.shipProductions ?? [],
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -39,6 +39,8 @@ import type {
|
||||
} from "./game-state";
|
||||
import type { CargoLoadType, Relation } from "../sync/order-types";
|
||||
import { isCargoLoadType, isRelation } from "../sync/order-types";
|
||||
import type { BattleReport } from "./battle-fetch";
|
||||
import { registerSyntheticBattle } from "./synthetic-battle";
|
||||
|
||||
export const SYNTHETIC_GAME_ID_PREFIX = "synthetic-";
|
||||
|
||||
@@ -59,18 +61,71 @@ export class SyntheticReportError extends Error {
|
||||
* loadSyntheticReportFromJSON validates the passed payload, decodes
|
||||
* it into a `GameReport`, registers it in the in-memory map under a
|
||||
* fresh `synthetic-<uuid>` id, and returns both the id and the
|
||||
* decoded report. Throws `SyntheticReportError` for malformed input.
|
||||
* decoded report.
|
||||
*
|
||||
* Accepts two on-disk shapes:
|
||||
*
|
||||
* 1. Envelope (Phase 27 legacy-report CLI):
|
||||
* `{ "version": 1, "report": <Report>, "battles": { <uuid>: <BattleReport> } }`
|
||||
* — battles are forwarded to `registerSyntheticBattle` so the
|
||||
* Battle Viewer can resolve them offline.
|
||||
* 2. Bare Report (pre-envelope synthetic JSON files) — same as
|
||||
* before; battle UUIDs in the report can still be clicked, but
|
||||
* the Viewer page will show "battle not found" because no
|
||||
* fixture was registered.
|
||||
*
|
||||
* Throws `SyntheticReportError` for malformed input in either shape.
|
||||
*/
|
||||
export function loadSyntheticReportFromJSON(json: unknown): {
|
||||
gameId: string;
|
||||
report: GameReport;
|
||||
} {
|
||||
const report = decodeSyntheticReport(json);
|
||||
const { reportPayload, battles } = extractEnvelope(json);
|
||||
const report = decodeSyntheticReport(reportPayload);
|
||||
for (const battle of battles) {
|
||||
registerSyntheticBattle(battle);
|
||||
}
|
||||
const gameId = SYNTHETIC_GAME_ID_PREFIX + crypto.randomUUID();
|
||||
SYNTHETIC_REPORTS.set(gameId, report);
|
||||
return { gameId, report };
|
||||
}
|
||||
|
||||
interface SyntheticEnvelope {
|
||||
version?: number;
|
||||
report?: unknown;
|
||||
battles?: Record<string, BattleReport>;
|
||||
}
|
||||
|
||||
/**
|
||||
* extractEnvelope distinguishes the v1 envelope shape from a bare
|
||||
* Report payload. The envelope check is `version === 1` to leave room
|
||||
* for future format bumps and to avoid mistaking a bare Report whose
|
||||
* top-level fields happen to include `report`/`battles` (none do
|
||||
* today) for an envelope.
|
||||
*/
|
||||
function extractEnvelope(json: unknown): {
|
||||
reportPayload: unknown;
|
||||
battles: BattleReport[];
|
||||
} {
|
||||
if (typeof json !== "object" || json === null) {
|
||||
// Defer the error to `decodeSyntheticReport`; it already
|
||||
// raises a `SyntheticReportError` with the right message.
|
||||
return { reportPayload: json, battles: [] };
|
||||
}
|
||||
const env = json as SyntheticEnvelope;
|
||||
if (env.version === 1 && env.report !== undefined) {
|
||||
const battlesMap = env.battles ?? {};
|
||||
const battles: BattleReport[] = [];
|
||||
for (const value of Object.values(battlesMap)) {
|
||||
if (value && typeof value === "object") {
|
||||
battles.push(value);
|
||||
}
|
||||
}
|
||||
return { reportPayload: env.report, battles };
|
||||
}
|
||||
return { reportPayload: json, battles: [] };
|
||||
}
|
||||
|
||||
/** getSyntheticReport returns the report registered under `gameId`,
|
||||
* or `undefined` if the entry was lost (e.g. page reload). */
|
||||
export function getSyntheticReport(gameId: string): GameReport | undefined {
|
||||
@@ -173,6 +228,12 @@ interface SyntheticOtherShipClass extends SyntheticShipClass {
|
||||
mass?: number;
|
||||
}
|
||||
|
||||
interface SyntheticBattle {
|
||||
id?: string;
|
||||
planet?: number;
|
||||
shots?: number;
|
||||
}
|
||||
|
||||
interface SyntheticBombing {
|
||||
planet?: number; // wire field "number"
|
||||
planetName?: string; // wire field "planetName"
|
||||
@@ -219,7 +280,7 @@ interface SyntheticReportRoot {
|
||||
incomingGroup?: SyntheticIncomingGroup[];
|
||||
unidentifiedGroup?: SyntheticUnidentifiedGroup[];
|
||||
localFleet?: SyntheticLocalFleet[];
|
||||
battle?: string[];
|
||||
battle?: SyntheticBattle[];
|
||||
bombing?: SyntheticBombing[];
|
||||
shipProduction?: SyntheticShipProductionRow[];
|
||||
}
|
||||
@@ -357,9 +418,17 @@ function decodeSyntheticReport(json: unknown): GameReport {
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
const battleIds: string[] = (root.battle ?? []).filter(
|
||||
(v): v is string => typeof v === "string" && v !== "",
|
||||
);
|
||||
const battles = (root.battle ?? [])
|
||||
.filter(
|
||||
(v): v is SyntheticBattle =>
|
||||
typeof v === "object" && v !== null && typeof v.id === "string" && v.id !== "",
|
||||
)
|
||||
.map((b) => ({
|
||||
id: b.id as string,
|
||||
planet: numOr0(b.planet),
|
||||
shots: numOr0(b.shots),
|
||||
}));
|
||||
const battleIds = battles.map((b) => b.id);
|
||||
|
||||
const bombings: ReportBombing[] = (root.bombing ?? []).map((b) => ({
|
||||
planetNumber: numOr0(b.planet),
|
||||
@@ -419,6 +488,7 @@ function decodeSyntheticReport(json: unknown): GameReport {
|
||||
players: collectPlayersFromSynthetic(root, race),
|
||||
otherScience,
|
||||
otherShipClass,
|
||||
battles,
|
||||
battleIds,
|
||||
bombings,
|
||||
shipProductions,
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Galaxy</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
||||
@@ -1,30 +1,172 @@
|
||||
<!--
|
||||
Phase 10 stub for the battle-log active view. Phase 27 wires the real
|
||||
battle viewer.
|
||||
Phase 27 — active-view wrapper around the BattleViewer. Loads the
|
||||
BattleReport for the supplied `gameId`/`turn`/`battleId` and either
|
||||
shows the radial playback (BattleViewer), a loading skeleton, or a
|
||||
not-found state.
|
||||
|
||||
This wrapper also bridges the surrounding GameReport's ship-class
|
||||
tables into a `(race, className) → ShipClassRef` lookup the viewer
|
||||
needs to size class circles by ship mass. The back-navigation
|
||||
buttons (`back to map` / `back to report`) live INSIDE the viewer
|
||||
header now — we just hand the routes down as callbacks so the
|
||||
viewer keeps its prop-driven contract.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import { getContext } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
type Props = { battleId: string };
|
||||
let { battleId }: Props = $props();
|
||||
import {
|
||||
BattleFetchError,
|
||||
fetchBattle,
|
||||
type BattleReport,
|
||||
} from "../../api/battle-fetch";
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import {
|
||||
RENDERED_REPORT_CONTEXT_KEY,
|
||||
type RenderedReportSource,
|
||||
} from "$lib/rendered-report.svelte";
|
||||
import {
|
||||
MapShipClassLookup,
|
||||
type ShipClassLookup,
|
||||
type ShipClassRef,
|
||||
} from "../battle-player/mass";
|
||||
|
||||
import BattleViewer from "../battle-player/battle-viewer.svelte";
|
||||
|
||||
let {
|
||||
gameId,
|
||||
turn,
|
||||
battleId,
|
||||
}: {
|
||||
gameId: string;
|
||||
turn: number;
|
||||
battleId: string;
|
||||
} = $props();
|
||||
|
||||
const rendered = getContext<RenderedReportSource | undefined>(
|
||||
RENDERED_REPORT_CONTEXT_KEY,
|
||||
);
|
||||
|
||||
const shipClassLookup = $derived.by<ShipClassLookup>(() => {
|
||||
const map = new Map<string, ShipClassRef>();
|
||||
const report = rendered?.report;
|
||||
if (report) {
|
||||
for (const cls of report.localShipClass) {
|
||||
map.set(`${report.race}::${cls.name}`, {
|
||||
drive: cls.drive,
|
||||
weapons: cls.weapons,
|
||||
armament: cls.armament,
|
||||
shields: cls.shields,
|
||||
cargo: cls.cargo,
|
||||
});
|
||||
}
|
||||
for (const cls of report.otherShipClass) {
|
||||
map.set(`${cls.race}::${cls.name}`, {
|
||||
drive: cls.drive,
|
||||
weapons: cls.weapons,
|
||||
armament: cls.armament,
|
||||
shields: cls.shields,
|
||||
cargo: cls.cargo,
|
||||
});
|
||||
}
|
||||
}
|
||||
return new MapShipClassLookup(map);
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
<section class="active-view" data-testid="active-view-battle" data-battle-id={battleId}>
|
||||
<h2>{i18n.t("game.view.battle")}</h2>
|
||||
<p>{i18n.t("game.shell.coming_soon")}</p>
|
||||
<section
|
||||
class="active-view"
|
||||
data-testid="active-view-battle"
|
||||
data-battle-id={battleId}
|
||||
>
|
||||
{#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}
|
||||
{shipClassLookup}
|
||||
onBackToMap={backToMap}
|
||||
onBackToReport={backToReport}
|
||||
/>
|
||||
{: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>
|
||||
|
||||
<style>
|
||||
.active-view {
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/*
|
||||
* The in-game shell renders this active view inside an
|
||||
* `.active-view-host` with `flex: 1; overflow-y: auto`, but
|
||||
* the surrounding `.game-shell` uses `min-height: 100vh`, so
|
||||
* without a hard upper bound the viewer pushes the whole
|
||||
* shell past the viewport. We pin the active view to `100dvh`
|
||||
* minus a small allowance for the header chrome (in-game
|
||||
* Header + optional HistoryBanner ≈ 66 px on desktop) so the
|
||||
* internal flex chain can split the remaining height between
|
||||
* the scene, scrubber, controls and log without forcing a
|
||||
* page-level scroll.
|
||||
*/
|
||||
height: calc(100dvh - 80px);
|
||||
max-height: calc(100dvh - 80px);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
font-family: system-ui, sans-serif;
|
||||
color: #d6dcf2;
|
||||
}
|
||||
.active-view h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.1rem;
|
||||
.status {
|
||||
margin: 2rem auto;
|
||||
max-width: 880px;
|
||||
color: #93a0d0;
|
||||
font-size: 0.95rem;
|
||||
text-align: center;
|
||||
}
|
||||
.active-view p {
|
||||
margin: 0;
|
||||
color: #555;
|
||||
.status.error {
|
||||
color: #e08585;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -21,6 +21,8 @@ preference the store already manages.
|
||||
-->
|
||||
<script lang="ts">
|
||||
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 {
|
||||
createRenderer,
|
||||
@@ -402,13 +404,40 @@ preference the store already manages.
|
||||
if (selection === undefined) return;
|
||||
const hit = handle.hitAt(cursorPx);
|
||||
if (hit === null) return;
|
||||
if (hit.primitive.kind !== "point") return;
|
||||
const target = hitLookup.get(hit.primitive.id);
|
||||
if (target === undefined) return;
|
||||
if (target.kind === "planet") {
|
||||
switch (target.kind) {
|
||||
case "planet":
|
||||
if (hit.primitive.kind !== "point") return;
|
||||
selection.selectPlanet(target.number);
|
||||
} else {
|
||||
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
|
||||
battle UUIDs (the full battle report is fetched lazily by Phase 27),
|
||||
so each row is a monospace, non-interactive `<span>` of the battle
|
||||
identifier. Phase 27 will turn each row into a link to
|
||||
`/games/<id>/battle/<uuid>`; until then dead links are worse than
|
||||
plain text.
|
||||
Phase 27 Report View — battles section. Each row is a link into the
|
||||
Battle Viewer at `/games/<id>/battle/<uuid>?turn=<turn>` where
|
||||
`turn` follows the current report's turn so history-mode views land
|
||||
on the right battle. Phase 23 rendered the same rows as inactive
|
||||
monospace `<span>`; the rewire here is the one-liner the Phase 23
|
||||
decision log called out.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { page } from "$app/state";
|
||||
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import {
|
||||
@@ -19,7 +20,9 @@ plain text.
|
||||
RENDERED_REPORT_CONTEXT_KEY,
|
||||
);
|
||||
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>
|
||||
|
||||
<section
|
||||
@@ -31,22 +34,23 @@ plain text.
|
||||
|
||||
{#if report === null}
|
||||
<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">
|
||||
{i18n.t("game.report.section.battles.empty")}
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="ids" data-testid="battles-list">
|
||||
{#each ids as id (id)}
|
||||
{#each battles as b (b.id)}
|
||||
<li>
|
||||
<span class="label">
|
||||
{i18n.t("game.report.section.battles.id_label")}
|
||||
</span>
|
||||
<span
|
||||
<a
|
||||
class="uuid"
|
||||
href={`/games/${gameId}/battle/${b.id}?turn=${turn}`}
|
||||
data-testid="report-battle-row"
|
||||
data-id={id}
|
||||
>{id}</span>
|
||||
data-id={b.id}
|
||||
>{b.id}</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -87,5 +91,10 @@ plain text.
|
||||
.uuid {
|
||||
color: #cfd7ff;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.uuid:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
<!--
|
||||
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 *cloud* of class circles
|
||||
arranged on a Vogel sunflower spiral. Spiral positions are
|
||||
reassigned per rank by their inward distance toward the planet so
|
||||
the rank-0 bucket (the bucket with the largest initial ship count)
|
||||
always sits at the most-inward Vogel slot.
|
||||
|
||||
Tech-variant groups of the same `(race, className)` collapse to one
|
||||
visual node — per-tech detail lives in Reports. Each circle's
|
||||
radius scales with the per-ship FullMass (sqrt) so heavy ships
|
||||
visually dominate. Order, position, radius and mass are locked at
|
||||
battle start; only NumberLeft (the label number) and per-bucket
|
||||
visibility change per frame. Empty buckets are hidden so the
|
||||
remaining ones keep their original spots without reshuffling.
|
||||
|
||||
Observer groups (`inBattle === false`) are filtered out by
|
||||
`buildFrames`. Same-race opponents are forbidden by the engine's
|
||||
combat filter, so a shot never collapses to a single visual node.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
|
||||
import type { BattleReport } from "../../api/battle-fetch";
|
||||
import {
|
||||
CORE_CONTEXT_KEY,
|
||||
type CoreHandle,
|
||||
} from "$lib/core-context.svelte";
|
||||
import { layoutRaces } from "./radial-layout";
|
||||
import {
|
||||
computeBattleGroupMass,
|
||||
radiusForMass,
|
||||
MAX_RADIUS,
|
||||
type ShipClassLookup,
|
||||
} from "./mass";
|
||||
import {
|
||||
buildGroupRaceMap,
|
||||
normaliseGroups,
|
||||
type Frame,
|
||||
} from "./timeline";
|
||||
|
||||
let {
|
||||
report,
|
||||
frame,
|
||||
shipClassLookup,
|
||||
shotVisible = true,
|
||||
}: {
|
||||
report: BattleReport;
|
||||
frame: Frame;
|
||||
shipClassLookup?: ShipClassLookup;
|
||||
shotVisible?: boolean;
|
||||
} = $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 BASE_STEP = 1.8 * MAX_RADIUS;
|
||||
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
|
||||
const MAX_CLUSTER_RADIUS = 0.6 * (RACE_RING_RADIUS - PLANET_RADIUS);
|
||||
const LABEL_MIN_Y = 24;
|
||||
|
||||
const coreHandle = getContext<CoreHandle | undefined>(CORE_CONTEXT_KEY);
|
||||
|
||||
const groupRace = $derived(buildGroupRaceMap(report.protocol));
|
||||
const allGroups = $derived(normaliseGroups(report));
|
||||
|
||||
type StaticBucket = {
|
||||
bucketKey: string;
|
||||
className: string;
|
||||
race: string;
|
||||
raceId: number;
|
||||
groupKeys: number[];
|
||||
initialNum: number;
|
||||
mass: number;
|
||||
radius: number;
|
||||
// Local offsets in the cluster's (u, v) basis. `u` always
|
||||
// points from the race anchor toward the planet, so a
|
||||
// constant local-frame layout produces the same "inward" feel
|
||||
// regardless of which slot on the outer ring the race
|
||||
// currently occupies (races rotate when peers die).
|
||||
offsetU: number;
|
||||
offsetV: number;
|
||||
};
|
||||
|
||||
// staticBucketsByRace locks the bucket roster, ordering, masses,
|
||||
// radii and local positions for the lifetime of this viewer. The
|
||||
// derivation only re-runs when `report` or the wasm `core` flip
|
||||
// (initial mount and core boot completion). Per-frame NumberLeft
|
||||
// changes do not touch this map — they live in `renderedByRace`.
|
||||
const staticBucketsByRace = $derived.by(() => {
|
||||
const core = coreHandle?.core ?? null;
|
||||
const out = new Map<number, StaticBucket[]>();
|
||||
const bucketIndex = new Map<string, StaticBucket>();
|
||||
for (const g of allGroups) {
|
||||
const bucketKey = `${g.raceId}::${g.group.className}`;
|
||||
let bucket = bucketIndex.get(bucketKey);
|
||||
if (bucket === undefined) {
|
||||
const classDef =
|
||||
shipClassLookup?.get(g.group.race, g.group.className) ?? null;
|
||||
const mass = core
|
||||
? computeBattleGroupMass(g.group, classDef, core)
|
||||
: 0;
|
||||
bucket = {
|
||||
bucketKey,
|
||||
className: g.group.className,
|
||||
race: g.group.race,
|
||||
raceId: g.raceId,
|
||||
groupKeys: [],
|
||||
initialNum: 0,
|
||||
mass,
|
||||
radius: MAX_RADIUS,
|
||||
offsetU: 0,
|
||||
offsetV: 0,
|
||||
};
|
||||
bucketIndex.set(bucketKey, bucket);
|
||||
const list = out.get(g.raceId) ?? [];
|
||||
list.push(bucket);
|
||||
out.set(g.raceId, list);
|
||||
}
|
||||
bucket.groupKeys.push(g.key);
|
||||
bucket.initialNum += g.group.num;
|
||||
}
|
||||
|
||||
// Per-battle mass normalisation: the heaviest bucket renders
|
||||
// at MAX_RADIUS; lighter ones scale by sqrt(m/max).
|
||||
let maxMass = 0;
|
||||
for (const bucket of bucketIndex.values()) {
|
||||
if (bucket.mass > maxMass) maxMass = bucket.mass;
|
||||
}
|
||||
for (const bucket of bucketIndex.values()) {
|
||||
bucket.radius = radiusForMass(bucket.mass, maxMass);
|
||||
}
|
||||
|
||||
// Sort each race's buckets by initial count (descending) +
|
||||
// className as a stable tie-break, then assign Vogel positions
|
||||
// reordered by inward dot product (offsetU desc) so the
|
||||
// largest-by-num bucket lands at the most-inward Vogel slot.
|
||||
for (const list of out.values()) {
|
||||
list.sort((a, b) => {
|
||||
if (b.initialNum !== a.initialNum) return b.initialNum - a.initialNum;
|
||||
return a.className.localeCompare(b.className);
|
||||
});
|
||||
const N = list.length;
|
||||
const denom = Math.max(1, Math.sqrt(Math.max(N, 1)));
|
||||
const step = Math.min(BASE_STEP, MAX_CLUSTER_RADIUS / denom);
|
||||
const positions = Array.from({ length: N }, (_, r) => {
|
||||
const radius = step * Math.sqrt(r);
|
||||
const angle = r * GOLDEN_ANGLE;
|
||||
return {
|
||||
offsetU: radius * Math.cos(angle),
|
||||
offsetV: radius * Math.sin(angle),
|
||||
};
|
||||
});
|
||||
positions.sort((a, b) => {
|
||||
if (b.offsetU !== a.offsetU) return b.offsetU - a.offsetU;
|
||||
return a.offsetV - b.offsetV;
|
||||
});
|
||||
for (let r = 0; r < N; r++) {
|
||||
list[r].offsetU = positions[r].offsetU;
|
||||
list[r].offsetV = positions[r].offsetV;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
type RenderedBucket = StaticBucket & { numLeft: number };
|
||||
|
||||
// renderedByRace overlays the per-frame `remaining` map onto the
|
||||
// static cluster: only buckets with `numLeft > 0` survive into
|
||||
// the render list, so an emptied class disappears from the cloud
|
||||
// while its neighbours keep their slots.
|
||||
const renderedByRace = $derived.by(() => {
|
||||
const out = new Map<number, RenderedBucket[]>();
|
||||
for (const [raceId, list] of staticBucketsByRace) {
|
||||
const filtered: RenderedBucket[] = [];
|
||||
for (const bucket of list) {
|
||||
let numLeft = 0;
|
||||
for (const key of bucket.groupKeys) {
|
||||
numLeft += frame.remaining.get(key) ?? 0;
|
||||
}
|
||||
if (numLeft > 0) filtered.push({ ...bucket, numLeft });
|
||||
}
|
||||
if (filtered.length > 0) out.set(raceId, filtered);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
// visibleBucketByGroupKey lets shot endpoints resolve to a node
|
||||
// only when the bucket is currently rendered. A phantom shot
|
||||
// against an already-empty bucket therefore returns `null` and
|
||||
// no line is drawn.
|
||||
const visibleBucketByGroupKey = $derived.by(() => {
|
||||
const out = new Map<number, RenderedBucket>();
|
||||
for (const list of renderedByRace.values()) {
|
||||
for (const bucket of list) {
|
||||
for (const key of bucket.groupKeys) {
|
||||
out.set(key, bucket);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
const raceLayout = $derived(
|
||||
layoutRaces(frame.activeRaceIds, {
|
||||
center: CENTER,
|
||||
radius: RACE_RING_RADIUS,
|
||||
}),
|
||||
);
|
||||
|
||||
type ClusterBasis = {
|
||||
anchorX: number;
|
||||
anchorY: number;
|
||||
ux: number;
|
||||
uy: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
};
|
||||
|
||||
const clusterBasisById = $derived.by(() => {
|
||||
const out = new Map<number, ClusterBasis>();
|
||||
for (const anchor of raceLayout) {
|
||||
const dx = CENTER.x - anchor.x;
|
||||
const dy = CENTER.y - anchor.y;
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
const ux = dx / len;
|
||||
const uy = dy / len;
|
||||
const vx = uy;
|
||||
const vy = -ux;
|
||||
out.set(anchor.raceId, {
|
||||
anchorX: anchor.x,
|
||||
anchorY: anchor.y,
|
||||
ux,
|
||||
uy,
|
||||
vx,
|
||||
vy,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
function worldPosition(basis: ClusterBasis, bucket: StaticBucket) {
|
||||
return {
|
||||
x: basis.anchorX + bucket.offsetU * basis.ux + bucket.offsetV * basis.vx,
|
||||
y: basis.anchorY + bucket.offsetU * basis.uy + bucket.offsetV * basis.vy,
|
||||
};
|
||||
}
|
||||
|
||||
function findClassCircleCenter(groupKey: number) {
|
||||
const bucket = visibleBucketByGroupKey.get(groupKey);
|
||||
if (!bucket) return null;
|
||||
const basis = clusterBasisById.get(bucket.raceId);
|
||||
if (!basis) return null;
|
||||
return worldPosition(basis, bucket);
|
||||
}
|
||||
|
||||
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, defenderKey: action.sd };
|
||||
});
|
||||
|
||||
const flashDefenderBucketKey = $derived.by(() => {
|
||||
if (!shotLine || !shotVisible) return null;
|
||||
const bucket = visibleBucketByGroupKey.get(shotLine.defenderKey);
|
||||
return bucket?.bucketKey ?? null;
|
||||
});
|
||||
|
||||
const raceLabelById = $derived.by(() => {
|
||||
const out = new Map<number, string>();
|
||||
for (const g of allGroups) {
|
||||
out.set(g.raceId, g.group.race);
|
||||
}
|
||||
for (const [, raceId] of groupRace) {
|
||||
if (!out.has(raceId)) out.set(raceId, `race ${raceId}`);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
// raceLabelYById finds a y just above the visible cluster's top
|
||||
// edge and clamps it to the SVG viewport so the north race
|
||||
// (anchor near the top) never has its label clipped off-canvas.
|
||||
const raceLabelYById = $derived.by(() => {
|
||||
const out = new Map<number, number>();
|
||||
for (const [raceId, list] of renderedByRace) {
|
||||
const basis = clusterBasisById.get(raceId);
|
||||
if (!basis || list.length === 0) continue;
|
||||
let topY = basis.anchorY;
|
||||
for (const bucket of list) {
|
||||
const world = worldPosition(basis, bucket);
|
||||
const top = world.y - bucket.radius;
|
||||
if (top < topY) topY = top;
|
||||
}
|
||||
const fallback = basis.anchorY - MAX_RADIUS - 12;
|
||||
const target = Math.min(topY - 12, fallback);
|
||||
out.set(raceId, Math.max(target, LABEL_MIN_Y));
|
||||
}
|
||||
return out;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svg
|
||||
class="battle-scene"
|
||||
viewBox="0 0 {VIEW_BOX} {VIEW_BOX}"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
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 = renderedByRace.get(anchor.raceId) ?? []}
|
||||
{@const basis = clusterBasisById.get(anchor.raceId)}
|
||||
{#if basis && cluster.length > 0}
|
||||
<g
|
||||
class="race-cluster"
|
||||
data-testid="battle-race-cluster"
|
||||
data-race-id={anchor.raceId}
|
||||
>
|
||||
<text
|
||||
x={anchor.x}
|
||||
y={raceLabelYById.get(anchor.raceId) ?? anchor.y - MAX_RADIUS - 12}
|
||||
text-anchor="middle"
|
||||
class="race-label"
|
||||
>{raceLabelById.get(anchor.raceId) ?? `race ${anchor.raceId}`}</text>
|
||||
{#each cluster as entry (entry.bucketKey)}
|
||||
{@const pos = worldPosition(basis, entry)}
|
||||
{@const flash =
|
||||
entry.bucketKey === flashDefenderBucketKey
|
||||
? shotLine?.destroyed
|
||||
? "destroyed"
|
||||
: "shielded"
|
||||
: null}
|
||||
<g
|
||||
class="class-marker"
|
||||
data-testid="battle-class-marker"
|
||||
data-bucket-key={entry.bucketKey}
|
||||
data-class-name={entry.className}
|
||||
data-flash={flash}
|
||||
>
|
||||
<circle cx={pos.x} cy={pos.y} r={entry.radius} />
|
||||
<text
|
||||
x={pos.x}
|
||||
y={pos.y + entry.radius + 12}
|
||||
text-anchor="middle"
|
||||
class="class-label"
|
||||
>{entry.className}:{entry.numLeft}</text>
|
||||
</g>
|
||||
{/each}
|
||||
</g>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if shotLine && shotVisible}
|
||||
<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: 100%;
|
||||
background: #0a0d1a;
|
||||
display: block;
|
||||
}
|
||||
.planet {
|
||||
fill: #2a2f40;
|
||||
stroke: #4a5066;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
.planet-label {
|
||||
fill: #6d7388;
|
||||
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;
|
||||
transition:
|
||||
fill 80ms ease-in,
|
||||
stroke 80ms ease-in;
|
||||
}
|
||||
.class-marker[data-flash="destroyed"] circle {
|
||||
fill: #ee3344;
|
||||
stroke: #ee3344;
|
||||
}
|
||||
.class-marker[data-flash="shielded"] circle {
|
||||
fill: #44dd66;
|
||||
stroke: #44dd66;
|
||||
}
|
||||
.class-label {
|
||||
fill: #b8c0e6;
|
||||
font-size: 11px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
.shot {
|
||||
stroke: #44dd66;
|
||||
stroke-width: 2;
|
||||
}
|
||||
.shot.destroyed {
|
||||
stroke: #ee3344;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,348 @@
|
||||
<!--
|
||||
BattleViewer — orchestrates the radial battle scene, the playback
|
||||
controls, and the accessibility text log for one BattleReport. Owns
|
||||
the playback state (`frameIndex`, `playing`, `speed`, `logOpen`).
|
||||
Layout reorganisation (latest iteration):
|
||||
|
||||
- The header carries the planet title, the back-navigation links and
|
||||
the frame counter so the scene captures the full viewer width and
|
||||
height beneath them.
|
||||
- A drag-seek slider sits between the scene and the controls; the
|
||||
user can scrub the playback timeline at any speed.
|
||||
- The text log collapses behind a toggle in the controls bar so a
|
||||
user who wants the biggest scene possible can hide it entirely.
|
||||
|
||||
The component is logically isolated: feed it any `BattleReport`
|
||||
matching `pkg/model/report/battle.go` and it plays back.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import type { BattleReport } from "../../api/battle-fetch";
|
||||
import type { ShipClassLookup } from "./mass";
|
||||
|
||||
import BattleScene from "./battle-scene.svelte";
|
||||
import PlaybackControls, {
|
||||
type PlaybackSpeed,
|
||||
} from "./playback-controls.svelte";
|
||||
import { buildFrames } from "./timeline";
|
||||
|
||||
let {
|
||||
report,
|
||||
shipClassLookup,
|
||||
onBackToMap,
|
||||
onBackToReport,
|
||||
}: {
|
||||
report: BattleReport;
|
||||
shipClassLookup?: ShipClassLookup;
|
||||
onBackToMap?: () => void;
|
||||
onBackToReport?: () => void;
|
||||
} = $props();
|
||||
|
||||
const frames = $derived(buildFrames(report));
|
||||
let frameIndex = $state(0);
|
||||
let playing = $state(false);
|
||||
let speed = $state<PlaybackSpeed>(1);
|
||||
let logOpen = $state(true);
|
||||
let shotVisible = $state(true);
|
||||
let logEl = $state<HTMLOListElement | null>(null);
|
||||
|
||||
const rawFrame = $derived(frames[Math.min(frameIndex, frames.length - 1)]);
|
||||
|
||||
// displayFrame freezes the layout at the penultimate frame's
|
||||
// state once the protocol's last action eliminates a race, so
|
||||
// the surviving cluster does not suddenly reflow onto the
|
||||
// planet ring on the very last shot. The frame counter still
|
||||
// advances to the final shot and `lastAction` still drives the
|
||||
// killing line + flash; only `remaining` and `activeRaceIds`
|
||||
// (the layout-determining state) freeze.
|
||||
const displayFrame = $derived.by(() => {
|
||||
const last = frames.length - 1;
|
||||
if (
|
||||
frameIndex === last &&
|
||||
last >= 1 &&
|
||||
frames[last].activeRaceIds.length < frames[last - 1].activeRaceIds.length
|
||||
) {
|
||||
const prev = frames[last - 1];
|
||||
const cur = frames[last];
|
||||
return {
|
||||
shotIndex: cur.shotIndex,
|
||||
lastAction: cur.lastAction,
|
||||
remaining: prev.remaining,
|
||||
activeRaceIds: prev.activeRaceIds,
|
||||
};
|
||||
}
|
||||
return rawFrame;
|
||||
});
|
||||
|
||||
// One tick per frame: blink the shot line off during the last
|
||||
// 10 % of the frame's interval, then advance. Effect re-arms
|
||||
// whenever frameIndex / playing / speed changes; previous
|
||||
// timers clean up through the return.
|
||||
$effect(() => {
|
||||
void frameIndex;
|
||||
void speed;
|
||||
shotVisible = true;
|
||||
if (!playing) return;
|
||||
const intervalMs = 400 / speed;
|
||||
const blinkOff = setTimeout(() => {
|
||||
shotVisible = false;
|
||||
}, intervalMs * 0.9);
|
||||
const advance = setTimeout(() => {
|
||||
if (frameIndex >= frames.length - 1) {
|
||||
playing = false;
|
||||
return;
|
||||
}
|
||||
frameIndex = frameIndex + 1;
|
||||
}, intervalMs);
|
||||
return () => {
|
||||
clearTimeout(blinkOff);
|
||||
clearTimeout(advance);
|
||||
};
|
||||
});
|
||||
|
||||
// Auto-scroll the visible log row into view so the highlight
|
||||
// keeps up with the timeline on long battles.
|
||||
$effect(() => {
|
||||
void displayFrame.shotIndex;
|
||||
if (!logOpen || logEl === null) return;
|
||||
const current = logEl.querySelector(
|
||||
'li[data-current="true"]',
|
||||
) as HTMLElement | null;
|
||||
if (current !== null) {
|
||||
current.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
});
|
||||
|
||||
function seekToShot(actionIndex: number) {
|
||||
playing = false;
|
||||
frameIndex = Math.max(
|
||||
0,
|
||||
Math.min(frames.length - 1, actionIndex + 1),
|
||||
);
|
||||
}
|
||||
|
||||
function onScrub(event: Event) {
|
||||
const target = event.currentTarget as HTMLInputElement;
|
||||
const value = Number(target.value);
|
||||
if (!Number.isFinite(value)) return;
|
||||
playing = false;
|
||||
frameIndex = Math.max(0, Math.min(frames.length - 1, Math.trunc(value)));
|
||||
}
|
||||
|
||||
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">
|
||||
<div class="back-row">
|
||||
{#if onBackToMap}
|
||||
<button
|
||||
type="button"
|
||||
class="back-btn"
|
||||
onclick={onBackToMap}
|
||||
data-testid="battle-back-to-map"
|
||||
>{i18n.t("game.battle.back_to_map")}</button>
|
||||
{/if}
|
||||
{#if onBackToReport}
|
||||
<button
|
||||
type="button"
|
||||
class="back-btn"
|
||||
onclick={onBackToReport}
|
||||
data-testid="battle-back-to-report"
|
||||
>{i18n.t("game.battle.back_to_report")}</button>
|
||||
{/if}
|
||||
</div>
|
||||
<h2 data-testid="battle-viewer-title">
|
||||
{i18n.t("game.battle.header_title", {
|
||||
planet_name: report.planetName,
|
||||
planet_number: String(report.planet),
|
||||
})}
|
||||
</h2>
|
||||
<span class="progress" data-testid="battle-frame-index">
|
||||
{displayFrame.shotIndex} / {report.protocol.length}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div class="scene">
|
||||
<BattleScene {report} frame={displayFrame} {shipClassLookup} {shotVisible} />
|
||||
</div>
|
||||
|
||||
<input
|
||||
class="scrubber"
|
||||
type="range"
|
||||
min="0"
|
||||
max={Math.max(0, frames.length - 1)}
|
||||
step="1"
|
||||
value={frameIndex}
|
||||
oninput={onScrub}
|
||||
aria-label={i18n.t("game.battle.controls.scrub")}
|
||||
data-testid="battle-scrubber"
|
||||
/>
|
||||
|
||||
<PlaybackControls
|
||||
bind:playing
|
||||
bind:frameIndex
|
||||
bind:speed
|
||||
bind:logOpen
|
||||
frameCount={frames.length}
|
||||
/>
|
||||
|
||||
{#if logOpen}
|
||||
<section
|
||||
class="log"
|
||||
aria-label={i18n.t("game.battle.accessibility.protocol_heading")}
|
||||
>
|
||||
<h3>{i18n.t("game.battle.accessibility.protocol_heading")}</h3>
|
||||
<ol bind:this={logEl} data-testid="battle-protocol-log">
|
||||
{#each report.protocol as _action, i (i)}
|
||||
<li
|
||||
data-testid="battle-protocol-log-item"
|
||||
data-current={i + 1 === displayFrame.shotIndex ? "true" : "false"}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="log-row-btn"
|
||||
onclick={() => seekToShot(i)}
|
||||
>{describeAction(i)}</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
margin: 0 auto;
|
||||
padding: 0.75rem 1rem;
|
||||
color: #d6dcf2;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.header h2 {
|
||||
flex: 1 1 auto;
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
text-align: center;
|
||||
}
|
||||
.back-row {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.back-btn {
|
||||
appearance: none;
|
||||
background: #1f2748;
|
||||
color: #d6dcf2;
|
||||
border: 1px solid #2c3568;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.back-btn:hover {
|
||||
background: #2a3463;
|
||||
}
|
||||
.progress {
|
||||
color: #93a0d0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.85rem;
|
||||
flex: 0 0 auto;
|
||||
min-width: 5rem;
|
||||
text-align: right;
|
||||
}
|
||||
.scene {
|
||||
background: #0a0d1a;
|
||||
border: 1px solid #1e264a;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.scrubber {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
flex: 0 0 auto;
|
||||
accent-color: #6d7bb5;
|
||||
}
|
||||
.log {
|
||||
flex: 0 1 auto;
|
||||
min-height: 4rem;
|
||||
max-height: 30vh;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.log h3 {
|
||||
margin: 0 0 0.3rem;
|
||||
color: #93a0d0;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.log ol {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 0.85rem;
|
||||
overflow-y: auto;
|
||||
color: #c6cdf0;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.log li {
|
||||
border-bottom: 1px solid #1c2240;
|
||||
}
|
||||
.log-row-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.15rem 0.4rem;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.log-row-btn:hover,
|
||||
.log-row-btn:focus-visible {
|
||||
background: #131a36;
|
||||
}
|
||||
.log li[data-current="true"] .log-row-btn {
|
||||
color: #ffe27a;
|
||||
font-weight: 600;
|
||||
background: #1a2240;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,109 @@
|
||||
// Mass helpers for the Battle Viewer.
|
||||
//
|
||||
// Phase 27 refinement: ship-class circles are sized by per-ship
|
||||
// FullMass (Empty + carrying), with a 6..24 px range. The viewer
|
||||
// resolves a `(race, className) → ShipClassRef` lookup from the
|
||||
// surrounding GameReport's `localShipClass` / `otherShipClass`
|
||||
// tables and feeds it through the wasm bridge to `pkg/calc/ship.go`.
|
||||
//
|
||||
// Pure utilities live here; the Svelte components consume them.
|
||||
|
||||
import type { Core } from "../../platform/core/index";
|
||||
import type { BattleReportGroup } from "../../api/battle-fetch";
|
||||
|
||||
/** Smallest visible ship circle. Picked so the `<class>:<n>` label
|
||||
* stays legible on every viewport. */
|
||||
export const MIN_RADIUS = 6;
|
||||
|
||||
/** Largest ship circle. Matches the Phase-27 baseline so heavy
|
||||
* ships keep their previous visual prominence. */
|
||||
export const MAX_RADIUS = 24;
|
||||
|
||||
/**
|
||||
* ShipClassRef is the minimum slice of a ship class needed to
|
||||
* compute its mass. Mirrors the relevant fields of
|
||||
* `ShipClassSummary` (own classes) and `ReportOtherShipClass`
|
||||
* (foreign classes) without coupling the viewer to either type.
|
||||
*/
|
||||
export interface ShipClassRef {
|
||||
drive: number;
|
||||
weapons: number;
|
||||
armament: number;
|
||||
shields: number;
|
||||
cargo: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* ShipClassLookup resolves `(race, className)` to a ship-class
|
||||
* descriptor. Returns `null` when the class is not in the parent
|
||||
* report — happens with legacy-mode foreign races that lack a
|
||||
* `<Race> Ship Types` block.
|
||||
*/
|
||||
export interface ShipClassLookup {
|
||||
get(race: string, className: string): ShipClassRef | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* computeBattleGroupMass returns the per-ship FullMass for a given
|
||||
* battle group. Mass=0 means "unknown" — either the wasm bridge
|
||||
* rejected the ship-class params (degenerate weapons/armament pair)
|
||||
* or the class did not resolve in the lookup. Either way the
|
||||
* caller's downstream `radiusForMass` falls back to MAX_RADIUS so
|
||||
* the node stays visible.
|
||||
*
|
||||
* Cargo never changes during a battle, so this can be cached per
|
||||
* `(race, className)` bucket for the lifetime of the viewer
|
||||
* session.
|
||||
*/
|
||||
export function computeBattleGroupMass(
|
||||
group: BattleReportGroup,
|
||||
classDef: ShipClassRef | null,
|
||||
core: Core,
|
||||
): number {
|
||||
if (classDef === null) return 0;
|
||||
const empty = core.emptyMass({
|
||||
drive: classDef.drive,
|
||||
weapons: classDef.weapons,
|
||||
armament: classDef.armament,
|
||||
shields: classDef.shields,
|
||||
cargo: classDef.cargo,
|
||||
});
|
||||
if (empty === null) return 0;
|
||||
const cargoTech = classDef.cargo * (group.tech.CARGO ?? 0);
|
||||
const carrying = core.carryingMass({
|
||||
load: group.loadQuantity,
|
||||
cargoTech,
|
||||
});
|
||||
return core.fullMass({ emptyMass: empty, carryingMass: carrying });
|
||||
}
|
||||
|
||||
/**
|
||||
* radiusForMass maps an absolute ship mass to a circle radius via
|
||||
* a per-battle normalisation: the heaviest visual node always
|
||||
* renders at MAX_RADIUS, lighter ones scale by sqrt(mass /
|
||||
* maxMassInBattle) so the smallest ships don't disappear and the
|
||||
* heaviest ones don't dominate the scene at >MAX_RADIUS. mass<=0
|
||||
* falls back to MAX_RADIUS so unresolved/invalid classes stay
|
||||
* visible.
|
||||
*/
|
||||
export function radiusForMass(mass: number, maxMassInBattle: number): number {
|
||||
if (maxMassInBattle <= 0 || mass <= 0) return MAX_RADIUS;
|
||||
const scaled = MIN_RADIUS + (MAX_RADIUS - MIN_RADIUS) * Math.sqrt(mass / maxMassInBattle);
|
||||
if (scaled < MIN_RADIUS) return MIN_RADIUS;
|
||||
if (scaled > MAX_RADIUS) return MAX_RADIUS;
|
||||
return scaled;
|
||||
}
|
||||
|
||||
/**
|
||||
* MapShipClassLookup is a `Map<string, ShipClassRef>`-backed
|
||||
* implementation of `ShipClassLookup`. Key encoding mirrors the
|
||||
* one battle.svelte uses when populating the lookup from the
|
||||
* parent GameReport.
|
||||
*/
|
||||
export class MapShipClassLookup implements ShipClassLookup {
|
||||
constructor(private readonly map: Map<string, ShipClassRef>) {}
|
||||
|
||||
get(race: string, className: string): ShipClassRef | null {
|
||||
return this.map.get(`${race}::${className}`) ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<!--
|
||||
PlaybackControls — rewind / step-back / play-pause / step-forward
|
||||
plus a single cycling speed button (1x → 2x → 4x → 6x → 1x) and a
|
||||
"log" toggle that the orchestrator uses to collapse the always-on
|
||||
text protocol when the user wants more space for the scene. Owns no
|
||||
state of its own; binds `playing`, `frameIndex`, `speed`, and
|
||||
`logOpen` from the orchestrator. Disables step/rewind when there's
|
||||
nowhere to go and step-forward when the timeline is at its end.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
|
||||
export type PlaybackSpeed = 1 | 2 | 4 | 6;
|
||||
|
||||
const SPEED_CYCLE: PlaybackSpeed[] = [1, 2, 4, 6];
|
||||
|
||||
let {
|
||||
playing = $bindable(),
|
||||
frameIndex = $bindable(),
|
||||
speed = $bindable(),
|
||||
logOpen = $bindable(),
|
||||
frameCount,
|
||||
}: {
|
||||
playing: boolean;
|
||||
frameIndex: number;
|
||||
speed: PlaybackSpeed;
|
||||
logOpen: boolean;
|
||||
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 cycleSpeed() {
|
||||
const idx = SPEED_CYCLE.indexOf(speed);
|
||||
const next = SPEED_CYCLE[(idx + 1) % SPEED_CYCLE.length];
|
||||
speed = next;
|
||||
}
|
||||
function toggleLog() {
|
||||
logOpen = !logOpen;
|
||||
}
|
||||
|
||||
const speedLabel = $derived(`${speed}x`);
|
||||
</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>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="speed-btn"
|
||||
onclick={cycleSpeed}
|
||||
title={i18n.t("game.battle.controls.speed_label")}
|
||||
aria-label={i18n.t("game.battle.controls.speed_label")}
|
||||
data-testid="battle-control-speed"
|
||||
data-speed={speed}
|
||||
>{speedLabel}</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="log-toggle"
|
||||
class:active={logOpen}
|
||||
onclick={toggleLog}
|
||||
aria-pressed={logOpen}
|
||||
aria-label={i18n.t("game.battle.controls.log_toggle")}
|
||||
data-testid="battle-control-log-toggle"
|
||||
>{i18n.t("game.battle.controls.log_toggle")} {logOpen ? "▲" : "▼"}</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;
|
||||
}
|
||||
.speed-btn {
|
||||
min-width: 3rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.log-toggle.active {
|
||||
background: #2a3463;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
// Radial layout for the BattleViewer.
|
||||
//
|
||||
// Places race anchors on a circle of radius `radius` around `center`
|
||||
// at equal angular spacing. For three or more races the first anchor
|
||||
// sits at the top (12 o'clock) and subsequent anchors march
|
||||
// clockwise. For exactly two races the pair is rotated 90° so they
|
||||
// face each other horizontally (3 o'clock vs 9 o'clock) — that keeps
|
||||
// every race label clear of the SVG top edge when only two clusters
|
||||
// remain, and reads as "the two sides facing off" naturally.
|
||||
//
|
||||
// When a race is eliminated mid-battle the caller filters it out of
|
||||
// `activeRaceIds` and the survivors are re-spaced on the next frame
|
||||
// through the same helper.
|
||||
|
||||
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 two participants we want a horizontal duel layout: race 0
|
||||
// at 9 o'clock, race 1 at 3 o'clock. For any other count the
|
||||
// first anchor lands at the top (12 o'clock) and the rest march
|
||||
// clockwise at equal spacing.
|
||||
const startAngle = count === 2 ? Math.PI : -Math.PI / 2;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const step = (2 * Math.PI) / count;
|
||||
const angle = startAngle + 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,143 @@
|
||||
// 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) {
|
||||
// Defence in depth: a malformed protocol that fires more
|
||||
// `Destroyed` rows than the group has ships would push
|
||||
// `runningRaceTotals` below zero and drop the race from
|
||||
// `activeRaceIds` prematurely. Real legacy data folds
|
||||
// duplicate `(race, className)` roster rows into the
|
||||
// same `BattleReportGroup` (parser + engine), so this
|
||||
// branch is hit only on a real shrink — but the clamp
|
||||
// keeps the math from going negative either way.
|
||||
const left = current.get(action.sd) ?? 0;
|
||||
if (left > 0) {
|
||||
current.set(action.sd, left - 1);
|
||||
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);
|
||||
}
|
||||
@@ -483,6 +483,27 @@ const en = {
|
||||
"game.report.section.battles.title": "battles",
|
||||
"game.report.section.battles.empty": "no battles last turn",
|
||||
"game.report.section.battles.id_label": "battle",
|
||||
"game.battle.title": "battle",
|
||||
"game.battle.header_title": "Battle on planet {planet_name} (#{planet_number})",
|
||||
"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.controls.speed_6x": "6x",
|
||||
"game.battle.controls.scrub": "scrub battle timeline",
|
||||
"game.battle.controls.log_toggle": "Log",
|
||||
"game.battle.log.destroyed": "{attacker_race}'s {attacker_class} destroyed {defender_race}'s {defender_class}",
|
||||
"game.battle.log.shielded": "{attacker_race}'s {attacker_class} hit {defender_race}'s {defender_class}, shields held",
|
||||
"game.battle.accessibility.protocol_heading": "battle log",
|
||||
"game.report.section.bombings.title": "bombings",
|
||||
"game.report.section.bombings.empty": "no bombings last turn",
|
||||
"game.report.section.bombings.column.planet": "planet",
|
||||
|
||||
@@ -484,6 +484,27 @@ const ru: Record<keyof typeof en, string> = {
|
||||
"game.report.section.battles.title": "сражения",
|
||||
"game.report.section.battles.empty": "сражений в этом ходу не было",
|
||||
"game.report.section.battles.id_label": "сражение",
|
||||
"game.battle.title": "сражение",
|
||||
"game.battle.header_title": "Битва на планете {planet_name} (#{planet_number})",
|
||||
"game.battle.controls.speed_6x": "6x",
|
||||
"game.battle.controls.scrub": "перемотать таймлайн битвы",
|
||||
"game.battle.controls.log_toggle": "Лог",
|
||||
"game.battle.loading": "загрузка сражения…",
|
||||
"game.battle.not_found": "сражение не найдено",
|
||||
"game.battle.back_to_report": "к отчёту",
|
||||
"game.battle.back_to_map": "к карте",
|
||||
"game.battle.controls.play": "запустить",
|
||||
"game.battle.controls.pause": "пауза",
|
||||
"game.battle.controls.step_forward": "шаг вперёд",
|
||||
"game.battle.controls.step_backward": "шаг назад",
|
||||
"game.battle.controls.rewind": "к началу",
|
||||
"game.battle.controls.speed_label": "скорость",
|
||||
"game.battle.controls.speed_1x": "1x",
|
||||
"game.battle.controls.speed_2x": "2x",
|
||||
"game.battle.controls.speed_4x": "4x",
|
||||
"game.battle.log.destroyed": "{attacker_class} расы {attacker_race} уничтожает {defender_class} расы {defender_race}",
|
||||
"game.battle.log.shielded": "{attacker_class} расы {attacker_race} попадает в {defender_class} расы {defender_race}, щиты выдержали",
|
||||
"game.battle.accessibility.protocol_heading": "протокол сражения",
|
||||
"game.report.section.bombings.title": "бомбардировки",
|
||||
"game.report.section.bombings.empty": "бомбардировок в этом ходу не было",
|
||||
"game.report.section.bombings.column.planet": "планета",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
import type { GameReport, ReportPlanet } from "../api/game-state";
|
||||
import type { ShipGroupRef } from "../lib/selection.svelte";
|
||||
import { buildBattleAndBombingMarkers } from "./battle-markers";
|
||||
import { shipGroupsToPrimitives } from "./ship-groups";
|
||||
import { World, type Primitive, type PrimitiveID, type Style } from "./world";
|
||||
|
||||
@@ -83,7 +84,9 @@ function priorityFor(kind: ReportPlanet["kind"]): number {
|
||||
*/
|
||||
export type HitTarget =
|
||||
| { kind: "planet"; number: number }
|
||||
| { kind: "shipGroup"; ref: ShipGroupRef };
|
||||
| { kind: "shipGroup"; ref: ShipGroupRef }
|
||||
| { kind: "battle"; battleId: string; planet: number }
|
||||
| { kind: "bombing"; planet: number };
|
||||
|
||||
export interface ReportToWorldResult {
|
||||
world: World;
|
||||
@@ -127,6 +130,14 @@ export function reportToWorld(report: GameReport): ReportToWorldResult {
|
||||
hitLookup.set(primId, { kind: "shipGroup", ref });
|
||||
}
|
||||
|
||||
const markers = buildBattleAndBombingMarkers(report);
|
||||
for (const prim of markers.primitives) {
|
||||
primitives.push(prim);
|
||||
}
|
||||
for (const [primId, target] of markers.lookup) {
|
||||
hitLookup.set(primId, target);
|
||||
}
|
||||
|
||||
const width = report.mapWidth > 0 ? report.mapWidth : 1;
|
||||
const height = report.mapHeight > 0 ? report.mapHeight : 1;
|
||||
return { world: new World(width, height, primitives), hitLookup };
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { ApplicationSummary, ApplicationSummaryT } from './application-summary.js';
|
||||
import { ApplicationSummary, ApplicationSummaryT } from '../lobby/application-summary.js';
|
||||
|
||||
|
||||
export class ApplicationSubmitResponse implements flatbuffers.IUnpackableObject<ApplicationSubmitResponseT> {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { ErrorBody, ErrorBodyT } from './error-body.js';
|
||||
import { ErrorBody, ErrorBodyT } from '../lobby/error-body.js';
|
||||
|
||||
|
||||
export class ErrorResponse implements flatbuffers.IUnpackableObject<ErrorResponseT> {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { GameSummary, GameSummaryT } from './game-summary.js';
|
||||
import { GameSummary, GameSummaryT } from '../lobby/game-summary.js';
|
||||
|
||||
|
||||
export class GameCreateResponse implements flatbuffers.IUnpackableObject<GameCreateResponseT> {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { InviteSummary, InviteSummaryT } from './invite-summary.js';
|
||||
import { InviteSummary, InviteSummaryT } from '../lobby/invite-summary.js';
|
||||
|
||||
|
||||
export class InviteDeclineResponse implements flatbuffers.IUnpackableObject<InviteDeclineResponseT> {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { InviteSummary, InviteSummaryT } from './invite-summary.js';
|
||||
import { InviteSummary, InviteSummaryT } from '../lobby/invite-summary.js';
|
||||
|
||||
|
||||
export class InviteRedeemResponse implements flatbuffers.IUnpackableObject<InviteRedeemResponseT> {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { ApplicationSummary, ApplicationSummaryT } from './application-summary.js';
|
||||
import { ApplicationSummary, ApplicationSummaryT } from '../lobby/application-summary.js';
|
||||
|
||||
|
||||
export class MyApplicationsListResponse implements flatbuffers.IUnpackableObject<MyApplicationsListResponseT> {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { GameSummary, GameSummaryT } from './game-summary.js';
|
||||
import { GameSummary, GameSummaryT } from '../lobby/game-summary.js';
|
||||
|
||||
|
||||
export class MyGamesListResponse implements flatbuffers.IUnpackableObject<MyGamesListResponseT> {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { InviteSummary, InviteSummaryT } from './invite-summary.js';
|
||||
import { InviteSummary, InviteSummaryT } from '../lobby/invite-summary.js';
|
||||
|
||||
|
||||
export class MyInvitesListResponse implements flatbuffers.IUnpackableObject<MyInvitesListResponseT> {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { GameSummary, GameSummaryT } from './game-summary.js';
|
||||
import { GameSummary, GameSummaryT } from '../lobby/game-summary.js';
|
||||
|
||||
|
||||
export class PublicGamesListResponse implements flatbuffers.IUnpackableObject<PublicGamesListResponseT> {
|
||||
|
||||
@@ -4,30 +4,30 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { CommandFleetMerge, CommandFleetMergeT } from './command-fleet-merge.js';
|
||||
import { CommandFleetSend, CommandFleetSendT } from './command-fleet-send.js';
|
||||
import { CommandPayload, unionToCommandPayload, unionListToCommandPayload } from './command-payload.js';
|
||||
import { CommandPlanetProduce, CommandPlanetProduceT } from './command-planet-produce.js';
|
||||
import { CommandPlanetRename, CommandPlanetRenameT } from './command-planet-rename.js';
|
||||
import { CommandPlanetRouteRemove, CommandPlanetRouteRemoveT } from './command-planet-route-remove.js';
|
||||
import { CommandPlanetRouteSet, CommandPlanetRouteSetT } from './command-planet-route-set.js';
|
||||
import { CommandRaceQuit, CommandRaceQuitT } from './command-race-quit.js';
|
||||
import { CommandRaceRelation, CommandRaceRelationT } from './command-race-relation.js';
|
||||
import { CommandRaceVote, CommandRaceVoteT } from './command-race-vote.js';
|
||||
import { CommandScienceCreate, CommandScienceCreateT } from './command-science-create.js';
|
||||
import { CommandScienceRemove, CommandScienceRemoveT } from './command-science-remove.js';
|
||||
import { CommandShipClassCreate, CommandShipClassCreateT } from './command-ship-class-create.js';
|
||||
import { CommandShipClassMerge, CommandShipClassMergeT } from './command-ship-class-merge.js';
|
||||
import { CommandShipClassRemove, CommandShipClassRemoveT } from './command-ship-class-remove.js';
|
||||
import { CommandShipGroupBreak, CommandShipGroupBreakT } from './command-ship-group-break.js';
|
||||
import { CommandShipGroupDismantle, CommandShipGroupDismantleT } from './command-ship-group-dismantle.js';
|
||||
import { CommandShipGroupJoinFleet, CommandShipGroupJoinFleetT } from './command-ship-group-join-fleet.js';
|
||||
import { CommandShipGroupLoad, CommandShipGroupLoadT } from './command-ship-group-load.js';
|
||||
import { CommandShipGroupMerge, CommandShipGroupMergeT } from './command-ship-group-merge.js';
|
||||
import { CommandShipGroupSend, CommandShipGroupSendT } from './command-ship-group-send.js';
|
||||
import { CommandShipGroupTransfer, CommandShipGroupTransferT } from './command-ship-group-transfer.js';
|
||||
import { CommandShipGroupUnload, CommandShipGroupUnloadT } from './command-ship-group-unload.js';
|
||||
import { CommandShipGroupUpgrade, CommandShipGroupUpgradeT } from './command-ship-group-upgrade.js';
|
||||
import { CommandFleetMerge, CommandFleetMergeT } from '../order/command-fleet-merge.js';
|
||||
import { CommandFleetSend, CommandFleetSendT } from '../order/command-fleet-send.js';
|
||||
import { CommandPayload, unionToCommandPayload, unionListToCommandPayload } from '../order/command-payload.js';
|
||||
import { CommandPlanetProduce, CommandPlanetProduceT } from '../order/command-planet-produce.js';
|
||||
import { CommandPlanetRename, CommandPlanetRenameT } from '../order/command-planet-rename.js';
|
||||
import { CommandPlanetRouteRemove, CommandPlanetRouteRemoveT } from '../order/command-planet-route-remove.js';
|
||||
import { CommandPlanetRouteSet, CommandPlanetRouteSetT } from '../order/command-planet-route-set.js';
|
||||
import { CommandRaceQuit, CommandRaceQuitT } from '../order/command-race-quit.js';
|
||||
import { CommandRaceRelation, CommandRaceRelationT } from '../order/command-race-relation.js';
|
||||
import { CommandRaceVote, CommandRaceVoteT } from '../order/command-race-vote.js';
|
||||
import { CommandScienceCreate, CommandScienceCreateT } from '../order/command-science-create.js';
|
||||
import { CommandScienceRemove, CommandScienceRemoveT } from '../order/command-science-remove.js';
|
||||
import { CommandShipClassCreate, CommandShipClassCreateT } from '../order/command-ship-class-create.js';
|
||||
import { CommandShipClassMerge, CommandShipClassMergeT } from '../order/command-ship-class-merge.js';
|
||||
import { CommandShipClassRemove, CommandShipClassRemoveT } from '../order/command-ship-class-remove.js';
|
||||
import { CommandShipGroupBreak, CommandShipGroupBreakT } from '../order/command-ship-group-break.js';
|
||||
import { CommandShipGroupDismantle, CommandShipGroupDismantleT } from '../order/command-ship-group-dismantle.js';
|
||||
import { CommandShipGroupJoinFleet, CommandShipGroupJoinFleetT } from '../order/command-ship-group-join-fleet.js';
|
||||
import { CommandShipGroupLoad, CommandShipGroupLoadT } from '../order/command-ship-group-load.js';
|
||||
import { CommandShipGroupMerge, CommandShipGroupMergeT } from '../order/command-ship-group-merge.js';
|
||||
import { CommandShipGroupSend, CommandShipGroupSendT } from '../order/command-ship-group-send.js';
|
||||
import { CommandShipGroupTransfer, CommandShipGroupTransferT } from '../order/command-ship-group-transfer.js';
|
||||
import { CommandShipGroupUnload, CommandShipGroupUnloadT } from '../order/command-ship-group-unload.js';
|
||||
import { CommandShipGroupUpgrade, CommandShipGroupUpgradeT } from '../order/command-ship-group-upgrade.js';
|
||||
|
||||
|
||||
export class CommandItem implements flatbuffers.IUnpackableObject<CommandItemT> {
|
||||
|
||||
@@ -2,29 +2,29 @@
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
import { CommandFleetMerge, CommandFleetMergeT } from './command-fleet-merge.js';
|
||||
import { CommandFleetSend, CommandFleetSendT } from './command-fleet-send.js';
|
||||
import { CommandPlanetProduce, CommandPlanetProduceT } from './command-planet-produce.js';
|
||||
import { CommandPlanetRename, CommandPlanetRenameT } from './command-planet-rename.js';
|
||||
import { CommandPlanetRouteRemove, CommandPlanetRouteRemoveT } from './command-planet-route-remove.js';
|
||||
import { CommandPlanetRouteSet, CommandPlanetRouteSetT } from './command-planet-route-set.js';
|
||||
import { CommandRaceQuit, CommandRaceQuitT } from './command-race-quit.js';
|
||||
import { CommandRaceRelation, CommandRaceRelationT } from './command-race-relation.js';
|
||||
import { CommandRaceVote, CommandRaceVoteT } from './command-race-vote.js';
|
||||
import { CommandScienceCreate, CommandScienceCreateT } from './command-science-create.js';
|
||||
import { CommandScienceRemove, CommandScienceRemoveT } from './command-science-remove.js';
|
||||
import { CommandShipClassCreate, CommandShipClassCreateT } from './command-ship-class-create.js';
|
||||
import { CommandShipClassMerge, CommandShipClassMergeT } from './command-ship-class-merge.js';
|
||||
import { CommandShipClassRemove, CommandShipClassRemoveT } from './command-ship-class-remove.js';
|
||||
import { CommandShipGroupBreak, CommandShipGroupBreakT } from './command-ship-group-break.js';
|
||||
import { CommandShipGroupDismantle, CommandShipGroupDismantleT } from './command-ship-group-dismantle.js';
|
||||
import { CommandShipGroupJoinFleet, CommandShipGroupJoinFleetT } from './command-ship-group-join-fleet.js';
|
||||
import { CommandShipGroupLoad, CommandShipGroupLoadT } from './command-ship-group-load.js';
|
||||
import { CommandShipGroupMerge, CommandShipGroupMergeT } from './command-ship-group-merge.js';
|
||||
import { CommandShipGroupSend, CommandShipGroupSendT } from './command-ship-group-send.js';
|
||||
import { CommandShipGroupTransfer, CommandShipGroupTransferT } from './command-ship-group-transfer.js';
|
||||
import { CommandShipGroupUnload, CommandShipGroupUnloadT } from './command-ship-group-unload.js';
|
||||
import { CommandShipGroupUpgrade, CommandShipGroupUpgradeT } from './command-ship-group-upgrade.js';
|
||||
import { CommandFleetMerge, CommandFleetMergeT } from '../order/command-fleet-merge.js';
|
||||
import { CommandFleetSend, CommandFleetSendT } from '../order/command-fleet-send.js';
|
||||
import { CommandPlanetProduce, CommandPlanetProduceT } from '../order/command-planet-produce.js';
|
||||
import { CommandPlanetRename, CommandPlanetRenameT } from '../order/command-planet-rename.js';
|
||||
import { CommandPlanetRouteRemove, CommandPlanetRouteRemoveT } from '../order/command-planet-route-remove.js';
|
||||
import { CommandPlanetRouteSet, CommandPlanetRouteSetT } from '../order/command-planet-route-set.js';
|
||||
import { CommandRaceQuit, CommandRaceQuitT } from '../order/command-race-quit.js';
|
||||
import { CommandRaceRelation, CommandRaceRelationT } from '../order/command-race-relation.js';
|
||||
import { CommandRaceVote, CommandRaceVoteT } from '../order/command-race-vote.js';
|
||||
import { CommandScienceCreate, CommandScienceCreateT } from '../order/command-science-create.js';
|
||||
import { CommandScienceRemove, CommandScienceRemoveT } from '../order/command-science-remove.js';
|
||||
import { CommandShipClassCreate, CommandShipClassCreateT } from '../order/command-ship-class-create.js';
|
||||
import { CommandShipClassMerge, CommandShipClassMergeT } from '../order/command-ship-class-merge.js';
|
||||
import { CommandShipClassRemove, CommandShipClassRemoveT } from '../order/command-ship-class-remove.js';
|
||||
import { CommandShipGroupBreak, CommandShipGroupBreakT } from '../order/command-ship-group-break.js';
|
||||
import { CommandShipGroupDismantle, CommandShipGroupDismantleT } from '../order/command-ship-group-dismantle.js';
|
||||
import { CommandShipGroupJoinFleet, CommandShipGroupJoinFleetT } from '../order/command-ship-group-join-fleet.js';
|
||||
import { CommandShipGroupLoad, CommandShipGroupLoadT } from '../order/command-ship-group-load.js';
|
||||
import { CommandShipGroupMerge, CommandShipGroupMergeT } from '../order/command-ship-group-merge.js';
|
||||
import { CommandShipGroupSend, CommandShipGroupSendT } from '../order/command-ship-group-send.js';
|
||||
import { CommandShipGroupTransfer, CommandShipGroupTransferT } from '../order/command-ship-group-transfer.js';
|
||||
import { CommandShipGroupUnload, CommandShipGroupUnloadT } from '../order/command-ship-group-unload.js';
|
||||
import { CommandShipGroupUpgrade, CommandShipGroupUpgradeT } from '../order/command-ship-group-upgrade.js';
|
||||
|
||||
|
||||
export enum CommandPayload {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { PlanetProduction } from './planet-production.js';
|
||||
import { PlanetProduction } from '../order/planet-production.js';
|
||||
|
||||
|
||||
export class CommandPlanetProduce implements flatbuffers.IUnpackableObject<CommandPlanetProduceT> {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { PlanetRouteLoadType } from './planet-route-load-type.js';
|
||||
import { PlanetRouteLoadType } from '../order/planet-route-load-type.js';
|
||||
|
||||
|
||||
export class CommandPlanetRouteRemove implements flatbuffers.IUnpackableObject<CommandPlanetRouteRemoveT> {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { PlanetRouteLoadType } from './planet-route-load-type.js';
|
||||
import { PlanetRouteLoadType } from '../order/planet-route-load-type.js';
|
||||
|
||||
|
||||
export class CommandPlanetRouteSet implements flatbuffers.IUnpackableObject<CommandPlanetRouteSetT> {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { Relation } from './relation.js';
|
||||
import { Relation } from '../order/relation.js';
|
||||
|
||||
|
||||
export class CommandRaceRelation implements flatbuffers.IUnpackableObject<CommandRaceRelationT> {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { ShipGroupCargo } from './ship-group-cargo.js';
|
||||
import { ShipGroupCargo } from '../order/ship-group-cargo.js';
|
||||
|
||||
|
||||
export class CommandShipGroupLoad implements flatbuffers.IUnpackableObject<CommandShipGroupLoadT> {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { ShipGroupUpgradeTech } from './ship-group-upgrade-tech.js';
|
||||
import { ShipGroupUpgradeTech } from '../order/ship-group-upgrade-tech.js';
|
||||
|
||||
|
||||
export class CommandShipGroupUpgrade implements flatbuffers.IUnpackableObject<CommandShipGroupUpgradeT> {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { UUID, UUIDT } from '../common/uuid.js';
|
||||
import { CommandItem, CommandItemT } from './command-item.js';
|
||||
import { CommandItem, CommandItemT } from '../order/command-item.js';
|
||||
|
||||
|
||||
export class UserGamesCommand implements flatbuffers.IUnpackableObject<UserGamesCommandT> {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { UserGamesOrder, UserGamesOrderT } from './user-games-order.js';
|
||||
import { UserGamesOrder, UserGamesOrderT } from '../order/user-games-order.js';
|
||||
|
||||
|
||||
export class UserGamesOrderGetResponse implements flatbuffers.IUnpackableObject<UserGamesOrderGetResponseT> {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { UUID, UUIDT } from '../common/uuid.js';
|
||||
import { CommandItem, CommandItemT } from './command-item.js';
|
||||
import { CommandItem, CommandItemT } from '../order/command-item.js';
|
||||
|
||||
|
||||
export class UserGamesOrderResponse implements flatbuffers.IUnpackableObject<UserGamesOrderResponseT> {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { UUID, UUIDT } from '../common/uuid.js';
|
||||
import { CommandItem, CommandItemT } from './command-item.js';
|
||||
import { CommandItem, CommandItemT } from '../order/command-item.js';
|
||||
|
||||
|
||||
export class UserGamesOrder implements flatbuffers.IUnpackableObject<UserGamesOrderT> {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
export { BattleSummary, BattleSummaryT } from './report/battle-summary.js';
|
||||
export { Bombing, BombingT } from './report/bombing.js';
|
||||
export { GameReportRequest, GameReportRequestT } from './report/game-report-request.js';
|
||||
export { IncomingGroup, IncomingGroupT } from './report/incoming-group.js';
|
||||
|
||||
@@ -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 { 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> {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { TechEntry, TechEntryT } from './tech-entry.js';
|
||||
import { TechEntry, TechEntryT } from '../report/tech-entry.js';
|
||||
|
||||
|
||||
export class OtherGroup implements flatbuffers.IUnpackableObject<OtherGroupT> {
|
||||
|
||||
@@ -4,24 +4,24 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { UUID, UUIDT } from '../common/uuid.js';
|
||||
import { Bombing, BombingT } from './bombing.js';
|
||||
import { IncomingGroup, IncomingGroupT } from './incoming-group.js';
|
||||
import { LocalFleet, LocalFleetT } from './local-fleet.js';
|
||||
import { LocalGroup, LocalGroupT } from './local-group.js';
|
||||
import { LocalPlanet, LocalPlanetT } from './local-planet.js';
|
||||
import { OtherGroup, OtherGroupT } from './other-group.js';
|
||||
import { OtherPlanet, OtherPlanetT } from './other-planet.js';
|
||||
import { OtherScience, OtherScienceT } from './other-science.js';
|
||||
import { OthersShipClass, OthersShipClassT } from './others-ship-class.js';
|
||||
import { Player, PlayerT } from './player.js';
|
||||
import { Route, RouteT } from './route.js';
|
||||
import { Science, ScienceT } from './science.js';
|
||||
import { ShipClass, ShipClassT } from './ship-class.js';
|
||||
import { ShipProduction, ShipProductionT } from './ship-production.js';
|
||||
import { UnidentifiedGroup, UnidentifiedGroupT } from './unidentified-group.js';
|
||||
import { UnidentifiedPlanet, UnidentifiedPlanetT } from './unidentified-planet.js';
|
||||
import { UninhabitedPlanet, UninhabitedPlanetT } from './uninhabited-planet.js';
|
||||
import { BattleSummary, BattleSummaryT } from '../report/battle-summary.js';
|
||||
import { Bombing, BombingT } from '../report/bombing.js';
|
||||
import { IncomingGroup, IncomingGroupT } from '../report/incoming-group.js';
|
||||
import { LocalFleet, LocalFleetT } from '../report/local-fleet.js';
|
||||
import { LocalGroup, LocalGroupT } from '../report/local-group.js';
|
||||
import { LocalPlanet, LocalPlanetT } from '../report/local-planet.js';
|
||||
import { OtherGroup, OtherGroupT } from '../report/other-group.js';
|
||||
import { OtherPlanet, OtherPlanetT } from '../report/other-planet.js';
|
||||
import { OtherScience, OtherScienceT } from '../report/other-science.js';
|
||||
import { OthersShipClass, OthersShipClassT } from '../report/others-ship-class.js';
|
||||
import { Player, PlayerT } from '../report/player.js';
|
||||
import { Route, RouteT } from '../report/route.js';
|
||||
import { Science, ScienceT } from '../report/science.js';
|
||||
import { ShipClass, ShipClassT } from '../report/ship-class.js';
|
||||
import { ShipProduction, ShipProductionT } from '../report/ship-production.js';
|
||||
import { UnidentifiedGroup, UnidentifiedGroupT } from '../report/unidentified-group.js';
|
||||
import { UnidentifiedPlanet, UnidentifiedPlanetT } from '../report/unidentified-planet.js';
|
||||
import { UninhabitedPlanet, UninhabitedPlanetT } from '../report/uninhabited-planet.js';
|
||||
|
||||
|
||||
export class Report implements flatbuffers.IUnpackableObject<ReportT> {
|
||||
@@ -136,9 +136,9 @@ otherShipClassLength():number {
|
||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
battle(index: number, obj?:UUID):UUID|null {
|
||||
battle(index: number, obj?:BattleSummary):BattleSummary|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 30);
|
||||
return offset ? (obj || new UUID()).__init(this.bb!.__vector(this.bb_pos + offset) + index * 16, this.bb!) : null;
|
||||
return offset ? (obj || new BattleSummary()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
|
||||
}
|
||||
|
||||
battleLength():number {
|
||||
@@ -386,8 +386,16 @@ static addBattle(builder:flatbuffers.Builder, battleOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(13, battleOffset, 0);
|
||||
}
|
||||
|
||||
static createBattleVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
|
||||
builder.startVector(4, data.length, 4);
|
||||
for (let i = data.length - 1; i >= 0; i--) {
|
||||
builder.addOffset(data[i]!);
|
||||
}
|
||||
return builder.endVector();
|
||||
}
|
||||
|
||||
static startBattleVector(builder:flatbuffers.Builder, numElems:number) {
|
||||
builder.startVector(16, numElems, 8);
|
||||
builder.startVector(4, numElems, 4);
|
||||
}
|
||||
|
||||
static addBombing(builder:flatbuffers.Builder, bombingOffset:flatbuffers.Offset) {
|
||||
@@ -641,7 +649,7 @@ unpack(): ReportT {
|
||||
this.bb!.createObjList<OtherScience, OtherScienceT>(this.otherScience.bind(this), this.otherScienceLength()),
|
||||
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<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<IncomingGroup, IncomingGroupT>(this.incomingGroup.bind(this), this.incomingGroupLength()),
|
||||
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.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.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.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());
|
||||
@@ -703,7 +711,7 @@ constructor(
|
||||
public otherScience: (OtherScienceT)[] = [],
|
||||
public localShipClass: (ShipClassT)[] = [],
|
||||
public otherShipClass: (OthersShipClassT)[] = [],
|
||||
public battle: (UUIDT)[] = [],
|
||||
public battle: (BattleSummaryT)[] = [],
|
||||
public bombing: (BombingT)[] = [],
|
||||
public incomingGroup: (IncomingGroupT)[] = [],
|
||||
public localPlanet: (LocalPlanetT)[] = [],
|
||||
@@ -727,7 +735,7 @@ pack(builder:flatbuffers.Builder): flatbuffers.Offset {
|
||||
const otherScience = Report.createOtherScienceVector(builder, builder.createObjectOffsetList(this.otherScience));
|
||||
const localShipClass = Report.createLocalShipClassVector(builder, builder.createObjectOffsetList(this.localShipClass));
|
||||
const otherShipClass = Report.createOtherShipClassVector(builder, builder.createObjectOffsetList(this.otherShipClass));
|
||||
const battle = builder.createStructOffsetList(this.battle, Report.startBattleVector);
|
||||
const battle = Report.createBattleVector(builder, builder.createObjectOffsetList(this.battle));
|
||||
const bombing = Report.createBombingVector(builder, builder.createObjectOffsetList(this.bombing));
|
||||
const incomingGroup = Report.createIncomingGroupVector(builder, builder.createObjectOffsetList(this.incomingGroup));
|
||||
const localPlanet = Report.createLocalPlanetVector(builder, builder.createObjectOffsetList(this.localPlanet));
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { RouteEntry, RouteEntryT } from './route-entry.js';
|
||||
import { RouteEntry, RouteEntryT } from '../report/route-entry.js';
|
||||
|
||||
|
||||
export class Route implements flatbuffers.IUnpackableObject<RouteT> {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { AccountView, AccountViewT } from './account-view.js';
|
||||
import { AccountView, AccountViewT } from '../user/account-view.js';
|
||||
|
||||
|
||||
export class AccountResponse implements flatbuffers.IUnpackableObject<AccountResponseT> {
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { ActiveLimit, ActiveLimitT } from './active-limit.js';
|
||||
import { ActiveSanction, ActiveSanctionT } from './active-sanction.js';
|
||||
import { EntitlementSnapshot, EntitlementSnapshotT } from './entitlement-snapshot.js';
|
||||
import { ActiveLimit, ActiveLimitT } from '../user/active-limit.js';
|
||||
import { ActiveSanction, ActiveSanctionT } from '../user/active-sanction.js';
|
||||
import { EntitlementSnapshot, EntitlementSnapshotT } from '../user/entitlement-snapshot.js';
|
||||
|
||||
|
||||
export class AccountView implements flatbuffers.IUnpackableObject<AccountViewT> {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { ActorRef, ActorRefT } from './actor-ref.js';
|
||||
import { ActorRef, ActorRefT } from '../user/actor-ref.js';
|
||||
|
||||
|
||||
export class ActiveLimit implements flatbuffers.IUnpackableObject<ActiveLimitT> {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { ActorRef, ActorRefT } from './actor-ref.js';
|
||||
import { ActorRef, ActorRefT } from '../user/actor-ref.js';
|
||||
|
||||
|
||||
export class ActiveSanction implements flatbuffers.IUnpackableObject<ActiveSanctionT> {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { ActorRef, ActorRefT } from './actor-ref.js';
|
||||
import { ActorRef, ActorRefT } from '../user/actor-ref.js';
|
||||
|
||||
|
||||
export class EntitlementSnapshot implements flatbuffers.IUnpackableObject<EntitlementSnapshotT> {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { ErrorBody, ErrorBodyT } from './error-body.js';
|
||||
import { ErrorBody, ErrorBodyT } from '../user/error-body.js';
|
||||
|
||||
|
||||
export class ErrorResponse implements flatbuffers.IUnpackableObject<ErrorResponseT> {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { DeviceSessionView, DeviceSessionViewT } from './device-session-view.js';
|
||||
import { DeviceSessionView, DeviceSessionViewT } from '../user/device-session-view.js';
|
||||
|
||||
|
||||
export class ListMySessionsResponse implements flatbuffers.IUnpackableObject<ListMySessionsResponseT> {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { DeviceSessionRevocationSummaryView, DeviceSessionRevocationSummaryViewT } from './device-session-revocation-summary-view.js';
|
||||
import { DeviceSessionRevocationSummaryView, DeviceSessionRevocationSummaryViewT } from '../user/device-session-revocation-summary-view.js';
|
||||
|
||||
|
||||
export class RevokeAllMySessionsResponse implements flatbuffers.IUnpackableObject<RevokeAllMySessionsResponseT> {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { DeviceSessionView, DeviceSessionViewT } from './device-session-view.js';
|
||||
import { DeviceSessionView, DeviceSessionViewT } from '../user/device-session-view.js';
|
||||
|
||||
|
||||
export class RevokeMySessionResponse implements flatbuffers.IUnpackableObject<RevokeMySessionResponseT> {
|
||||
|
||||
@@ -326,7 +326,19 @@ fresh.
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { cache } = await loadStore();
|
||||
// Synthetic mode still needs the wasm `Core` so
|
||||
// components that bridge to `pkg/calc/ship.go`
|
||||
// (designer preview, BattleViewer mass radii) can
|
||||
// resolve their math against the same engine helpers
|
||||
// the live path uses. The live branch below also
|
||||
// calls `loadCore()`; without it here the Battle
|
||||
// Viewer rendered every ship-class circle at
|
||||
// MAX_RADIUS in synthetic mode.
|
||||
const [{ cache }, core] = await Promise.all([
|
||||
loadStore(),
|
||||
loadCore(),
|
||||
]);
|
||||
coreHolder.set(core);
|
||||
await Promise.all([
|
||||
gameState.initSynthetic({ cache, gameId, report }),
|
||||
orderDraft.init({ cache, gameId }),
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/state";
|
||||
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>
|
||||
|
||||
<BattleView battleId={page.params.battleId ?? ""} />
|
||||
<BattleView
|
||||
gameId={page.params.id ?? ""}
|
||||
{turn}
|
||||
battleId={page.params.battleId ?? ""}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
// 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 {
|
||||
MAX_RADIUS,
|
||||
MIN_RADIUS,
|
||||
radiusForMass,
|
||||
} from "../src/lib/battle-player/mass";
|
||||
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 on the horizontal axis (9 vs 3 o'clock)", () => {
|
||||
// Special-case duel layout: two anchors face each other on
|
||||
// the horizontal axis so neither cluster's race label clips
|
||||
// against the SVG top edge.
|
||||
const result = layoutRaces([0, 1], { center, radius });
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].x).toBeCloseTo(center.x - radius, 5);
|
||||
expect(result[0].y).toBeCloseTo(center.y, 5);
|
||||
expect(result[1].x).toBeCloseTo(center.x + radius, 5);
|
||||
expect(result[1].y).toBeCloseTo(center.y, 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]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFrames phantom-destroy clamp", () => {
|
||||
it("does not drop a race when destroyed shots exceed initial counts", () => {
|
||||
// Race "Phantom" has a single group with 2 ships; the engine
|
||||
// emits five Destroyed shots against it (legacy emitter quirk
|
||||
// reproduced in KNNTS041 planet #7). The group goes to 0
|
||||
// after two real destroys; the remaining three are phantoms
|
||||
// and must not push raceTotals into negatives or drop the
|
||||
// race from activeRaceIds prematurely. Race "Survivor" keeps
|
||||
// its single ship throughout so it stays active alongside
|
||||
// Phantom until Phantom legitimately empties.
|
||||
const report: BattleReport = {
|
||||
id: "phantom-battle",
|
||||
planet: 1,
|
||||
planetName: "P",
|
||||
races: { "0": "phantom-uuid", "1": "survivor-uuid" },
|
||||
ships: {
|
||||
"10": {
|
||||
race: "Phantom",
|
||||
className: "Drone",
|
||||
tech: {},
|
||||
num: 2,
|
||||
numLeft: 0,
|
||||
loadType: "",
|
||||
loadQuantity: 0,
|
||||
inBattle: true,
|
||||
},
|
||||
"20": {
|
||||
race: "Survivor",
|
||||
className: "Hawk",
|
||||
tech: {},
|
||||
num: 1,
|
||||
numLeft: 1,
|
||||
loadType: "",
|
||||
loadQuantity: 0,
|
||||
inBattle: true,
|
||||
},
|
||||
},
|
||||
protocol: [
|
||||
{ a: 1, sa: 20, d: 0, sd: 10, x: true }, // real kill #1
|
||||
{ a: 1, sa: 20, d: 0, sd: 10, x: true }, // real kill #2 → group=0
|
||||
{ a: 1, sa: 20, d: 0, sd: 10, x: true }, // phantom #1
|
||||
{ a: 1, sa: 20, d: 0, sd: 10, x: true }, // phantom #2
|
||||
{ a: 1, sa: 20, d: 0, sd: 10, x: true }, // phantom #3
|
||||
],
|
||||
};
|
||||
const frames = buildFrames(report);
|
||||
expect(frames[2].remaining.get(10)).toBe(0);
|
||||
// After the 2nd real destroy Phantom has 0 ships in its only
|
||||
// group and must drop out of activeRaceIds.
|
||||
expect(frames[2].activeRaceIds).toEqual([1]);
|
||||
// Phantoms past frame 2 must NOT keep decrementing — group
|
||||
// stays at 0, totals don't go negative, and Survivor remains
|
||||
// the only active race for the remainder of the protocol.
|
||||
expect(frames[5].remaining.get(10)).toBe(0);
|
||||
expect(frames[5].activeRaceIds).toEqual([1]);
|
||||
});
|
||||
|
||||
it("keeps a race active while phantom destroys hit one of its empty groups", () => {
|
||||
// One race ("Doublet"), two groups of different class. Class
|
||||
// A gets all five Destroyed shots; class B never gets hit.
|
||||
// Class A only has 2 ships → 3 phantoms. The race must stay
|
||||
// active because class B's single ship is intact.
|
||||
const report: BattleReport = {
|
||||
id: "doublet-battle",
|
||||
planet: 2,
|
||||
planetName: "P2",
|
||||
races: { "0": "doublet-uuid", "1": "attacker-uuid" },
|
||||
ships: {
|
||||
"10": {
|
||||
race: "Doublet",
|
||||
className: "A",
|
||||
tech: {},
|
||||
num: 2,
|
||||
numLeft: 0,
|
||||
loadType: "",
|
||||
loadQuantity: 0,
|
||||
inBattle: true,
|
||||
},
|
||||
"11": {
|
||||
race: "Doublet",
|
||||
className: "B",
|
||||
tech: {},
|
||||
num: 1,
|
||||
numLeft: 1,
|
||||
loadType: "",
|
||||
loadQuantity: 0,
|
||||
inBattle: true,
|
||||
},
|
||||
"20": {
|
||||
race: "Attacker",
|
||||
className: "Gun",
|
||||
tech: {},
|
||||
num: 1,
|
||||
numLeft: 1,
|
||||
loadType: "",
|
||||
loadQuantity: 0,
|
||||
inBattle: true,
|
||||
},
|
||||
},
|
||||
protocol: [
|
||||
// Open the protocol with a shot that names class B so
|
||||
// normaliseGroups picks it up (groups never referenced
|
||||
// in the protocol are filtered out of the visual
|
||||
// roster); the shot misses so class B stays intact.
|
||||
{ a: 1, sa: 20, d: 0, sd: 11, x: false },
|
||||
{ a: 1, sa: 20, d: 0, sd: 10, x: true },
|
||||
{ a: 1, sa: 20, d: 0, sd: 10, x: true },
|
||||
{ a: 1, sa: 20, d: 0, sd: 10, x: true },
|
||||
{ a: 1, sa: 20, d: 0, sd: 10, x: true },
|
||||
{ a: 1, sa: 20, d: 0, sd: 10, x: true },
|
||||
],
|
||||
};
|
||||
const frames = buildFrames(report);
|
||||
// After all 6 actions: Doublet:A is at 0 (group capped at 2
|
||||
// real destroys + 3 phantoms), Doublet:B unchanged at 1, so
|
||||
// race totals = 1 → race stays active.
|
||||
expect(frames[6].remaining.get(10)).toBe(0);
|
||||
expect(frames[6].remaining.get(11)).toBe(1);
|
||||
expect(frames[6].activeRaceIds.sort()).toEqual([0, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("radiusForMass", () => {
|
||||
it("returns MAX_RADIUS when mass is zero", () => {
|
||||
expect(radiusForMass(0, 100)).toBe(MAX_RADIUS);
|
||||
});
|
||||
|
||||
it("returns MAX_RADIUS when maxMassInBattle is zero", () => {
|
||||
expect(radiusForMass(50, 0)).toBe(MAX_RADIUS);
|
||||
});
|
||||
|
||||
it("returns MAX_RADIUS at the per-battle ceiling", () => {
|
||||
expect(radiusForMass(100, 100)).toBeCloseTo(MAX_RADIUS, 5);
|
||||
});
|
||||
|
||||
it("scales by sqrt(mass / maxMass): one quarter of max mass = halfway", () => {
|
||||
const r = radiusForMass(25, 100);
|
||||
const expected = MIN_RADIUS + (MAX_RADIUS - MIN_RADIUS) * 0.5;
|
||||
expect(r).toBeCloseTo(expected, 5);
|
||||
});
|
||||
|
||||
it("never returns a radius below MIN_RADIUS or above MAX_RADIUS", () => {
|
||||
expect(radiusForMass(1, Number.MAX_SAFE_INTEGER)).toBeGreaterThanOrEqual(MIN_RADIUS);
|
||||
expect(radiusForMass(Number.MAX_SAFE_INTEGER, 1)).toBeLessThanOrEqual(MAX_RADIUS);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
// 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 }],
|
||||
localShipClass: [
|
||||
{
|
||||
name: "Cruiser",
|
||||
drive: 10,
|
||||
armament: 2,
|
||||
weapons: 5,
|
||||
shields: 5,
|
||||
cargo: 2,
|
||||
},
|
||||
],
|
||||
otherShipClass: [
|
||||
{
|
||||
race: "Bajori",
|
||||
name: "Hawk",
|
||||
drive: 12,
|
||||
armament: 1,
|
||||
weapons: 4,
|
||||
shields: 2,
|
||||
cargo: 0,
|
||||
mass: 75,
|
||||
},
|
||||
],
|
||||
});
|
||||
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();
|
||||
});
|
||||
|
||||
test("viewer fits the desktop viewport without a vertical scroll", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name.startsWith("chromium-mobile"),
|
||||
"desktop-only height-fit check",
|
||||
);
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 720 });
|
||||
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-scene")).toBeVisible();
|
||||
|
||||
// Phase 27 refinement: viewer + log fit the viewport; the
|
||||
// internal log scrolls inside its own pane rather than
|
||||
// growing the page. Allow a small tolerance for fractional
|
||||
// pixel rounding around flex math, but reject any
|
||||
// scrollable overflow beyond a couple of pixels.
|
||||
// Phase 27 refinement: viewer + log fit the viewport; the
|
||||
// internal log scrolls inside its own pane rather than
|
||||
// growing the page. Allow a small tolerance for fractional
|
||||
// pixel rounding around flex math.
|
||||
const overflow = await page.evaluate(
|
||||
() => document.documentElement.scrollHeight - window.innerHeight,
|
||||
);
|
||||
expect(overflow).toBeLessThanOrEqual(4);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import { Builder } from "flatbuffers";
|
||||
|
||||
import { UUID } from "../../../src/proto/galaxy/fbs/common";
|
||||
import {
|
||||
BattleSummary,
|
||||
Bombing,
|
||||
LocalPlanet,
|
||||
OtherPlanet,
|
||||
@@ -108,6 +109,12 @@ export interface OtherShipClassFixture extends ShipClassFixture {
|
||||
mass?: number;
|
||||
}
|
||||
|
||||
export interface BattleSummaryFixture {
|
||||
id: string;
|
||||
planet: number;
|
||||
shots: number;
|
||||
}
|
||||
|
||||
export interface BombingFixture {
|
||||
planetNumber: number;
|
||||
planet: string;
|
||||
@@ -149,7 +156,7 @@ export interface ReportFixture {
|
||||
myVoteFor?: string;
|
||||
otherScience?: OtherScienceFixture[];
|
||||
otherShipClass?: OtherShipClassFixture[];
|
||||
battles?: string[];
|
||||
battles?: BattleSummaryFixture[];
|
||||
bombings?: BombingFixture[];
|
||||
shipProductions?: ShipProductionFixture[];
|
||||
}
|
||||
@@ -397,17 +404,22 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
|
||||
shipProductionOffsets.length === 0
|
||||
? null
|
||||
: Report.createShipProductionVector(builder, shipProductionOffsets);
|
||||
// `battle` is a struct vector (16 bytes per UUID, alignment 8), so
|
||||
// it uses the start/inline-write/end pattern rather than a typical
|
||||
// offset-list helper. Iterating in reverse matches the FlatBuffers
|
||||
// convention that the vector is built end-to-start.
|
||||
// Phase 27 — `battle` carries `BattleSummary` tables, each with
|
||||
// an inline `id:UUID` struct plus `planet` and `shots` slots.
|
||||
const battleVec = (() => {
|
||||
const ids = fixture.battles ?? [];
|
||||
if (ids.length === 0) return null;
|
||||
Report.startBattleVector(builder, ids.length);
|
||||
for (let i = ids.length - 1; i >= 0; i--) {
|
||||
const [hi, lo] = uuidToHiLo(ids[i]!);
|
||||
UUID.createUUID(builder, hi, lo);
|
||||
const summaries = fixture.battles ?? [];
|
||||
if (summaries.length === 0) return null;
|
||||
const offsets = summaries.map((s) => {
|
||||
const [hi, lo] = uuidToHiLo(s.id);
|
||||
BattleSummary.startBattleSummary(builder);
|
||||
BattleSummary.addId(builder, UUID.createUUID(builder, hi, lo));
|
||||
BattleSummary.addPlanet(builder, BigInt(s.planet));
|
||||
BattleSummary.addShots(builder, BigInt(s.shots));
|
||||
return BattleSummary.endBattleSummary(builder);
|
||||
});
|
||||
Report.startBattleVector(builder, offsets.length);
|
||||
for (let i = offsets.length - 1; i >= 0; i--) {
|
||||
builder.addOffset(offsets[i]);
|
||||
}
|
||||
return builder.endVector();
|
||||
})();
|
||||
|
||||
@@ -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: "Bajori", name: "Hawk", drive: 12, armament: 1, weapons: 4, shields: 2, cargo: 0, mass: 75 },
|
||||
],
|
||||
battles: [BATTLE_ID],
|
||||
battles: [{ id: BATTLE_ID, planet: 1, shots: 12 }],
|
||||
bombings: [
|
||||
{ planetNumber: 1, planet: "Earth", owner: "Earthlings", attacker: "Bajori", production: "Cruiser", industry: 500, population: 200, colonists: 12, capital: 30, material: 5, attackPower: 250, wiped: false },
|
||||
{ planetNumber: 99, planet: "DW-99", owner: "Earthlings", attacker: "Bajori", production: "Dron", industry: 0, population: 0, colonists: 0, capital: 0, material: 0, attackPower: 800, wiped: true },
|
||||
|
||||
@@ -76,18 +76,30 @@ describe("active-view stubs", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("battle stub stamps the battleId on the host element", () => {
|
||||
const ui = render(BattleView, { props: { battleId: "b-42" } });
|
||||
test("battle view stamps the battleId and shows the loading placeholder", () => {
|
||||
// Phase 27 replaces the Phase 10 stub with the Battle Viewer
|
||||
// wrapper. The latest layout iteration moved the back-
|
||||
// navigation buttons inside `BattleViewer` so they only mount
|
||||
// once the BattleReport finishes loading. The wrapper itself
|
||||
// always renders the `active-view-battle` host with the
|
||||
// `data-battle-id` stamp and a localized loading copy until
|
||||
// the fetcher resolves.
|
||||
const ui = render(BattleView, {
|
||||
props: { gameId: "synthetic-test", turn: 0, battleId: "b-42" },
|
||||
});
|
||||
const node = ui.getByTestId("active-view-battle");
|
||||
expect(node).toHaveAttribute("data-battle-id", "b-42");
|
||||
expect(node).toHaveTextContent("battle log");
|
||||
expect(ui.getByTestId("battle-loading")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("battle stub accepts an empty battleId for the list URL", () => {
|
||||
const ui = render(BattleView, { props: { battleId: "" } });
|
||||
test("battle view surfaces the not-found state for an empty battleId", () => {
|
||||
const ui = render(BattleView, {
|
||||
props: { gameId: "synthetic-test", turn: 0, battleId: "" },
|
||||
});
|
||||
expect(ui.getByTestId("active-view-battle")).toHaveAttribute(
|
||||
"data-battle-id",
|
||||
"",
|
||||
);
|
||||
expect(ui.getByTestId("battle-not-found")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
// every spec to enumerate the full GameReport surface.
|
||||
|
||||
import type {
|
||||
ReportBattle,
|
||||
ReportBombing,
|
||||
ReportIncomingShipGroup,
|
||||
ReportLocalFleet,
|
||||
@@ -36,6 +37,7 @@ export const EMPTY_SHIP_GROUPS: {
|
||||
players: ReportPlayer[];
|
||||
otherScience: ReportOtherScience[];
|
||||
otherShipClass: ReportOtherShipClass[];
|
||||
battles: ReportBattle[];
|
||||
battleIds: string[];
|
||||
bombings: ReportBombing[];
|
||||
shipProductions: ReportShipProduction[];
|
||||
@@ -53,6 +55,7 @@ export const EMPTY_SHIP_GROUPS: {
|
||||
players: [],
|
||||
otherScience: [],
|
||||
otherShipClass: [],
|
||||
battles: [],
|
||||
battleIds: [],
|
||||
bombings: [],
|
||||
shipProductions: [],
|
||||
|
||||
@@ -75,6 +75,7 @@ function makeReport(
|
||||
players: [],
|
||||
otherScience: [],
|
||||
otherShipClass: [],
|
||||
battles: [],
|
||||
battleIds: [],
|
||||
bombings: [],
|
||||
shipProductions: [],
|
||||
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
isSyntheticGameId,
|
||||
loadSyntheticReportFromJSON,
|
||||
} from "../src/api/synthetic-report";
|
||||
import type { BattleReport } from "../src/api/battle-fetch";
|
||||
import {
|
||||
lookupSyntheticBattle,
|
||||
resetSyntheticBattles,
|
||||
} from "../src/api/synthetic-battle";
|
||||
|
||||
function syntheticJSON(extra: Record<string, unknown> = {}): unknown {
|
||||
return {
|
||||
@@ -244,3 +249,55 @@ describe("getSyntheticReport", () => {
|
||||
expect(getSyntheticReport("synthetic-missing")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("envelope shape (v1)", () => {
|
||||
test("forwards battles to the synthetic-battle registry", () => {
|
||||
resetSyntheticBattles();
|
||||
const battle: BattleReport = {
|
||||
id: "11111111-1111-1111-1111-111111111111",
|
||||
planet: 17,
|
||||
planetName: "Castle",
|
||||
races: { "0": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" },
|
||||
ships: {
|
||||
"0": {
|
||||
race: "KnightErrants",
|
||||
className: "Drone",
|
||||
tech: { DRIVE: 1 },
|
||||
num: 1,
|
||||
numLeft: 0,
|
||||
loadType: "",
|
||||
loadQuantity: 0,
|
||||
inBattle: true,
|
||||
},
|
||||
},
|
||||
protocol: [],
|
||||
};
|
||||
const envelope = {
|
||||
version: 1,
|
||||
report: syntheticJSON(),
|
||||
battles: { [battle.id]: battle },
|
||||
};
|
||||
|
||||
const { gameId, report } = loadSyntheticReportFromJSON(envelope);
|
||||
expect(gameId.startsWith(SYNTHETIC_GAME_ID_PREFIX)).toBe(true);
|
||||
expect(report.turn).toBe(39);
|
||||
expect(lookupSyntheticBattle(battle.id)).toEqual(battle);
|
||||
});
|
||||
|
||||
test("missing battles field leaves the registry untouched", () => {
|
||||
resetSyntheticBattles();
|
||||
const envelope = {
|
||||
version: 1,
|
||||
report: syntheticJSON(),
|
||||
};
|
||||
loadSyntheticReportFromJSON(envelope);
|
||||
expect(lookupSyntheticBattle("any")).toBeNull();
|
||||
});
|
||||
|
||||
test("bare Report (no envelope) still loads — backward compat", () => {
|
||||
resetSyntheticBattles();
|
||||
const { report } = loadSyntheticReportFromJSON(syntheticJSON());
|
||||
expect(report.turn).toBe(39);
|
||||
expect(lookupSyntheticBattle("any")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user