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
7 changed files with 384 additions and 70 deletions
Showing only changes of commit 17a3afd5e9 - Show all commits
+35
View File
@@ -47,6 +47,15 @@ hull. The per-bucket label `<className>:<numLeft>` sums NumberLeft
across the underlying groups; per-tech detail is available in the 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
initial ship count (`num` summed across tech variants, descending).
As ships die during playback only the label number changes — every
bucket keeps its slot in the Vogel spiral, so the user does not see
the cluster reshuffle when a class empties. Vogel positions are
then reassigned per rank by their inward distance toward the
planet, so the rank-0 bucket (the largest at battle start) always
sits at the most-inward spiral slot.
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
`(race, className) → ShipClassRef` lookup from the surrounding `(race, className) → ShipClassRef` lookup from the surrounding
@@ -95,6 +104,32 @@ 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".
Each log row is also a `<button>`: a click or Enter/Space jumps
playback to that shot (pauses and seeks). The list auto-scrolls
the current row into view as the timeline advances, so the user
does not have to chase the highlight on long battles.
## Playback details
On play, the shot line + the defender circle's colour flash gate
on a per-frame timer that blinks them off during the last 10 % of
the frame's duration. Two consecutive shots from the same attacker
on the same defender therefore look like two distinct pulses
rather than one continuous line. On pause the line and flash
stay drawn so the user can study the current shot.
## Phantom destroys
Legacy emitters (the `dg` engine format that feeds the synthetic-
report path) occasionally log more `Destroyed` lines against a
ship-group bucket than the bucket's initial population — the
emitter keeps recording hits past the moment the group emptied.
`buildFrames` clamps each per-group remaining count at zero and
only decrements race totals on a real shrink, so a race stays on
the scene until its actual ships are gone. The phantom shots still
draw a line during the frame they belong to; only the running
counters are protected.
## Height fit ## Height fit
The viewer is pinned to the viewport: `.active-view` uses The viewer is pinned to the viewport: `.active-view` uses
@@ -3,8 +3,13 @@ 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 *cloud* of class circles outer ring, each race rendered as a *cloud* of class circles
arranged on a Vogel sunflower spiral biased toward the planet. arranged on a Vogel sunflower spiral. Spiral positions are
Tech-variant groups of the same (race, className) collapse to one reassigned per rank by their inward distance toward the planet so
the rank-0 bucket (heaviest by NumberLeft) always sits at the
most-inward Vogel slot — the cloud visually leans toward the
planet without the cluster anchor needing a manual offset.
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 — the 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.
@@ -39,20 +44,20 @@ collapse to a single visual node.
report, report,
frame, frame,
shipClassLookup, shipClassLookup,
shotVisible = true,
}: { }: {
report: BattleReport; report: BattleReport;
frame: Frame; frame: Frame;
shipClassLookup?: ShipClassLookup; shipClassLookup?: ShipClassLookup;
shotVisible?: boolean;
} = $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;
// Vogel sunflower step + half-circle bias toward planet.
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 ANCHOR_BIAS = 0.25; // fraction of step pushed toward planet
const MAX_CLUSTER_RADIUS = 0.6 * (RACE_RING_RADIUS - PLANET_RADIUS); const MAX_CLUSTER_RADIUS = 0.6 * (RACE_RING_RADIUS - PLANET_RADIUS);
const coreHandle = getContext<CoreHandle | undefined>(CORE_CONTEXT_KEY); const coreHandle = getContext<CoreHandle | undefined>(CORE_CONTEXT_KEY);
@@ -66,6 +71,7 @@ collapse to a single visual node.
race: string; race: string;
raceId: number; raceId: number;
groupKeys: number[]; groupKeys: number[];
initialNum: number;
numLeft: number; numLeft: number;
mass: number; mass: number;
radius: number; radius: number;
@@ -79,7 +85,6 @@ collapse to a single visual node.
// those nodes. // those nodes.
const clustersByRace = $derived.by(() => { const clustersByRace = $derived.by(() => {
const core = coreHandle?.core ?? null; 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>(); const bucketIndex = new Map<string, ClusterEntry>();
for (const g of allGroups) { for (const g of allGroups) {
@@ -97,6 +102,7 @@ collapse to a single visual node.
race: g.group.race, race: g.group.race,
raceId: g.raceId, raceId: g.raceId,
groupKeys: [], groupKeys: [],
initialNum: 0,
numLeft: 0, numLeft: 0,
mass, mass,
radius: MAX_RADIUS, radius: MAX_RADIUS,
@@ -107,6 +113,7 @@ collapse to a single visual node.
out.set(g.raceId, list); out.set(g.raceId, list);
} }
bucket.groupKeys.push(g.key); bucket.groupKeys.push(g.key);
bucket.initialNum += g.group.num;
bucket.numLeft += frame.remaining.get(g.key) ?? 0; bucket.numLeft += frame.remaining.get(g.key) ?? 0;
} }
@@ -120,19 +127,20 @@ collapse to a single visual node.
bucket.radius = radiusForMass(bucket.mass, maxMass); bucket.radius = radiusForMass(bucket.mass, maxMass);
} }
// Sort buckets in each cluster by NumberLeft desc → rank 0 is // Order is locked by initial ship count, NOT live numLeft:
// the biggest group (will be placed closest to planet). // the user wants every bucket to keep its visual slot through
// the battle. Rank 0 = the bucket that *started* with the
// most ships, which the legacy game heuristic treats as
// "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.numLeft !== a.numLeft) return b.numLeft - a.numLeft; if (b.initialNum !== a.initialNum) return b.initialNum - a.initialNum;
return a.className.localeCompare(b.className); return a.className.localeCompare(b.className);
}); });
} }
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 bucketByGroupKey = $derived.by(() => {
const out = new Map<number, ClusterEntry>(); const out = new Map<number, ClusterEntry>();
for (const list of clustersByRace.values()) { for (const list of clustersByRace.values()) {
@@ -162,11 +170,6 @@ collapse to a single visual node.
step: 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 clusterBasisById = $derived.by(() => {
const out = new Map<number, ClusterBasis>(); const out = new Map<number, ClusterBasis>();
for (const anchor of raceLayout) { for (const anchor of raceLayout) {
@@ -175,27 +178,60 @@ collapse to a single visual node.
const len = Math.hypot(dx, dy) || 1; const len = Math.hypot(dx, dy) || 1;
const ux = dx / len; const ux = dx / len;
const uy = dy / len; const uy = dy / len;
const vx = uy; // (ux, uy) rotated 90° clockwise → (uy, -ux); const vx = uy;
const vy = -ux; // use mirror to keep label below cluster. const vy = -ux;
const count = (clustersByRace.get(anchor.raceId) ?? []).length; const count = (clustersByRace.get(anchor.raceId) ?? []).length;
const baseStep = BASE_STEP;
const denom = Math.max(1, Math.sqrt(Math.max(count, 1))); const denom = Math.max(1, Math.sqrt(Math.max(count, 1)));
const step = Math.min(baseStep, MAX_CLUSTER_RADIUS / denom); const step = Math.min(BASE_STEP, MAX_CLUSTER_RADIUS / denom);
const anchorX = anchor.x + ANCHOR_BIAS * step * ux; out.set(anchor.raceId, {
const anchorY = anchor.y + ANCHOR_BIAS * step * uy; anchorX: anchor.x,
out.set(anchor.raceId, { anchorX, anchorY, ux, uy, vx, vy, step }); anchorY: anchor.y,
ux,
uy,
vx,
vy,
step,
});
} }
return out; return out;
}); });
function nodePosition(basis: ClusterBasis, rank: number) { // vogelLocalsByRace generates Vogel sunflower positions for each
const radius = basis.step * Math.sqrt(rank); // cluster and reassigns them by inward dot product (`offsetU`
const angle = rank * GOLDEN_ANGLE; // descending) so the rank-0 bucket always lands at the most-
const offsetU = radius * Math.cos(angle); // inward spiral point. With the original `r * GOLDEN_ANGLE` angle
const offsetV = radius * Math.sin(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 { return {
x: basis.anchorX + offsetU * basis.ux + offsetV * basis.vx, offsetU: radius * Math.cos(angle),
y: basis.anchorY + offsetU * basis.uy + offsetV * basis.vy, 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 {
x: basis.anchorX + local.offsetU * basis.ux + local.offsetV * basis.vx,
y: basis.anchorY + local.offsetU * basis.uy + local.offsetV * basis.vy,
}; };
} }
@@ -206,8 +242,9 @@ collapse to a single visual node.
if (!basis) return null; if (!basis) return null;
const cluster = clustersByRace.get(bucket.raceId) ?? []; const cluster = clustersByRace.get(bucket.raceId) ?? [];
const rank = cluster.indexOf(bucket); const rank = cluster.indexOf(bucket);
if (rank === -1) return null; const locals = vogelLocalsByRace.get(bucket.raceId);
return nodePosition(basis, rank); if (rank === -1 || locals === undefined) return null;
return worldPosition(basis, locals[rank]);
} }
const shotLine = $derived.by(() => { const shotLine = $derived.by(() => {
@@ -216,7 +253,13 @@ collapse to a single visual node.
const from = findClassCircleCenter(action.sa); const from = findClassCircleCenter(action.sa);
const to = findClassCircleCenter(action.sd); const to = findClassCircleCenter(action.sd);
if (!from || !to) return null; if (!from || !to) return null;
return { from, to, destroyed: action.x }; return { from, to, destroyed: action.x, defenderKey: action.sd };
});
const flashDefenderBucketKey = $derived.by(() => {
if (!shotLine || !shotVisible) return null;
const bucket = bucketByGroupKey.get(shotLine.defenderKey);
return bucket?.bucketKey ?? null;
}); });
const raceLabelById = $derived.by(() => { const raceLabelById = $derived.by(() => {
@@ -224,15 +267,34 @@ collapse to a single visual node.
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) { for (const [, raceId] of groupRace) {
if (!out.has(raceId)) out.set(raceId, `race ${raceId}`); if (!out.has(raceId)) out.set(raceId, `race ${raceId}`);
} }
return out; return out;
}); });
// raceLabelYById computes a label position that sits above the
// cluster's bounding top so the race name never hides inside the
// cloud. The label is also clamped to a minimum distance from the
// race anchor so a single-bucket cluster still has its label
// rendered just above the only circle.
const raceLabelYById = $derived.by(() => {
const out = new Map<number, number>();
for (const [raceId, cluster] of clustersByRace) {
const basis = clusterBasisById.get(raceId);
const locals = vogelLocalsByRace.get(raceId);
if (!basis || !locals) continue;
let topY = basis.anchorY;
for (let i = 0; i < cluster.length; i++) {
const world = worldPosition(basis, locals[i]);
const y = world.y - cluster[i].radius;
if (y < topY) topY = y;
}
const fallback = basis.anchorY - MAX_RADIUS - 12;
out.set(raceId, Math.min(topY - 12, fallback));
}
return out;
});
</script> </script>
<svg <svg
@@ -260,6 +322,7 @@ collapse to a single visual node.
{#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)} {@const basis = clusterBasisById.get(anchor.raceId)}
{@const locals = vogelLocalsByRace.get(anchor.raceId) ?? []}
<g <g
class="race-cluster" class="race-cluster"
data-testid="battle-race-cluster" data-testid="battle-race-cluster"
@@ -267,18 +330,26 @@ collapse to a single visual node.
> >
<text <text
x={anchor.x} x={anchor.x}
y={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} {#if basis}
{#each cluster as entry, rank (entry.bucketKey)} {#each cluster as entry, rank (entry.bucketKey)}
{@const pos = nodePosition(basis, rank)} {@const local = locals[rank]}
{@const pos = local ? worldPosition(basis, local) : { x: anchor.x, y: anchor.y }}
{@const flash =
entry.bucketKey === flashDefenderBucketKey
? shotLine?.destroyed
? "destroyed"
: "shielded"
: null}
<g <g
class="class-marker" class="class-marker"
data-testid="battle-class-marker" data-testid="battle-class-marker"
data-bucket-key={entry.bucketKey} data-bucket-key={entry.bucketKey}
data-class-name={entry.className} data-class-name={entry.className}
data-flash={flash}
> >
<circle cx={pos.x} cy={pos.y} r={entry.radius} /> <circle cx={pos.x} cy={pos.y} r={entry.radius} />
<text <text
@@ -293,7 +364,7 @@ collapse to a single visual node.
</g> </g>
{/each} {/each}
{#if shotLine} {#if shotLine && shotVisible}
<line <line
x1={shotLine.from.x} x1={shotLine.from.x}
y1={shotLine.from.y} y1={shotLine.from.y}
@@ -315,12 +386,12 @@ collapse to a single visual node.
display: block; display: block;
} }
.planet { .planet {
fill: #2a345f; fill: #2a2f40;
stroke: #5b6aa3; stroke: #4a5066;
stroke-width: 2; stroke-width: 1.5;
} }
.planet-label { .planet-label {
fill: #c4caea; fill: #6d7388;
font-size: 18px; font-size: 18px;
font-family: ui-sans-serif, system-ui, sans-serif; font-family: ui-sans-serif, system-ui, sans-serif;
} }
@@ -334,6 +405,17 @@ collapse to a single visual node.
fill: #1a2042; fill: #1a2042;
stroke: #6d7bb5; stroke: #6d7bb5;
stroke-width: 1.5; stroke-width: 1.5;
transition:
fill 80ms ease-in,
stroke 80ms ease-in;
}
.class-marker[data-flash="destroyed"] circle {
fill: #ee3344;
stroke: #ee3344;
}
.class-marker[data-flash="shielded"] circle {
fill: #44dd66;
stroke: #44dd66;
} }
.class-label { .class-label {
fill: #b8c0e6; fill: #b8c0e6;
@@ -6,8 +6,6 @@ is logically isolated: feed it any `BattleReport` matching
`pkg/model/report/battle.go` and it plays back. `pkg/model/report/battle.go` and it plays back.
--> -->
<script lang="ts"> <script lang="ts">
import { onDestroy } from "svelte";
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 type { ShipClassLookup } from "./mass";
@@ -28,35 +26,67 @@ is logically isolated: feed it any `BattleReport` matching
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<1 | 2 | 4>(1);
// shotVisible blinks off for the last 10% of each frame so two
// 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 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)]);
// 1x = 400 ms per frame, 2x = 200 ms, 4x = 100 ms. The timer is // Schedule one tick per frame instead of a long-running
// rescheduled whenever `speed` or `playing` flips. // setInterval so the blink and the frame-advance share the same
let timer: ReturnType<typeof setInterval> | null = null; // timeline. The effect re-runs whenever frameIndex / playing /
// speed changes; on each run we (re)arm the blink + advance
// timers and let the previous run's timers be cleared by the
// cleanup return.
$effect(() => { $effect(() => {
if (timer !== null) { // Track changes via direct reads.
clearInterval(timer); void frameIndex;
timer = null; void speed;
} shotVisible = true;
if (!playing) return; if (!playing) return;
const intervalMs = 400 / speed; const intervalMs = 400 / speed;
timer = setInterval(() => { const blinkOff = setTimeout(() => {
shotVisible = false;
}, intervalMs * 0.9);
const advance = setTimeout(() => {
if (frameIndex >= frames.length - 1) { if (frameIndex >= frames.length - 1) {
playing = false; playing = false;
return; return;
} }
frameIndex = frameIndex + 1; frameIndex = frameIndex + 1;
}, intervalMs); }, intervalMs);
return () => {
clearTimeout(blinkOff);
clearTimeout(advance);
};
}); });
onDestroy(() => { // Auto-scroll the log so the current row stays visible as the
if (timer !== null) { // timeline advances. `block: "nearest"` keeps the scroll movement
clearInterval(timer); // gentle — the row lands at the closest edge of the visible
timer = null; // portion instead of jumping to the centre.
$effect(() => {
void frame.shotIndex;
if (logEl === null) return;
const current = logEl.querySelector(
'li[data-current="true"]',
) as HTMLElement | null;
if (current !== null) {
current.scrollIntoView({ block: "nearest", behavior: "smooth" });
} }
}); });
function seekToShot(actionIndex: number) {
playing = false;
frameIndex = Math.max(
0,
Math.min(frames.length - 1, actionIndex + 1),
);
}
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)];
@@ -88,7 +118,7 @@ is logically isolated: feed it any `BattleReport` matching
</header> </header>
<div class="scene"> <div class="scene">
<BattleScene {report} {frame} {shipClassLookup} /> <BattleScene {report} {frame} {shipClassLookup} {shotVisible} />
</div> </div>
<PlaybackControls <PlaybackControls
@@ -103,12 +133,18 @@ is logically isolated: feed it any `BattleReport` matching
aria-label={i18n.t("game.battle.accessibility.protocol_heading")} aria-label={i18n.t("game.battle.accessibility.protocol_heading")}
> >
<h3>{i18n.t("game.battle.accessibility.protocol_heading")}</h3> <h3>{i18n.t("game.battle.accessibility.protocol_heading")}</h3>
<ol data-testid="battle-protocol-log"> <ol bind:this={logEl} data-testid="battle-protocol-log">
{#each report.protocol as _action, i (i)} {#each report.protocol as _action, i (i)}
<li <li
data-testid="battle-protocol-log-item" data-testid="battle-protocol-log-item"
data-current={i + 1 === frame.shotIndex ? "true" : "false"} data-current={i + 1 === frame.shotIndex ? "true" : "false"}
>{describeAction(i)}</li> >
<button
type="button"
class="log-row-btn"
onclick={() => seekToShot(i)}
>{describeAction(i)}</button>
</li>
{/each} {/each}
</ol> </ol>
</section> </section>
@@ -180,11 +216,26 @@ is logically isolated: feed it any `BattleReport` matching
min-height: 0; min-height: 0;
} }
.log li { .log li {
padding: 0.15rem 0;
border-bottom: 1px solid #1c2240; border-bottom: 1px solid #1c2240;
} }
.log li[data-current="true"] { .log-row-btn {
display: block;
width: 100%;
text-align: left;
padding: 0.15rem 0.4rem;
background: transparent;
border: 0;
color: inherit;
font: inherit;
cursor: pointer;
}
.log-row-btn:hover,
.log-row-btn:focus-visible {
background: #131a36;
}
.log li[data-current="true"] .log-row-btn {
color: #ffe27a; color: #ffe27a;
font-weight: 600; font-weight: 600;
background: #1a2240;
} }
</style> </style>
@@ -57,7 +57,7 @@ already at its end.
disabled={frameIndex === 0} disabled={frameIndex === 0}
aria-label={i18n.t("game.battle.controls.step_backward")} aria-label={i18n.t("game.battle.controls.step_backward")}
data-testid="battle-control-step-back" data-testid="battle-control-step-back"
>◀︎</button> >◀︎◀︎</button>
<button <button
type="button" type="button"
onclick={togglePlay} onclick={togglePlay}
+14 -2
View File
@@ -105,15 +105,27 @@ export function buildFrames(report: BattleReport): Frame[] {
for (let i = 0; i < report.protocol.length; i++) { for (let i = 0; i < report.protocol.length; i++) {
const action = report.protocol[i]; const action = report.protocol[i];
if (action.x) { if (action.x) {
// Decrement only when the targeted group actually has
// ships left. Legacy emitters (the `dg` text format used
// by the synthetic-report path) sometimes ship more
// `Destroyed` lines than the group's initial population —
// looks like the engine keeps logging hits against an
// already-empty ship-group bucket. Without this guard
// `runningRaceTotals` decrements on every phantom and the
// race vanishes from `activeRaceIds` long before its
// real groups were all destroyed (KNNTS041 battle on
// planet 7, frame ≈ 406 of 2317). The line still draws
// for that frame so the user sees the shot happen.
const left = current.get(action.sd) ?? 0; const left = current.get(action.sd) ?? 0;
const next = Math.max(0, left - 1); if (left > 0) {
current.set(action.sd, next); current.set(action.sd, left - 1);
const raceId = groupRaceByKey.get(action.sd); const raceId = groupRaceByKey.get(action.sd);
if (raceId !== undefined) { if (raceId !== undefined) {
const t = (runningRaceTotals.get(raceId) ?? 0) - 1; const t = (runningRaceTotals.get(raceId) ?? 0) - 1;
runningRaceTotals.set(raceId, Math.max(0, t)); runningRaceTotals.set(raceId, Math.max(0, t));
} }
} }
}
frames.push({ frames.push({
shotIndex: i + 1, shotIndex: i + 1,
remaining: new Map(current), remaining: new Map(current),
@@ -326,7 +326,19 @@ fresh.
return; return;
} }
try { try {
const { cache } = await loadStore(); // Synthetic mode still needs the wasm `Core` so
// components that bridge to `pkg/calc/ship.go`
// (designer preview, BattleViewer mass radii) can
// resolve their math against the same engine helpers
// the live path uses. The live branch below also
// calls `loadCore()`; without it here the Battle
// Viewer rendered every ship-class circle at
// MAX_RADIUS in synthetic mode.
const [{ cache }, core] = await Promise.all([
loadStore(),
loadCore(),
]);
coreHolder.set(core);
await Promise.all([ await Promise.all([
gameState.initSynthetic({ cache, gameId, report }), gameState.initSynthetic({ cache, gameId, report }),
orderDraft.init({ cache, gameId }), orderDraft.init({ cache, gameId }),
+122
View File
@@ -150,6 +150,128 @@ describe("buildFrames", () => {
}); });
}); });
describe("buildFrames phantom-destroy clamp", () => {
it("does not drop a race when destroyed shots exceed initial counts", () => {
// Race "Phantom" has a single group with 2 ships; the engine
// emits five Destroyed shots against it (legacy emitter quirk
// reproduced in KNNTS041 planet #7). The group goes to 0
// after two real destroys; the remaining three are phantoms
// and must not push raceTotals into negatives or drop the
// race from activeRaceIds prematurely. Race "Survivor" keeps
// its single ship throughout so it stays active alongside
// Phantom until Phantom legitimately empties.
const report: BattleReport = {
id: "phantom-battle",
planet: 1,
planetName: "P",
races: { "0": "phantom-uuid", "1": "survivor-uuid" },
ships: {
"10": {
race: "Phantom",
className: "Drone",
tech: {},
num: 2,
numLeft: 0,
loadType: "",
loadQuantity: 0,
inBattle: true,
},
"20": {
race: "Survivor",
className: "Hawk",
tech: {},
num: 1,
numLeft: 1,
loadType: "",
loadQuantity: 0,
inBattle: true,
},
},
protocol: [
{ a: 1, sa: 20, d: 0, sd: 10, x: true }, // real kill #1
{ a: 1, sa: 20, d: 0, sd: 10, x: true }, // real kill #2 → group=0
{ a: 1, sa: 20, d: 0, sd: 10, x: true }, // phantom #1
{ a: 1, sa: 20, d: 0, sd: 10, x: true }, // phantom #2
{ a: 1, sa: 20, d: 0, sd: 10, x: true }, // phantom #3
],
};
const frames = buildFrames(report);
expect(frames[2].remaining.get(10)).toBe(0);
// After the 2nd real destroy Phantom has 0 ships in its only
// group and must drop out of activeRaceIds.
expect(frames[2].activeRaceIds).toEqual([1]);
// Phantoms past frame 2 must NOT keep decrementing — group
// stays at 0, totals don't go negative, and Survivor remains
// the only active race for the remainder of the protocol.
expect(frames[5].remaining.get(10)).toBe(0);
expect(frames[5].activeRaceIds).toEqual([1]);
});
it("keeps a race active while phantom destroys hit one of its empty groups", () => {
// One race ("Doublet"), two groups of different class. Class
// A gets all five Destroyed shots; class B never gets hit.
// Class A only has 2 ships → 3 phantoms. The race must stay
// active because class B's single ship is intact.
const report: BattleReport = {
id: "doublet-battle",
planet: 2,
planetName: "P2",
races: { "0": "doublet-uuid", "1": "attacker-uuid" },
ships: {
"10": {
race: "Doublet",
className: "A",
tech: {},
num: 2,
numLeft: 0,
loadType: "",
loadQuantity: 0,
inBattle: true,
},
"11": {
race: "Doublet",
className: "B",
tech: {},
num: 1,
numLeft: 1,
loadType: "",
loadQuantity: 0,
inBattle: true,
},
"20": {
race: "Attacker",
className: "Gun",
tech: {},
num: 1,
numLeft: 1,
loadType: "",
loadQuantity: 0,
inBattle: true,
},
},
protocol: [
// Open the protocol with a shot that names class B so
// normaliseGroups picks it up (groups never referenced
// in the protocol are filtered out of the visual
// roster); the shot misses so class B stays intact.
{ a: 1, sa: 20, d: 0, sd: 11, x: false },
{ a: 1, sa: 20, d: 0, sd: 10, x: true },
{ a: 1, sa: 20, d: 0, sd: 10, x: true },
{ a: 1, sa: 20, d: 0, sd: 10, x: true },
{ a: 1, sa: 20, d: 0, sd: 10, x: true },
{ a: 1, sa: 20, d: 0, sd: 10, x: true },
],
};
const frames = buildFrames(report);
// After all 6 actions: Doublet:A is at 0 (group capped at 2
// real destroys + 3 phantoms), Doublet:B unchanged at 1, so
// race totals = 1 → race stays active.
expect(frames[6].remaining.get(10)).toBe(0);
expect(frames[6].remaining.get(11)).toBe(1);
expect(frames[6].activeRaceIds.sort()).toEqual([0, 1]);
});
});
describe("radiusForMass", () => { describe("radiusForMass", () => {
it("returns MAX_RADIUS when mass is zero", () => { it("returns MAX_RADIUS when mass is zero", () => {
expect(radiusForMass(0, 100)).toBe(MAX_RADIUS); expect(radiusForMass(0, 100)).toBe(MAX_RADIUS);