Files
galaxy-game/ui/frontend/tests/battle-player.test.ts
T
Ilia Denisov 17a3afd5e9 ui/phase-27: viewer polish + phantom-destroy clamp
Nine BattleViewer refinements from the latest review pass:

1. Mass radii were uniform in synthetic mode because
   `+layout.svelte` skipped `loadCore()` on the synthetic branch.
   The wasm bridge to `pkg/calc/ship.go` now boots in both modes
   so `computeBattleGroupMass` resolves a real FullMass and
   `radiusForMass` produces a per-battle scale.

2. Phantom-destroy clamp in `buildFrames`. Legacy emitters
   (KNNTS041 planet #7) log many more `Destroyed` lines against a
   group than the group's initial population — at frame 406 of
   2317 the race totals previously hit zero on phantom shots and
   the scene blanked while playback continued silently. We now
   only shrink the per-group remaining count and the race totals
   when the group still has ships. The line still draws on
   phantom frames; only the counters stay sane.

3. Vogel sunflower positions are now reassigned by inward dot
   product before being handed to ranks: the rank-0 bucket — the
   one with the largest initial ship count — always lands at the
   most-inward spiral slot. The previous quarter-step anchor bias
   was too weak; ranks r ≥ 2 routinely overtook rank-0 toward
   the planet. The anchor offset is gone.

4. Bucket order inside a cluster is locked at battle start by
   each bucket's *initial* ship count (`num`), not its live
   `numLeft`. The position of every class circle stays put for
   the whole battle; only the label number changes as ships die.

5. Shot line + defender flash blink on a per-frame timer during
   play. The line stays on for the first 90 % of frame duration,
   off for the last 10 %, so two consecutive shots from the same
   attacker on the same defender look like two distinct pulses.
   On pause the line and flash stay drawn for inspection.

6. The defender's class circle now flashes red (destroyed) or
   green (shielded) in sync with the shot line, so the eye
   catches *who* was hit, not just where the line lands.

7. Battle log rows are buttons. Click / Enter / Space pauses
   playback and seeks to that shot. The list also auto-scrolls
   the current row into view so the highlight does not race off
   the bottom on long battles.

8. Race labels now sit above the cloud's bounding top instead of
   a fixed offset, so a dense cluster does not swallow its own
   race name.

9. Planet glyph + label switch to neutral grey
   (`#2a2f40` / `#4a5066` / `#6d7388`), keeping the planet "in the
   background" rather than competing with the combatants.

Step-back icon switched to `◀︎◀︎` to mirror step-forward.

Tests: two new Vitest cases cover the phantom-destroy clamp
(single-race wipe, mixed-class race survives a class wipe). The
existing 642 Vitest tests stay green; all four `battle-viewer`
Playwright cases pass.

Docs: `ui/docs/battle-viewer-ux.md` rewrites the cluster section
(locked order + Vogel reassignment), adds Playback Details (blink
+ flash semantics), and a Phantom Destroys section explaining the
clamp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 16:44:46 +02:00

300 lines
9.1 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 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]);
});
});
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);
});
});