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:
@@ -0,0 +1,190 @@
|
||||
// Phase 27 unit tests for battle and bombing map markers.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { GameReport } from "../src/api/game-state";
|
||||
import {
|
||||
battleMarkerStrokeWidth,
|
||||
BATTLE_MARKER_COLOR,
|
||||
BOMBING_MARKER_COLOR_DAMAGED,
|
||||
BOMBING_MARKER_COLOR_WIPED,
|
||||
buildBattleAndBombingMarkers,
|
||||
} from "../src/map/battle-markers";
|
||||
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
||||
|
||||
describe("battleMarkerStrokeWidth", () => {
|
||||
it("clamps to 1 px at one shot", () => {
|
||||
expect(battleMarkerStrokeWidth(1)).toBe(1);
|
||||
});
|
||||
|
||||
it("clamps to 5 px at 100 shots", () => {
|
||||
expect(battleMarkerStrokeWidth(100)).toBe(5);
|
||||
});
|
||||
|
||||
it("caps above 100 shots at 5 px", () => {
|
||||
expect(battleMarkerStrokeWidth(250)).toBe(5);
|
||||
});
|
||||
|
||||
it("interpolates linearly between 1 and 100 shots", () => {
|
||||
// ~halfway: 50 shots → 1 + 49 * 4 / 99 ≈ 2.98
|
||||
expect(battleMarkerStrokeWidth(50)).toBeCloseTo(2.98, 2);
|
||||
});
|
||||
});
|
||||
|
||||
function makeReport(overrides: Partial<GameReport>): GameReport {
|
||||
return {
|
||||
turn: 1,
|
||||
mapWidth: 200,
|
||||
mapHeight: 200,
|
||||
planetCount: 0,
|
||||
race: "Earthlings",
|
||||
planets: [],
|
||||
localShipClass: [],
|
||||
routes: [],
|
||||
localPlayerDrive: 0,
|
||||
localPlayerWeapons: 0,
|
||||
localPlayerShields: 0,
|
||||
localPlayerCargo: 0,
|
||||
...EMPTY_SHIP_GROUPS,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildBattleAndBombingMarkers", () => {
|
||||
it("returns no primitives when both battles and bombings are empty", () => {
|
||||
const report = makeReport({});
|
||||
const out = buildBattleAndBombingMarkers(report);
|
||||
expect(out.primitives).toEqual([]);
|
||||
expect(out.lookup.size).toBe(0);
|
||||
});
|
||||
|
||||
it("emits two yellow lines through opposite corners of the planet square per battle", () => {
|
||||
const report = makeReport({
|
||||
planets: [
|
||||
{
|
||||
number: 4,
|
||||
name: "Test",
|
||||
kind: "local",
|
||||
x: 10,
|
||||
y: 20,
|
||||
size: 50,
|
||||
resources: 0,
|
||||
industryStockpile: 0,
|
||||
materialsStockpile: 0,
|
||||
population: 0,
|
||||
colonists: 0,
|
||||
industry: 0,
|
||||
freeIndustry: 0,
|
||||
production: "MAT",
|
||||
owner: null,
|
||||
},
|
||||
],
|
||||
battles: [
|
||||
{ id: "11111111-1111-1111-1111-111111111111", planet: 4, shots: 100 },
|
||||
],
|
||||
});
|
||||
|
||||
const out = buildBattleAndBombingMarkers(report);
|
||||
const lines = out.primitives.filter((p) => p.kind === "line");
|
||||
expect(lines).toHaveLength(2);
|
||||
// Same yellow colour, 5 px wide for a 100-shot battle.
|
||||
for (const l of lines) {
|
||||
expect(l.style.strokeColor).toBe(BATTLE_MARKER_COLOR);
|
||||
expect(l.style.strokeWidthPx).toBe(5);
|
||||
}
|
||||
// First line: top-left → bottom-right corner of the planet square.
|
||||
const [a, b] = lines as Array<typeof lines[number] & { x1: number; y1: number; x2: number; y2: number }>;
|
||||
expect(a.x1).toBeLessThan(a.x2);
|
||||
expect(a.y1).toBeLessThan(a.y2);
|
||||
// Second line: top-right → bottom-left.
|
||||
expect(b.x1).toBeLessThan(b.x2);
|
||||
expect(b.y1).toBeGreaterThan(b.y2);
|
||||
});
|
||||
|
||||
it("skips battles whose planet is not in the planet list", () => {
|
||||
const report = makeReport({
|
||||
battles: [
|
||||
{ id: "11111111-1111-1111-1111-111111111111", planet: 99, shots: 4 },
|
||||
],
|
||||
});
|
||||
const out = buildBattleAndBombingMarkers(report);
|
||||
expect(out.primitives).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("emits one yellow ring per damaged bombing and red per wiped", () => {
|
||||
const report = makeReport({
|
||||
planets: [
|
||||
{
|
||||
number: 1,
|
||||
name: "A",
|
||||
kind: "local",
|
||||
x: 1,
|
||||
y: 2,
|
||||
size: 50,
|
||||
resources: 0,
|
||||
industryStockpile: 0,
|
||||
materialsStockpile: 0,
|
||||
population: 0,
|
||||
colonists: 0,
|
||||
industry: 0,
|
||||
freeIndustry: 0,
|
||||
production: "MAT",
|
||||
owner: null,
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
name: "B",
|
||||
kind: "local",
|
||||
x: 5,
|
||||
y: 6,
|
||||
size: 50,
|
||||
resources: 0,
|
||||
industryStockpile: 0,
|
||||
materialsStockpile: 0,
|
||||
population: 0,
|
||||
colonists: 0,
|
||||
industry: 0,
|
||||
freeIndustry: 0,
|
||||
production: "MAT",
|
||||
owner: null,
|
||||
},
|
||||
],
|
||||
bombings: [
|
||||
{
|
||||
planetNumber: 1,
|
||||
planet: "A",
|
||||
owner: "X",
|
||||
attacker: "Y",
|
||||
production: "MAT",
|
||||
industry: 0,
|
||||
population: 0,
|
||||
colonists: 0,
|
||||
industryStockpile: 0,
|
||||
materialsStockpile: 0,
|
||||
attackPower: 1,
|
||||
wiped: false,
|
||||
},
|
||||
{
|
||||
planetNumber: 2,
|
||||
planet: "B",
|
||||
owner: "X",
|
||||
attacker: "Y",
|
||||
production: "MAT",
|
||||
industry: 0,
|
||||
population: 0,
|
||||
colonists: 0,
|
||||
industryStockpile: 0,
|
||||
materialsStockpile: 0,
|
||||
attackPower: 1,
|
||||
wiped: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const out = buildBattleAndBombingMarkers(report);
|
||||
const rings = out.primitives.filter((p) => p.kind === "circle");
|
||||
expect(rings).toHaveLength(2);
|
||||
expect(rings[0].style.strokeColor).toBe(BOMBING_MARKER_COLOR_DAMAGED);
|
||||
expect(rings[1].style.strokeColor).toBe(BOMBING_MARKER_COLOR_WIPED);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import { Builder } from "flatbuffers";
|
||||
|
||||
import { UUID } from "../../../src/proto/galaxy/fbs/common";
|
||||
import {
|
||||
BattleSummary,
|
||||
Bombing,
|
||||
LocalPlanet,
|
||||
OtherPlanet,
|
||||
@@ -108,6 +109,12 @@ export interface OtherShipClassFixture extends ShipClassFixture {
|
||||
mass?: number;
|
||||
}
|
||||
|
||||
export interface BattleSummaryFixture {
|
||||
id: string;
|
||||
planet: number;
|
||||
shots: number;
|
||||
}
|
||||
|
||||
export interface BombingFixture {
|
||||
planetNumber: number;
|
||||
planet: string;
|
||||
@@ -149,7 +156,7 @@ export interface ReportFixture {
|
||||
myVoteFor?: string;
|
||||
otherScience?: OtherScienceFixture[];
|
||||
otherShipClass?: OtherShipClassFixture[];
|
||||
battles?: string[];
|
||||
battles?: BattleSummaryFixture[];
|
||||
bombings?: BombingFixture[];
|
||||
shipProductions?: ShipProductionFixture[];
|
||||
}
|
||||
@@ -397,17 +404,22 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
|
||||
shipProductionOffsets.length === 0
|
||||
? null
|
||||
: Report.createShipProductionVector(builder, shipProductionOffsets);
|
||||
// `battle` is a struct vector (16 bytes per UUID, alignment 8), so
|
||||
// it uses the start/inline-write/end pattern rather than a typical
|
||||
// offset-list helper. Iterating in reverse matches the FlatBuffers
|
||||
// convention that the vector is built end-to-start.
|
||||
// Phase 27 — `battle` carries `BattleSummary` tables, each with
|
||||
// an inline `id:UUID` struct plus `planet` and `shots` slots.
|
||||
const battleVec = (() => {
|
||||
const ids = fixture.battles ?? [];
|
||||
if (ids.length === 0) return null;
|
||||
Report.startBattleVector(builder, ids.length);
|
||||
for (let i = ids.length - 1; i >= 0; i--) {
|
||||
const [hi, lo] = uuidToHiLo(ids[i]!);
|
||||
UUID.createUUID(builder, hi, lo);
|
||||
const summaries = fixture.battles ?? [];
|
||||
if (summaries.length === 0) return null;
|
||||
const offsets = summaries.map((s) => {
|
||||
const [hi, lo] = uuidToHiLo(s.id);
|
||||
BattleSummary.startBattleSummary(builder);
|
||||
BattleSummary.addId(builder, UUID.createUUID(builder, hi, lo));
|
||||
BattleSummary.addPlanet(builder, BigInt(s.planet));
|
||||
BattleSummary.addShots(builder, BigInt(s.shots));
|
||||
return BattleSummary.endBattleSummary(builder);
|
||||
});
|
||||
Report.startBattleVector(builder, offsets.length);
|
||||
for (let i = offsets.length - 1; i >= 0; i--) {
|
||||
builder.addOffset(offsets[i]);
|
||||
}
|
||||
return builder.endVector();
|
||||
})();
|
||||
|
||||
@@ -151,7 +151,7 @@ async function mockGateway(page: Page): Promise<void> {
|
||||
{ race: "Andori", name: "Spear", drive: 8, armament: 4, weapons: 6, shields: 3, cargo: 1, mass: 90 },
|
||||
{ race: "Bajori", name: "Hawk", drive: 12, armament: 1, weapons: 4, shields: 2, cargo: 0, mass: 75 },
|
||||
],
|
||||
battles: [BATTLE_ID],
|
||||
battles: [{ id: BATTLE_ID, planet: 1, shots: 12 }],
|
||||
bombings: [
|
||||
{ planetNumber: 1, planet: "Earth", owner: "Earthlings", attacker: "Bajori", production: "Cruiser", industry: 500, population: 200, colonists: 12, capital: 30, material: 5, attackPower: 250, wiped: false },
|
||||
{ planetNumber: 99, planet: "DW-99", owner: "Earthlings", attacker: "Bajori", production: "Dron", industry: 0, population: 0, colonists: 0, capital: 0, material: 0, attackPower: 800, wiped: true },
|
||||
|
||||
@@ -76,18 +76,30 @@ describe("active-view stubs", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("battle stub stamps the battleId on the host element", () => {
|
||||
const ui = render(BattleView, { props: { battleId: "b-42" } });
|
||||
test("battle view stamps the battleId and 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: [],
|
||||
|
||||
Reference in New Issue
Block a user