diff --git a/ui/src/pages/recording-playback/__tests__/UnitsTab.test.tsx b/ui/src/pages/recording-playback/__tests__/UnitsTab.test.tsx
index e1b4fb41..b894f4af 100644
--- a/ui/src/pages/recording-playback/__tests__/UnitsTab.test.tsx
+++ b/ui/src/pages/recording-playback/__tests__/UnitsTab.test.tsx
@@ -227,7 +227,7 @@ describe("UnitsTab", () => {
expect(twos.length).toBeGreaterThanOrEqual(1);
});
- it("counts deleted units as dead and styles them", () => {
+ it("styles a despawned unit (no kill event) as inactive", () => {
const { engine, renderer } = createTestEngine();
engine.loadRecording(
makeManifest([
@@ -254,7 +254,6 @@ describe("UnitsTab", () => {
]),
);
- // Advance to frame 1 so snapshots are populated
engine.seekTo(1);
render(() => (
@@ -263,12 +262,155 @@ describe("UnitsTab", () => {
));
- // Deleted unit counts as dead: alive=1, total=2
- expect(screen.getByText("1")).toBeTruthy(); // alive count in group header
-
- // Deleted unit row must have the dead styling
+ // Unit despawned without a kill event → inactive styling, not dead
const deletedRow = screen.getByText("Deleted Unit").closest("button");
- expect(deletedRow?.className).toMatch(/unitRowDead/);
+ expect(deletedRow?.className).toMatch(/unitRowInactive/);
+ expect(deletedRow?.className).not.toMatch(/unitRowDead/);
+ });
+
+ it("styles a unit killed by a kill event as dead", () => {
+ const { engine, renderer } = createTestEngine();
+ engine.loadRecording(
+ makeManifest(
+ [
+ unitDef({
+ id: 1,
+ name: "Victim",
+ side: "WEST",
+ groupName: "Alpha",
+ role: "Trooper",
+ positions: [
+ { position: [100, 200], direction: 0, alive: 1 },
+ { position: [100, 200], direction: 0, alive: 0 }, // dead at frame 1
+ ],
+ }),
+ unitDef({
+ id: 2,
+ name: "Killer",
+ side: "EAST",
+ groupName: "Bravo",
+ role: "Trooper",
+ positions: [
+ { position: [50, 50], direction: 0, alive: 1 },
+ { position: [50, 50], direction: 0, alive: 1 },
+ ],
+ }),
+ ],
+ [killedEvent(1, 1, 2)],
+ ),
+ );
+
+ setActiveSide("WEST");
+ engine.seekTo(1);
+
+ render(() => (
+
+
+
+ ));
+
+ const row = screen.getByText("Victim").closest("button");
+ expect(row?.className).toMatch(/unitRowDead/);
+ expect(row?.className).not.toMatch(/unitRowInactive/);
+ });
+
+ it("keeps dead styling for a killed unit even after body despawns", () => {
+ const { engine, renderer } = createTestEngine();
+ engine.loadRecording(
+ makeManifest(
+ [
+ unitDef({
+ id: 1,
+ name: "Victim",
+ side: "WEST",
+ groupName: "Alpha",
+ role: "Trooper",
+ endFrame: 3, // body despawns at frame 3
+ positions: [
+ { position: [100, 200], direction: 0, alive: 1 },
+ { position: [100, 200], direction: 0, alive: 0 },
+ { position: [100, 200], direction: 0, alive: 0 },
+ { position: [100, 200], direction: 0, alive: 0 },
+ ],
+ }),
+ unitDef({
+ id: 2,
+ name: "Killer",
+ side: "EAST",
+ groupName: "Bravo",
+ role: "Trooper",
+ positions: [
+ { position: [50, 50], direction: 0, alive: 1 },
+ { position: [50, 50], direction: 0, alive: 1 },
+ ],
+ }),
+ ],
+ [killedEvent(1, 1, 2)],
+ ),
+ );
+
+ setActiveSide("WEST");
+ engine.seekTo(10); // well beyond endFrame=3, no snapshot for unit 1
+
+ render(() => (
+
+
+
+ ));
+
+ // No snapshot but deaths=1 → still dead, not inactive
+ const row = screen.getByText("Victim").closest("button");
+ expect(row?.className).toMatch(/unitRowDead/);
+ expect(row?.className).not.toMatch(/unitRowInactive/);
+ });
+
+ it("styles a respawned unit as alive even when they have prior deaths", () => {
+ const { engine, renderer } = createTestEngine();
+ engine.loadRecording(
+ makeManifest(
+ [
+ unitDef({
+ id: 1,
+ name: "Respawner",
+ side: "WEST",
+ groupName: "Alpha",
+ role: "Trooper",
+ positions: [
+ { position: [100, 200], direction: 0, alive: 1 },
+ { position: [100, 200], direction: 0, alive: 0 }, // killed
+ { position: [200, 300], direction: 0, alive: 1 }, // respawned
+ ],
+ }),
+ unitDef({
+ id: 2,
+ name: "Killer",
+ side: "EAST",
+ groupName: "Bravo",
+ role: "Trooper",
+ positions: [
+ { position: [50, 50], direction: 0, alive: 1 },
+ { position: [50, 50], direction: 0, alive: 1 },
+ { position: [50, 50], direction: 0, alive: 1 },
+ ],
+ }),
+ ],
+ [killedEvent(1, 1, 2)],
+ ),
+ );
+
+ setActiveSide("WEST");
+ engine.seekTo(2); // respawned frame — alive=1 in snapshot, deaths=1 in events
+
+ render(() => (
+
+
+
+ ));
+
+ const row = screen.getByText("Respawner").closest("button");
+ // Alive in snapshot takes priority — no dead or inactive class
+ expect(row?.className).not.toMatch(/unitRowDead/);
+ expect(row?.className).not.toMatch(/unitRowInactive/);
});
it("only renders populated side tabs when multiple sides have units", () => {
diff --git a/ui/src/pages/recording-playback/components/SidePanel.module.css b/ui/src/pages/recording-playback/components/SidePanel.module.css
index 974f5ef5..199c9ce5 100644
--- a/ui/src/pages/recording-playback/components/SidePanel.module.css
+++ b/ui/src/pages/recording-playback/components/SidePanel.module.css
@@ -213,6 +213,10 @@
background: color-mix(in srgb, var(--accent-primary) 8%, transparent);
}
+.unitRowInactive {
+ opacity: 0.45;
+}
+
.unitRowDead {
opacity: 0.45;
}
@@ -245,10 +249,14 @@
color: var(--text-secondary);
}
-.unitNameDead {
+.unitNameInactive {
color: var(--text-dimmer);
}
+.unitNameDead {
+ color: var(--accent-warning);
+}
+
.unitAiBadge {
font-size: var(--font-size-xs);
color: var(--text-dimmest);
diff --git a/ui/src/pages/recording-playback/components/UnitsTab.tsx b/ui/src/pages/recording-playback/components/UnitsTab.tsx
index ae60b02e..cc390967 100644
--- a/ui/src/pages/recording-playback/components/UnitsTab.tsx
+++ b/ui/src/pages/recording-playback/components/UnitsTab.tsx
@@ -69,16 +69,18 @@ export function UnitsTab(props: UnitsTabProps): JSX.Element {
}
});
- const isAlive = (unitId: number): boolean => {
- const snap = engine.entitySnapshots().get(unitId);
- return snap ? !!snap.alive : false;
- };
-
// Frame-aware kill counts
const killDeathCounts = createMemo(() =>
engine.eventManager.getKillDeathCounts(engine.currentFrame()),
);
+ const getUnitStatus = (unitId: number): "alive" | "dead" | "inactive" => {
+ const snap = engine.entitySnapshots().get(unitId);
+ if (snap && snap.alive) return "alive";
+ if ((killDeathCounts().deaths.get(unitId) ?? 0) > 0) return "dead";
+ return "inactive";
+ };
+
const groups = createMemo((): GroupData[] => {
const units = unitsForSide(activeSide());
const groupMap = new Map();
@@ -108,11 +110,12 @@ export function UnitsTab(props: UnitsTabProps): JSX.Element {
};
const aliveCount = (units: Unit[]): number => {
- // Access snapshots for reactivity
+ // Access both reactive sources so this recomputes on snapshot or kill-event changes
engine.entitySnapshots();
+ killDeathCounts();
let count = 0;
for (const u of units) {
- if (isAlive(u.id)) count++;
+ if (getUnitStatus(u.id) === "alive") count++;
}
return count;
};
@@ -185,7 +188,7 @@ export function UnitsTab(props: UnitsTabProps): JSX.Element {
{(unit) => {
- const alive = () => isAlive(unit.id);
+ const status = () => getUnitStatus(unit.id);
const selected = () => selectedUnit() === unit.id;
return (
<>
@@ -193,7 +196,8 @@ export function UnitsTab(props: UnitsTabProps): JSX.Element {
class={styles.unitRow}
classList={{
[styles.unitRowSelected]: selected(),
- [styles.unitRowDead]: !alive(),
+ [styles.unitRowDead]: status() === "dead",
+ [styles.unitRowInactive]: status() === "inactive",
}}
onClick={() =>
setSelectedUnit(selected() ? null : unit.id)
@@ -211,8 +215,9 @@ export function UnitsTab(props: UnitsTabProps): JSX.Element {
{unit.name || `Unit ${unit.id}`}