ui: plan 01-27 done #1

Merged
developer merged 120 commits from ai/ui-client into main 2026-05-13 18:55:14 +00:00
10 changed files with 397 additions and 286 deletions
Showing only changes of commit e2aba856b5 - Show all commits
+42 -16
View File
@@ -48,13 +48,22 @@ across the underlying groups; per-tech detail is available in the
Reports view (Foreign Ship Classes / My Ship Types). Reports view (Foreign Ship Classes / My Ship Types).
Bucket order inside a cluster is **locked at battle start** by the Bucket order inside a cluster is **locked at battle start** by the
initial ship count (`num` summed across tech variants, descending). initial ship count (`num` summed across tech variants, descending),
As ships die during playback only the label number changes — every together with mass, radius and local position. The static layout
bucket keeps its slot in the Vogel spiral, so the user does not see lives in `staticBucketsByRace`; the per-frame derivation
the cluster reshuffle when a class empties. Vogel positions are `renderedByRace` overlays the live `NumberLeft` and drops buckets
then reassigned per rank by their inward distance toward the once they hit zero. The remaining buckets keep their slots in the
planet, so the rank-0 bucket (the largest at battle start) always cloud, so the cluster does not reshuffle when a class empties — the
sits at the most-inward spiral slot. empty bucket simply disappears.
Vogel positions are reassigned per rank by their inward distance
toward the planet, so the rank-0 bucket (the largest by initial
ship count) always sits at the most-inward spiral slot.
When two races remain in battle the radial layout switches to the
horizontal duel: race 0 at 9 o'clock, race 1 at 3 o'clock. This
keeps both race labels clear of the SVG top edge and reads as the
two sides facing off naturally.
Circle radius scales with per-ship FullMass (Empty + Carrying via Circle radius scales with per-ship FullMass (Empty + Carrying via
the per-ship `LoadQuantity`). The viewer resolves a the per-ship `LoadQuantity`). The viewer resolves a
@@ -80,20 +89,25 @@ produces the "shot-shot-shot" pulse the user wanted.
## Playback controls ## Playback controls
`lib/battle-player/playback-controls.svelte` ships the full set: `lib/battle-player/playback-controls.svelte` ships:
| Control | Effect | | Control | Effect |
| ------------- | ------------------------------------------ | | ------------------ | ------------------------------------------------------- |
| ⏮ rewind | Stop, jump to frame 0 | | ⏮ rewind | Stop, jump to frame 0 |
| ◀︎ step back | Stop, frame ← frame 1 | | ◀︎◀︎ step back | Stop, frame ← frame 1 |
| ▶︎ / ⏸ play | Toggle continuous playback | | ▶︎ / ⏸ play | Toggle continuous playback |
| ▶︎▶︎ step fwd | Stop, frame ← frame + 1 | | ▶︎▶︎ step forward | Stop, frame ← frame + 1 |
| 1x / 2x / 4x | Speed switch: 400 / 200 / 100 ms per frame | | `Nx` cycle speed | Single button, cycles 1x 2x 4x → 6x → 1x; the label shows the current speed (400 / 200 / 100 / 67 ms per frame) |
| `Log ▲▼` toggle | Collapses / expands the always-visible text protocol so the user can give the scene the full viewer height |
When the timeline is at its end and the user hits play, the frame When the timeline is at its end and the user hits play, the frame
counter wraps to 0 and continues. Step buttons disable themselves at counter wraps to 0 and continues. Step buttons disable themselves at
their boundary. their boundary.
A drag-seek slider sits between the scene and the controls. Dragging
pauses playback and lands `frameIndex` on the chosen shot — handy
for jumping to the moment a particular race started losing ground.
## Accessibility ## Accessibility
Below the scene the viewer renders a static `<ol>` text protocol — Below the scene the viewer renders a static `<ol>` text protocol —
@@ -130,12 +144,24 @@ the scene until its actual ships are gone. The phantom shots still
draw a line during the frame they belong to; only the running draw a line during the frame they belong to; only the running
counters are protected. counters are protected.
## Header + layout
The viewer header carries three rows of chrome in a single line:
the back-navigation buttons (`back to map` / `back to report`) on
the left, a centred title — `Battle on planet <name> (<#number>)`,
i18n key `game.battle.header_title` — and the frame counter on the
right. Pulling navigation into the header frees the entire viewer
area for the scene; the `.viewer` container has no `max-width` cap,
so on wide monitors the scene scales up while the log keeps its
internal 30 dvh scroll.
## Height fit ## Height fit
The viewer is pinned to the viewport: `.active-view` uses The viewer is pinned to the viewport: `.active-view` uses
`calc(100dvh 80px)` so the in-game-shell header + optional `calc(100dvh 80px)` so the in-game-shell header + optional
HistoryBanner do not push the scene below the fold. Inside the HistoryBanner do not push the scene below the fold. Inside the
viewer, the scene grows (`flex: 1`) and the log shrinks to a viewer, the scene grows (`flex: 1`), the scrubber + controls hold
their natural height, and the log (when expanded) shrinks to a
30 dvh ceiling with its own internal scroll, so the page itself 30 dvh ceiling with its own internal scroll, so the page itself
never scrolls vertically. The 80 px allowance maps to the current never scrolls vertically. The 80 px allowance maps to the current
Header + HistoryBanner total on desktop; mobile breakpoints reuse Header + HistoryBanner total on desktop; mobile breakpoints reuse
+23 -55
View File
@@ -6,9 +6,10 @@ not-found state.
This wrapper also bridges the surrounding GameReport's ship-class This wrapper also bridges the surrounding GameReport's ship-class
tables into a `(race, className) → ShipClassRef` lookup the viewer tables into a `(race, className) → ShipClassRef` lookup the viewer
needs to size class circles by ship mass. The viewer remains needs to size class circles by ship mass. The back-navigation
prop-driven; we just resolve the lookup once here so the lower buttons (`back to map` / `back to report`) live INSIDE the viewer
component does not have to know about `RenderedReportSource`. header now — we just hand the routes down as callbacks so the
viewer keeps its prop-driven contract.
--> -->
<script lang="ts"> <script lang="ts">
import { getContext } from "svelte"; import { getContext } from "svelte";
@@ -46,12 +47,6 @@ component does not have to know about `RenderedReportSource`.
RENDERED_REPORT_CONTEXT_KEY, 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 shipClassLookup = $derived.by<ShipClassLookup>(() => {
const map = new Map<string, ShipClassRef>(); const map = new Map<string, ShipClassRef>();
const report = rendered?.report; const report = rendered?.report;
@@ -115,28 +110,22 @@ component does not have to know about `RenderedReportSource`.
} }
</script> </script>
<section class="active-view" data-testid="active-view-battle" data-battle-id={battleId}> <section
<nav class="back-row"> class="active-view"
<button data-testid="active-view-battle"
type="button" data-battle-id={battleId}
class="back-btn" >
onclick={backToMap}
data-testid="battle-back-to-map"
>{i18n.t("game.battle.back_to_map")}</button>
<button
type="button"
class="back-btn"
onclick={backToReport}
data-testid="battle-back-to-report"
>{i18n.t("game.battle.back_to_report")}</button>
</nav>
{#if state.kind === "loading"} {#if state.kind === "loading"}
<p class="status" data-testid="battle-loading"> <p class="status" data-testid="battle-loading">
{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} shipClassLookup={shipClassLookup} /> <BattleViewer
report={state.report}
{shipClassLookup}
onBackToMap={backToMap}
onBackToReport={backToReport}
/>
{: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")}
@@ -153,44 +142,23 @@ component does not have to know about `RenderedReportSource`.
/* /*
* The in-game shell renders this active view inside an * The in-game shell renders this active view inside an
* `.active-view-host` with `flex: 1; overflow-y: auto`, but * `.active-view-host` with `flex: 1; overflow-y: auto`, but
* the surrounding `.game-shell` uses `min-height: 100vh`, * the surrounding `.game-shell` uses `min-height: 100vh`, so
* so without a hard upper bound the viewer pushes the * without a hard upper bound the viewer pushes the whole
* whole shell past the viewport. We pin the active view to * shell past the viewport. We pin the active view to `100dvh`
* `100dvh` minus a small allowance for the header chrome * minus a small allowance for the header chrome (in-game
* (in-game Header + optional HistoryBanner = ~66 px on * Header + optional HistoryBanner 66 px on desktop) so the
* desktop) so the internal flex chain can split the * internal flex chain can split the remaining height between
* remaining height between the scene and the always- * the scene, scrubber, controls and log without forcing a
* visible log without forcing a page-level scroll. * page-level scroll.
*/ */
height: calc(100dvh - 80px); height: calc(100dvh - 80px);
max-height: calc(100dvh - 80px); max-height: calc(100dvh - 80px);
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
padding: 1rem;
box-sizing: border-box; box-sizing: border-box;
font-family: system-ui, sans-serif; font-family: system-ui, sans-serif;
color: #d6dcf2; color: #d6dcf2;
} }
.back-row {
display: flex;
gap: 0.5rem;
max-width: 880px;
margin: 0 auto 1rem;
flex: 0 0 auto;
}
.back-btn {
appearance: none;
background: #1f2748;
color: #d6dcf2;
border: 1px solid #2c3568;
padding: 0.35rem 0.7rem;
border-radius: 3px;
cursor: pointer;
font-size: 0.85rem;
}
.back-btn:hover {
background: #2a3463;
}
.status { .status {
margin: 2rem auto; margin: 2rem auto;
max-width: 880px; max-width: 880px;
@@ -5,19 +5,20 @@ Layout: planet at the centre, race anchors equally spaced on an
outer ring, each race rendered as a *cloud* of class circles outer ring, each race rendered as a *cloud* of class circles
arranged on a Vogel sunflower spiral. Spiral positions are arranged on a Vogel sunflower spiral. Spiral positions are
reassigned per rank by their inward distance toward the planet so reassigned per rank by their inward distance toward the planet so
the rank-0 bucket (heaviest by NumberLeft) always sits at the the rank-0 bucket (the bucket with the largest initial ship count)
most-inward Vogel slot — the cloud visually leans toward the always sits at the most-inward Vogel slot.
planet without the cluster anchor needing a manual offset.
Tech-variant groups of the same `(race, className)` collapse to one Tech-variant groups of the same `(race, className)` collapse to one
visual node — the per-tech detail lives in Reports. Each circle's visual node — per-tech detail lives in Reports. Each circle's
radius scales with the per-ship FullMass (sqrt) so heavy ships radius scales with the per-ship FullMass (sqrt) so heavy ships
visually dominate. visually dominate. Order, position, radius and mass are locked at
battle start; only NumberLeft (the label number) and per-bucket
visibility change per frame. Empty buckets are hidden so the
remaining ones keep their original spots without reshuffling.
Observer groups (`inBattle === false`) are filtered out by Observer groups (`inBattle === false`) are filtered out by
`buildFrames`, so they never appear here. Same-race opponents are `buildFrames`. Same-race opponents are forbidden by the engine's
forbidden by the engine's combat filter, so a shot can never combat filter, so a shot never collapses to a single visual node.
collapse to a single visual node.
--> -->
<script lang="ts"> <script lang="ts">
import { getContext } from "svelte"; import { getContext } from "svelte";
@@ -59,34 +60,40 @@ collapse to a single visual node.
const BASE_STEP = 1.8 * MAX_RADIUS; const BASE_STEP = 1.8 * MAX_RADIUS;
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5)); const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
const MAX_CLUSTER_RADIUS = 0.6 * (RACE_RING_RADIUS - PLANET_RADIUS); const MAX_CLUSTER_RADIUS = 0.6 * (RACE_RING_RADIUS - PLANET_RADIUS);
const LABEL_MIN_Y = 24;
const coreHandle = getContext<CoreHandle | undefined>(CORE_CONTEXT_KEY); 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 StaticBucket = {
bucketKey: string; bucketKey: string;
className: string; className: string;
race: string; race: string;
raceId: number; raceId: number;
groupKeys: number[]; groupKeys: number[];
initialNum: number; initialNum: number;
numLeft: number;
mass: number; mass: number;
radius: number; radius: number;
// Local offsets in the cluster's (u, v) basis. `u` always
// points from the race anchor toward the planet, so a
// constant local-frame layout produces the same "inward" feel
// regardless of which slot on the outer ring the race
// currently occupies (races rotate when peers die).
offsetU: number;
offsetV: number;
}; };
// Aggregate every `(raceId, className)` into a single bucket and // staticBucketsByRace locks the bucket roster, ordering, masses,
// compute per-bucket NumberLeft (sum across tech-variants) and // radii and local positions for the lifetime of this viewer. The
// per-ship FullMass via the wasm bridge. mass=0 when the class // derivation only re-runs when `report` or the wasm `core` flip
// either doesn't resolve in the lookup or the calc rejects its // (initial mount and core boot completion). Per-frame NumberLeft
// params; downstream `radiusForMass` falls back to MAX_RADIUS for // changes do not touch this map — they live in `renderedByRace`.
// those nodes. const staticBucketsByRace = $derived.by(() => {
const clustersByRace = $derived.by(() => {
const core = coreHandle?.core ?? null; const core = coreHandle?.core ?? null;
const out = new Map<number, ClusterEntry[]>(); const out = new Map<number, StaticBucket[]>();
const bucketIndex = new Map<string, ClusterEntry>(); const bucketIndex = new Map<string, StaticBucket>();
for (const g of allGroups) { for (const g of allGroups) {
const bucketKey = `${g.raceId}::${g.group.className}`; const bucketKey = `${g.raceId}::${g.group.className}`;
let bucket = bucketIndex.get(bucketKey); let bucket = bucketIndex.get(bucketKey);
@@ -103,9 +110,10 @@ collapse to a single visual node.
raceId: g.raceId, raceId: g.raceId,
groupKeys: [], groupKeys: [],
initialNum: 0, initialNum: 0,
numLeft: 0,
mass, mass,
radius: MAX_RADIUS, radius: MAX_RADIUS,
offsetU: 0,
offsetV: 0,
}; };
bucketIndex.set(bucketKey, bucket); bucketIndex.set(bucketKey, bucket);
const list = out.get(g.raceId) ?? []; const list = out.get(g.raceId) ?? [];
@@ -114,11 +122,10 @@ collapse to a single visual node.
} }
bucket.groupKeys.push(g.key); bucket.groupKeys.push(g.key);
bucket.initialNum += g.group.num; bucket.initialNum += g.group.num;
bucket.numLeft += frame.remaining.get(g.key) ?? 0;
} }
// Per-battle mass normalisation: the heaviest visual node // Per-battle mass normalisation: the heaviest bucket renders
// renders at MAX_RADIUS; lighter ones scale by sqrt(m/max). // at MAX_RADIUS; lighter ones scale by sqrt(m/max).
let maxMass = 0; let maxMass = 0;
for (const bucket of bucketIndex.values()) { for (const bucket of bucketIndex.values()) {
if (bucket.mass > maxMass) maxMass = bucket.mass; if (bucket.mass > maxMass) maxMass = bucket.mass;
@@ -127,23 +134,67 @@ collapse to a single visual node.
bucket.radius = radiusForMass(bucket.mass, maxMass); bucket.radius = radiusForMass(bucket.mass, maxMass);
} }
// Order is locked by initial ship count, NOT live numLeft: // Sort each race's buckets by initial count (descending) +
// the user wants every bucket to keep its visual slot through // className as a stable tie-break, then assign Vogel positions
// the battle. Rank 0 = the bucket that *started* with the // reordered by inward dot product (offsetU desc) so the
// most ships, which the legacy game heuristic treats as // largest-by-num bucket lands at the most-inward Vogel slot.
// "cover" placed in front of more valuable hulls.
for (const list of out.values()) { for (const list of out.values()) {
list.sort((a, b) => { list.sort((a, b) => {
if (b.initialNum !== a.initialNum) return b.initialNum - a.initialNum; if (b.initialNum !== a.initialNum) return b.initialNum - a.initialNum;
return a.className.localeCompare(b.className); return a.className.localeCompare(b.className);
}); });
const N = list.length;
const denom = Math.max(1, Math.sqrt(Math.max(N, 1)));
const step = Math.min(BASE_STEP, MAX_CLUSTER_RADIUS / denom);
const positions = Array.from({ length: N }, (_, r) => {
const radius = step * Math.sqrt(r);
const angle = r * GOLDEN_ANGLE;
return {
offsetU: radius * Math.cos(angle),
offsetV: radius * Math.sin(angle),
};
});
positions.sort((a, b) => {
if (b.offsetU !== a.offsetU) return b.offsetU - a.offsetU;
return a.offsetV - b.offsetV;
});
for (let r = 0; r < N; r++) {
list[r].offsetU = positions[r].offsetU;
list[r].offsetV = positions[r].offsetV;
}
} }
return out; return out;
}); });
const bucketByGroupKey = $derived.by(() => { type RenderedBucket = StaticBucket & { numLeft: number };
const out = new Map<number, ClusterEntry>();
for (const list of clustersByRace.values()) { // renderedByRace overlays the per-frame `remaining` map onto the
// static cluster: only buckets with `numLeft > 0` survive into
// the render list, so an emptied class disappears from the cloud
// while its neighbours keep their slots.
const renderedByRace = $derived.by(() => {
const out = new Map<number, RenderedBucket[]>();
for (const [raceId, list] of staticBucketsByRace) {
const filtered: RenderedBucket[] = [];
for (const bucket of list) {
let numLeft = 0;
for (const key of bucket.groupKeys) {
numLeft += frame.remaining.get(key) ?? 0;
}
if (numLeft > 0) filtered.push({ ...bucket, numLeft });
}
if (filtered.length > 0) out.set(raceId, filtered);
}
return out;
});
// visibleBucketByGroupKey lets shot endpoints resolve to a node
// only when the bucket is currently rendered. A phantom shot
// against an already-empty bucket therefore returns `null` and
// no line is drawn.
const visibleBucketByGroupKey = $derived.by(() => {
const out = new Map<number, RenderedBucket>();
for (const list of renderedByRace.values()) {
for (const bucket of list) { for (const bucket of list) {
for (const key of bucket.groupKeys) { for (const key of bucket.groupKeys) {
out.set(key, bucket); out.set(key, bucket);
@@ -167,7 +218,6 @@ collapse to a single visual node.
uy: number; uy: number;
vx: number; vx: number;
vy: number; vy: number;
step: number;
}; };
const clusterBasisById = $derived.by(() => { const clusterBasisById = $derived.by(() => {
@@ -180,9 +230,6 @@ collapse to a single visual node.
const uy = dy / len; const uy = dy / len;
const vx = uy; const vx = uy;
const vy = -ux; const vy = -ux;
const count = (clustersByRace.get(anchor.raceId) ?? []).length;
const denom = Math.max(1, Math.sqrt(Math.max(count, 1)));
const step = Math.min(BASE_STEP, MAX_CLUSTER_RADIUS / denom);
out.set(anchor.raceId, { out.set(anchor.raceId, {
anchorX: anchor.x, anchorX: anchor.x,
anchorY: anchor.y, anchorY: anchor.y,
@@ -190,61 +237,24 @@ collapse to a single visual node.
uy, uy,
vx, vx,
vy, vy,
step,
}); });
} }
return out; return out;
}); });
// vogelLocalsByRace generates Vogel sunflower positions for each function worldPosition(basis: ClusterBasis, bucket: StaticBucket) {
// cluster and reassigns them by inward dot product (`offsetU`
// descending) so the rank-0 bucket always lands at the most-
// inward spiral point. With the original `r * GOLDEN_ANGLE` angle
// scheme, ranks with r ≥ 2 can land further toward the planet
// than r = 0; the reassignment makes the "biggest group near the
// planet" invariant exact.
const vogelLocalsByRace = $derived.by(() => {
const out = new Map<number, { offsetU: number; offsetV: number }[]>();
for (const [raceId, cluster] of clustersByRace) {
const basis = clusterBasisById.get(raceId);
if (!basis) continue;
const positions = Array.from({ length: cluster.length }, (_, r) => {
const radius = basis.step * Math.sqrt(r);
const angle = r * GOLDEN_ANGLE;
return {
offsetU: radius * Math.cos(angle),
offsetV: radius * Math.sin(angle),
};
});
positions.sort((a, b) => {
if (b.offsetU !== a.offsetU) return b.offsetU - a.offsetU;
return a.offsetV - b.offsetV;
});
out.set(raceId, positions);
}
return out;
});
function worldPosition(
basis: ClusterBasis,
local: { offsetU: number; offsetV: number },
) {
return { return {
x: basis.anchorX + local.offsetU * basis.ux + local.offsetV * basis.vx, x: basis.anchorX + bucket.offsetU * basis.ux + bucket.offsetV * basis.vx,
y: basis.anchorY + local.offsetU * basis.uy + local.offsetV * basis.vy, y: basis.anchorY + bucket.offsetU * basis.uy + bucket.offsetV * basis.vy,
}; };
} }
function findClassCircleCenter(groupKey: number) { function findClassCircleCenter(groupKey: number) {
const bucket = bucketByGroupKey.get(groupKey); const bucket = visibleBucketByGroupKey.get(groupKey);
if (!bucket) return null; if (!bucket) return null;
const basis = clusterBasisById.get(bucket.raceId); const basis = clusterBasisById.get(bucket.raceId);
if (!basis) return null; if (!basis) return null;
const cluster = clustersByRace.get(bucket.raceId) ?? []; return worldPosition(basis, bucket);
const rank = cluster.indexOf(bucket);
const locals = vogelLocalsByRace.get(bucket.raceId);
if (rank === -1 || locals === undefined) return null;
return worldPosition(basis, locals[rank]);
} }
const shotLine = $derived.by(() => { const shotLine = $derived.by(() => {
@@ -258,7 +268,7 @@ collapse to a single visual node.
const flashDefenderBucketKey = $derived.by(() => { const flashDefenderBucketKey = $derived.by(() => {
if (!shotLine || !shotVisible) return null; if (!shotLine || !shotVisible) return null;
const bucket = bucketByGroupKey.get(shotLine.defenderKey); const bucket = visibleBucketByGroupKey.get(shotLine.defenderKey);
return bucket?.bucketKey ?? null; return bucket?.bucketKey ?? null;
}); });
@@ -273,25 +283,23 @@ collapse to a single visual node.
return out; return out;
}); });
// raceLabelYById computes a label position that sits above the // raceLabelYById finds a y just above the visible cluster's top
// cluster's bounding top so the race name never hides inside the // edge and clamps it to the SVG viewport so the north race
// cloud. The label is also clamped to a minimum distance from the // (anchor near the top) never has its label clipped off-canvas.
// race anchor so a single-bucket cluster still has its label
// rendered just above the only circle.
const raceLabelYById = $derived.by(() => { const raceLabelYById = $derived.by(() => {
const out = new Map<number, number>(); const out = new Map<number, number>();
for (const [raceId, cluster] of clustersByRace) { for (const [raceId, list] of renderedByRace) {
const basis = clusterBasisById.get(raceId); const basis = clusterBasisById.get(raceId);
const locals = vogelLocalsByRace.get(raceId); if (!basis || list.length === 0) continue;
if (!basis || !locals) continue;
let topY = basis.anchorY; let topY = basis.anchorY;
for (let i = 0; i < cluster.length; i++) { for (const bucket of list) {
const world = worldPosition(basis, locals[i]); const world = worldPosition(basis, bucket);
const y = world.y - cluster[i].radius; const top = world.y - bucket.radius;
if (y < topY) topY = y; if (top < topY) topY = top;
} }
const fallback = basis.anchorY - MAX_RADIUS - 12; const fallback = basis.anchorY - MAX_RADIUS - 12;
out.set(raceId, Math.min(topY - 12, fallback)); const target = Math.min(topY - 12, fallback);
out.set(raceId, Math.max(target, LABEL_MIN_Y));
} }
return out; return out;
}); });
@@ -320,24 +328,22 @@ collapse to a single visual node.
>{report.planetName} (#{report.planet})</text> >{report.planetName} (#{report.planet})</text>
{#each raceLayout as anchor (anchor.raceId)} {#each raceLayout as anchor (anchor.raceId)}
{@const cluster = clustersByRace.get(anchor.raceId) ?? []} {@const cluster = renderedByRace.get(anchor.raceId) ?? []}
{@const basis = clusterBasisById.get(anchor.raceId)} {@const basis = clusterBasisById.get(anchor.raceId)}
{@const locals = vogelLocalsByRace.get(anchor.raceId) ?? []} {#if basis && cluster.length > 0}
<g <g
class="race-cluster" class="race-cluster"
data-testid="battle-race-cluster" data-testid="battle-race-cluster"
data-race-id={anchor.raceId} data-race-id={anchor.raceId}
> >
<text <text
x={anchor.x} x={anchor.x}
y={raceLabelYById.get(anchor.raceId) ?? anchor.y - MAX_RADIUS - 12} y={raceLabelYById.get(anchor.raceId) ?? 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>
{#if basis} {#each cluster as entry (entry.bucketKey)}
{#each cluster as entry, rank (entry.bucketKey)} {@const pos = worldPosition(basis, entry)}
{@const local = locals[rank]}
{@const pos = local ? worldPosition(basis, local) : { x: anchor.x, y: anchor.y }}
{@const flash = {@const flash =
entry.bucketKey === flashDefenderBucketKey entry.bucketKey === flashDefenderBucketKey
? shotLine?.destroyed ? shotLine?.destroyed
@@ -360,8 +366,8 @@ collapse to a single visual node.
>{entry.className}:{entry.numLeft}</text> >{entry.className}:{entry.numLeft}</text>
</g> </g>
{/each} {/each}
{/if} </g>
</g> {/if}
{/each} {/each}
{#if shotLine && shotVisible} {#if shotLine && shotVisible}
@@ -1,9 +1,19 @@
<!-- <!--
BattleViewer — orchestrates the radial battle scene, the playback BattleViewer — orchestrates the radial battle scene, the playback
controls, and the accessibility text log for one BattleReport. Owns controls, and the accessibility text log for one BattleReport. Owns
the playback state (`frameIndex`, `playing`, `speed`). The component the playback state (`frameIndex`, `playing`, `speed`, `logOpen`).
is logically isolated: feed it any `BattleReport` matching Layout reorganisation (latest iteration):
`pkg/model/report/battle.go` and it plays back.
- The header carries the planet title, the back-navigation links and
the frame counter so the scene captures the full viewer width and
height beneath them.
- A drag-seek slider sits between the scene and the controls; the
user can scrub the playback timeline at any speed.
- The text log collapses behind a toggle in the controls bar so a
user who wants the biggest scene possible can hide it entirely.
The component is logically isolated: feed it any `BattleReport`
matching `pkg/model/report/battle.go` and it plays back.
--> -->
<script lang="ts"> <script lang="ts">
import { i18n } from "$lib/i18n/index.svelte"; import { i18n } from "$lib/i18n/index.svelte";
@@ -11,38 +21,38 @@ is logically isolated: feed it any `BattleReport` matching
import type { ShipClassLookup } from "./mass"; 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, {
type PlaybackSpeed,
} from "./playback-controls.svelte";
import { buildFrames } from "./timeline"; import { buildFrames } from "./timeline";
let { let {
report, report,
shipClassLookup, shipClassLookup,
onBackToMap,
onBackToReport,
}: { }: {
report: BattleReport; report: BattleReport;
shipClassLookup?: ShipClassLookup; shipClassLookup?: ShipClassLookup;
onBackToMap?: () => void;
onBackToReport?: () => void;
} = $props(); } = $props();
const frames = $derived(buildFrames(report)); const frames = $derived(buildFrames(report));
let frameIndex = $state(0); let frameIndex = $state(0);
let playing = $state(false); let playing = $state(false);
let speed = $state<1 | 2 | 4>(1); let speed = $state<PlaybackSpeed>(1);
// shotVisible blinks off for the last 10% of each frame so two let logOpen = $state(true);
// consecutive shots from the same attacker on the same defender
// look like two distinct flashes, not one continuous line. On
// pause the line stays drawn so the user can study it.
let shotVisible = $state(true); let shotVisible = $state(true);
let logEl = $state<HTMLOListElement | null>(null); let logEl = $state<HTMLOListElement | null>(null);
const frame = $derived(frames[Math.min(frameIndex, frames.length - 1)]); const frame = $derived(frames[Math.min(frameIndex, frames.length - 1)]);
// Schedule one tick per frame instead of a long-running // One tick per frame: blink the shot line off during the last
// setInterval so the blink and the frame-advance share the same // 10 % of the frame's interval, then advance. Effect re-arms
// timeline. The effect re-runs whenever frameIndex / playing / // whenever frameIndex / playing / speed changes; previous
// speed changes; on each run we (re)arm the blink + advance // timers clean up through the return.
// timers and let the previous run's timers be cleared by the
// cleanup return.
$effect(() => { $effect(() => {
// Track changes via direct reads.
void frameIndex; void frameIndex;
void speed; void speed;
shotVisible = true; shotVisible = true;
@@ -64,13 +74,11 @@ is logically isolated: feed it any `BattleReport` matching
}; };
}); });
// Auto-scroll the log so the current row stays visible as the // Auto-scroll the visible log row into view so the highlight
// timeline advances. `block: "nearest"` keeps the scroll movement // keeps up with the timeline on long battles.
// gentle — the row lands at the closest edge of the visible
// portion instead of jumping to the centre.
$effect(() => { $effect(() => {
void frame.shotIndex; void frame.shotIndex;
if (logEl === null) return; if (!logOpen || logEl === null) return;
const current = logEl.querySelector( const current = logEl.querySelector(
'li[data-current="true"]', 'li[data-current="true"]',
) as HTMLElement | null; ) as HTMLElement | null;
@@ -87,6 +95,14 @@ is logically isolated: feed it any `BattleReport` matching
); );
} }
function onScrub(event: Event) {
const target = event.currentTarget as HTMLInputElement;
const value = Number(target.value);
if (!Number.isFinite(value)) return;
playing = false;
frameIndex = Math.max(0, Math.min(frames.length - 1, Math.trunc(value)));
}
function describeAction(index: number): string { function describeAction(index: number): string {
const action = report.protocol[index]; const action = report.protocol[index];
const attackerGroup = report.ships[String(action.sa)]; const attackerGroup = report.ships[String(action.sa)];
@@ -109,8 +125,29 @@ is logically isolated: feed it any `BattleReport` matching
<div class="viewer" data-testid="battle-viewer"> <div class="viewer" data-testid="battle-viewer">
<header class="header"> <header class="header">
<div class="back-row">
{#if onBackToMap}
<button
type="button"
class="back-btn"
onclick={onBackToMap}
data-testid="battle-back-to-map"
>{i18n.t("game.battle.back_to_map")}</button>
{/if}
{#if onBackToReport}
<button
type="button"
class="back-btn"
onclick={onBackToReport}
data-testid="battle-back-to-report"
>{i18n.t("game.battle.back_to_report")}</button>
{/if}
</div>
<h2 data-testid="battle-viewer-title"> <h2 data-testid="battle-viewer-title">
{i18n.t("game.battle.title")} {i18n.t("game.battle.header_title", {
planet_name: report.planetName,
planet_number: String(report.planet),
})}
</h2> </h2>
<span class="progress" data-testid="battle-frame-index"> <span class="progress" data-testid="battle-frame-index">
{frame.shotIndex} / {report.protocol.length} {frame.shotIndex} / {report.protocol.length}
@@ -121,65 +158,103 @@ is logically isolated: feed it any `BattleReport` matching
<BattleScene {report} {frame} {shipClassLookup} {shotVisible} /> <BattleScene {report} {frame} {shipClassLookup} {shotVisible} />
</div> </div>
<input
class="scrubber"
type="range"
min="0"
max={Math.max(0, frames.length - 1)}
step="1"
value={frameIndex}
oninput={onScrub}
aria-label={i18n.t("game.battle.controls.scrub")}
data-testid="battle-scrubber"
/>
<PlaybackControls <PlaybackControls
bind:playing bind:playing
bind:frameIndex bind:frameIndex
bind:speed bind:speed
bind:logOpen
frameCount={frames.length} frameCount={frames.length}
/> />
<section {#if logOpen}
class="log" <section
aria-label={i18n.t("game.battle.accessibility.protocol_heading")} class="log"
> aria-label={i18n.t("game.battle.accessibility.protocol_heading")}
<h3>{i18n.t("game.battle.accessibility.protocol_heading")}</h3> >
<ol bind:this={logEl} data-testid="battle-protocol-log"> <h3>{i18n.t("game.battle.accessibility.protocol_heading")}</h3>
{#each report.protocol as _action, i (i)} <ol bind:this={logEl} data-testid="battle-protocol-log">
<li {#each report.protocol as _action, i (i)}
data-testid="battle-protocol-log-item" <li
data-current={i + 1 === frame.shotIndex ? "true" : "false"} data-testid="battle-protocol-log-item"
> data-current={i + 1 === frame.shotIndex ? "true" : "false"}
<button >
type="button" <button
class="log-row-btn" type="button"
onclick={() => seekToShot(i)} class="log-row-btn"
>{describeAction(i)}</button> onclick={() => seekToShot(i)}
</li> >{describeAction(i)}</button>
{/each} </li>
</ol> {/each}
</section> </ol>
</section>
{/if}
</div> </div>
<style> <style>
.viewer { .viewer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0.5rem;
max-width: 880px;
width: 100%; width: 100%;
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
margin: 0 auto; margin: 0 auto;
padding: 1rem; padding: 0.75rem 1rem;
color: #d6dcf2; color: #d6dcf2;
font-family: inherit; font-family: inherit;
box-sizing: border-box;
} }
.header { .header {
display: flex; display: flex;
justify-content: space-between; align-items: center;
align-items: baseline; gap: 0.75rem;
flex: 0 0 auto; flex: 0 0 auto;
} }
.header h2 { .header h2 {
flex: 1 1 auto;
margin: 0; margin: 0;
font-size: 1.1rem; font-size: 1.05rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.04em;
text-align: center;
}
.back-row {
display: flex;
gap: 0.3rem;
flex: 0 0 auto;
}
.back-btn {
appearance: none;
background: #1f2748;
color: #d6dcf2;
border: 1px solid #2c3568;
padding: 0.3rem 0.6rem;
border-radius: 3px;
cursor: pointer;
font-size: 0.8rem;
}
.back-btn:hover {
background: #2a3463;
} }
.progress { .progress {
color: #93a0d0; color: #93a0d0;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
font-size: 0.9rem; font-size: 0.85rem;
flex: 0 0 auto;
min-width: 5rem;
text-align: right;
} }
.scene { .scene {
background: #0a0d1a; background: #0a0d1a;
@@ -189,6 +264,12 @@ is logically isolated: feed it any `BattleReport` matching
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
} }
.scrubber {
width: 100%;
margin: 0;
flex: 0 0 auto;
accent-color: #6d7bb5;
}
.log { .log {
flex: 0 1 auto; flex: 0 1 auto;
min-height: 4rem; min-height: 4rem;
@@ -198,15 +279,15 @@ is logically isolated: feed it any `BattleReport` matching
flex-direction: column; flex-direction: column;
} }
.log h3 { .log h3 {
margin: 0 0 0.4rem; margin: 0 0 0.3rem;
color: #93a0d0; color: #93a0d0;
font-size: 0.8rem; font-size: 0.75rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
flex: 0 0 auto; flex: 0 0 auto;
} }
.log ol { .log ol {
list-style: decimal inside; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
font-size: 0.85rem; font-size: 0.85rem;
@@ -1,22 +1,30 @@
<!-- <!--
PlaybackControls — rewind / step-back / play-pause / step-forward PlaybackControls — rewind / step-back / play-pause / step-forward
plus a 1x/2x/4x speed switch. Owns no playback state; bind `playing`, plus a single cycling speed button (1x → 2x → 4x → 6x → 1x) and a
`frameIndex`, and `speed` from the orchestrator. Disables step/rewind "log" toggle that the orchestrator uses to collapse the always-on
when there's nowhere to go and disables forward when the timeline is text protocol when the user wants more space for the scene. Owns no
already at its end. state of its own; binds `playing`, `frameIndex`, `speed`, and
`logOpen` from the orchestrator. Disables step/rewind when there's
nowhere to go and step-forward when the timeline is at its end.
--> -->
<script lang="ts"> <script lang="ts">
import { i18n } from "$lib/i18n/index.svelte"; import { i18n } from "$lib/i18n/index.svelte";
export type PlaybackSpeed = 1 | 2 | 4 | 6;
const SPEED_CYCLE: PlaybackSpeed[] = [1, 2, 4, 6];
let { let {
playing = $bindable(), playing = $bindable(),
frameIndex = $bindable(), frameIndex = $bindable(),
speed = $bindable(), speed = $bindable(),
logOpen = $bindable(),
frameCount, frameCount,
}: { }: {
playing: boolean; playing: boolean;
frameIndex: number; frameIndex: number;
speed: 1 | 2 | 4; speed: PlaybackSpeed;
logOpen: boolean;
frameCount: number; frameCount: number;
} = $props(); } = $props();
@@ -38,9 +46,16 @@ already at its end.
playing = false; playing = false;
if (frameIndex < frameCount - 1) frameIndex = frameIndex + 1; if (frameIndex < frameCount - 1) frameIndex = frameIndex + 1;
} }
function setSpeed(value: 1 | 2 | 4) { function cycleSpeed() {
speed = value; const idx = SPEED_CYCLE.indexOf(speed);
const next = SPEED_CYCLE[(idx + 1) % SPEED_CYCLE.length];
speed = next;
} }
function toggleLog() {
logOpen = !logOpen;
}
const speedLabel = $derived(`${speed}x`);
</script> </script>
<div class="controls" data-testid="battle-controls"> <div class="controls" data-testid="battle-controls">
@@ -77,25 +92,25 @@ already at its end.
<div class="spacer" aria-hidden="true"></div> <div class="spacer" aria-hidden="true"></div>
<span class="speed-label">{i18n.t("game.battle.controls.speed_label")}</span>
<button <button
type="button" type="button"
class:active={speed === 1} class="speed-btn"
onclick={() => setSpeed(1)} onclick={cycleSpeed}
data-testid="battle-control-speed-1x" title={i18n.t("game.battle.controls.speed_label")}
>{i18n.t("game.battle.controls.speed_1x")}</button> aria-label={i18n.t("game.battle.controls.speed_label")}
data-testid="battle-control-speed"
data-speed={speed}
>{speedLabel}</button>
<button <button
type="button" type="button"
class:active={speed === 2} class="log-toggle"
onclick={() => setSpeed(2)} class:active={logOpen}
data-testid="battle-control-speed-2x" onclick={toggleLog}
>{i18n.t("game.battle.controls.speed_2x")}</button> aria-pressed={logOpen}
<button aria-label={i18n.t("game.battle.controls.log_toggle")}
type="button" data-testid="battle-control-log-toggle"
class:active={speed === 4} >{i18n.t("game.battle.controls.log_toggle")} {logOpen ? "▲" : "▼"}</button>
onclick={() => setSpeed(4)}
data-testid="battle-control-speed-4x"
>{i18n.t("game.battle.controls.speed_4x")}</button>
</div> </div>
<style> <style>
@@ -130,16 +145,11 @@ already at its end.
opacity: 0.4; opacity: 0.4;
cursor: not-allowed; cursor: not-allowed;
} }
button.active { .speed-btn {
background: #3a4585; min-width: 3rem;
border-color: #5d6cb8; font-variant-numeric: tabular-nums;
color: #ffffff;
} }
.speed-label { .log-toggle.active {
color: #93a0d0; background: #2a3463;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-right: 0.2rem;
} }
</style> </style>
@@ -1,11 +1,16 @@
// Radial layout for the BattleViewer. // Radial layout for the BattleViewer.
// //
// Places race anchors on a circle of radius `radius` around `center` // Places race anchors on a circle of radius `radius` around `center`
// at equal angular spacing. The first anchor sits at the top (12 // at equal angular spacing. For three or more races the first anchor
// o'clock); subsequent anchors march clockwise. When a race is // sits at the top (12 o'clock) and subsequent anchors march
// eliminated mid-battle, the caller filters it out of `activeRaceIds` // clockwise. For exactly two races the pair is rotated 90° so they
// and the survivors are re-spaced on the next frame. The same helper // face each other horizontally (3 o'clock vs 9 o'clock) — that keeps
// drives both the initial layout and that re-distribution. // every race label clear of the SVG top edge when only two clusters
// remain, and reads as "the two sides facing off" naturally.
//
// When a race is eliminated mid-battle the caller filters it out of
// `activeRaceIds` and the survivors are re-spaced on the next frame
// through the same helper.
export interface RaceAnchor { export interface RaceAnchor {
raceId: number; raceId: number;
@@ -35,10 +40,14 @@ export function layoutRaces(
if (count === 0) return []; if (count === 0) return [];
const { center, radius } = options; const { center, radius } = options;
const out: RaceAnchor[] = []; const out: RaceAnchor[] = [];
// For two participants we want a horizontal duel layout: race 0
// at 9 o'clock, race 1 at 3 o'clock. For any other count the
// first anchor lands at the top (12 o'clock) and the rest march
// clockwise at equal spacing.
const startAngle = count === 2 ? Math.PI : -Math.PI / 2;
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
// 12 o'clock = -PI/2 in math convention; clockwise → +i*step.
const step = (2 * Math.PI) / count; const step = (2 * Math.PI) / count;
const angle = -Math.PI / 2 + i * step; const angle = startAngle + i * step;
out.push({ out.push({
raceId: activeRaceIds[i], raceId: activeRaceIds[i],
x: center.x + radius * Math.cos(angle), x: center.x + radius * Math.cos(angle),
+4
View File
@@ -484,6 +484,7 @@ const en = {
"game.report.section.battles.empty": "no battles last turn", "game.report.section.battles.empty": "no battles last turn",
"game.report.section.battles.id_label": "battle", "game.report.section.battles.id_label": "battle",
"game.battle.title": "battle", "game.battle.title": "battle",
"game.battle.header_title": "Battle on planet {planet_name} (#{planet_number})",
"game.battle.loading": "loading battle…", "game.battle.loading": "loading battle…",
"game.battle.not_found": "battle not found", "game.battle.not_found": "battle not found",
"game.battle.back_to_report": "back to report", "game.battle.back_to_report": "back to report",
@@ -497,6 +498,9 @@ const en = {
"game.battle.controls.speed_1x": "1x", "game.battle.controls.speed_1x": "1x",
"game.battle.controls.speed_2x": "2x", "game.battle.controls.speed_2x": "2x",
"game.battle.controls.speed_4x": "4x", "game.battle.controls.speed_4x": "4x",
"game.battle.controls.speed_6x": "6x",
"game.battle.controls.scrub": "scrub battle timeline",
"game.battle.controls.log_toggle": "Log",
"game.battle.log.destroyed": "{attacker_race}'s {attacker_class} destroyed {defender_race}'s {defender_class}", "game.battle.log.destroyed": "{attacker_race}'s {attacker_class} destroyed {defender_race}'s {defender_class}",
"game.battle.log.shielded": "{attacker_race}'s {attacker_class} hit {defender_race}'s {defender_class}, shields held", "game.battle.log.shielded": "{attacker_race}'s {attacker_class} hit {defender_race}'s {defender_class}, shields held",
"game.battle.accessibility.protocol_heading": "battle log", "game.battle.accessibility.protocol_heading": "battle log",
+4
View File
@@ -485,6 +485,10 @@ const ru: Record<keyof typeof en, string> = {
"game.report.section.battles.empty": "сражений в этом ходу не было", "game.report.section.battles.empty": "сражений в этом ходу не было",
"game.report.section.battles.id_label": "сражение", "game.report.section.battles.id_label": "сражение",
"game.battle.title": "сражение", "game.battle.title": "сражение",
"game.battle.header_title": "Битва на планете {planet_name} (#{planet_number})",
"game.battle.controls.speed_6x": "6x",
"game.battle.controls.scrub": "перемотать таймлайн битвы",
"game.battle.controls.log_toggle": "Лог",
"game.battle.loading": "загрузка сражения…", "game.battle.loading": "загрузка сражения…",
"game.battle.not_found": "сражение не найдено", "game.battle.not_found": "сражение не найдено",
"game.battle.back_to_report": "к отчёту", "game.battle.back_to_report": "к отчёту",
+8 -5
View File
@@ -34,13 +34,16 @@ describe("layoutRaces", () => {
expect(result[0].y).toBeCloseTo(center.y - radius, 5); expect(result[0].y).toBeCloseTo(center.y - radius, 5);
}); });
it("places two races at opposite poles (180° apart)", () => { 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 }); const result = layoutRaces([0, 1], { center, radius });
expect(result).toHaveLength(2); expect(result).toHaveLength(2);
expect(result[0].x).toBeCloseTo(center.x, 5); expect(result[0].x).toBeCloseTo(center.x - radius, 5);
expect(result[0].y).toBeCloseTo(center.y - radius, 5); expect(result[0].y).toBeCloseTo(center.y, 5);
expect(result[1].x).toBeCloseTo(center.x, 5); expect(result[1].x).toBeCloseTo(center.x + radius, 5);
expect(result[1].y).toBeCloseTo(center.y + radius, 5); expect(result[1].y).toBeCloseTo(center.y, 5);
}); });
it("places three races at 120° intervals", () => { it("places three races at 120° intervals", () => {
+8 -8
View File
@@ -76,20 +76,20 @@ describe("active-view stubs", () => {
); );
}); });
test("battle view stamps the battleId and renders the back-to-map link", () => { test("battle view stamps the battleId and shows the loading placeholder", () => {
// Phase 27 replaces the Phase 10 stub with the Battle Viewer // Phase 27 replaces the Phase 10 stub with the Battle Viewer
// wrapper. The wrapper mounts the loading copy until the // wrapper. The latest layout iteration moved the back-
// fetcher resolves (component test runs in jsdom without a // navigation buttons inside `BattleViewer` so they only mount
// network); the back buttons and the data-battle-id stamp are // once the BattleReport finishes loading. The wrapper itself
// rendered unconditionally so the orchestrator scaffold is the // always renders the `active-view-battle` host with the
// stable hook the active-view shell relies on. // `data-battle-id` stamp and a localized loading copy until
// the fetcher resolves.
const ui = render(BattleView, { const ui = render(BattleView, {
props: { gameId: "synthetic-test", turn: 0, battleId: "b-42" }, props: { gameId: "synthetic-test", turn: 0, battleId: "b-42" },
}); });
const node = ui.getByTestId("active-view-battle"); const node = ui.getByTestId("active-view-battle");
expect(node).toHaveAttribute("data-battle-id", "b-42"); expect(node).toHaveAttribute("data-battle-id", "b-42");
expect(ui.getByTestId("battle-back-to-map")).toBeInTheDocument(); expect(ui.getByTestId("battle-loading")).toBeInTheDocument();
expect(ui.getByTestId("battle-back-to-report")).toBeInTheDocument();
}); });
test("battle view surfaces the not-found state for an empty battleId", () => { test("battle view surfaces the not-found state for an empty battleId", () => {