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}`}