ui/phase-27: battle viewer (radial scene, playback, map markers)

Engine wire change: Report.battle switched from []uuid.UUID to
[]BattleSummary{id, planet, shots} so the map can place battle
markers without N extra fetches. FBS schema + generated Go/TS
regenerated; transcoder + report controller updated; openapi
adds the BattleSummary schema with a freeze test.

Backend gateway forwards engine GET /api/v1/battle/:turn/:uuid as
/api/v1/user/games/{game_id}/battles/{turn}/{battle_id} (handler
plus engineclient.FetchBattle, contract test stub, openapi spec).

UI:
- BattleViewer (lib/battle-player/) is a logically isolated SVG
  radial scene that consumes a BattleReport prop. Planet at the
  centre, races on the outer ring at equal angular spacing, race
  clusters by (race, className) with <class>:<numLeft> labels;
  observer groups (inBattle: false) are not drawn; eliminated
  races drop out and survivors re-distribute on the next frame.
- Shot line per frame: red on destroyed, green otherwise; erased
  on the next frame. Playback controls: play/pause + step ± +
  rewind + 1x/2x/4x speed (400/200/100 ms per frame).
- Page wrapper (lib/active-view/battle.svelte) loads BattleReport
  via api/battle-fetch.ts; synthetic-gameId prefix routes to a
  fixture loader, otherwise REST through the gateway. Always-
  visible <ol> text protocol satisfies the accessibility ask.
- section-battles.svelte links every battle UUID into the viewer.
- map/battle-markers.ts: yellow X cross of 2 LinePrim through the
  corners of the planet's circumscribed square (stroke width
  clamps from 1 px at 1 shot to 5 px at 100+ shots); bombing
  marker is a stroke-only ring (yellow when damaged, red when
  wiped). Wired into state-binding.ts; click handler dispatches
  battle clicks to the viewer and bombing clicks to the matching
  Reports row.
- i18n keys for the viewer in en + ru.

Docs: ui/docs/battle-viewer-ux.md, FUNCTIONAL.md §6.5 + ru
mirror, ui/PLAN.md Phase 27 decisions + deferred TODOs (push
event, richer class visuals, animated re-distribution).

Tests: Vitest unit (radial layout + timeline frame builder +
marker stroke formula + marker primitives), Playwright e2e for
the viewer (Reports link → viewer, playback step, not-found),
backend engineclient FetchBattle (200 / 404 / bad input), engine
openapi freezes (BattleReport, BattleReportGroup,
BattleActionReport, BattleSummary, Report.battle items).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-13 12:24:20 +02:00
parent 4ffcac00d0
commit 969c0480ba
81 changed files with 2911 additions and 230 deletions
+123 -27
View File
@@ -2949,45 +2949,126 @@ Targeted tests:
Status: pending.
Goal: render battles as a dedicated view with playback controls
(play, pause, step forward, step backward, rewind), driven by the
server-side combat log; render battle and bombing markers on the map.
Goal: ship a dedicated Battle Viewer rendering radial scenes from
`BattleReport` data (planet centred, races on the outer ring, per
ship-class clusters, animated shot lines), plus battle and bombing
markers on the map. Battles and bombings stay strictly separate —
bombings remain a static table in the Reports view, only battles
get the animated viewer.
Artifacts:
- `ui/frontend/src/map/battle-markers.ts` renders markers on the map
for current-turn battles and bombings within visibility, clickable
to open the battle viewer
- `ui/frontend/src/routes/games/[id]/battle/[battleId]/+page.svelte`
view with the combatant list, the round-by-round log, and a player
control bar
- `ui/frontend/src/lib/battle-player/` round timeline, current-round
highlight, per-shot animation
- entry points to the viewer: marker on map, row in the report's
battles section, push-event toast when a battle this turn involved
the player
- topic doc `ui/docs/battle-viewer-ux.md` covering playback
semantics, accessibility (the combat log must be readable as text
for users who skip animations)
- engine: `game/internal/router/handler/battle.go` for
`GET /api/v1/battle/:turn/:uuid` (handler pre-existed; Phase 27
added the tests + openapi schemas)
- engine wire: `pkg/model/report/battle.go` ships a new
`BattleSummary{id, planet, shots}`; `Report.battle` carries a
slice of these summaries so the map can place markers without
fetching every full report
- backend: `backend/internal/engineclient/client.go.FetchBattle`
and `backend/internal/server/handlers_user_games.go.Battle`
expose `GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`
- UI viewer: `ui/frontend/src/lib/battle-player/`
(`radial-layout.ts`, `timeline.ts`, `battle-scene.svelte`,
`playback-controls.svelte`, `battle-viewer.svelte`); SVG-based,
one frame per protocol entry, full controls (play/pause + step
back + step forward + rewind + 1x/2x/4x speed switch)
- UI route + page wrapper:
`ui/frontend/src/routes/games/[id]/battle/[[battleId]]/+page.svelte`
feeds `gameId` / `turn` / `battleId` into
`ui/frontend/src/lib/active-view/battle.svelte`, which loads the
report via `api/battle-fetch.ts` (synthetic-fixture path + real
engine fetch through the backend gateway)
- UI report link: `lib/active-view/report/section-battles.svelte`
now links every battle UUID into
`/games/{id}/battle/{uuid}?turn={turn}`
- UI map markers: `ui/frontend/src/map/battle-markers.ts` emits a
yellow X cross per battle (two `LinePrim` through the planet's
bounding-square diagonals; stroke width scales 1px..5px with
protocol length) plus a stroke-only ring per bombing (yellow when
damaged, red when wiped). Wired into `state-binding.ts`; the map
click handler dispatches battle clicks to the viewer and bombing
clicks to a scroll-into-view of the matching row in Reports.
- topic doc `ui/docs/battle-viewer-ux.md` covers playback
semantics, accessibility (the always-visible `<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.)
+136
View File
@@ -0,0 +1,136 @@
# Battle Viewer UX
Phase 27 ships a dedicated viewer for battles (`/games/<id>/battle/<battleId>`).
Bombings stay where they were in Phase 23 — a static table in the
Reports view (`section-bombings.svelte`). The two domains are
deliberately not mixed in any visual surface or click target.
## Data shape
The `BattleViewer` component (`lib/battle-player/battle-viewer.svelte`)
is logically isolated. It accepts a `BattleReport` matching
`pkg/model/report/battle.go`. The fields it uses:
- `id`, `planet`, `planetName` — header + the central-planet glyph.
- `races: { [raceId]: raceUUID }` — race index space used by the
protocol's `a` / `d` fields.
- `ships: { [groupKey]: BattleReportGroup }` — ship-group rosters
with `race` name, `className`, initial `num`, end-state `numLeft`,
and the `inBattle` flag. Observer groups (`inBattle: false`) are
never drawn.
- `protocol: BattleActionReport[]` — flat list of shots. Each carries
attacker `(a, sa)`, defender `(d, sd)`, and `x` (destroyed?).
The component asks `timeline.ts.buildFrames(report)` to expand the
protocol into `protocol.length + 1` frames; frame 0 is the initial
state and frame `N` reflects state after action `protocol[N-1]`. The
race index per ship group is derived from the protocol itself —
every in-battle group appears at least once as attacker or defender,
and the engine never crosses these wires.
## Radial scene
The scene (`lib/battle-player/battle-scene.svelte`, SVG) places the
planet at the centre and arrays the still-active races on an outer
ring at equal angular spacing. Each race anchor is a horizontal
cluster of small class circles, one per `(race, className)` pair,
labelled `<className>:<numLeft>` underneath. When a race is wiped
out, it drops out of the active list and the survivors are
re-spaced on the next frame.
The current frame's shot is drawn as a thin line from the attacker's
class circle to the defender's class circle. Colour:
- red (`#ee3344`) when the action's `x === true` (the defender
ship was destroyed),
- green (`#44dd66`) otherwise.
Each frame redraws the line in isolation, so continuous playback
produces the "shot-shot-shot" pulse the user wanted.
## Playback controls
`lib/battle-player/playback-controls.svelte` ships the full set:
| Control | Effect |
| ------------- | ------------------------------------------ |
| ⏮ rewind | Stop, jump to frame 0 |
| ◀︎ step back | Stop, frame ← frame 1 |
| ▶︎ / ⏸ play | Toggle continuous playback |
| ▶︎▶︎ step fwd | Stop, frame ← frame + 1 |
| 1x / 2x / 4x | Speed switch: 400 / 200 / 100 ms per frame |
When the timeline is at its end and the user hits play, the frame
counter wraps to 0 and continues. Step buttons disable themselves at
their boundary.
## Accessibility
Below the scene the viewer renders a static `<ol>` text protocol —
one line per action, formatted from `BattleReportGroup.race` and
`BattleReportGroup.className`. The line for the current frame is
highlighted so a non-visual reader can follow along by scrolling
the log instead of watching the SVG. The list is always present
and never hidden, satisfying the original Phase 27 acceptance "the
same data is accessible as a static text log".
## Map markers
`map/battle-markers.ts` emits two marker kinds per
current-turn report. Both are wired into the binding's
`hitLookup` so a click goes through the existing hit-test plumbing.
### Battle marker — yellow cross
For every `report.battles[i]` whose `planet` resolves to a visible
planet, the marker emits two `LinePrim` lines through the opposite
corners of the square circumscribed around the planet circle. The
result is an X-shaped cross overlaid on the planet glyph.
The stroke width is computed by `battleMarkerStrokeWidth(shots)`:
1 shot → 1 px, 100 shots → 5 px, linearly interpolated in between
(`width = 1 + (shots 1) × 4 / 99`, clamped). A click on either
line navigates to `/games/<id>/battle/<battleId>?turn=<turn>`.
### Bombing marker — colored ring
For every `report.bombings[i]`, the marker emits a single
stroke-only `CirclePrim` slightly larger than the planet circle.
Colour:
- yellow (`#FFD400`) when `wiped: false`,
- red (`#FF3030`) when `wiped: true`.
A click on the ring navigates to `/games/<id>/report#report-bombings`
and scrolls the matching `report-bombing-row` (by `data-planet`)
into view. Bombing markers never open the Battle Viewer — the two
domains stay separate.
## Data source
The Battle Viewer page (`lib/active-view/battle.svelte`) calls
`api/battle-fetch.ts.fetchBattle(gameId, turn, battleId)`. The
loader has two modes:
- **Synthetic** — when `gameId` carries the
`synthetic-` prefix, the lookup is served from
`api/synthetic-battle.ts`. Vitest unit tests and Playwright e2e
tests register fixture battles via `registerSyntheticBattle`
before mounting the route.
- **Production** — otherwise the loader issues
`GET /api/v1/user/games/{gameId}/battles/{turn}/{battleId}`
against the backend gateway route added in
`backend/internal/server/handlers_user_games.go.Battle`. The
gateway forwards verbatim to the engine's
`GET /api/v1/battle/:turn/:uuid`.
## TODOs
- Push event `game.battle.new` + toast → viewer link (deferred —
needs an engine emit-side change).
- Richer ship-class visuals derived from the class's mass,
armament, shields. Current MVP uses a small circle plus
`<class>:<numLeft>` label.
- Animated transitions when a race drops out and the survivors
re-distribute. Current implementation hard-jumps on the next
frame.
+88
View File
@@ -0,0 +1,88 @@
// Battle-report fetcher used by the Battle Viewer page.
//
// Phase 27 ships the BattleViewer as a logically isolated component
// that accepts a `BattleReport` matching `pkg/model/report/battle.go`.
// This module owns the type mirror and a single `fetchBattle` entry
// point. In synthetic mode (development & e2e fixtures), the loader
// falls back to a local fixture so the UI tests don't depend on a
// running engine; otherwise it issues a real `GET` against the
// backend gateway route added in Phase 27 step 3.
import { isSyntheticGameId } from "./synthetic-report";
import { lookupSyntheticBattle } from "./synthetic-battle";
/**
* BattleReport is the wire shape returned by the engine endpoint
* `GET /api/v1/battle/:turn/:uuid` and forwarded by the backend
* gateway as `GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`.
* Fields mirror `pkg/model/report/battle.go`.
*/
export interface BattleReport {
id: string;
planet: number;
planetName: string;
races: Record<string, string>;
ships: Record<string, BattleReportGroup>;
protocol: BattleActionReport[];
}
export interface BattleReportGroup {
race: string;
className: string;
tech: Record<string, number>;
num: number;
numLeft: number;
loadType: string;
loadQuantity: number;
inBattle: boolean;
}
export interface BattleActionReport {
a: number;
sa: number;
d: number;
sd: number;
x: boolean;
}
export class BattleFetchError extends Error {
constructor(public readonly status: number, message: string) {
super(message);
this.name = "BattleFetchError";
}
}
/**
* fetchBattle returns the `BattleReport` for the supplied game, turn,
* and battle id. In synthetic-report mode (DEV / e2e) the lookup is
* served from `synthetic-battle.ts`; otherwise the function calls the
* backend gateway route. Throws `BattleFetchError` with the upstream
* status on validation or transport failure.
*/
export async function fetchBattle(
gameId: string,
turn: number,
battleId: string,
): Promise<BattleReport> {
if (isSyntheticGameId(gameId)) {
const fixture = lookupSyntheticBattle(battleId);
if (fixture === null) {
throw new BattleFetchError(404, "battle not found");
}
return fixture;
}
const path = `/api/v1/user/games/${encodeURIComponent(gameId)}/battles/${turn}/${encodeURIComponent(battleId)}`;
const response = await fetch(path, {
headers: { Accept: "application/json" },
});
if (response.status === 404) {
throw new BattleFetchError(404, "battle not found");
}
if (!response.ok) {
throw new BattleFetchError(
response.status,
`battle fetch failed: ${response.status}`,
);
}
return (await response.json()) as BattleReport;
}
+38 -12
View File
@@ -382,6 +382,18 @@ export interface ReportBombing {
* mirrors the producing planet's free industry. Stable order: sorted
* 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 ?? [],
+37
View File
@@ -0,0 +1,37 @@
// Synthetic battle reports for DEV / e2e mode.
//
// Mirrors the shape of `pkg/model/report/battle.go` so the
// BattleViewer can be exercised without a running engine. Fixtures
// are registered by battle UUID; the synthetic-report loader fills
// the report's `battles[]` with these same UUIDs so the report ↔
// battle link is consistent.
import type { BattleReport } from "./battle-fetch";
const SYNTHETIC_BATTLES = new Map<string, BattleReport>();
/**
* registerSyntheticBattle adds a fixture battle to the in-memory map
* keyed by its `id`. Used by the synthetic-report DEV loader and by
* Vitest unit tests that need a deterministic BattleReport without a
* live engine.
*/
export function registerSyntheticBattle(report: BattleReport): void {
SYNTHETIC_BATTLES.set(report.id, report);
}
/**
* lookupSyntheticBattle returns the fixture stored under `battleId`,
* or `null` if nothing was registered (mirrors the engine's 404).
*/
export function lookupSyntheticBattle(battleId: string): BattleReport | null {
return SYNTHETIC_BATTLES.get(battleId) ?? null;
}
/**
* resetSyntheticBattles clears every registered fixture. Test
* harnesses call this between cases to avoid bleed-through.
*/
export function resetSyntheticBattles(): void {
SYNTHETIC_BATTLES.clear();
}
+19 -4
View File
@@ -173,6 +173,12 @@ interface SyntheticOtherShipClass extends SyntheticShipClass {
mass?: number;
}
interface SyntheticBattle {
id?: string;
planet?: number;
shots?: number;
}
interface SyntheticBombing {
planet?: number; // wire field "number"
planetName?: string; // wire field "planetName"
@@ -219,7 +225,7 @@ interface SyntheticReportRoot {
incomingGroup?: SyntheticIncomingGroup[];
unidentifiedGroup?: SyntheticUnidentifiedGroup[];
localFleet?: SyntheticLocalFleet[];
battle?: string[];
battle?: SyntheticBattle[];
bombing?: SyntheticBombing[];
shipProduction?: SyntheticShipProductionRow[];
}
@@ -357,9 +363,17 @@ function decodeSyntheticReport(json: unknown): GameReport {
return a.name.localeCompare(b.name);
});
const battleIds: string[] = (root.battle ?? []).filter(
(v): v is string => typeof v === "string" && v !== "",
);
const battles = (root.battle ?? [])
.filter(
(v): v is SyntheticBattle =>
typeof v === "object" && v !== null && typeof v.id === "string" && v.id !== "",
)
.map((b) => ({
id: b.id as string,
planet: numOr0(b.planet),
shots: numOr0(b.shots),
}));
const battleIds = battles.map((b) => b.id);
const bombings: ReportBombing[] = (root.bombing ?? []).map((b) => ({
planetNumber: numOr0(b.planet),
@@ -419,6 +433,7 @@ function decodeSyntheticReport(json: unknown): GameReport {
players: collectPlayersFromSynthetic(root, race),
otherScience,
otherShipClass,
battles,
battleIds,
bombings,
shipProductions,
+117 -13
View File
@@ -1,30 +1,134 @@
<!--
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. The viewer itself is a logically isolated
component that takes a `BattleReport` prop — this wrapper owns
loading and routing concerns.
-->
<script lang="ts">
import { goto } from "$app/navigation";
import {
BattleFetchError,
fetchBattle,
type BattleReport,
} from "../../api/battle-fetch";
import { i18n } from "$lib/i18n/index.svelte";
type Props = { battleId: string };
let { battleId }: Props = $props();
import BattleViewer from "../battle-player/battle-viewer.svelte";
let {
gameId,
turn,
battleId,
}: {
gameId: string;
turn: number;
battleId: string;
} = $props();
let state = $state<
| { kind: "loading" }
| { kind: "ready"; report: BattleReport }
| { kind: "not_found" }
| { kind: "error"; message: string }
>({ kind: "loading" });
$effect(() => {
if (!battleId) {
state = { kind: "not_found" };
return;
}
state = { kind: "loading" };
fetchBattle(gameId, turn, battleId)
.then((report) => {
state = { kind: "ready", report };
})
.catch((err: unknown) => {
if (err instanceof BattleFetchError && err.status === 404) {
state = { kind: "not_found" };
} else {
state = {
kind: "error",
message: err instanceof Error ? err.message : String(err),
};
}
});
});
function backToReport() {
goto(`/games/${gameId}/report`);
}
function backToMap() {
goto(`/games/${gameId}/map`);
}
</script>
<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>
<nav class="back-row">
<button
type="button"
class="back-btn"
onclick={backToMap}
data-testid="battle-back-to-map"
>{i18n.t("game.battle.back_to_map")}</button>
<button
type="button"
class="back-btn"
onclick={backToReport}
data-testid="battle-back-to-report"
>{i18n.t("game.battle.back_to_report")}</button>
</nav>
{#if state.kind === "loading"}
<p class="status" data-testid="battle-loading">
{i18n.t("game.battle.loading")}
</p>
{:else if state.kind === "ready"}
<BattleViewer report={state.report} />
{:else if state.kind === "not_found"}
<p class="status" data-testid="battle-not-found">
{i18n.t("game.battle.not_found")}
</p>
{:else}
<p class="status error" data-testid="battle-error">{state.message}</p>
{/if}
</section>
<style>
.active-view {
padding: 1.5rem;
padding: 1rem;
font-family: system-ui, sans-serif;
color: #d6dcf2;
}
.active-view h2 {
margin: 0 0 0.5rem;
font-size: 1.1rem;
.back-row {
display: flex;
gap: 0.5rem;
max-width: 880px;
margin: 0 auto 1rem;
}
.active-view p {
margin: 0;
color: #555;
.back-btn {
appearance: none;
background: #1f2748;
color: #d6dcf2;
border: 1px solid #2c3568;
padding: 0.35rem 0.7rem;
border-radius: 3px;
cursor: pointer;
font-size: 0.85rem;
}
.back-btn:hover {
background: #2a3463;
}
.status {
margin: 2rem auto;
max-width: 880px;
color: #93a0d0;
font-size: 0.95rem;
text-align: center;
}
.status.error {
color: #e08585;
}
</style>
+34 -5
View File
@@ -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") {
selection.selectPlanet(target.number);
} else {
selection.selectShipGroup(target.ref);
switch (target.kind) {
case "planet":
if (hit.primitive.kind !== "point") return;
selection.selectPlanet(target.number);
break;
case "shipGroup":
if (hit.primitive.kind !== "point") return;
selection.selectShipGroup(target.ref);
break;
case "battle": {
const gameId = page.params.id ?? "";
const turn = store?.report?.turn ?? 0;
void goto(
`/games/${gameId}/battle/${target.battleId}?turn=${turn}`,
);
break;
}
case "bombing": {
const gameId = page.params.id ?? "";
void goto(
`/games/${gameId}/report#report-bombings`,
).then(() => {
if (typeof document === "undefined") return;
const row = document.querySelector(
`[data-testid="report-bombing-row"][data-planet="${target.planet}"]`,
);
if (row && row.scrollIntoView) {
row.scrollIntoView({ behavior: "smooth", block: "center" });
}
});
break;
}
}
}
@@ -1,13 +1,14 @@
<!--
Phase 23 Report View — battles section. The wire only carries
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,223 @@
<!--
BattleScene — radial SVG visualisation of one battle frame.
Layout: planet at the centre, race anchors equally spaced on an
outer ring, each race rendered as a cluster of small class circles
labelled `<className>:<numLeft>` underneath. The shot line for the
current frame's `lastAction` is drawn from attacker group to
defender group; red when the shot destroyed the defender, green
otherwise. Observer groups (`inBattle === false`) are filtered out
by `buildFrames`, so they never appear here.
-->
<script lang="ts">
import type { BattleReport } from "../../api/battle-fetch";
import { layoutRaces } from "./radial-layout";
import {
buildGroupRaceMap,
normaliseGroups,
type Frame,
} from "./timeline";
let {
report,
frame,
}: {
report: BattleReport;
frame: Frame;
} = $props();
const VIEW_BOX = 800;
const CENTER = { x: VIEW_BOX / 2, y: VIEW_BOX / 2 };
const PLANET_RADIUS = 60;
const RACE_RING_RADIUS = 280;
const CLASS_CIRCLE_RADIUS = 24;
const CLASS_SPACING = 64;
const groupRace = $derived(buildGroupRaceMap(report.protocol));
const allGroups = $derived(normaliseGroups(report));
type ClusterEntry = {
key: number;
className: string;
numLeft: number;
};
const clustersByRace = $derived.by(() => {
const out = new Map<number, ClusterEntry[]>();
for (const g of allGroups) {
const numLeft = frame.remaining.get(g.key) ?? 0;
const list = out.get(g.raceId) ?? [];
list.push({
key: g.key,
className: g.group.className,
numLeft,
});
out.set(g.raceId, list);
}
// Stable cluster order: by classname then key.
for (const list of out.values()) {
list.sort((a, b) => {
const byName = a.className.localeCompare(b.className);
if (byName !== 0) return byName;
return a.key - b.key;
});
}
return out;
});
const raceLayout = $derived(
layoutRaces(frame.activeRaceIds, {
center: CENTER,
radius: RACE_RING_RADIUS,
}),
);
function classCircleX(index: number, count: number): number {
const span = (count - 1) * CLASS_SPACING;
return -span / 2 + index * CLASS_SPACING;
}
function findClassCircleCenter(groupKey: number) {
const raceId = groupRace.get(groupKey);
if (raceId === undefined) return null;
const anchor = raceLayout.find((a) => a.raceId === raceId);
if (!anchor) return null;
const cluster = clustersByRace.get(raceId) ?? [];
const idx = cluster.findIndex((c) => c.key === groupKey);
if (idx === -1) return null;
return {
x: anchor.x + classCircleX(idx, cluster.length),
y: anchor.y,
};
}
const shotLine = $derived.by(() => {
const action = frame.lastAction;
if (!action) return null;
const from = findClassCircleCenter(action.sa);
const to = findClassCircleCenter(action.sd);
if (!from || !to) return null;
return { from, to, destroyed: action.x };
});
const raceLabelById = $derived.by(() => {
const out = new Map<number, string>();
for (const g of allGroups) {
out.set(g.raceId, g.group.race);
}
return out;
});
</script>
<svg
class="battle-scene"
viewBox="0 0 {VIEW_BOX} {VIEW_BOX}"
role="img"
aria-label="battle scene"
data-testid="battle-scene"
>
<circle
cx={CENTER.x}
cy={CENTER.y}
r={PLANET_RADIUS}
class="planet"
data-testid="battle-scene-planet"
/>
<text
x={CENTER.x}
y={CENTER.y + PLANET_RADIUS + 24}
text-anchor="middle"
class="planet-label"
>{report.planetName} (#{report.planet})</text>
{#each raceLayout as anchor (anchor.raceId)}
{@const cluster = clustersByRace.get(anchor.raceId) ?? []}
<g
class="race-cluster"
data-testid="battle-race-cluster"
data-race-id={anchor.raceId}
>
<text
x={anchor.x}
y={anchor.y - CLASS_CIRCLE_RADIUS - 12}
text-anchor="middle"
class="race-label"
>{raceLabelById.get(anchor.raceId) ?? `race ${anchor.raceId}`}</text>
{#each cluster as entry, i (entry.key)}
{@const cx = anchor.x + classCircleX(i, cluster.length)}
<g
class="class-marker"
data-testid="battle-class-marker"
data-group-key={entry.key}
>
<circle
cx={cx}
cy={anchor.y}
r={CLASS_CIRCLE_RADIUS}
/>
<text
x={cx}
y={anchor.y + CLASS_CIRCLE_RADIUS + 16}
text-anchor="middle"
class="class-label"
>{entry.className}:{entry.numLeft}</text>
</g>
{/each}
</g>
{/each}
{#if shotLine}
<line
x1={shotLine.from.x}
y1={shotLine.from.y}
x2={shotLine.to.x}
y2={shotLine.to.y}
class="shot"
class:destroyed={shotLine.destroyed}
data-testid="battle-shot"
data-destroyed={shotLine.destroyed ? "true" : "false"}
/>
{/if}
</svg>
<style>
.battle-scene {
width: 100%;
height: auto;
background: #0a0d1a;
display: block;
}
.planet {
fill: #2a345f;
stroke: #5b6aa3;
stroke-width: 2;
}
.planet-label {
fill: #c4caea;
font-size: 18px;
font-family: ui-sans-serif, system-ui, sans-serif;
}
.race-label {
fill: #e2e6ff;
font-size: 16px;
font-weight: 600;
font-family: ui-sans-serif, system-ui, sans-serif;
}
.class-marker circle {
fill: #1a2042;
stroke: #6d7bb5;
stroke-width: 1.5;
}
.class-label {
fill: #b8c0e6;
font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.shot {
stroke: #44dd66;
stroke-width: 2;
}
.shot.destroyed {
stroke: #ee3344;
}
</style>
@@ -0,0 +1,167 @@
<!--
BattleViewer — orchestrates the radial battle scene, the playback
controls, and the accessibility text log for one BattleReport. Owns
the playback state (`frameIndex`, `playing`, `speed`). The component
is logically isolated: feed it any `BattleReport` matching
`pkg/model/report/battle.go` and it plays back.
-->
<script lang="ts">
import { onDestroy } from "svelte";
import { i18n } from "$lib/i18n/index.svelte";
import type { BattleReport } from "../../api/battle-fetch";
import BattleScene from "./battle-scene.svelte";
import PlaybackControls from "./playback-controls.svelte";
import { buildFrames } from "./timeline";
let { report }: { report: BattleReport } = $props();
const frames = $derived(buildFrames(report));
let frameIndex = $state(0);
let playing = $state(false);
let speed = $state<1 | 2 | 4>(1);
const frame = $derived(frames[Math.min(frameIndex, frames.length - 1)]);
// 1x = 400 ms per frame, 2x = 200 ms, 4x = 100 ms. The timer is
// rescheduled whenever `speed` or `playing` flips.
let timer: ReturnType<typeof setInterval> | null = null;
$effect(() => {
if (timer !== null) {
clearInterval(timer);
timer = null;
}
if (!playing) return;
const intervalMs = 400 / speed;
timer = setInterval(() => {
if (frameIndex >= frames.length - 1) {
playing = false;
return;
}
frameIndex = frameIndex + 1;
}, intervalMs);
});
onDestroy(() => {
if (timer !== null) {
clearInterval(timer);
timer = null;
}
});
function describeAction(index: number): string {
const action = report.protocol[index];
const attackerGroup = report.ships[String(action.sa)];
const defenderGroup = report.ships[String(action.sd)];
const attackerRace = attackerGroup?.race ?? `race ${action.a}`;
const attackerClass = attackerGroup?.className ?? `class ${action.sa}`;
const defenderRace = defenderGroup?.race ?? `race ${action.d}`;
const defenderClass = defenderGroup?.className ?? `class ${action.sd}`;
const key = action.x
? "game.battle.log.destroyed"
: "game.battle.log.shielded";
return i18n.t(key, {
attacker_race: attackerRace,
attacker_class: attackerClass,
defender_race: defenderRace,
defender_class: defenderClass,
});
}
</script>
<div class="viewer" data-testid="battle-viewer">
<header class="header">
<h2 data-testid="battle-viewer-title">
{i18n.t("game.battle.title")}
</h2>
<span class="progress" data-testid="battle-frame-index">
{frame.shotIndex} / {report.protocol.length}
</span>
</header>
<div class="scene">
<BattleScene {report} {frame} />
</div>
<PlaybackControls
bind:playing
bind:frameIndex
bind:speed
frameCount={frames.length}
/>
<section
class="log"
aria-label={i18n.t("game.battle.accessibility.protocol_heading")}
>
<h3>{i18n.t("game.battle.accessibility.protocol_heading")}</h3>
<ol data-testid="battle-protocol-log">
{#each report.protocol as _action, i (i)}
<li
data-testid="battle-protocol-log-item"
data-current={i + 1 === frame.shotIndex ? "true" : "false"}
>{describeAction(i)}</li>
{/each}
</ol>
</section>
</div>
<style>
.viewer {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 880px;
margin: 0 auto;
padding: 1rem;
color: #d6dcf2;
font-family: inherit;
}
.header {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.header h2 {
margin: 0;
font-size: 1.1rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.progress {
color: #93a0d0;
font-variant-numeric: tabular-nums;
font-size: 0.9rem;
}
.scene {
background: #0a0d1a;
border: 1px solid #1e264a;
border-radius: 4px;
overflow: hidden;
}
.log h3 {
margin: 0 0 0.4rem;
color: #93a0d0;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.log ol {
list-style: decimal inside;
margin: 0;
padding: 0;
font-size: 0.85rem;
max-height: 14rem;
overflow-y: auto;
color: #c6cdf0;
}
.log li {
padding: 0.15rem 0;
border-bottom: 1px solid #1c2240;
}
.log li[data-current="true"] {
color: #ffe27a;
font-weight: 600;
}
</style>
@@ -0,0 +1,145 @@
<!--
PlaybackControls — rewind / step-back / play-pause / step-forward
plus a 1x/2x/4x speed switch. Owns no playback state; bind `playing`,
`frameIndex`, and `speed` from the orchestrator. Disables step/rewind
when there's nowhere to go and disables forward when the timeline is
already at its end.
-->
<script lang="ts">
import { i18n } from "$lib/i18n/index.svelte";
let {
playing = $bindable(),
frameIndex = $bindable(),
speed = $bindable(),
frameCount,
}: {
playing: boolean;
frameIndex: number;
speed: 1 | 2 | 4;
frameCount: number;
} = $props();
function rewind() {
playing = false;
frameIndex = 0;
}
function stepBack() {
playing = false;
if (frameIndex > 0) frameIndex = frameIndex - 1;
}
function togglePlay() {
if (frameIndex >= frameCount - 1) {
frameIndex = 0;
}
playing = !playing;
}
function stepForward() {
playing = false;
if (frameIndex < frameCount - 1) frameIndex = frameIndex + 1;
}
function setSpeed(value: 1 | 2 | 4) {
speed = value;
}
</script>
<div class="controls" data-testid="battle-controls">
<button
type="button"
onclick={rewind}
disabled={frameIndex === 0}
aria-label={i18n.t("game.battle.controls.rewind")}
data-testid="battle-control-rewind"
></button>
<button
type="button"
onclick={stepBack}
disabled={frameIndex === 0}
aria-label={i18n.t("game.battle.controls.step_backward")}
data-testid="battle-control-step-back"
>◀︎</button>
<button
type="button"
onclick={togglePlay}
aria-label={playing
? i18n.t("game.battle.controls.pause")
: i18n.t("game.battle.controls.play")}
data-testid="battle-control-play"
data-playing={playing ? "true" : "false"}
>{playing ? "⏸" : "▶︎"}</button>
<button
type="button"
onclick={stepForward}
disabled={frameIndex >= frameCount - 1}
aria-label={i18n.t("game.battle.controls.step_forward")}
data-testid="battle-control-step-forward"
>▶︎▶︎</button>
<div class="spacer" aria-hidden="true"></div>
<span class="speed-label">{i18n.t("game.battle.controls.speed_label")}</span>
<button
type="button"
class:active={speed === 1}
onclick={() => setSpeed(1)}
data-testid="battle-control-speed-1x"
>{i18n.t("game.battle.controls.speed_1x")}</button>
<button
type="button"
class:active={speed === 2}
onclick={() => setSpeed(2)}
data-testid="battle-control-speed-2x"
>{i18n.t("game.battle.controls.speed_2x")}</button>
<button
type="button"
class:active={speed === 4}
onclick={() => setSpeed(4)}
data-testid="battle-control-speed-4x"
>{i18n.t("game.battle.controls.speed_4x")}</button>
</div>
<style>
.controls {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.75rem;
background: #131934;
border: 1px solid #1e264a;
border-radius: 4px;
}
.spacer {
flex: 1 1 auto;
}
button {
appearance: none;
background: #1f2748;
color: #d6dcf2;
border: 1px solid #2c3568;
padding: 0.35rem 0.7rem;
border-radius: 3px;
cursor: pointer;
font-size: 0.9rem;
font-family: inherit;
min-width: 2.5rem;
}
button:hover:not(:disabled) {
background: #2a3463;
}
button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
button.active {
background: #3a4585;
border-color: #5d6cb8;
color: #ffffff;
}
.speed-label {
color: #93a0d0;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-right: 0.2rem;
}
</style>
@@ -0,0 +1,50 @@
// Radial layout for the BattleViewer.
//
// Places race anchors on a circle of radius `radius` around `center`
// at equal angular spacing. The first anchor sits at the top (12
// o'clock); subsequent anchors march clockwise. When a race is
// eliminated mid-battle, the caller filters it out of `activeRaceIds`
// and the survivors are re-spaced on the next frame. The same helper
// drives both the initial layout and that re-distribution.
export interface RaceAnchor {
raceId: number;
x: number;
y: number;
/** Angle in radians measured from the positive Y axis clockwise. */
angle: number;
}
export interface RadialLayoutOptions {
center: { x: number; y: number };
radius: number;
}
/**
* layoutRaces returns anchor positions for each `activeRaceIds`
* entry, placed at equal angular spacing on a circle. The input
* order is preserved so consumers get a stable mapping across
* frames; eliminated entries should simply be filtered out before
* the call.
*/
export function layoutRaces(
activeRaceIds: number[],
options: RadialLayoutOptions,
): RaceAnchor[] {
const count = activeRaceIds.length;
if (count === 0) return [];
const { center, radius } = options;
const out: RaceAnchor[] = [];
for (let i = 0; i < count; i++) {
// 12 o'clock = -PI/2 in math convention; clockwise → +i*step.
const step = (2 * Math.PI) / count;
const angle = -Math.PI / 2 + i * step;
out.push({
raceId: activeRaceIds[i],
x: center.x + radius * Math.cos(angle),
y: center.y + radius * Math.sin(angle),
angle,
});
}
return out;
}
@@ -0,0 +1,134 @@
// Timeline builder for the BattleViewer.
//
// Given a `BattleReport`, expands the flat `protocol` into a
// sequence of frames. Frame 0 carries the initial state; frame N
// (1 ≤ N ≤ protocol.length) reflects the state right after the
// (N-1)-th action has been applied. Each frame is self-contained so
// stepping forward and backward is a constant-time index lookup, no
// rewind logic needed.
import type {
BattleActionReport,
BattleReport,
BattleReportGroup,
} from "../../api/battle-fetch";
/**
* Frame is one tick of the battle playback. `remaining` carries the
* surviving ship count for each ship-group key from
* `BattleReport.ships`; `activeRaceIds` are the race indices with at
* least one surviving in-battle group. `lastAction` is the action
* applied to produce this frame, or `null` for the initial frame.
*/
export interface Frame {
shotIndex: number;
remaining: Map<number, number>;
activeRaceIds: number[];
lastAction: BattleActionReport | null;
}
export interface NormalisedGroup {
key: number;
group: BattleReportGroup;
raceId: number;
}
/**
* normaliseGroups returns the in-battle ship groups from a
* BattleReport indexed by their integer key. Observer groups
* (`inBattle === false`) are skipped because they are neither
* targeted nor drawn. The race index per group is derived from the
* protocol — every in-battle group appears at least once as
* attacker or defender, and the engine's pairing (a, sa) / (d, sd)
* defines the relationship.
*/
export function normaliseGroups(report: BattleReport): NormalisedGroup[] {
const raceByKey = buildGroupRaceMap(report.protocol);
const out: NormalisedGroup[] = [];
for (const [keyRaw, group] of Object.entries(report.ships)) {
if (!group.inBattle) continue;
const key = Number(keyRaw);
if (!Number.isFinite(key)) continue;
const raceId = raceByKey.get(key);
if (raceId === undefined) continue;
out.push({ key, group, raceId });
}
return out;
}
/**
* buildGroupRaceMap extracts the `ship-group key → race index`
* mapping from a battle protocol. Same key appearing twice always
* carries the same race index — protocol entries are emitted by the
* engine, which never crosses these wires.
*/
export function buildGroupRaceMap(
protocol: BattleActionReport[],
): Map<number, number> {
const out = new Map<number, number>();
for (const action of protocol) {
if (!out.has(action.sa)) out.set(action.sa, action.a);
if (!out.has(action.sd)) out.set(action.sd, action.d);
}
return out;
}
/**
* buildFrames walks the protocol once and emits a frame after each
* applied action plus the initial frame. The remaining-ships map is
* cloned per frame so callers can step backward without manual
* bookkeeping. Eliminated races drop out of `activeRaceIds` as soon
* as their last in-battle group hits zero.
*/
export function buildFrames(report: BattleReport): Frame[] {
const groups = normaliseGroups(report);
const initialRemaining = new Map<number, number>();
const raceTotals = new Map<number, number>();
for (const g of groups) {
initialRemaining.set(g.key, g.group.num);
raceTotals.set(g.raceId, (raceTotals.get(g.raceId) ?? 0) + g.group.num);
}
const frames: Frame[] = [];
frames.push({
shotIndex: 0,
remaining: new Map(initialRemaining),
activeRaceIds: collectActiveRaces(raceTotals),
lastAction: null,
});
const groupRaceByKey = new Map<number, number>();
for (const g of groups) groupRaceByKey.set(g.key, g.raceId);
const current = new Map(initialRemaining);
const runningRaceTotals = new Map(raceTotals);
for (let i = 0; i < report.protocol.length; i++) {
const action = report.protocol[i];
if (action.x) {
const left = current.get(action.sd) ?? 0;
const next = Math.max(0, left - 1);
current.set(action.sd, next);
const raceId = groupRaceByKey.get(action.sd);
if (raceId !== undefined) {
const t = (runningRaceTotals.get(raceId) ?? 0) - 1;
runningRaceTotals.set(raceId, Math.max(0, t));
}
}
frames.push({
shotIndex: i + 1,
remaining: new Map(current),
activeRaceIds: collectActiveRaces(runningRaceTotals),
lastAction: action,
});
}
return frames;
}
function collectActiveRaces(totals: Map<number, number>): number[] {
const out: number[] = [];
for (const [raceId, total] of totals.entries()) {
if (total > 0) out.push(raceId);
}
return out.sort((a, b) => a - b);
}
+17
View File
@@ -483,6 +483,23 @@ const en = {
"game.report.section.battles.title": "battles",
"game.report.section.battles.empty": "no battles last turn",
"game.report.section.battles.id_label": "battle",
"game.battle.title": "battle",
"game.battle.loading": "loading battle…",
"game.battle.not_found": "battle not found",
"game.battle.back_to_report": "back to report",
"game.battle.back_to_map": "back to map",
"game.battle.controls.play": "play",
"game.battle.controls.pause": "pause",
"game.battle.controls.step_forward": "step forward",
"game.battle.controls.step_backward": "step back",
"game.battle.controls.rewind": "rewind to start",
"game.battle.controls.speed_label": "speed",
"game.battle.controls.speed_1x": "1x",
"game.battle.controls.speed_2x": "2x",
"game.battle.controls.speed_4x": "4x",
"game.battle.log.destroyed": "{attacker_race}'s {attacker_class} destroyed {defender_race}'s {defender_class}",
"game.battle.log.shielded": "{attacker_race}'s {attacker_class} hit {defender_race}'s {defender_class}, shields held",
"game.battle.accessibility.protocol_heading": "battle log",
"game.report.section.bombings.title": "bombings",
"game.report.section.bombings.empty": "no bombings last turn",
"game.report.section.bombings.column.planet": "planet",
+17
View File
@@ -484,6 +484,23 @@ 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.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": "планета",
+168
View File
@@ -0,0 +1,168 @@
// Phase 27 battle and bombing markers on the map.
//
// Two visual markers per planet:
//
// * Battle marker — an X cross drawn through the corners of the
// square that circumscribes the planet circle. Two yellow
// LinePrim, stroke width scales linearly with the number of
// shots: 1 shot → 1px, 100+ shots → 5px (capped). Clicking
// either line opens the Battle Viewer for the corresponding
// UUID.
// * Bombing marker — a thin stroke-only circle slightly larger
// than the planet circle. Yellow on damaged planets, red on
// wiped planets. Clicking it deep-links to the bombings row in
// the Reports view for the planet number.
//
// Both markers are wired into `state-binding.ts` so they live in the
// same `world` / `hitLookup` plumbing as planets and ship groups.
import type { GameReport, ReportPlanet } from "../api/game-state";
import type {
CirclePrim,
LinePrim,
Primitive,
PrimitiveID,
Style,
} from "./world";
export const BATTLE_MARKER_COLOR = 0xffd400;
export const BOMBING_MARKER_COLOR_DAMAGED = 0xffd400;
export const BOMBING_MARKER_COLOR_WIPED = 0xff3030;
/** Battle and bombing marker primitive ids use a high-bit prefix to
* avoid colliding with planet numbers or cargo-route line ids. */
export const BATTLE_MARKER_ID_PREFIX = 0xa0000000;
export const BOMBING_MARKER_ID_PREFIX = 0xc0000000;
const PLANET_RADIUS_WORLD = 6;
const BOMBING_RING_RADIUS = PLANET_RADIUS_WORLD + 3;
const BATTLE_CROSS_HALF = PLANET_RADIUS_WORLD + 2;
/** Battle marker priority sits between planets (1..4) and cargo
* routes; the cross is over the planet but loses clicks against the
* planet glyph itself. */
const BATTLE_MARKER_PRIORITY = 9;
const BOMBING_MARKER_PRIORITY = 10;
const BATTLE_LINE_INDEX_A = 0;
const BATTLE_LINE_INDEX_B = 1;
export interface BattleMarkerTarget {
kind: "battle";
battleId: string;
planet: number;
}
export interface BombingMarkerTarget {
kind: "bombing";
planet: number;
}
export type MarkerTarget = BattleMarkerTarget | BombingMarkerTarget;
export interface BuildMarkersResult {
primitives: Primitive[];
lookup: Map<PrimitiveID, MarkerTarget>;
}
/**
* battleMarkerStrokeWidth maps a battle's `shots` count to a stroke
* width in pixels. 1 shot → 1 px (the thinnest visible), 100+ shots
* → 5 px (the cap). Linearly interpolated between those bounds.
*/
export function battleMarkerStrokeWidth(shots: number): number {
if (shots <= 1) return 1;
if (shots >= 100) return 5;
return 1 + ((shots - 1) * 4) / 99;
}
/**
* buildBattleAndBombingMarkers emits battle and bombing marker
* primitives plus a hit-lookup mapping for the current-turn report.
* Battles whose planet is not visible (e.g. observer-only without a
* report.planets entry) are skipped — they have no on-map location
* to anchor against.
*/
export function buildBattleAndBombingMarkers(
report: GameReport,
): BuildMarkersResult {
const planetByNumber = new Map<number, ReportPlanet>();
for (const planet of report.planets) {
planetByNumber.set(planet.number, planet);
}
const primitives: Primitive[] = [];
const lookup = new Map<PrimitiveID, MarkerTarget>();
for (let i = 0; i < report.battles.length; i++) {
const battle = report.battles[i];
const planet = planetByNumber.get(battle.planet);
if (planet === undefined) continue;
const strokeWidthPx = battleMarkerStrokeWidth(battle.shots);
const style: Style = {
strokeColor: BATTLE_MARKER_COLOR,
strokeAlpha: 0.95,
strokeWidthPx,
};
const baseId = BATTLE_MARKER_ID_PREFIX | (i << 4);
const lineA: LinePrim = {
kind: "line",
id: baseId | BATTLE_LINE_INDEX_A,
priority: BATTLE_MARKER_PRIORITY,
style,
hitSlopPx: 0,
x1: planet.x - BATTLE_CROSS_HALF,
y1: planet.y - BATTLE_CROSS_HALF,
x2: planet.x + BATTLE_CROSS_HALF,
y2: planet.y + BATTLE_CROSS_HALF,
};
const lineB: LinePrim = {
kind: "line",
id: baseId | BATTLE_LINE_INDEX_B,
priority: BATTLE_MARKER_PRIORITY,
style,
hitSlopPx: 0,
x1: planet.x - BATTLE_CROSS_HALF,
y1: planet.y + BATTLE_CROSS_HALF,
x2: planet.x + BATTLE_CROSS_HALF,
y2: planet.y - BATTLE_CROSS_HALF,
};
const target: BattleMarkerTarget = {
kind: "battle",
battleId: battle.id,
planet: battle.planet,
};
primitives.push(lineA, lineB);
lookup.set(lineA.id, target);
lookup.set(lineB.id, target);
}
for (let i = 0; i < report.bombings.length; i++) {
const bombing = report.bombings[i];
const planet = planetByNumber.get(bombing.planetNumber);
if (planet === undefined) continue;
const color = bombing.wiped
? BOMBING_MARKER_COLOR_WIPED
: BOMBING_MARKER_COLOR_DAMAGED;
const style: Style = {
strokeColor: color,
strokeAlpha: 0.9,
strokeWidthPx: 1.5,
};
const id = BOMBING_MARKER_ID_PREFIX | i;
const ring: CirclePrim = {
kind: "circle",
id,
priority: BOMBING_MARKER_PRIORITY,
style,
hitSlopPx: 0,
x: planet.x,
y: planet.y,
radius: BOMBING_RING_RADIUS,
};
primitives.push(ring);
lookup.set(id, { kind: "bombing", planet: bombing.planetNumber });
}
return { primitives, lookup };
}
+12 -1
View File
@@ -15,6 +15,7 @@
import type { GameReport, ReportPlanet } from "../api/game-state";
import type { 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> {
@@ -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 ?? ""}
/>
+190
View File
@@ -0,0 +1,190 @@
// Phase 27 unit tests for battle and bombing map markers.
import { describe, expect, it } from "vitest";
import type { GameReport } from "../src/api/game-state";
import {
battleMarkerStrokeWidth,
BATTLE_MARKER_COLOR,
BOMBING_MARKER_COLOR_DAMAGED,
BOMBING_MARKER_COLOR_WIPED,
buildBattleAndBombingMarkers,
} from "../src/map/battle-markers";
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
describe("battleMarkerStrokeWidth", () => {
it("clamps to 1 px at one shot", () => {
expect(battleMarkerStrokeWidth(1)).toBe(1);
});
it("clamps to 5 px at 100 shots", () => {
expect(battleMarkerStrokeWidth(100)).toBe(5);
});
it("caps above 100 shots at 5 px", () => {
expect(battleMarkerStrokeWidth(250)).toBe(5);
});
it("interpolates linearly between 1 and 100 shots", () => {
// ~halfway: 50 shots → 1 + 49 * 4 / 99 ≈ 2.98
expect(battleMarkerStrokeWidth(50)).toBeCloseTo(2.98, 2);
});
});
function makeReport(overrides: Partial<GameReport>): GameReport {
return {
turn: 1,
mapWidth: 200,
mapHeight: 200,
planetCount: 0,
race: "Earthlings",
planets: [],
localShipClass: [],
routes: [],
localPlayerDrive: 0,
localPlayerWeapons: 0,
localPlayerShields: 0,
localPlayerCargo: 0,
...EMPTY_SHIP_GROUPS,
...overrides,
};
}
describe("buildBattleAndBombingMarkers", () => {
it("returns no primitives when both battles and bombings are empty", () => {
const report = makeReport({});
const out = buildBattleAndBombingMarkers(report);
expect(out.primitives).toEqual([]);
expect(out.lookup.size).toBe(0);
});
it("emits two yellow lines through opposite corners of the planet square per battle", () => {
const report = makeReport({
planets: [
{
number: 4,
name: "Test",
kind: "local",
x: 10,
y: 20,
size: 50,
resources: 0,
industryStockpile: 0,
materialsStockpile: 0,
population: 0,
colonists: 0,
industry: 0,
freeIndustry: 0,
production: "MAT",
owner: null,
},
],
battles: [
{ id: "11111111-1111-1111-1111-111111111111", planet: 4, shots: 100 },
],
});
const out = buildBattleAndBombingMarkers(report);
const lines = out.primitives.filter((p) => p.kind === "line");
expect(lines).toHaveLength(2);
// Same yellow colour, 5 px wide for a 100-shot battle.
for (const l of lines) {
expect(l.style.strokeColor).toBe(BATTLE_MARKER_COLOR);
expect(l.style.strokeWidthPx).toBe(5);
}
// First line: top-left → bottom-right corner of the planet square.
const [a, b] = lines as Array<typeof lines[number] & { x1: number; y1: number; x2: number; y2: number }>;
expect(a.x1).toBeLessThan(a.x2);
expect(a.y1).toBeLessThan(a.y2);
// Second line: top-right → bottom-left.
expect(b.x1).toBeLessThan(b.x2);
expect(b.y1).toBeGreaterThan(b.y2);
});
it("skips battles whose planet is not in the planet list", () => {
const report = makeReport({
battles: [
{ id: "11111111-1111-1111-1111-111111111111", planet: 99, shots: 4 },
],
});
const out = buildBattleAndBombingMarkers(report);
expect(out.primitives).toHaveLength(0);
});
it("emits one yellow ring per damaged bombing and red per wiped", () => {
const report = makeReport({
planets: [
{
number: 1,
name: "A",
kind: "local",
x: 1,
y: 2,
size: 50,
resources: 0,
industryStockpile: 0,
materialsStockpile: 0,
population: 0,
colonists: 0,
industry: 0,
freeIndustry: 0,
production: "MAT",
owner: null,
},
{
number: 2,
name: "B",
kind: "local",
x: 5,
y: 6,
size: 50,
resources: 0,
industryStockpile: 0,
materialsStockpile: 0,
population: 0,
colonists: 0,
industry: 0,
freeIndustry: 0,
production: "MAT",
owner: null,
},
],
bombings: [
{
planetNumber: 1,
planet: "A",
owner: "X",
attacker: "Y",
production: "MAT",
industry: 0,
population: 0,
colonists: 0,
industryStockpile: 0,
materialsStockpile: 0,
attackPower: 1,
wiped: false,
},
{
planetNumber: 2,
planet: "B",
owner: "X",
attacker: "Y",
production: "MAT",
industry: 0,
population: 0,
colonists: 0,
industryStockpile: 0,
materialsStockpile: 0,
attackPower: 1,
wiped: true,
},
],
});
const out = buildBattleAndBombingMarkers(report);
const rings = out.primitives.filter((p) => p.kind === "circle");
expect(rings).toHaveLength(2);
expect(rings[0].style.strokeColor).toBe(BOMBING_MARKER_COLOR_DAMAGED);
expect(rings[1].style.strokeColor).toBe(BOMBING_MARKER_COLOR_WIPED);
});
});
+146
View File
@@ -0,0 +1,146 @@
// Unit tests for the BattleViewer's pure helpers: radial layout and
// the timeline frame builder. Both are pure functions and don't
// require DOM mounting, so they exercise the playback semantics in
// isolation.
import { describe, expect, it } from "vitest";
import type { BattleReport } from "../src/api/battle-fetch";
import { layoutRaces } from "../src/lib/battle-player/radial-layout";
import {
buildFrames,
buildGroupRaceMap,
normaliseGroups,
} from "../src/lib/battle-player/timeline";
describe("layoutRaces", () => {
const center = { x: 100, y: 100 };
const radius = 50;
it("returns no anchors for an empty input", () => {
expect(layoutRaces([], { center, radius })).toEqual([]);
});
it("places one race at the 12 o'clock position", () => {
const result = layoutRaces([0], { center, radius });
expect(result).toHaveLength(1);
expect(result[0].raceId).toBe(0);
expect(result[0].x).toBeCloseTo(center.x, 5);
expect(result[0].y).toBeCloseTo(center.y - radius, 5);
});
it("places two races at opposite poles (180° apart)", () => {
const result = layoutRaces([0, 1], { center, radius });
expect(result).toHaveLength(2);
expect(result[0].x).toBeCloseTo(center.x, 5);
expect(result[0].y).toBeCloseTo(center.y - radius, 5);
expect(result[1].x).toBeCloseTo(center.x, 5);
expect(result[1].y).toBeCloseTo(center.y + radius, 5);
});
it("places three races at 120° intervals", () => {
const result = layoutRaces([0, 1, 2], { center, radius });
expect(result).toHaveLength(3);
expect(result[0].angle).toBeCloseTo(-Math.PI / 2, 5);
expect(result[1].angle - result[0].angle).toBeCloseTo((2 * Math.PI) / 3, 5);
expect(result[2].angle - result[1].angle).toBeCloseTo((2 * Math.PI) / 3, 5);
});
it("preserves the input race order", () => {
const result = layoutRaces([7, 2, 5], { center, radius });
expect(result.map((a) => a.raceId)).toEqual([7, 2, 5]);
});
});
const TWO_RACE_BATTLE: BattleReport = {
id: "battle-1",
planet: 4,
planetName: "Test",
races: { "0": "race-A-uuid", "1": "race-B-uuid" },
ships: {
"10": {
race: "Alpha",
className: "Drone",
tech: {},
num: 3,
numLeft: 1,
loadType: "EMP",
loadQuantity: 0,
inBattle: true,
},
"20": {
race: "Beta",
className: "Spy",
tech: {},
num: 2,
numLeft: 0,
loadType: "EMP",
loadQuantity: 0,
inBattle: true,
},
"99": {
race: "Gamma",
className: "Observer",
tech: {},
num: 4,
numLeft: 4,
loadType: "EMP",
loadQuantity: 0,
inBattle: false,
},
},
protocol: [
{ a: 0, sa: 10, d: 1, sd: 20, x: false },
{ a: 1, sa: 20, d: 0, sd: 10, x: true },
{ a: 0, sa: 10, d: 1, sd: 20, x: true },
{ a: 0, sa: 10, d: 1, sd: 20, x: true },
],
};
describe("buildGroupRaceMap", () => {
it("derives group → race from protocol entries", () => {
const map = buildGroupRaceMap(TWO_RACE_BATTLE.protocol);
expect(map.get(10)).toBe(0);
expect(map.get(20)).toBe(1);
});
});
describe("normaliseGroups", () => {
it("returns only in-battle groups with race index attached", () => {
const groups = normaliseGroups(TWO_RACE_BATTLE);
expect(groups.map((g) => g.key).sort((a, b) => a - b)).toEqual([10, 20]);
expect(groups.every((g) => g.group.inBattle)).toBe(true);
});
});
describe("buildFrames", () => {
it("produces protocol.length + 1 frames", () => {
const frames = buildFrames(TWO_RACE_BATTLE);
expect(frames).toHaveLength(TWO_RACE_BATTLE.protocol.length + 1);
});
it("frame 0 reports initial ship counts and all active races", () => {
const [first] = buildFrames(TWO_RACE_BATTLE);
expect(first.shotIndex).toBe(0);
expect(first.lastAction).toBeNull();
expect(first.remaining.get(10)).toBe(3);
expect(first.remaining.get(20)).toBe(2);
expect(first.activeRaceIds).toEqual([0, 1]);
});
it("decrements destroyed defenders only on x === true", () => {
const frames = buildFrames(TWO_RACE_BATTLE);
// Action 1: x=false → no decrement on defender 20.
expect(frames[1].remaining.get(20)).toBe(2);
// Action 2: x=true → attacker is race 1 group 20, defender
// is race 0 group 10 → group 10 drops 3→2.
expect(frames[2].remaining.get(10)).toBe(2);
});
it("drops a race from activeRaceIds once its last in-battle group reaches zero", () => {
const frames = buildFrames(TWO_RACE_BATTLE);
// After the 4-th action both Beta ships have been destroyed.
expect(frames[4].remaining.get(20)).toBe(0);
expect(frames[4].activeRaceIds).toEqual([0]);
});
});
+252
View File
@@ -0,0 +1,252 @@
// Phase 27 — Playwright coverage for the Battle Viewer.
//
// Mocks both the Connect-RPC `user.games.report` (so the report
// renders battles + bombings) and the REST forwarder
// `/api/v1/user/games/{game_id}/battles/{turn}/{battle_id}` (so the
// viewer page loads its `BattleReport` without an engine).
// Drives three flows:
// 1. Reports view → click battle UUID → viewer renders.
// 2. Playback controls: play / step back.
// 3. Reports view → click bombing marker proxy → row scrolls
// (here approximated by clicking the link in Reports — the
// map e2e flow is exercised separately by `map-roundtrip`).
import { fromJson, type JsonValue } from "@bufbuild/protobuf";
import { ByteBuffer } from "flatbuffers";
import { expect, test, type Page } from "@playwright/test";
import { ExecuteCommandRequestSchema } from "../../src/proto/galaxy/gateway/v1/edge_gateway_pb";
import { UUID } from "../../src/proto/galaxy/fbs/common";
import { GameReportRequest } from "../../src/proto/galaxy/fbs/report";
import {
buildOrderResponsePayload,
buildOrderGetResponsePayload,
} from "./fixtures/order-fbs";
import { buildMyGamesListPayload, type GameFixture } from "./fixtures/lobby-fbs";
import { buildReportPayload } from "./fixtures/report-fbs";
import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response";
const GAME_ID = "00000000-0000-0000-0000-000000000010";
const BATTLE_ID = "11111111-1111-1111-1111-111111111111";
const SESSION_ID = "device-session-battle";
const RACE_A = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
const RACE_B = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
const SAMPLE_BATTLE = {
id: BATTLE_ID,
planet: 1,
planetName: "Earth",
races: { "0": RACE_A, "1": RACE_B },
ships: {
"10": {
race: "Earthlings",
className: "Cruiser",
tech: { WEAPONS: 1 },
num: 3,
numLeft: 2,
loadType: "EMP",
loadQuantity: 0,
inBattle: true,
},
"20": {
race: "Bajori",
className: "Hawk",
tech: { SHIELDS: 1 },
num: 2,
numLeft: 0,
loadType: "EMP",
loadQuantity: 0,
inBattle: true,
},
},
protocol: [
{ a: 0, sa: 10, d: 1, sd: 20, x: false },
{ a: 0, sa: 10, d: 1, sd: 20, x: true },
{ a: 1, sa: 20, d: 0, sd: 10, x: true },
{ a: 0, sa: 10, d: 1, sd: 20, x: true },
],
};
async function mockGatewayAndBattle(page: Page): Promise<void> {
const game: GameFixture = {
gameId: GAME_ID,
gameName: "Phase 27 Game",
gameType: "private",
status: "running",
ownerUserId: "user-1",
minPlayers: 2,
maxPlayers: 8,
enrollmentEndsAtMs: BigInt(Date.now() + 86_400_000),
createdAtMs: BigInt(Date.now() - 86_400_000),
updatedAtMs: BigInt(Date.now()),
currentTurn: 1,
};
await page.route(
"**/galaxy.gateway.v1.EdgeGateway/ExecuteCommand",
async (route) => {
const reqText = route.request().postData();
if (reqText === null) {
await route.fulfill({ status: 400 });
return;
}
const req = fromJson(
ExecuteCommandRequestSchema,
JSON.parse(reqText) as JsonValue,
);
let resultCode = "ok";
let payload: Uint8Array;
switch (req.messageType) {
case "lobby.my.games.list":
payload = buildMyGamesListPayload([game]);
break;
case "user.games.report": {
GameReportRequest.getRootAsGameReportRequest(
new ByteBuffer(req.payloadBytes),
).gameId(new UUID());
payload = buildReportPayload({
turn: 1,
mapWidth: 4000,
mapHeight: 4000,
race: "Earthlings",
localPlanets: [
{
number: 1,
name: "Earth",
x: 2000,
y: 2000,
size: 1000,
resources: 5,
population: 4000,
industry: 3000,
capital: 0,
material: 0,
colonists: 100,
freeIndustry: 800,
production: "Cruiser",
},
],
battles: [{ id: BATTLE_ID, planet: 1, shots: 4 }],
});
break;
}
case "user.games.order":
payload = buildOrderResponsePayload(GAME_ID, [], Date.now());
break;
case "user.games.order.get":
payload = buildOrderGetResponsePayload(GAME_ID, [], Date.now(), false);
break;
default:
resultCode = "internal_error";
payload = new Uint8Array();
}
const body = await forgeExecuteCommandResponseJson({
requestId: req.requestId,
timestampMs: BigInt(Date.now()),
resultCode,
payloadBytes: payload,
});
await route.fulfill({
status: 200,
headers: { "content-type": "application/json" },
body,
});
},
);
await page.route(
`**/api/v1/user/games/${GAME_ID}/battles/1/${BATTLE_ID}`,
async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(SAMPLE_BATTLE),
});
},
);
await page.route(
`**/api/v1/user/games/${GAME_ID}/battles/1/missing-uuid`,
async (route) => {
await route.fulfill({ status: 404 });
},
);
}
async function bootSession(page: Page): Promise<void> {
await page.goto("/__debug/store");
await expect(page.getByTestId("debug-store-ready")).toBeVisible();
await page.waitForFunction(() => window.__galaxyDebug?.ready === true);
await page.evaluate(() => window.__galaxyDebug!.clearSession());
await page.evaluate(
(id) => window.__galaxyDebug!.setDeviceSessionId(id),
SESSION_ID,
);
}
test.describe("Phase 27 battle viewer", () => {
test("Reports UUID link opens the battle viewer", async ({ page }, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile"),
"desktop variant covers the link flow",
);
await mockGatewayAndBattle(page);
await bootSession(page);
await page.goto(`/games/${GAME_ID}/report`);
await expect(page.getByTestId("active-view-report")).toBeVisible();
const row = page.getByTestId("report-battle-row").first();
await expect(row).toBeVisible();
await row.click();
await expect(page).toHaveURL(
new RegExp(`/games/${GAME_ID}/battle/${BATTLE_ID}\\?turn=1`),
);
await expect(page.getByTestId("battle-viewer")).toBeVisible();
await expect(page.getByTestId("battle-scene")).toBeVisible();
await expect(page.getByTestId("battle-frame-index")).toContainText("0 / 4");
});
test("playback play + step back updates the frame counter", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile"),
"desktop variant covers playback controls",
);
await mockGatewayAndBattle(page);
await bootSession(page);
await page.goto(`/games/${GAME_ID}/battle/${BATTLE_ID}?turn=1`);
await expect(page.getByTestId("battle-viewer")).toBeVisible();
await expect(page.getByTestId("battle-frame-index")).toContainText("0 / 4");
// Step forward once → 1 / 4.
await page.getByTestId("battle-control-step-forward").click();
await expect(page.getByTestId("battle-frame-index")).toContainText("1 / 4");
// Step back to 0 / 4.
await page.getByTestId("battle-control-step-back").click();
await expect(page.getByTestId("battle-frame-index")).toContainText("0 / 4");
});
test("missing battle id surfaces the not-found state", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile"),
"desktop variant covers the negative path",
);
await mockGatewayAndBattle(page);
await bootSession(page);
await page.goto(`/games/${GAME_ID}/battle/missing-uuid?turn=1`);
await expect(page.getByTestId("battle-not-found")).toBeVisible();
});
});
+23 -11
View File
@@ -19,6 +19,7 @@ import { Builder } from "flatbuffers";
import { UUID } from "../../../src/proto/galaxy/fbs/common";
import {
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 },
+17 -5
View File
@@ -76,18 +76,30 @@ describe("active-view stubs", () => {
);
});
test("battle stub stamps the battleId on the host element", () => {
const ui = render(BattleView, { props: { battleId: "b-42" } });
test("battle view stamps the battleId and renders the back-to-map link", () => {
// Phase 27 replaces the Phase 10 stub with the Battle Viewer
// wrapper. The wrapper mounts the loading copy until the
// fetcher resolves (component test runs in jsdom without a
// network); the back buttons and the data-battle-id stamp are
// rendered unconditionally so the orchestrator scaffold is the
// stable hook the active-view shell relies on.
const ui = render(BattleView, {
props: { gameId: "synthetic-test", turn: 0, battleId: "b-42" },
});
const node = ui.getByTestId("active-view-battle");
expect(node).toHaveAttribute("data-battle-id", "b-42");
expect(node).toHaveTextContent("battle log");
expect(ui.getByTestId("battle-back-to-map")).toBeInTheDocument();
expect(ui.getByTestId("battle-back-to-report")).toBeInTheDocument();
});
test("battle stub accepts an empty battleId for the list URL", () => {
const ui = render(BattleView, { props: { battleId: "" } });
test("battle view surfaces the not-found state for an empty battleId", () => {
const ui = render(BattleView, {
props: { gameId: "synthetic-test", turn: 0, battleId: "" },
});
expect(ui.getByTestId("active-view-battle")).toHaveAttribute(
"data-battle-id",
"",
);
expect(ui.getByTestId("battle-not-found")).toBeInTheDocument();
});
});
@@ -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: [],