e2aba856b5
Layout reshuffle so the scene captures the maximum viewer area: - Header collapses three rows into one: `back to map` / `back to report` on the left, the centred title `Battle on planet <name> (#<number>)` (new i18n key `game.battle.header_title`), and the frame counter on the right. The wrapper `.active-view` no longer renders its own back-row; routes flow through props. - Viewer drops the `max-width: 880px` cap so on a wide monitor the scene scales up across the full active-view-host. - A drag-seek `<input type="range">` sits between the scene and the controls; dragging pauses playback and lands `frameIndex` on the chosen shot. - Speed control is one cycling button: `1x → 2x → 4x → 6x → 1x`. The label shows the current speed; the new 6x adds a 67 ms frame interval for skimming a long timeline. - The text protocol log is now collapsible behind a `Log ▲▼` toggle in the controls bar. The toggle is its own button; the default state stays expanded. Collapsing the log hands the remaining height to the scene. - Numerical list markers (`1. 2. 3.`) are dropped from the log; `list-style: none` keeps each row visually clean. Static cluster + visibility filter: - `staticBucketsByRace` now locks bucket order, mass, radius and local Vogel-spiral positions for the lifetime of the viewer; it only re-derives when `report` or the wasm `core` change. - `renderedByRace` overlays the per-frame `remaining` map and drops buckets whose `numLeft` hits zero. The surviving buckets keep their slots, so a class emptying never reshuffles the cluster — the empty bucket simply disappears. - A shot whose attacker or defender bucket is no longer visible draws no line (phantom shots into already-empty buckets are silently skipped, matching the user expectation that pup at 0 should stop attracting fire visually). - Race label clamps to a minimum y inside the SVG viewport so three-or-more-race layouts with a north anchor never clip the top race name off-canvas. Duel layout (user suggestion): - `layoutRaces` rotates the radial start angle by 90° when only two participants remain, so race 0 lands at 9 o'clock and race 1 at 3 o'clock. The pair faces off horizontally; neither label pushes against the SVG top edge. The existing test for two-race positions is updated accordingly. Tests: the existing `layoutRaces` two-race case is rewritten for the horizontal duel; the `game-shell-stubs` battle case checks the loading placeholder (back buttons now live in the loaded viewer, not the wrapper). 644 Vitest cases stay green; 4 Playwright battle-viewer cases stay green. Docs: `ui/docs/battle-viewer-ux.md` documents the static cluster / visibility filter, the duel layout, the scrubber, the cycling speed button and the collapsible log. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
303 lines
9.3 KiB
TypeScript
303 lines
9.3 KiB
TypeScript
// Unit tests for the BattleViewer's pure helpers: radial layout and
|
|
// the timeline frame builder. Both are pure functions and don't
|
|
// require DOM mounting, so they exercise the playback semantics in
|
|
// isolation.
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import type { BattleReport } from "../src/api/battle-fetch";
|
|
import { layoutRaces } from "../src/lib/battle-player/radial-layout";
|
|
import {
|
|
MAX_RADIUS,
|
|
MIN_RADIUS,
|
|
radiusForMass,
|
|
} from "../src/lib/battle-player/mass";
|
|
import {
|
|
buildFrames,
|
|
buildGroupRaceMap,
|
|
normaliseGroups,
|
|
} from "../src/lib/battle-player/timeline";
|
|
|
|
describe("layoutRaces", () => {
|
|
const center = { x: 100, y: 100 };
|
|
const radius = 50;
|
|
|
|
it("returns no anchors for an empty input", () => {
|
|
expect(layoutRaces([], { center, radius })).toEqual([]);
|
|
});
|
|
|
|
it("places one race at the 12 o'clock position", () => {
|
|
const result = layoutRaces([0], { center, radius });
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].raceId).toBe(0);
|
|
expect(result[0].x).toBeCloseTo(center.x, 5);
|
|
expect(result[0].y).toBeCloseTo(center.y - radius, 5);
|
|
});
|
|
|
|
it("places two races on the horizontal axis (9 vs 3 o'clock)", () => {
|
|
// Special-case duel layout: two anchors face each other on
|
|
// the horizontal axis so neither cluster's race label clips
|
|
// against the SVG top edge.
|
|
const result = layoutRaces([0, 1], { center, radius });
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].x).toBeCloseTo(center.x - radius, 5);
|
|
expect(result[0].y).toBeCloseTo(center.y, 5);
|
|
expect(result[1].x).toBeCloseTo(center.x + radius, 5);
|
|
expect(result[1].y).toBeCloseTo(center.y, 5);
|
|
});
|
|
|
|
it("places three races at 120° intervals", () => {
|
|
const result = layoutRaces([0, 1, 2], { center, radius });
|
|
expect(result).toHaveLength(3);
|
|
expect(result[0].angle).toBeCloseTo(-Math.PI / 2, 5);
|
|
expect(result[1].angle - result[0].angle).toBeCloseTo((2 * Math.PI) / 3, 5);
|
|
expect(result[2].angle - result[1].angle).toBeCloseTo((2 * Math.PI) / 3, 5);
|
|
});
|
|
|
|
it("preserves the input race order", () => {
|
|
const result = layoutRaces([7, 2, 5], { center, radius });
|
|
expect(result.map((a) => a.raceId)).toEqual([7, 2, 5]);
|
|
});
|
|
});
|
|
|
|
const TWO_RACE_BATTLE: BattleReport = {
|
|
id: "battle-1",
|
|
planet: 4,
|
|
planetName: "Test",
|
|
races: { "0": "race-A-uuid", "1": "race-B-uuid" },
|
|
ships: {
|
|
"10": {
|
|
race: "Alpha",
|
|
className: "Drone",
|
|
tech: {},
|
|
num: 3,
|
|
numLeft: 1,
|
|
loadType: "EMP",
|
|
loadQuantity: 0,
|
|
inBattle: true,
|
|
},
|
|
"20": {
|
|
race: "Beta",
|
|
className: "Spy",
|
|
tech: {},
|
|
num: 2,
|
|
numLeft: 0,
|
|
loadType: "EMP",
|
|
loadQuantity: 0,
|
|
inBattle: true,
|
|
},
|
|
"99": {
|
|
race: "Gamma",
|
|
className: "Observer",
|
|
tech: {},
|
|
num: 4,
|
|
numLeft: 4,
|
|
loadType: "EMP",
|
|
loadQuantity: 0,
|
|
inBattle: false,
|
|
},
|
|
},
|
|
protocol: [
|
|
{ a: 0, sa: 10, d: 1, sd: 20, x: false },
|
|
{ a: 1, sa: 20, d: 0, sd: 10, x: true },
|
|
{ a: 0, sa: 10, d: 1, sd: 20, x: true },
|
|
{ a: 0, sa: 10, d: 1, sd: 20, x: true },
|
|
],
|
|
};
|
|
|
|
describe("buildGroupRaceMap", () => {
|
|
it("derives group → race from protocol entries", () => {
|
|
const map = buildGroupRaceMap(TWO_RACE_BATTLE.protocol);
|
|
expect(map.get(10)).toBe(0);
|
|
expect(map.get(20)).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("normaliseGroups", () => {
|
|
it("returns only in-battle groups with race index attached", () => {
|
|
const groups = normaliseGroups(TWO_RACE_BATTLE);
|
|
expect(groups.map((g) => g.key).sort((a, b) => a - b)).toEqual([10, 20]);
|
|
expect(groups.every((g) => g.group.inBattle)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("buildFrames", () => {
|
|
it("produces protocol.length + 1 frames", () => {
|
|
const frames = buildFrames(TWO_RACE_BATTLE);
|
|
expect(frames).toHaveLength(TWO_RACE_BATTLE.protocol.length + 1);
|
|
});
|
|
|
|
it("frame 0 reports initial ship counts and all active races", () => {
|
|
const [first] = buildFrames(TWO_RACE_BATTLE);
|
|
expect(first.shotIndex).toBe(0);
|
|
expect(first.lastAction).toBeNull();
|
|
expect(first.remaining.get(10)).toBe(3);
|
|
expect(first.remaining.get(20)).toBe(2);
|
|
expect(first.activeRaceIds).toEqual([0, 1]);
|
|
});
|
|
|
|
it("decrements destroyed defenders only on x === true", () => {
|
|
const frames = buildFrames(TWO_RACE_BATTLE);
|
|
// Action 1: x=false → no decrement on defender 20.
|
|
expect(frames[1].remaining.get(20)).toBe(2);
|
|
// Action 2: x=true → attacker is race 1 group 20, defender
|
|
// is race 0 group 10 → group 10 drops 3→2.
|
|
expect(frames[2].remaining.get(10)).toBe(2);
|
|
});
|
|
|
|
it("drops a race from activeRaceIds once its last in-battle group reaches zero", () => {
|
|
const frames = buildFrames(TWO_RACE_BATTLE);
|
|
// After the 4-th action both Beta ships have been destroyed.
|
|
expect(frames[4].remaining.get(20)).toBe(0);
|
|
expect(frames[4].activeRaceIds).toEqual([0]);
|
|
});
|
|
});
|
|
|
|
describe("buildFrames phantom-destroy clamp", () => {
|
|
it("does not drop a race when destroyed shots exceed initial counts", () => {
|
|
// Race "Phantom" has a single group with 2 ships; the engine
|
|
// emits five Destroyed shots against it (legacy emitter quirk
|
|
// reproduced in KNNTS041 planet #7). The group goes to 0
|
|
// after two real destroys; the remaining three are phantoms
|
|
// and must not push raceTotals into negatives or drop the
|
|
// race from activeRaceIds prematurely. Race "Survivor" keeps
|
|
// its single ship throughout so it stays active alongside
|
|
// Phantom until Phantom legitimately empties.
|
|
const report: BattleReport = {
|
|
id: "phantom-battle",
|
|
planet: 1,
|
|
planetName: "P",
|
|
races: { "0": "phantom-uuid", "1": "survivor-uuid" },
|
|
ships: {
|
|
"10": {
|
|
race: "Phantom",
|
|
className: "Drone",
|
|
tech: {},
|
|
num: 2,
|
|
numLeft: 0,
|
|
loadType: "",
|
|
loadQuantity: 0,
|
|
inBattle: true,
|
|
},
|
|
"20": {
|
|
race: "Survivor",
|
|
className: "Hawk",
|
|
tech: {},
|
|
num: 1,
|
|
numLeft: 1,
|
|
loadType: "",
|
|
loadQuantity: 0,
|
|
inBattle: true,
|
|
},
|
|
},
|
|
protocol: [
|
|
{ a: 1, sa: 20, d: 0, sd: 10, x: true }, // real kill #1
|
|
{ a: 1, sa: 20, d: 0, sd: 10, x: true }, // real kill #2 → group=0
|
|
{ a: 1, sa: 20, d: 0, sd: 10, x: true }, // phantom #1
|
|
{ a: 1, sa: 20, d: 0, sd: 10, x: true }, // phantom #2
|
|
{ a: 1, sa: 20, d: 0, sd: 10, x: true }, // phantom #3
|
|
],
|
|
};
|
|
const frames = buildFrames(report);
|
|
expect(frames[2].remaining.get(10)).toBe(0);
|
|
// After the 2nd real destroy Phantom has 0 ships in its only
|
|
// group and must drop out of activeRaceIds.
|
|
expect(frames[2].activeRaceIds).toEqual([1]);
|
|
// Phantoms past frame 2 must NOT keep decrementing — group
|
|
// stays at 0, totals don't go negative, and Survivor remains
|
|
// the only active race for the remainder of the protocol.
|
|
expect(frames[5].remaining.get(10)).toBe(0);
|
|
expect(frames[5].activeRaceIds).toEqual([1]);
|
|
});
|
|
|
|
it("keeps a race active while phantom destroys hit one of its empty groups", () => {
|
|
// One race ("Doublet"), two groups of different class. Class
|
|
// A gets all five Destroyed shots; class B never gets hit.
|
|
// Class A only has 2 ships → 3 phantoms. The race must stay
|
|
// active because class B's single ship is intact.
|
|
const report: BattleReport = {
|
|
id: "doublet-battle",
|
|
planet: 2,
|
|
planetName: "P2",
|
|
races: { "0": "doublet-uuid", "1": "attacker-uuid" },
|
|
ships: {
|
|
"10": {
|
|
race: "Doublet",
|
|
className: "A",
|
|
tech: {},
|
|
num: 2,
|
|
numLeft: 0,
|
|
loadType: "",
|
|
loadQuantity: 0,
|
|
inBattle: true,
|
|
},
|
|
"11": {
|
|
race: "Doublet",
|
|
className: "B",
|
|
tech: {},
|
|
num: 1,
|
|
numLeft: 1,
|
|
loadType: "",
|
|
loadQuantity: 0,
|
|
inBattle: true,
|
|
},
|
|
"20": {
|
|
race: "Attacker",
|
|
className: "Gun",
|
|
tech: {},
|
|
num: 1,
|
|
numLeft: 1,
|
|
loadType: "",
|
|
loadQuantity: 0,
|
|
inBattle: true,
|
|
},
|
|
},
|
|
protocol: [
|
|
// Open the protocol with a shot that names class B so
|
|
// normaliseGroups picks it up (groups never referenced
|
|
// in the protocol are filtered out of the visual
|
|
// roster); the shot misses so class B stays intact.
|
|
{ a: 1, sa: 20, d: 0, sd: 11, x: false },
|
|
{ a: 1, sa: 20, d: 0, sd: 10, x: true },
|
|
{ a: 1, sa: 20, d: 0, sd: 10, x: true },
|
|
{ a: 1, sa: 20, d: 0, sd: 10, x: true },
|
|
{ a: 1, sa: 20, d: 0, sd: 10, x: true },
|
|
{ a: 1, sa: 20, d: 0, sd: 10, x: true },
|
|
],
|
|
};
|
|
const frames = buildFrames(report);
|
|
// After all 6 actions: Doublet:A is at 0 (group capped at 2
|
|
// real destroys + 3 phantoms), Doublet:B unchanged at 1, so
|
|
// race totals = 1 → race stays active.
|
|
expect(frames[6].remaining.get(10)).toBe(0);
|
|
expect(frames[6].remaining.get(11)).toBe(1);
|
|
expect(frames[6].activeRaceIds.sort()).toEqual([0, 1]);
|
|
});
|
|
});
|
|
|
|
describe("radiusForMass", () => {
|
|
it("returns MAX_RADIUS when mass is zero", () => {
|
|
expect(radiusForMass(0, 100)).toBe(MAX_RADIUS);
|
|
});
|
|
|
|
it("returns MAX_RADIUS when maxMassInBattle is zero", () => {
|
|
expect(radiusForMass(50, 0)).toBe(MAX_RADIUS);
|
|
});
|
|
|
|
it("returns MAX_RADIUS at the per-battle ceiling", () => {
|
|
expect(radiusForMass(100, 100)).toBeCloseTo(MAX_RADIUS, 5);
|
|
});
|
|
|
|
it("scales by sqrt(mass / maxMass): one quarter of max mass = halfway", () => {
|
|
const r = radiusForMass(25, 100);
|
|
const expected = MIN_RADIUS + (MAX_RADIUS - MIN_RADIUS) * 0.5;
|
|
expect(r).toBeCloseTo(expected, 5);
|
|
});
|
|
|
|
it("never returns a radius below MIN_RADIUS or above MAX_RADIUS", () => {
|
|
expect(radiusForMass(1, Number.MAX_SAFE_INTEGER)).toBeGreaterThanOrEqual(MIN_RADIUS);
|
|
expect(radiusForMass(Number.MAX_SAFE_INTEGER, 1)).toBeLessThanOrEqual(MAX_RADIUS);
|
|
});
|
|
});
|
|
|