Files
Ilia Denisov 969c0480ba 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>
2026-05-13 12:24:20 +02:00

479 lines
16 KiB
TypeScript

// Phase 11 helpers for forging FlatBuffers report payloads in e2e
// tests. Mirrors the engine's `report.Report` shape so the mocked
// gateway can return realistic data without standing up the real
// engine container.
//
// Phase 11 only renders planets, so the helpers keep the report shape
// minimal (turn / dimensions / planet vectors). Phase 13 extended the
// fixture with the optional rich planet fields (size, resources,
// stockpiles, population, industry, colonists, production, free
// industry) so the inspector e2e can drive the read-only display
// against realistic values. Phase 15 adds a minimal `LocalShipClass`
// projection so the planet inspector's Build-Ship sub-picker has data
// in e2e specs (`name` only — Phase 17 widens this when ship-class
// CRUD lands). Phase 21 adds a `LocalScience` projection so the
// sciences table and the planet production picker's Research sub-row
// have data in e2e specs.
import { Builder } from "flatbuffers";
import { UUID } from "../../../src/proto/galaxy/fbs/common";
import {
BattleSummary,
Bombing,
LocalPlanet,
OtherPlanet,
OtherScience,
OthersShipClass,
Player,
Report,
Route,
RouteEntry,
Science,
ShipClass,
ShipProduction,
UnidentifiedPlanet,
UninhabitedPlanet,
} from "../../../src/proto/galaxy/fbs/report";
export interface PlanetFixture {
number: number;
name: string;
x: number;
y: number;
size?: number;
resources?: number;
capital?: number;
material?: number;
}
export interface InhabitedFixture extends PlanetFixture {
population?: number;
colonists?: number;
industry?: number;
production?: string;
freeIndustry?: number;
}
export interface OtherPlanetFixture extends InhabitedFixture {
owner: string;
}
export interface ShipClassFixture {
name: string;
drive?: number;
armament?: number;
weapons?: number;
shields?: number;
cargo?: number;
}
export interface ScienceFixture {
name: string;
drive?: number;
weapons?: number;
shields?: number;
cargo?: number;
}
export interface PlayerFixture {
name: string;
drive?: number;
weapons?: number;
shields?: number;
cargo?: number;
population?: number;
industry?: number;
planets?: number;
relation?: "WAR" | "PEACE" | "-";
votes?: number;
extinct?: boolean;
}
export interface RouteEntryFixture {
loadType: "COL" | "CAP" | "MAT" | "EMP";
destinationPlanetNumber: number;
}
export interface RouteFixture {
sourcePlanetNumber: number;
entries: RouteEntryFixture[];
}
export interface OtherScienceFixture extends ScienceFixture {
race: string;
}
export interface OtherShipClassFixture extends ShipClassFixture {
race: string;
mass?: number;
}
export interface BattleSummaryFixture {
id: string;
planet: number;
shots: number;
}
export interface BombingFixture {
planetNumber: number;
planet: string;
owner: string;
attacker: string;
production?: string;
industry?: number;
population?: number;
colonists?: number;
capital?: number;
material?: number;
attackPower?: number;
wiped?: boolean;
}
export interface ShipProductionFixture {
planet: number;
class: string;
cost?: number;
prodUsed?: number;
percent?: number;
free?: number;
}
export interface ReportFixture {
turn: number;
mapWidth?: number;
mapHeight?: number;
localPlanets?: InhabitedFixture[];
otherPlanets?: OtherPlanetFixture[];
uninhabitedPlanets?: PlanetFixture[];
unidentifiedPlanets?: { number: number; x: number; y: number }[];
localShipClass?: ShipClassFixture[];
localScience?: ScienceFixture[];
race?: string;
players?: PlayerFixture[];
routes?: RouteFixture[];
myVotes?: number;
myVoteFor?: string;
otherScience?: OtherScienceFixture[];
otherShipClass?: OtherShipClassFixture[];
battles?: BattleSummaryFixture[];
bombings?: BombingFixture[];
shipProductions?: ShipProductionFixture[];
}
export function buildReportPayload(fixture: ReportFixture): Uint8Array {
const builder = new Builder(512);
const localOffsets = (fixture.localPlanets ?? []).map((planet) => {
const name = builder.createString(planet.name);
const production =
planet.production !== undefined
? builder.createString(planet.production)
: null;
LocalPlanet.startLocalPlanet(builder);
LocalPlanet.addNumber(builder, BigInt(planet.number));
LocalPlanet.addX(builder, planet.x);
LocalPlanet.addY(builder, planet.y);
LocalPlanet.addName(builder, name);
LocalPlanet.addSize(builder, planet.size ?? 10);
LocalPlanet.addResources(builder, planet.resources ?? 0.5);
LocalPlanet.addCapital(builder, planet.capital ?? 0);
LocalPlanet.addMaterial(builder, planet.material ?? 0);
LocalPlanet.addPopulation(builder, planet.population ?? 0);
LocalPlanet.addIndustry(builder, planet.industry ?? 0);
LocalPlanet.addColonists(builder, planet.colonists ?? 0);
if (production !== null) LocalPlanet.addProduction(builder, production);
LocalPlanet.addFreeIndustry(builder, planet.freeIndustry ?? 0);
return LocalPlanet.endLocalPlanet(builder);
});
const otherOffsets = (fixture.otherPlanets ?? []).map((planet) => {
const name = builder.createString(planet.name);
const owner = builder.createString(planet.owner);
const production =
planet.production !== undefined
? builder.createString(planet.production)
: null;
OtherPlanet.startOtherPlanet(builder);
OtherPlanet.addNumber(builder, BigInt(planet.number));
OtherPlanet.addX(builder, planet.x);
OtherPlanet.addY(builder, planet.y);
OtherPlanet.addName(builder, name);
OtherPlanet.addOwner(builder, owner);
OtherPlanet.addSize(builder, planet.size ?? 9);
OtherPlanet.addResources(builder, planet.resources ?? 0.5);
OtherPlanet.addCapital(builder, planet.capital ?? 0);
OtherPlanet.addMaterial(builder, planet.material ?? 0);
OtherPlanet.addPopulation(builder, planet.population ?? 0);
OtherPlanet.addIndustry(builder, planet.industry ?? 0);
OtherPlanet.addColonists(builder, planet.colonists ?? 0);
if (production !== null) OtherPlanet.addProduction(builder, production);
OtherPlanet.addFreeIndustry(builder, planet.freeIndustry ?? 0);
return OtherPlanet.endOtherPlanet(builder);
});
const uninhabitedOffsets = (fixture.uninhabitedPlanets ?? []).map(
(planet) => {
const name = builder.createString(planet.name);
UninhabitedPlanet.startUninhabitedPlanet(builder);
UninhabitedPlanet.addNumber(builder, BigInt(planet.number));
UninhabitedPlanet.addX(builder, planet.x);
UninhabitedPlanet.addY(builder, planet.y);
UninhabitedPlanet.addName(builder, name);
UninhabitedPlanet.addSize(builder, planet.size ?? 6);
UninhabitedPlanet.addResources(builder, planet.resources ?? 0.5);
UninhabitedPlanet.addCapital(builder, planet.capital ?? 0);
UninhabitedPlanet.addMaterial(builder, planet.material ?? 0);
return UninhabitedPlanet.endUninhabitedPlanet(builder);
},
);
const unidentifiedOffsets = (fixture.unidentifiedPlanets ?? []).map(
(planet) => {
UnidentifiedPlanet.startUnidentifiedPlanet(builder);
UnidentifiedPlanet.addNumber(builder, BigInt(planet.number));
UnidentifiedPlanet.addX(builder, planet.x);
UnidentifiedPlanet.addY(builder, planet.y);
return UnidentifiedPlanet.endUnidentifiedPlanet(builder);
},
);
const localShipClassOffsets = (fixture.localShipClass ?? []).map((cls) => {
const name = builder.createString(cls.name);
ShipClass.startShipClass(builder);
ShipClass.addName(builder, name);
ShipClass.addDrive(builder, cls.drive ?? 0);
ShipClass.addArmament(builder, BigInt(cls.armament ?? 0));
ShipClass.addWeapons(builder, cls.weapons ?? 0);
ShipClass.addShields(builder, cls.shields ?? 0);
ShipClass.addCargo(builder, cls.cargo ?? 0);
return ShipClass.endShipClass(builder);
});
const localScienceOffsets = (fixture.localScience ?? []).map((sci) => {
const name = builder.createString(sci.name);
Science.startScience(builder);
Science.addName(builder, name);
Science.addDrive(builder, sci.drive ?? 0);
Science.addWeapons(builder, sci.weapons ?? 0);
Science.addShields(builder, sci.shields ?? 0);
Science.addCargo(builder, sci.cargo ?? 0);
return Science.endScience(builder);
});
const playerOffsets = (fixture.players ?? []).map((p) => {
const name = builder.createString(p.name);
const relation =
p.relation === undefined ? null : builder.createString(p.relation);
Player.startPlayer(builder);
Player.addName(builder, name);
Player.addDrive(builder, p.drive ?? 1);
Player.addWeapons(builder, p.weapons ?? 0);
Player.addShields(builder, p.shields ?? 0);
Player.addCargo(builder, p.cargo ?? 0);
Player.addPopulation(builder, p.population ?? 0);
Player.addIndustry(builder, p.industry ?? 0);
Player.addPlanets(builder, p.planets ?? 0);
if (relation !== null) Player.addRelation(builder, relation);
Player.addVotes(builder, p.votes ?? 0);
Player.addExtinct(builder, p.extinct ?? false);
return Player.endPlayer(builder);
});
const routeOffsets = (fixture.routes ?? []).map((route) => {
const entryOffsets = route.entries.map((entry) => {
const valueOffset = builder.createString(entry.loadType);
RouteEntry.startRouteEntry(builder);
RouteEntry.addKey(builder, BigInt(entry.destinationPlanetNumber));
RouteEntry.addValue(builder, valueOffset);
return RouteEntry.endRouteEntry(builder);
});
const entriesVec = Route.createRouteVector(builder, entryOffsets);
Route.startRoute(builder);
Route.addPlanet(builder, BigInt(route.sourcePlanetNumber));
Route.addRoute(builder, entriesVec);
return Route.endRoute(builder);
});
const otherScienceOffsets = (fixture.otherScience ?? []).map((sci) => {
const race = builder.createString(sci.race);
const name = builder.createString(sci.name);
OtherScience.startOtherScience(builder);
OtherScience.addRace(builder, race);
OtherScience.addName(builder, name);
OtherScience.addDrive(builder, sci.drive ?? 0);
OtherScience.addWeapons(builder, sci.weapons ?? 0);
OtherScience.addShields(builder, sci.shields ?? 0);
OtherScience.addCargo(builder, sci.cargo ?? 0);
return OtherScience.endOtherScience(builder);
});
const otherShipClassOffsets = (fixture.otherShipClass ?? []).map((cls) => {
const race = builder.createString(cls.race);
const name = builder.createString(cls.name);
OthersShipClass.startOthersShipClass(builder);
OthersShipClass.addRace(builder, race);
OthersShipClass.addName(builder, name);
OthersShipClass.addDrive(builder, cls.drive ?? 0);
OthersShipClass.addArmament(builder, BigInt(cls.armament ?? 0));
OthersShipClass.addWeapons(builder, cls.weapons ?? 0);
OthersShipClass.addShields(builder, cls.shields ?? 0);
OthersShipClass.addCargo(builder, cls.cargo ?? 0);
OthersShipClass.addMass(builder, cls.mass ?? 0);
return OthersShipClass.endOthersShipClass(builder);
});
const bombingOffsets = (fixture.bombings ?? []).map((b) => {
const planet = builder.createString(b.planet);
const owner = builder.createString(b.owner);
const attacker = builder.createString(b.attacker);
const production = builder.createString(b.production ?? "");
Bombing.startBombing(builder);
Bombing.addNumber(builder, BigInt(b.planetNumber));
Bombing.addPlanet(builder, planet);
Bombing.addOwner(builder, owner);
Bombing.addAttacker(builder, attacker);
Bombing.addProduction(builder, production);
Bombing.addIndustry(builder, b.industry ?? 0);
Bombing.addPopulation(builder, b.population ?? 0);
Bombing.addColonists(builder, b.colonists ?? 0);
Bombing.addCapital(builder, b.capital ?? 0);
Bombing.addMaterial(builder, b.material ?? 0);
Bombing.addAttackPower(builder, b.attackPower ?? 0);
Bombing.addWiped(builder, b.wiped ?? false);
return Bombing.endBombing(builder);
});
const shipProductionOffsets = (fixture.shipProductions ?? []).map((sp) => {
const className = builder.createString(sp.class);
ShipProduction.startShipProduction(builder);
ShipProduction.addPlanet(builder, BigInt(sp.planet));
ShipProduction.addClass(builder, className);
ShipProduction.addCost(builder, sp.cost ?? 0);
ShipProduction.addProdUsed(builder, sp.prodUsed ?? 0);
ShipProduction.addPercent(builder, sp.percent ?? 0);
ShipProduction.addFree(builder, sp.free ?? 0);
return ShipProduction.endShipProduction(builder);
});
const localVec =
localOffsets.length === 0
? null
: Report.createLocalPlanetVector(builder, localOffsets);
const otherVec =
otherOffsets.length === 0
? null
: Report.createOtherPlanetVector(builder, otherOffsets);
const uninhabitedVec =
uninhabitedOffsets.length === 0
? null
: Report.createUninhabitedPlanetVector(builder, uninhabitedOffsets);
const unidentifiedVec =
unidentifiedOffsets.length === 0
? null
: Report.createUnidentifiedPlanetVector(builder, unidentifiedOffsets);
const localShipClassVec =
localShipClassOffsets.length === 0
? null
: Report.createLocalShipClassVector(builder, localShipClassOffsets);
const localScienceVec =
localScienceOffsets.length === 0
? null
: Report.createLocalScienceVector(builder, localScienceOffsets);
const playerVec =
playerOffsets.length === 0
? null
: Report.createPlayerVector(builder, playerOffsets);
const routeVec =
routeOffsets.length === 0
? null
: Report.createRouteVector(builder, routeOffsets);
const otherScienceVec =
otherScienceOffsets.length === 0
? null
: Report.createOtherScienceVector(builder, otherScienceOffsets);
const otherShipClassVec =
otherShipClassOffsets.length === 0
? null
: Report.createOtherShipClassVector(builder, otherShipClassOffsets);
const bombingVec =
bombingOffsets.length === 0
? null
: Report.createBombingVector(builder, bombingOffsets);
const shipProductionVec =
shipProductionOffsets.length === 0
? null
: Report.createShipProductionVector(builder, shipProductionOffsets);
// Phase 27 — `battle` carries `BattleSummary` tables, each with
// an inline `id:UUID` struct plus `planet` and `shots` slots.
const battleVec = (() => {
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();
})();
const raceOffset =
fixture.race === undefined ? null : builder.createString(fixture.race);
const voteForOffset =
fixture.myVoteFor === undefined
? null
: builder.createString(fixture.myVoteFor);
const totalPlanets =
(fixture.localPlanets ?? []).length +
(fixture.otherPlanets ?? []).length +
(fixture.uninhabitedPlanets ?? []).length +
(fixture.unidentifiedPlanets ?? []).length;
Report.startReport(builder);
Report.addTurn(builder, BigInt(fixture.turn));
Report.addWidth(builder, fixture.mapWidth ?? 4000);
Report.addHeight(builder, fixture.mapHeight ?? 4000);
Report.addPlanetCount(builder, totalPlanets);
if (raceOffset !== null) Report.addRace(builder, raceOffset);
if (fixture.myVotes !== undefined) Report.addVotes(builder, fixture.myVotes);
if (voteForOffset !== null) Report.addVoteFor(builder, voteForOffset);
if (playerVec !== null) Report.addPlayer(builder, playerVec);
if (localVec !== null) Report.addLocalPlanet(builder, localVec);
if (otherVec !== null) Report.addOtherPlanet(builder, otherVec);
if (uninhabitedVec !== null) Report.addUninhabitedPlanet(builder, uninhabitedVec);
if (unidentifiedVec !== null) Report.addUnidentifiedPlanet(builder, unidentifiedVec);
if (localShipClassVec !== null)
Report.addLocalShipClass(builder, localShipClassVec);
if (localScienceVec !== null)
Report.addLocalScience(builder, localScienceVec);
if (routeVec !== null) Report.addRoute(builder, routeVec);
if (otherScienceVec !== null)
Report.addOtherScience(builder, otherScienceVec);
if (otherShipClassVec !== null)
Report.addOtherShipClass(builder, otherShipClassVec);
if (battleVec !== null) Report.addBattle(builder, battleVec);
if (bombingVec !== null) Report.addBombing(builder, bombingVec);
if (shipProductionVec !== null)
Report.addShipProduction(builder, shipProductionVec);
const reportOff = Report.endReport(builder);
builder.finish(reportOff);
return builder.asUint8Array();
}
function uuidToHiLo(value: string): [bigint, bigint] {
const hex = value.replace(/-/g, "").toLowerCase();
if (hex.length !== 32 || /[^0-9a-f]/.test(hex)) {
throw new Error(`buildReportPayload: invalid battle uuid ${value}`);
}
const hi = BigInt(`0x${hex.slice(0, 16)}`);
const lo = BigInt(`0x${hex.slice(16, 32)}`);
return [hi, lo];
}