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
+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: [],