ui/phase-27: mass-based circles + cloud cluster + height fit
Three Phase-27 BattleViewer refinements on top of the radial scene:
1. Height fit. The viewer is pinned to `calc(100dvh − 80px)` so it
never pushes the in-game shell past the viewport. `.active-view`
gains `overflow: hidden` + flex column; `.viewer` becomes a
`flex: 1` child; the always-visible text log shrinks to a 30 dvh
ceiling with its own scroll. A global `body { margin: 0 }`
reset (added to `app.html`) plugs the 16 px the browser's
default body margin used to leak.
2. Mass-based ship-class circles. New `lib/battle-player/mass.ts`
carries the radius formula and the per-battle FullMass compute:
`MIN_RADIUS + (MAX_RADIUS − MIN_RADIUS) * sqrt(mass / max)`,
clamped to `[6, 24] px`. FullMass goes through the existing
wasm bridge (`emptyMass` → `carryingMass` → `fullMass`) — no
new wire fields. The viewer page resolves a
`(race, className) → ShipClassRef` lookup from the parent
GameReport's `localShipClass` + `otherShipClass` tables and
passes it to the viewer via context. Unknown class or
degenerate (weapons/armament) params fall back to MAX_RADIUS
so the bucket stays visible.
3. Cloud cluster layout. Cluster key shifts from per-group
`g.key` to `(raceId, className)` so tech-variants of the same
hull collapse into one visual bucket. The horizontal
classCircleX row is replaced by a Vogel sunflower spiral in
the local `(u, v)` basis — `u` points from the race anchor to
the planet, `v` is `u` rotated 90° clockwise. Buckets are
sorted by NumberLeft desc; the cluster anchor is pushed inward
by a quarter step so rank-0 sits closest to the planet. The
step is adaptive (`min(baseStep, MAX_CLUSTER_RADIUS / sqrt(N))`)
so clusters with many classes do not spill into neighbours.
Tests:
- Vitest: `radiusForMass` covering zero / max / quarter-mass /
out-of-range cases (6 cases).
- Playwright: new `battle-viewer.spec.ts` case asserts
`document.documentElement.scrollHeight - window.innerHeight ≤ 4`
at a 1280×720 desktop viewport. The existing fixture gains
`localShipClass` + `otherShipClass` so the lookup has data to
render proportional circles.
Docs: `ui/docs/battle-viewer-ux.md` rewrites the "Radial scene"
section (cloud layout, mass-based radius, height fit) and adds
a "Height fit" subsection. `docs/FUNCTIONAL.md` §6.5 (+ ru
mirror) get the one-line story about per-mass sizing, cluster
aggregation, and the viewport-locked layout.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+11
-3
@@ -712,10 +712,18 @@ which forwards verbatim to the engine's
|
|||||||
|
|
||||||
Visual model is radial: the planet sits at the centre, races are
|
Visual model is radial: the planet sits at the centre, races are
|
||||||
placed at equal angular spacing on an outer ring, and each race is
|
placed at equal angular spacing on an outer ring, and each race is
|
||||||
rendered as a horizontal cluster of small ship-class circles
|
rendered as a cloud of ship-class circles arranged on a Vogel
|
||||||
labelled `<className>:<numLeft>`. Observer groups (`inBattle:
|
sunflower spiral biased toward the planet (the largest group by
|
||||||
|
NumberLeft sits closest to the planet, lighter buckets fan behind).
|
||||||
|
Tech-variants of the same `(race, className)` collapse into one
|
||||||
|
visual bucket labelled `<className>:<numLeft>`; per-class detail
|
||||||
|
stays available in the Reports view. Circle radius scales with
|
||||||
|
per-ship FullMass (range `[6, 24] px`, per-battle normalisation)
|
||||||
|
so heavy ships visually dominate. Observer groups (`inBattle:
|
||||||
false`) are not drawn. Eliminated races drop out and the survivors
|
false`) are not drawn. Eliminated races drop out and the survivors
|
||||||
re-spread on the next frame.
|
re-spread on the next frame. The viewer is pinned to the viewport
|
||||||
|
(scene grows, log scrolls internally) so no page-level scroll
|
||||||
|
appears.
|
||||||
|
|
||||||
Each frame is one protocol entry; the shot is drawn as a thin line
|
Each frame is one protocol entry; the shot is drawn as a thin line
|
||||||
from attacker to defender, red on `destroyed`, green otherwise.
|
from attacker to defender, red on `destroyed`, green otherwise.
|
||||||
|
|||||||
+13
-5
@@ -729,11 +729,19 @@ Battle Viewer — отдельное представление, заменяю
|
|||||||
`GET /api/v1/battle/:turn/:uuid`.
|
`GET /api/v1/battle/:turn/:uuid`.
|
||||||
|
|
||||||
Визуальная модель — радиальная: планета в центре, расы по внешней
|
Визуальная модель — радиальная: планета в центре, расы по внешней
|
||||||
окружности на равных угловых интервалах, внутри расы — горизонтальный
|
окружности на равных угловых интервалах, внутри расы — облако
|
||||||
кластер маленьких кружков по классам кораблей с подписями
|
кружков по классам кораблей, выложенное Vogel-спиралью с биасом к
|
||||||
`<className>:<numLeft>` под каждым. Наблюдатели (`inBattle: false`)
|
планете (самая многочисленная группа по NumberLeft — ближе к
|
||||||
не рисуются. Выбывшие расы убираются из сцены, оставшиеся
|
планете, остальные раскручиваются спиралью позади). Tech-варианты
|
||||||
перераспределяются на следующем кадре.
|
одного `(race, className)` схлопываются в один визуальный нод
|
||||||
|
`<className>:<numLeft>`; детали по тех-уровням остаются в Reports.
|
||||||
|
Радиус кружка масштабируется по FullMass корабля (диапазон
|
||||||
|
`[6, 24] px`, нормировка на самую тяжёлую группу в битве), так что
|
||||||
|
тяжёлые корабли визуально доминируют. Наблюдатели (`inBattle:
|
||||||
|
false`) не рисуются. Выбывшие расы убираются из сцены, оставшиеся
|
||||||
|
перераспределяются на следующем кадре. Viewer закреплён по высоте
|
||||||
|
viewport-а: сцена растягивается, лог скроллит внутри — никаких
|
||||||
|
скроллов на уровне страницы.
|
||||||
|
|
||||||
Каждый кадр — одна запись протокола; выстрел рисуется тонкой линией
|
Каждый кадр — одна запись протокола; выстрел рисуется тонкой линией
|
||||||
от атакующего к защитнику, красной при `destroyed`, зелёной иначе.
|
от атакующего к защитнику, красной при `destroyed`, зелёной иначе.
|
||||||
|
|||||||
@@ -32,12 +32,33 @@ and the engine never crosses these wires.
|
|||||||
|
|
||||||
The scene (`lib/battle-player/battle-scene.svelte`, SVG) places the
|
The scene (`lib/battle-player/battle-scene.svelte`, SVG) places the
|
||||||
planet at the centre and arrays the still-active races on an outer
|
planet at the centre and arrays the still-active races on an outer
|
||||||
ring at equal angular spacing. Each race anchor is a horizontal
|
ring at equal angular spacing. Each race anchor hosts a *cloud* of
|
||||||
cluster of small class circles, one per `(race, className)` pair,
|
class circles arranged on a Vogel sunflower spiral biased toward the
|
||||||
labelled `<className>:<numLeft>` underneath. When a race is wiped
|
planet (the cluster anchor is pushed inward by a quarter step so the
|
||||||
out, it drops out of the active list and the survivors are
|
rank-0 node — the heaviest group by NumberLeft — sits closest to the
|
||||||
|
planet, and the spiral fans the rest behind it). When a race is
|
||||||
|
wiped out, it drops out of the active list and the survivors are
|
||||||
re-spaced on the next frame.
|
re-spaced on the next frame.
|
||||||
|
|
||||||
|
Each class circle is one *bucket* keyed by `(race, className)`:
|
||||||
|
tech-variants of the same class collapse into one node so the scene
|
||||||
|
stays readable when a race fields a dozen tech levels of the same
|
||||||
|
hull. The per-bucket label `<className>:<numLeft>` sums NumberLeft
|
||||||
|
across the underlying groups; per-tech detail is available in the
|
||||||
|
Reports view (Foreign Ship Classes / My Ship Types).
|
||||||
|
|
||||||
|
Circle radius scales with per-ship FullMass (Empty + Carrying via
|
||||||
|
the per-ship `LoadQuantity`). The viewer resolves a
|
||||||
|
`(race, className) → ShipClassRef` lookup from the surrounding
|
||||||
|
`GameReport.localShipClass` + `otherShipClass` tables and runs it
|
||||||
|
through the existing wasm bridge to `pkg/calc/ship.go`
|
||||||
|
(`emptyMass` + `carryingMass` + `fullMass`). The radius is then
|
||||||
|
`MIN_RADIUS + (MAX_RADIUS − MIN_RADIUS) × sqrt(mass / maxMassInBattle)`
|
||||||
|
clamped to `[6, 24]` pixels — per-battle normalisation, so the
|
||||||
|
heaviest ship in any given battle renders at the cap. Unknown class
|
||||||
|
or invalid params fall back to MAX_RADIUS so the bucket stays
|
||||||
|
visible.
|
||||||
|
|
||||||
The current frame's shot is drawn as a thin line from the attacker's
|
The current frame's shot is drawn as a thin line from the attacker's
|
||||||
class circle to the defender's class circle. Colour:
|
class circle to the defender's class circle. Colour:
|
||||||
|
|
||||||
@@ -74,6 +95,17 @@ the log instead of watching the SVG. The list is always present
|
|||||||
and never hidden, satisfying the original Phase 27 acceptance "the
|
and never hidden, satisfying the original Phase 27 acceptance "the
|
||||||
same data is accessible as a static text log".
|
same data is accessible as a static text log".
|
||||||
|
|
||||||
|
## Height fit
|
||||||
|
|
||||||
|
The viewer is pinned to the viewport: `.active-view` uses
|
||||||
|
`calc(100dvh − 80px)` so the in-game-shell header + optional
|
||||||
|
HistoryBanner do not push the scene below the fold. Inside the
|
||||||
|
viewer, the scene grows (`flex: 1`) and the log shrinks to a
|
||||||
|
30 dvh ceiling with its own internal scroll, so the page itself
|
||||||
|
never scrolls vertically. The 80 px allowance maps to the current
|
||||||
|
Header + HistoryBanner total on desktop; mobile breakpoints reuse
|
||||||
|
the same calc because dvh tracks the dynamic viewport.
|
||||||
|
|
||||||
## Map markers
|
## Map markers
|
||||||
|
|
||||||
`map/battle-markers.ts` emits two marker kinds per
|
`map/battle-markers.ts` emits two marker kinds per
|
||||||
|
|||||||
@@ -5,6 +5,12 @@
|
|||||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Galaxy</title>
|
<title>Galaxy</title>
|
||||||
|
<style>
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
@@ -2,11 +2,16 @@
|
|||||||
Phase 27 — active-view wrapper around the BattleViewer. Loads the
|
Phase 27 — active-view wrapper around the BattleViewer. Loads the
|
||||||
BattleReport for the supplied `gameId`/`turn`/`battleId` and either
|
BattleReport for the supplied `gameId`/`turn`/`battleId` and either
|
||||||
shows the radial playback (BattleViewer), a loading skeleton, or a
|
shows the radial playback (BattleViewer), a loading skeleton, or a
|
||||||
not-found state. The viewer itself is a logically isolated
|
not-found state.
|
||||||
component that takes a `BattleReport` prop — this wrapper owns
|
|
||||||
loading and routing concerns.
|
This wrapper also bridges the surrounding GameReport's ship-class
|
||||||
|
tables into a `(race, className) → ShipClassRef` lookup the viewer
|
||||||
|
needs to size class circles by ship mass. The viewer remains
|
||||||
|
prop-driven; we just resolve the lookup once here so the lower
|
||||||
|
component does not have to know about `RenderedReportSource`.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +20,15 @@ loading and routing concerns.
|
|||||||
type BattleReport,
|
type BattleReport,
|
||||||
} from "../../api/battle-fetch";
|
} from "../../api/battle-fetch";
|
||||||
import { i18n } from "$lib/i18n/index.svelte";
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
|
import {
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
type RenderedReportSource,
|
||||||
|
} from "$lib/rendered-report.svelte";
|
||||||
|
import {
|
||||||
|
MapShipClassLookup,
|
||||||
|
type ShipClassLookup,
|
||||||
|
type ShipClassRef,
|
||||||
|
} from "../battle-player/mass";
|
||||||
|
|
||||||
import BattleViewer from "../battle-player/battle-viewer.svelte";
|
import BattleViewer from "../battle-player/battle-viewer.svelte";
|
||||||
|
|
||||||
@@ -28,6 +42,42 @@ loading and routing concerns.
|
|||||||
battleId: string;
|
battleId: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
const rendered = getContext<RenderedReportSource | undefined>(
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
);
|
||||||
|
|
||||||
|
// shipClassLookup turns `(race, className)` into the five class
|
||||||
|
// parameters required by `calc.EmptyMass`. Local classes belong
|
||||||
|
// to the report recipient (`report.race`); foreign classes carry
|
||||||
|
// their own `race` field. Lookup is cheap to rebuild whenever the
|
||||||
|
// report changes — the active-view-host re-renders on turn flips
|
||||||
|
// anyway.
|
||||||
|
const shipClassLookup = $derived.by<ShipClassLookup>(() => {
|
||||||
|
const map = new Map<string, ShipClassRef>();
|
||||||
|
const report = rendered?.report;
|
||||||
|
if (report) {
|
||||||
|
for (const cls of report.localShipClass) {
|
||||||
|
map.set(`${report.race}::${cls.name}`, {
|
||||||
|
drive: cls.drive,
|
||||||
|
weapons: cls.weapons,
|
||||||
|
armament: cls.armament,
|
||||||
|
shields: cls.shields,
|
||||||
|
cargo: cls.cargo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const cls of report.otherShipClass) {
|
||||||
|
map.set(`${cls.race}::${cls.name}`, {
|
||||||
|
drive: cls.drive,
|
||||||
|
weapons: cls.weapons,
|
||||||
|
armament: cls.armament,
|
||||||
|
shields: cls.shields,
|
||||||
|
cargo: cls.cargo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new MapShipClassLookup(map);
|
||||||
|
});
|
||||||
|
|
||||||
let state = $state<
|
let state = $state<
|
||||||
| { kind: "loading" }
|
| { kind: "loading" }
|
||||||
| { kind: "ready"; report: BattleReport }
|
| { kind: "ready"; report: BattleReport }
|
||||||
@@ -86,7 +136,7 @@ loading and routing concerns.
|
|||||||
{i18n.t("game.battle.loading")}
|
{i18n.t("game.battle.loading")}
|
||||||
</p>
|
</p>
|
||||||
{:else if state.kind === "ready"}
|
{:else if state.kind === "ready"}
|
||||||
<BattleViewer report={state.report} />
|
<BattleViewer report={state.report} shipClassLookup={shipClassLookup} />
|
||||||
{:else if state.kind === "not_found"}
|
{:else if state.kind === "not_found"}
|
||||||
<p class="status" data-testid="battle-not-found">
|
<p class="status" data-testid="battle-not-found">
|
||||||
{i18n.t("game.battle.not_found")}
|
{i18n.t("game.battle.not_found")}
|
||||||
@@ -98,7 +148,26 @@ loading and routing concerns.
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.active-view {
|
.active-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
/*
|
||||||
|
* The in-game shell renders this active view inside an
|
||||||
|
* `.active-view-host` with `flex: 1; overflow-y: auto`, but
|
||||||
|
* the surrounding `.game-shell` uses `min-height: 100vh`,
|
||||||
|
* so without a hard upper bound the viewer pushes the
|
||||||
|
* whole shell past the viewport. We pin the active view to
|
||||||
|
* `100dvh` minus a small allowance for the header chrome
|
||||||
|
* (in-game Header + optional HistoryBanner = ~66 px on
|
||||||
|
* desktop) so the internal flex chain can split the
|
||||||
|
* remaining height between the scene and the always-
|
||||||
|
* visible log without forcing a page-level scroll.
|
||||||
|
*/
|
||||||
|
height: calc(100dvh - 80px);
|
||||||
|
max-height: calc(100dvh - 80px);
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
font-family: system-ui, sans-serif;
|
font-family: system-ui, sans-serif;
|
||||||
color: #d6dcf2;
|
color: #d6dcf2;
|
||||||
}
|
}
|
||||||
@@ -107,6 +176,7 @@ loading and routing concerns.
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
max-width: 880px;
|
max-width: 880px;
|
||||||
margin: 0 auto 1rem;
|
margin: 0 auto 1rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
.back-btn {
|
.back-btn {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
|
|||||||
@@ -2,16 +2,33 @@
|
|||||||
BattleScene — radial SVG visualisation of one battle frame.
|
BattleScene — radial SVG visualisation of one battle frame.
|
||||||
|
|
||||||
Layout: planet at the centre, race anchors equally spaced on an
|
Layout: planet at the centre, race anchors equally spaced on an
|
||||||
outer ring, each race rendered as a cluster of small class circles
|
outer ring, each race rendered as a *cloud* of class circles
|
||||||
labelled `<className>:<numLeft>` underneath. The shot line for the
|
arranged on a Vogel sunflower spiral biased toward the planet.
|
||||||
current frame's `lastAction` is drawn from attacker group to
|
Tech-variant groups of the same (race, className) collapse to one
|
||||||
defender group; red when the shot destroyed the defender, green
|
visual node — the per-tech detail lives in Reports. Each circle's
|
||||||
otherwise. Observer groups (`inBattle === false`) are filtered out
|
radius scales with the per-ship FullMass (sqrt) so heavy ships
|
||||||
by `buildFrames`, so they never appear here.
|
visually dominate.
|
||||||
|
|
||||||
|
Observer groups (`inBattle === false`) are filtered out by
|
||||||
|
`buildFrames`, so they never appear here. Same-race opponents are
|
||||||
|
forbidden by the engine's combat filter, so a shot can never
|
||||||
|
collapse to a single visual node.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
|
||||||
import type { BattleReport } from "../../api/battle-fetch";
|
import type { BattleReport } from "../../api/battle-fetch";
|
||||||
|
import {
|
||||||
|
CORE_CONTEXT_KEY,
|
||||||
|
type CoreHandle,
|
||||||
|
} from "$lib/core-context.svelte";
|
||||||
import { layoutRaces } from "./radial-layout";
|
import { layoutRaces } from "./radial-layout";
|
||||||
|
import {
|
||||||
|
computeBattleGroupMass,
|
||||||
|
radiusForMass,
|
||||||
|
MAX_RADIUS,
|
||||||
|
type ShipClassLookup,
|
||||||
|
} from "./mass";
|
||||||
import {
|
import {
|
||||||
buildGroupRaceMap,
|
buildGroupRaceMap,
|
||||||
normaliseGroups,
|
normaliseGroups,
|
||||||
@@ -21,50 +38,113 @@ by `buildFrames`, so they never appear here.
|
|||||||
let {
|
let {
|
||||||
report,
|
report,
|
||||||
frame,
|
frame,
|
||||||
|
shipClassLookup,
|
||||||
}: {
|
}: {
|
||||||
report: BattleReport;
|
report: BattleReport;
|
||||||
frame: Frame;
|
frame: Frame;
|
||||||
|
shipClassLookup?: ShipClassLookup;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const VIEW_BOX = 800;
|
const VIEW_BOX = 800;
|
||||||
const CENTER = { x: VIEW_BOX / 2, y: VIEW_BOX / 2 };
|
const CENTER = { x: VIEW_BOX / 2, y: VIEW_BOX / 2 };
|
||||||
const PLANET_RADIUS = 60;
|
const PLANET_RADIUS = 60;
|
||||||
const RACE_RING_RADIUS = 280;
|
const RACE_RING_RADIUS = 280;
|
||||||
const CLASS_CIRCLE_RADIUS = 24;
|
// Vogel sunflower step + half-circle bias toward planet.
|
||||||
const CLASS_SPACING = 64;
|
const BASE_STEP = 1.8 * MAX_RADIUS;
|
||||||
|
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
|
||||||
|
const ANCHOR_BIAS = 0.25; // fraction of step pushed toward planet
|
||||||
|
const MAX_CLUSTER_RADIUS = 0.6 * (RACE_RING_RADIUS - PLANET_RADIUS);
|
||||||
|
|
||||||
|
const coreHandle = getContext<CoreHandle | undefined>(CORE_CONTEXT_KEY);
|
||||||
|
|
||||||
const groupRace = $derived(buildGroupRaceMap(report.protocol));
|
const groupRace = $derived(buildGroupRaceMap(report.protocol));
|
||||||
const allGroups = $derived(normaliseGroups(report));
|
const allGroups = $derived(normaliseGroups(report));
|
||||||
|
|
||||||
type ClusterEntry = {
|
type ClusterEntry = {
|
||||||
key: number;
|
bucketKey: string;
|
||||||
className: string;
|
className: string;
|
||||||
|
race: string;
|
||||||
|
raceId: number;
|
||||||
|
groupKeys: number[];
|
||||||
numLeft: number;
|
numLeft: number;
|
||||||
|
mass: number;
|
||||||
|
radius: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Aggregate every `(raceId, className)` into a single bucket and
|
||||||
|
// compute per-bucket NumberLeft (sum across tech-variants) and
|
||||||
|
// per-ship FullMass via the wasm bridge. mass=0 when the class
|
||||||
|
// either doesn't resolve in the lookup or the calc rejects its
|
||||||
|
// params; downstream `radiusForMass` falls back to MAX_RADIUS for
|
||||||
|
// those nodes.
|
||||||
const clustersByRace = $derived.by(() => {
|
const clustersByRace = $derived.by(() => {
|
||||||
|
const core = coreHandle?.core ?? null;
|
||||||
|
// First pass: build the bucket list per race.
|
||||||
const out = new Map<number, ClusterEntry[]>();
|
const out = new Map<number, ClusterEntry[]>();
|
||||||
|
const bucketIndex = new Map<string, ClusterEntry>();
|
||||||
for (const g of allGroups) {
|
for (const g of allGroups) {
|
||||||
const numLeft = frame.remaining.get(g.key) ?? 0;
|
const bucketKey = `${g.raceId}::${g.group.className}`;
|
||||||
const list = out.get(g.raceId) ?? [];
|
let bucket = bucketIndex.get(bucketKey);
|
||||||
list.push({
|
if (bucket === undefined) {
|
||||||
key: g.key,
|
const classDef =
|
||||||
|
shipClassLookup?.get(g.group.race, g.group.className) ?? null;
|
||||||
|
const mass = core
|
||||||
|
? computeBattleGroupMass(g.group, classDef, core)
|
||||||
|
: 0;
|
||||||
|
bucket = {
|
||||||
|
bucketKey,
|
||||||
className: g.group.className,
|
className: g.group.className,
|
||||||
numLeft,
|
race: g.group.race,
|
||||||
});
|
raceId: g.raceId,
|
||||||
|
groupKeys: [],
|
||||||
|
numLeft: 0,
|
||||||
|
mass,
|
||||||
|
radius: MAX_RADIUS,
|
||||||
|
};
|
||||||
|
bucketIndex.set(bucketKey, bucket);
|
||||||
|
const list = out.get(g.raceId) ?? [];
|
||||||
|
list.push(bucket);
|
||||||
out.set(g.raceId, list);
|
out.set(g.raceId, list);
|
||||||
}
|
}
|
||||||
// Stable cluster order: by classname then key.
|
bucket.groupKeys.push(g.key);
|
||||||
|
bucket.numLeft += frame.remaining.get(g.key) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-battle mass normalisation: the heaviest visual node
|
||||||
|
// renders at MAX_RADIUS; lighter ones scale by sqrt(m/max).
|
||||||
|
let maxMass = 0;
|
||||||
|
for (const bucket of bucketIndex.values()) {
|
||||||
|
if (bucket.mass > maxMass) maxMass = bucket.mass;
|
||||||
|
}
|
||||||
|
for (const bucket of bucketIndex.values()) {
|
||||||
|
bucket.radius = radiusForMass(bucket.mass, maxMass);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort buckets in each cluster by NumberLeft desc → rank 0 is
|
||||||
|
// the biggest group (will be placed closest to planet).
|
||||||
for (const list of out.values()) {
|
for (const list of out.values()) {
|
||||||
list.sort((a, b) => {
|
list.sort((a, b) => {
|
||||||
const byName = a.className.localeCompare(b.className);
|
if (b.numLeft !== a.numLeft) return b.numLeft - a.numLeft;
|
||||||
if (byName !== 0) return byName;
|
return a.className.localeCompare(b.className);
|
||||||
return a.key - b.key;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// bucketByGroupKey lets shot endpoints look up the aggregated
|
||||||
|
// node by any of its constituent ship-group keys.
|
||||||
|
const bucketByGroupKey = $derived.by(() => {
|
||||||
|
const out = new Map<number, ClusterEntry>();
|
||||||
|
for (const list of clustersByRace.values()) {
|
||||||
|
for (const bucket of list) {
|
||||||
|
for (const key of bucket.groupKeys) {
|
||||||
|
out.set(key, bucket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
const raceLayout = $derived(
|
const raceLayout = $derived(
|
||||||
layoutRaces(frame.activeRaceIds, {
|
layoutRaces(frame.activeRaceIds, {
|
||||||
center: CENTER,
|
center: CENTER,
|
||||||
@@ -72,23 +152,62 @@ by `buildFrames`, so they never appear here.
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
function classCircleX(index: number, count: number): number {
|
type ClusterBasis = {
|
||||||
const span = (count - 1) * CLASS_SPACING;
|
anchorX: number;
|
||||||
return -span / 2 + index * CLASS_SPACING;
|
anchorY: number;
|
||||||
|
ux: number;
|
||||||
|
uy: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
step: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// clusterBasisById carries the local frame for every active
|
||||||
|
// race's cluster: `u` points from the race anchor toward the
|
||||||
|
// planet, `v` is `u` rotated 90° clockwise, and `step` is the
|
||||||
|
// adaptive Vogel-spiral step (scaled down when many class
|
||||||
|
// buckets share a cluster to stay inside MAX_CLUSTER_RADIUS).
|
||||||
|
const clusterBasisById = $derived.by(() => {
|
||||||
|
const out = new Map<number, ClusterBasis>();
|
||||||
|
for (const anchor of raceLayout) {
|
||||||
|
const dx = CENTER.x - anchor.x;
|
||||||
|
const dy = CENTER.y - anchor.y;
|
||||||
|
const len = Math.hypot(dx, dy) || 1;
|
||||||
|
const ux = dx / len;
|
||||||
|
const uy = dy / len;
|
||||||
|
const vx = uy; // (ux, uy) rotated 90° clockwise → (uy, -ux);
|
||||||
|
const vy = -ux; // use mirror to keep label below cluster.
|
||||||
|
const count = (clustersByRace.get(anchor.raceId) ?? []).length;
|
||||||
|
const baseStep = BASE_STEP;
|
||||||
|
const denom = Math.max(1, Math.sqrt(Math.max(count, 1)));
|
||||||
|
const step = Math.min(baseStep, MAX_CLUSTER_RADIUS / denom);
|
||||||
|
const anchorX = anchor.x + ANCHOR_BIAS * step * ux;
|
||||||
|
const anchorY = anchor.y + ANCHOR_BIAS * step * uy;
|
||||||
|
out.set(anchor.raceId, { anchorX, anchorY, ux, uy, vx, vy, step });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
function nodePosition(basis: ClusterBasis, rank: number) {
|
||||||
|
const radius = basis.step * Math.sqrt(rank);
|
||||||
|
const angle = rank * GOLDEN_ANGLE;
|
||||||
|
const offsetU = radius * Math.cos(angle);
|
||||||
|
const offsetV = radius * Math.sin(angle);
|
||||||
|
return {
|
||||||
|
x: basis.anchorX + offsetU * basis.ux + offsetV * basis.vx,
|
||||||
|
y: basis.anchorY + offsetU * basis.uy + offsetV * basis.vy,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function findClassCircleCenter(groupKey: number) {
|
function findClassCircleCenter(groupKey: number) {
|
||||||
const raceId = groupRace.get(groupKey);
|
const bucket = bucketByGroupKey.get(groupKey);
|
||||||
if (raceId === undefined) return null;
|
if (!bucket) return null;
|
||||||
const anchor = raceLayout.find((a) => a.raceId === raceId);
|
const basis = clusterBasisById.get(bucket.raceId);
|
||||||
if (!anchor) return null;
|
if (!basis) return null;
|
||||||
const cluster = clustersByRace.get(raceId) ?? [];
|
const cluster = clustersByRace.get(bucket.raceId) ?? [];
|
||||||
const idx = cluster.findIndex((c) => c.key === groupKey);
|
const rank = cluster.indexOf(bucket);
|
||||||
if (idx === -1) return null;
|
if (rank === -1) return null;
|
||||||
return {
|
return nodePosition(basis, rank);
|
||||||
x: anchor.x + classCircleX(idx, cluster.length),
|
|
||||||
y: anchor.y,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const shotLine = $derived.by(() => {
|
const shotLine = $derived.by(() => {
|
||||||
@@ -105,6 +224,13 @@ by `buildFrames`, so they never appear here.
|
|||||||
for (const g of allGroups) {
|
for (const g of allGroups) {
|
||||||
out.set(g.raceId, g.group.race);
|
out.set(g.raceId, g.group.race);
|
||||||
}
|
}
|
||||||
|
// `groupRace` covers protocol-derived races even when no group
|
||||||
|
// is left to read from `allGroups` (shouldn't happen with
|
||||||
|
// `inBattle: true` rosters, but keeps the label resolver
|
||||||
|
// defensive).
|
||||||
|
for (const [, raceId] of groupRace) {
|
||||||
|
if (!out.has(raceId)) out.set(raceId, `race ${raceId}`);
|
||||||
|
}
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -112,6 +238,7 @@ by `buildFrames`, so they never appear here.
|
|||||||
<svg
|
<svg
|
||||||
class="battle-scene"
|
class="battle-scene"
|
||||||
viewBox="0 0 {VIEW_BOX} {VIEW_BOX}"
|
viewBox="0 0 {VIEW_BOX} {VIEW_BOX}"
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label="battle scene"
|
aria-label="battle scene"
|
||||||
data-testid="battle-scene"
|
data-testid="battle-scene"
|
||||||
@@ -132,6 +259,7 @@ by `buildFrames`, so they never appear here.
|
|||||||
|
|
||||||
{#each raceLayout as anchor (anchor.raceId)}
|
{#each raceLayout as anchor (anchor.raceId)}
|
||||||
{@const cluster = clustersByRace.get(anchor.raceId) ?? []}
|
{@const cluster = clustersByRace.get(anchor.raceId) ?? []}
|
||||||
|
{@const basis = clusterBasisById.get(anchor.raceId)}
|
||||||
<g
|
<g
|
||||||
class="race-cluster"
|
class="race-cluster"
|
||||||
data-testid="battle-race-cluster"
|
data-testid="battle-race-cluster"
|
||||||
@@ -139,30 +267,29 @@ by `buildFrames`, so they never appear here.
|
|||||||
>
|
>
|
||||||
<text
|
<text
|
||||||
x={anchor.x}
|
x={anchor.x}
|
||||||
y={anchor.y - CLASS_CIRCLE_RADIUS - 12}
|
y={anchor.y - MAX_RADIUS - 12}
|
||||||
text-anchor="middle"
|
text-anchor="middle"
|
||||||
class="race-label"
|
class="race-label"
|
||||||
>{raceLabelById.get(anchor.raceId) ?? `race ${anchor.raceId}`}</text>
|
>{raceLabelById.get(anchor.raceId) ?? `race ${anchor.raceId}`}</text>
|
||||||
{#each cluster as entry, i (entry.key)}
|
{#if basis}
|
||||||
{@const cx = anchor.x + classCircleX(i, cluster.length)}
|
{#each cluster as entry, rank (entry.bucketKey)}
|
||||||
|
{@const pos = nodePosition(basis, rank)}
|
||||||
<g
|
<g
|
||||||
class="class-marker"
|
class="class-marker"
|
||||||
data-testid="battle-class-marker"
|
data-testid="battle-class-marker"
|
||||||
data-group-key={entry.key}
|
data-bucket-key={entry.bucketKey}
|
||||||
|
data-class-name={entry.className}
|
||||||
>
|
>
|
||||||
<circle
|
<circle cx={pos.x} cy={pos.y} r={entry.radius} />
|
||||||
cx={cx}
|
|
||||||
cy={anchor.y}
|
|
||||||
r={CLASS_CIRCLE_RADIUS}
|
|
||||||
/>
|
|
||||||
<text
|
<text
|
||||||
x={cx}
|
x={pos.x}
|
||||||
y={anchor.y + CLASS_CIRCLE_RADIUS + 16}
|
y={pos.y + entry.radius + 12}
|
||||||
text-anchor="middle"
|
text-anchor="middle"
|
||||||
class="class-label"
|
class="class-label"
|
||||||
>{entry.className}:{entry.numLeft}</text>
|
>{entry.className}:{entry.numLeft}</text>
|
||||||
</g>
|
</g>
|
||||||
{/each}
|
{/each}
|
||||||
|
{/if}
|
||||||
</g>
|
</g>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
@@ -183,7 +310,7 @@ by `buildFrames`, so they never appear here.
|
|||||||
<style>
|
<style>
|
||||||
.battle-scene {
|
.battle-scene {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: 100%;
|
||||||
background: #0a0d1a;
|
background: #0a0d1a;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@@ -210,7 +337,7 @@ by `buildFrames`, so they never appear here.
|
|||||||
}
|
}
|
||||||
.class-label {
|
.class-label {
|
||||||
fill: #b8c0e6;
|
fill: #b8c0e6;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
}
|
}
|
||||||
.shot {
|
.shot {
|
||||||
|
|||||||
@@ -10,12 +10,19 @@ is logically isolated: feed it any `BattleReport` matching
|
|||||||
|
|
||||||
import { i18n } from "$lib/i18n/index.svelte";
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
import type { BattleReport } from "../../api/battle-fetch";
|
import type { BattleReport } from "../../api/battle-fetch";
|
||||||
|
import type { ShipClassLookup } from "./mass";
|
||||||
|
|
||||||
import BattleScene from "./battle-scene.svelte";
|
import BattleScene from "./battle-scene.svelte";
|
||||||
import PlaybackControls from "./playback-controls.svelte";
|
import PlaybackControls from "./playback-controls.svelte";
|
||||||
import { buildFrames } from "./timeline";
|
import { buildFrames } from "./timeline";
|
||||||
|
|
||||||
let { report }: { report: BattleReport } = $props();
|
let {
|
||||||
|
report,
|
||||||
|
shipClassLookup,
|
||||||
|
}: {
|
||||||
|
report: BattleReport;
|
||||||
|
shipClassLookup?: ShipClassLookup;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
const frames = $derived(buildFrames(report));
|
const frames = $derived(buildFrames(report));
|
||||||
let frameIndex = $state(0);
|
let frameIndex = $state(0);
|
||||||
@@ -81,7 +88,7 @@ is logically isolated: feed it any `BattleReport` matching
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="scene">
|
<div class="scene">
|
||||||
<BattleScene {report} {frame} />
|
<BattleScene {report} {frame} {shipClassLookup} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PlaybackControls
|
<PlaybackControls
|
||||||
@@ -113,6 +120,9 @@ is logically isolated: feed it any `BattleReport` matching
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
max-width: 880px;
|
max-width: 880px;
|
||||||
|
width: 100%;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
color: #d6dcf2;
|
color: #d6dcf2;
|
||||||
@@ -122,6 +132,7 @@ is logically isolated: feed it any `BattleReport` matching
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
.header h2 {
|
.header h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -139,6 +150,16 @@ is logically isolated: feed it any `BattleReport` matching
|
|||||||
border: 1px solid #1e264a;
|
border: 1px solid #1e264a;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.log {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-height: 4rem;
|
||||||
|
max-height: 30vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.log h3 {
|
.log h3 {
|
||||||
margin: 0 0 0.4rem;
|
margin: 0 0 0.4rem;
|
||||||
@@ -146,15 +167,17 @@ is logically isolated: feed it any `BattleReport` matching
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
.log ol {
|
.log ol {
|
||||||
list-style: decimal inside;
|
list-style: decimal inside;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
max-height: 14rem;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
color: #c6cdf0;
|
color: #c6cdf0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
.log li {
|
.log li {
|
||||||
padding: 0.15rem 0;
|
padding: 0.15rem 0;
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// Mass helpers for the Battle Viewer.
|
||||||
|
//
|
||||||
|
// Phase 27 refinement: ship-class circles are sized by per-ship
|
||||||
|
// FullMass (Empty + carrying), with a 6..24 px range. The viewer
|
||||||
|
// resolves a `(race, className) → ShipClassRef` lookup from the
|
||||||
|
// surrounding GameReport's `localShipClass` / `otherShipClass`
|
||||||
|
// tables and feeds it through the wasm bridge to `pkg/calc/ship.go`.
|
||||||
|
//
|
||||||
|
// Pure utilities live here; the Svelte components consume them.
|
||||||
|
|
||||||
|
import type { Core } from "../../platform/core/index";
|
||||||
|
import type { BattleReportGroup } from "../../api/battle-fetch";
|
||||||
|
|
||||||
|
/** Smallest visible ship circle. Picked so the `<class>:<n>` label
|
||||||
|
* stays legible on every viewport. */
|
||||||
|
export const MIN_RADIUS = 6;
|
||||||
|
|
||||||
|
/** Largest ship circle. Matches the Phase-27 baseline so heavy
|
||||||
|
* ships keep their previous visual prominence. */
|
||||||
|
export const MAX_RADIUS = 24;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ShipClassRef is the minimum slice of a ship class needed to
|
||||||
|
* compute its mass. Mirrors the relevant fields of
|
||||||
|
* `ShipClassSummary` (own classes) and `ReportOtherShipClass`
|
||||||
|
* (foreign classes) without coupling the viewer to either type.
|
||||||
|
*/
|
||||||
|
export interface ShipClassRef {
|
||||||
|
drive: number;
|
||||||
|
weapons: number;
|
||||||
|
armament: number;
|
||||||
|
shields: number;
|
||||||
|
cargo: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ShipClassLookup resolves `(race, className)` to a ship-class
|
||||||
|
* descriptor. Returns `null` when the class is not in the parent
|
||||||
|
* report — happens with legacy-mode foreign races that lack a
|
||||||
|
* `<Race> Ship Types` block.
|
||||||
|
*/
|
||||||
|
export interface ShipClassLookup {
|
||||||
|
get(race: string, className: string): ShipClassRef | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* computeBattleGroupMass returns the per-ship FullMass for a given
|
||||||
|
* battle group. Mass=0 means "unknown" — either the wasm bridge
|
||||||
|
* rejected the ship-class params (degenerate weapons/armament pair)
|
||||||
|
* or the class did not resolve in the lookup. Either way the
|
||||||
|
* caller's downstream `radiusForMass` falls back to MAX_RADIUS so
|
||||||
|
* the node stays visible.
|
||||||
|
*
|
||||||
|
* Cargo never changes during a battle, so this can be cached per
|
||||||
|
* `(race, className)` bucket for the lifetime of the viewer
|
||||||
|
* session.
|
||||||
|
*/
|
||||||
|
export function computeBattleGroupMass(
|
||||||
|
group: BattleReportGroup,
|
||||||
|
classDef: ShipClassRef | null,
|
||||||
|
core: Core,
|
||||||
|
): number {
|
||||||
|
if (classDef === null) return 0;
|
||||||
|
const empty = core.emptyMass({
|
||||||
|
drive: classDef.drive,
|
||||||
|
weapons: classDef.weapons,
|
||||||
|
armament: classDef.armament,
|
||||||
|
shields: classDef.shields,
|
||||||
|
cargo: classDef.cargo,
|
||||||
|
});
|
||||||
|
if (empty === null) return 0;
|
||||||
|
const cargoTech = classDef.cargo * (group.tech.CARGO ?? 0);
|
||||||
|
const carrying = core.carryingMass({
|
||||||
|
load: group.loadQuantity,
|
||||||
|
cargoTech,
|
||||||
|
});
|
||||||
|
return core.fullMass({ emptyMass: empty, carryingMass: carrying });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* radiusForMass maps an absolute ship mass to a circle radius via
|
||||||
|
* a per-battle normalisation: the heaviest visual node always
|
||||||
|
* renders at MAX_RADIUS, lighter ones scale by sqrt(mass /
|
||||||
|
* maxMassInBattle) so the smallest ships don't disappear and the
|
||||||
|
* heaviest ones don't dominate the scene at >MAX_RADIUS. mass<=0
|
||||||
|
* falls back to MAX_RADIUS so unresolved/invalid classes stay
|
||||||
|
* visible.
|
||||||
|
*/
|
||||||
|
export function radiusForMass(mass: number, maxMassInBattle: number): number {
|
||||||
|
if (maxMassInBattle <= 0 || mass <= 0) return MAX_RADIUS;
|
||||||
|
const scaled = MIN_RADIUS + (MAX_RADIUS - MIN_RADIUS) * Math.sqrt(mass / maxMassInBattle);
|
||||||
|
if (scaled < MIN_RADIUS) return MIN_RADIUS;
|
||||||
|
if (scaled > MAX_RADIUS) return MAX_RADIUS;
|
||||||
|
return scaled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MapShipClassLookup is a `Map<string, ShipClassRef>`-backed
|
||||||
|
* implementation of `ShipClassLookup`. Key encoding mirrors the
|
||||||
|
* one battle.svelte uses when populating the lookup from the
|
||||||
|
* parent GameReport.
|
||||||
|
*/
|
||||||
|
export class MapShipClassLookup implements ShipClassLookup {
|
||||||
|
constructor(private readonly map: Map<string, ShipClassRef>) {}
|
||||||
|
|
||||||
|
get(race: string, className: string): ShipClassRef | null {
|
||||||
|
return this.map.get(`${race}::${className}`) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,11 @@ import { describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import type { BattleReport } from "../src/api/battle-fetch";
|
import type { BattleReport } from "../src/api/battle-fetch";
|
||||||
import { layoutRaces } from "../src/lib/battle-player/radial-layout";
|
import { layoutRaces } from "../src/lib/battle-player/radial-layout";
|
||||||
|
import {
|
||||||
|
MAX_RADIUS,
|
||||||
|
MIN_RADIUS,
|
||||||
|
radiusForMass,
|
||||||
|
} from "../src/lib/battle-player/mass";
|
||||||
import {
|
import {
|
||||||
buildFrames,
|
buildFrames,
|
||||||
buildGroupRaceMap,
|
buildGroupRaceMap,
|
||||||
@@ -144,3 +149,29 @@ describe("buildFrames", () => {
|
|||||||
expect(frames[4].activeRaceIds).toEqual([0]);
|
expect(frames[4].activeRaceIds).toEqual([0]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,28 @@ async function mockGatewayAndBattle(page: Page): Promise<void> {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
battles: [{ id: BATTLE_ID, planet: 1, shots: 4 }],
|
battles: [{ id: BATTLE_ID, planet: 1, shots: 4 }],
|
||||||
|
localShipClass: [
|
||||||
|
{
|
||||||
|
name: "Cruiser",
|
||||||
|
drive: 10,
|
||||||
|
armament: 2,
|
||||||
|
weapons: 5,
|
||||||
|
shields: 5,
|
||||||
|
cargo: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
otherShipClass: [
|
||||||
|
{
|
||||||
|
race: "Bajori",
|
||||||
|
name: "Hawk",
|
||||||
|
drive: 12,
|
||||||
|
armament: 1,
|
||||||
|
weapons: 4,
|
||||||
|
shields: 2,
|
||||||
|
cargo: 0,
|
||||||
|
mass: 75,
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -249,4 +271,35 @@ test.describe("Phase 27 battle viewer", () => {
|
|||||||
|
|
||||||
await expect(page.getByTestId("battle-not-found")).toBeVisible();
|
await expect(page.getByTestId("battle-not-found")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("viewer fits the desktop viewport without a vertical scroll", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.skip(
|
||||||
|
testInfo.project.name.startsWith("chromium-mobile"),
|
||||||
|
"desktop-only height-fit check",
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1280, height: 720 });
|
||||||
|
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-scene")).toBeVisible();
|
||||||
|
|
||||||
|
// Phase 27 refinement: viewer + log fit the viewport; the
|
||||||
|
// internal log scrolls inside its own pane rather than
|
||||||
|
// growing the page. Allow a small tolerance for fractional
|
||||||
|
// pixel rounding around flex math, but reject any
|
||||||
|
// scrollable overflow beyond a couple of pixels.
|
||||||
|
// Phase 27 refinement: viewer + log fit the viewport; the
|
||||||
|
// internal log scrolls inside its own pane rather than
|
||||||
|
// growing the page. Allow a small tolerance for fractional
|
||||||
|
// pixel rounding around flex math.
|
||||||
|
const overflow = await page.evaluate(
|
||||||
|
() => document.documentElement.scrollHeight - window.innerHeight,
|
||||||
|
);
|
||||||
|
expect(overflow).toBeLessThanOrEqual(4);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user