ui: plan 01-27 done #1
@@ -47,6 +47,15 @@ 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).
|
||||
|
||||
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
|
||||
the per-ship `LoadQuantity`). The viewer resolves a
|
||||
`(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
|
||||
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
|
||||
|
||||
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
|
||||
outer ring, each race rendered as a *cloud* of class circles
|
||||
arranged on a Vogel sunflower spiral biased toward the planet.
|
||||
Tech-variant groups of the same (race, className) collapse to one
|
||||
arranged on a Vogel sunflower spiral. Spiral positions are
|
||||
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
|
||||
radius scales with the per-ship FullMass (sqrt) so heavy ships
|
||||
visually dominate.
|
||||
@@ -39,20 +44,20 @@ collapse to a single visual node.
|
||||
report,
|
||||
frame,
|
||||
shipClassLookup,
|
||||
shotVisible = true,
|
||||
}: {
|
||||
report: BattleReport;
|
||||
frame: Frame;
|
||||
shipClassLookup?: ShipClassLookup;
|
||||
shotVisible?: boolean;
|
||||
} = $props();
|
||||
|
||||
const VIEW_BOX = 800;
|
||||
const CENTER = { x: VIEW_BOX / 2, y: VIEW_BOX / 2 };
|
||||
const PLANET_RADIUS = 60;
|
||||
const RACE_RING_RADIUS = 280;
|
||||
// Vogel sunflower step + half-circle bias toward planet.
|
||||
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);
|
||||
@@ -66,6 +71,7 @@ collapse to a single visual node.
|
||||
race: string;
|
||||
raceId: number;
|
||||
groupKeys: number[];
|
||||
initialNum: number;
|
||||
numLeft: number;
|
||||
mass: number;
|
||||
radius: number;
|
||||
@@ -79,7 +85,6 @@ collapse to a single visual node.
|
||||
// those nodes.
|
||||
const clustersByRace = $derived.by(() => {
|
||||
const core = coreHandle?.core ?? null;
|
||||
// First pass: build the bucket list per race.
|
||||
const out = new Map<number, ClusterEntry[]>();
|
||||
const bucketIndex = new Map<string, ClusterEntry>();
|
||||
for (const g of allGroups) {
|
||||
@@ -97,6 +102,7 @@ collapse to a single visual node.
|
||||
race: g.group.race,
|
||||
raceId: g.raceId,
|
||||
groupKeys: [],
|
||||
initialNum: 0,
|
||||
numLeft: 0,
|
||||
mass,
|
||||
radius: MAX_RADIUS,
|
||||
@@ -107,6 +113,7 @@ collapse to a single visual node.
|
||||
out.set(g.raceId, list);
|
||||
}
|
||||
bucket.groupKeys.push(g.key);
|
||||
bucket.initialNum += g.group.num;
|
||||
bucket.numLeft += frame.remaining.get(g.key) ?? 0;
|
||||
}
|
||||
|
||||
@@ -120,19 +127,20 @@ collapse to a single visual node.
|
||||
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).
|
||||
// Order is locked by initial ship count, NOT live numLeft:
|
||||
// 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()) {
|
||||
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 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()) {
|
||||
@@ -162,11 +170,6 @@ collapse to a single visual node.
|
||||
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) {
|
||||
@@ -175,27 +178,60 @@ collapse to a single visual node.
|
||||
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 vx = uy;
|
||||
const vy = -ux;
|
||||
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 });
|
||||
const step = Math.min(BASE_STEP, MAX_CLUSTER_RADIUS / denom);
|
||||
out.set(anchor.raceId, {
|
||||
anchorX: anchor.x,
|
||||
anchorY: anchor.y,
|
||||
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);
|
||||
// vogelLocalsByRace generates Vogel sunflower positions for each
|
||||
// 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 {
|
||||
x: basis.anchorX + offsetU * basis.ux + offsetV * basis.vx,
|
||||
y: basis.anchorY + offsetU * basis.uy + offsetV * basis.vy,
|
||||
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;
|
||||
const cluster = clustersByRace.get(bucket.raceId) ?? [];
|
||||
const rank = cluster.indexOf(bucket);
|
||||
if (rank === -1) return null;
|
||||
return nodePosition(basis, rank);
|
||||
const locals = vogelLocalsByRace.get(bucket.raceId);
|
||||
if (rank === -1 || locals === undefined) return null;
|
||||
return worldPosition(basis, locals[rank]);
|
||||
}
|
||||
|
||||
const shotLine = $derived.by(() => {
|
||||
@@ -216,7 +253,13 @@ collapse to a single visual node.
|
||||
const from = findClassCircleCenter(action.sa);
|
||||
const to = findClassCircleCenter(action.sd);
|
||||
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(() => {
|
||||
@@ -224,15 +267,34 @@ collapse to a single visual node.
|
||||
for (const g of allGroups) {
|
||||
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;
|
||||
});
|
||||
|
||||
// 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>
|
||||
|
||||
<svg
|
||||
@@ -260,6 +322,7 @@ collapse to a single visual node.
|
||||
{#each raceLayout as anchor (anchor.raceId)}
|
||||
{@const cluster = clustersByRace.get(anchor.raceId) ?? []}
|
||||
{@const basis = clusterBasisById.get(anchor.raceId)}
|
||||
{@const locals = vogelLocalsByRace.get(anchor.raceId) ?? []}
|
||||
<g
|
||||
class="race-cluster"
|
||||
data-testid="battle-race-cluster"
|
||||
@@ -267,18 +330,26 @@ collapse to a single visual node.
|
||||
>
|
||||
<text
|
||||
x={anchor.x}
|
||||
y={anchor.y - MAX_RADIUS - 12}
|
||||
y={raceLabelYById.get(anchor.raceId) ?? anchor.y - MAX_RADIUS - 12}
|
||||
text-anchor="middle"
|
||||
class="race-label"
|
||||
>{raceLabelById.get(anchor.raceId) ?? `race ${anchor.raceId}`}</text>
|
||||
{#if basis}
|
||||
{#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
|
||||
class="class-marker"
|
||||
data-testid="battle-class-marker"
|
||||
data-bucket-key={entry.bucketKey}
|
||||
data-class-name={entry.className}
|
||||
data-flash={flash}
|
||||
>
|
||||
<circle cx={pos.x} cy={pos.y} r={entry.radius} />
|
||||
<text
|
||||
@@ -293,7 +364,7 @@ collapse to a single visual node.
|
||||
</g>
|
||||
{/each}
|
||||
|
||||
{#if shotLine}
|
||||
{#if shotLine && shotVisible}
|
||||
<line
|
||||
x1={shotLine.from.x}
|
||||
y1={shotLine.from.y}
|
||||
@@ -315,12 +386,12 @@ collapse to a single visual node.
|
||||
display: block;
|
||||
}
|
||||
.planet {
|
||||
fill: #2a345f;
|
||||
stroke: #5b6aa3;
|
||||
stroke-width: 2;
|
||||
fill: #2a2f40;
|
||||
stroke: #4a5066;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
.planet-label {
|
||||
fill: #c4caea;
|
||||
fill: #6d7388;
|
||||
font-size: 18px;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
@@ -334,6 +405,17 @@ collapse to a single visual node.
|
||||
fill: #1a2042;
|
||||
stroke: #6d7bb5;
|
||||
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 {
|
||||
fill: #b8c0e6;
|
||||
|
||||
@@ -6,8 +6,6 @@ is logically isolated: feed it any `BattleReport` matching
|
||||
`pkg/model/report/battle.go` and it plays back.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onDestroy } from "svelte";
|
||||
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import type { BattleReport } from "../../api/battle-fetch";
|
||||
import type { ShipClassLookup } from "./mass";
|
||||
@@ -28,35 +26,67 @@ is logically isolated: feed it any `BattleReport` matching
|
||||
let frameIndex = $state(0);
|
||||
let playing = $state(false);
|
||||
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)]);
|
||||
|
||||
// 1x = 400 ms per frame, 2x = 200 ms, 4x = 100 ms. The timer is
|
||||
// rescheduled whenever `speed` or `playing` flips.
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
// Schedule one tick per frame instead of a long-running
|
||||
// setInterval so the blink and the frame-advance share the same
|
||||
// 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(() => {
|
||||
if (timer !== null) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
// Track changes via direct reads.
|
||||
void frameIndex;
|
||||
void speed;
|
||||
shotVisible = true;
|
||||
if (!playing) return;
|
||||
const intervalMs = 400 / speed;
|
||||
timer = setInterval(() => {
|
||||
const blinkOff = setTimeout(() => {
|
||||
shotVisible = false;
|
||||
}, intervalMs * 0.9);
|
||||
const advance = setTimeout(() => {
|
||||
if (frameIndex >= frames.length - 1) {
|
||||
playing = false;
|
||||
return;
|
||||
}
|
||||
frameIndex = frameIndex + 1;
|
||||
}, intervalMs);
|
||||
return () => {
|
||||
clearTimeout(blinkOff);
|
||||
clearTimeout(advance);
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (timer !== null) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
// Auto-scroll the log so the current row stays visible as the
|
||||
// timeline advances. `block: "nearest"` keeps the scroll movement
|
||||
// gentle — the row lands at the closest edge of the visible
|
||||
// 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 {
|
||||
const action = report.protocol[index];
|
||||
const attackerGroup = report.ships[String(action.sa)];
|
||||
@@ -88,7 +118,7 @@ is logically isolated: feed it any `BattleReport` matching
|
||||
</header>
|
||||
|
||||
<div class="scene">
|
||||
<BattleScene {report} {frame} {shipClassLookup} />
|
||||
<BattleScene {report} {frame} {shipClassLookup} {shotVisible} />
|
||||
</div>
|
||||
|
||||
<PlaybackControls
|
||||
@@ -103,12 +133,18 @@ is logically isolated: feed it any `BattleReport` matching
|
||||
aria-label={i18n.t("game.battle.accessibility.protocol_heading")}
|
||||
>
|
||||
<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)}
|
||||
<li
|
||||
data-testid="battle-protocol-log-item"
|
||||
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}
|
||||
</ol>
|
||||
</section>
|
||||
@@ -180,11 +216,26 @@ is logically isolated: feed it any `BattleReport` matching
|
||||
min-height: 0;
|
||||
}
|
||||
.log li {
|
||||
padding: 0.15rem 0;
|
||||
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;
|
||||
font-weight: 600;
|
||||
background: #1a2240;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -57,7 +57,7 @@ already at its end.
|
||||
disabled={frameIndex === 0}
|
||||
aria-label={i18n.t("game.battle.controls.step_backward")}
|
||||
data-testid="battle-control-step-back"
|
||||
>◀︎</button>
|
||||
>◀︎◀︎</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={togglePlay}
|
||||
|
||||
@@ -105,13 +105,25 @@ export function buildFrames(report: BattleReport): Frame[] {
|
||||
for (let i = 0; i < report.protocol.length; i++) {
|
||||
const action = report.protocol[i];
|
||||
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 next = Math.max(0, left - 1);
|
||||
current.set(action.sd, next);
|
||||
const raceId = groupRaceByKey.get(action.sd);
|
||||
if (raceId !== undefined) {
|
||||
const t = (runningRaceTotals.get(raceId) ?? 0) - 1;
|
||||
runningRaceTotals.set(raceId, Math.max(0, t));
|
||||
if (left > 0) {
|
||||
current.set(action.sd, left - 1);
|
||||
const raceId = groupRaceByKey.get(action.sd);
|
||||
if (raceId !== undefined) {
|
||||
const t = (runningRaceTotals.get(raceId) ?? 0) - 1;
|
||||
runningRaceTotals.set(raceId, Math.max(0, t));
|
||||
}
|
||||
}
|
||||
}
|
||||
frames.push({
|
||||
|
||||
@@ -326,7 +326,19 @@ fresh.
|
||||
return;
|
||||
}
|
||||
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([
|
||||
gameState.initSynthetic({ cache, gameId, report }),
|
||||
orderDraft.init({ cache, gameId }),
|
||||
|
||||
@@ -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", () => {
|
||||
it("returns MAX_RADIUS when mass is zero", () => {
|
||||
expect(radiusForMass(0, 100)).toBe(MAX_RADIUS);
|
||||
|
||||
Reference in New Issue
Block a user