From 54c2049c03cb1223870d563262b624308f886c76 Mon Sep 17 00:00:00 2001 From: Keith Avery Date: Sun, 26 Apr 2026 08:46:51 -0400 Subject: [PATCH] =?UTF-8?q?fix(panel):=20remove=20stale=20HP=20rendering?= =?UTF-8?q?=20per=20ADR-014=20=E2=80=94=20character/party=20panel=20reads?= =?UTF-8?q?=20"Edge"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S2-BUG (playtest 2026-04-26): the character panel header and the inline party rows rendered an "HP N/M" badge that pulled from EdgePool under a misleading label. ADR-014 ("Diamonds and Coal") and ADR-078 ("Edge / Composure") removed HP from CreatureCore in favor of the EdgePool composure currency — Sebastien-axis (mechanics-first) players in the playgroup notice this immediately and ask why HP is showing. This patch: - src/components/CharacterPanel.tsx - Renames the header badge: HpBadge → EdgeBadge, "HP N/M" → "Edge N/M", testid character-hp-badge → character-edge-badge, aria-label "Hit points N of M" → "Edge N of M". - Renames the inline party row: testid party-member-hp-{id} → party-member-edge-{id}, "HP N/M" → "Edge N/M". - Comments now reference ADR-014 / ADR-078 with a note that the legacy wire field names (hp/hp_max on CharacterSummary, current_hp/max_hp on PartyMember) are kept until a protocol-level rename (out of scope — would require save migration). - src/components/CharacterSheet.tsx - hp / hp_max field doc-comments updated to call out the schema rename and the legacy wire-name caveat. - src/components/__tests__/CharacterPanel.test.tsx - Updated existing assertions to look for "Edge N/M" + the new testids; added rename-completeness lock tests that assert the legacy testids and "HP N/M" text never appear in the panel header OR the party section. - src/__tests__/edge-badge-party-status-wiring.test.tsx (new) - Wiring test that drives a synthetic PARTY_STATUS payload through the App.tsx fan-out shape (hp/hp_max derived from current_hp/max_hp) into a real CharacterPanel render, asserting Edge labels end-to-end and no legacy HP testids/text anywhere. Required by CLAUDE.md "Every test suite needs a wiring test". Coordinates with the server PR (slabgorb/sidequest-server#63, commit e303d57df95c5a03ede12e3e0f4abc7f207e9f72) which removed the matching HP emission from the chargen.complete log line and the character_creation.character_built OTEL event. --- .../edge-badge-party-status-wiring.test.tsx | 132 ++++++++++++++++++ src/components/CharacterPanel.tsx | 43 +++--- src/components/CharacterSheet.tsx | 12 +- .../__tests__/CharacterPanel.test.tsx | 71 +++++++--- 4 files changed, 217 insertions(+), 41 deletions(-) create mode 100644 src/__tests__/edge-badge-party-status-wiring.test.tsx diff --git a/src/__tests__/edge-badge-party-status-wiring.test.tsx b/src/__tests__/edge-badge-party-status-wiring.test.tsx new file mode 100644 index 0000000..4fe081b --- /dev/null +++ b/src/__tests__/edge-badge-party-status-wiring.test.tsx @@ -0,0 +1,132 @@ +/** + * S2-BUG (playtest 2026-04-26) — wiring lock for ADR-014 / ADR-078 schema. + * + * Server emits PARTY_STATUS with `members[].current_hp` / `members[].max_hp` + * (legacy wire field names — the values are the EdgePool, not HP). App.tsx + * fans those into CharacterSummary.hp / hp_max, which the CharacterPanel + * renders as the Edge badge in the header AND as the inline party-row Edge. + * + * This wiring test drives that whole pipeline through the React tree so that + * a regression that re-introduces an "HP" label anywhere in the path is + * caught at the integration boundary, not just in the component unit tests. + */ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { CharacterPanel } from "@/components/CharacterPanel"; +import type { CharacterSummary } from "@/types/party"; +import type { CharacterSheetData } from "@/components/CharacterSheet"; + +// Mirror the App.tsx PARTY_STATUS handler shape (App.tsx:673-694) so that +// when this test wakes someone up two months from now, they can grep the +// app code and find the same field plumbing. +function partyStatusToSummary( + m: Record, +): CharacterSummary { + return { + player_id: (m.player_id as string) ?? "", + name: (m.name as string) ?? "", + character_name: + (m.character_name as string) ?? (m.name as string) ?? "", + hp: (m.current_hp as number) ?? 0, + hp_max: (m.max_hp as number) ?? 0, + status_effects: (m.statuses as string[]) ?? [], + class: (m.class as string) ?? "", + level: (m.level as number) ?? 1, + portrait_url: (m.portrait_url as string) || undefined, + current_location: (m.current_location as string) ?? "", + }; +} + +function partyStatusToSheet(m: Record): CharacterSheetData { + const sheet = (m.sheet as Record) ?? {}; + return { + name: (m.character_name as string) ?? (m.name as string) ?? "", + class: (m.class as string) ?? "", + race: (sheet.race as string) || undefined, + level: (m.level as number) ?? 1, + hp: + typeof m.current_hp === "number" + ? (m.current_hp as number) + : undefined, + hp_max: + typeof m.max_hp === "number" ? (m.max_hp as number) : undefined, + stats: (sheet.stats as Record) ?? {}, + abilities: (sheet.abilities as string[]) ?? [], + backstory: (sheet.backstory as string) ?? "", + portrait_url: (m.portrait_url as string) || undefined, + current_location: (m.current_location as string) ?? "", + }; +} + +describe("CharacterPanel — PARTY_STATUS wiring (ADR-014 schema)", () => { + it("renders Edge label end-to-end from a synthetic PARTY_STATUS payload", () => { + // Synthetic PARTY_STATUS members payload as the server actually emits it + // (sidequest/server/session_handler.py:_build_session_start_party_status). + // current_hp / max_hp are pulled from character.core.edge.current/.max + // — see ADR-014 / ADR-078. + const members = [ + { + player_id: "kael-pid", + name: "KeithPlayer", + character_name: "Kael", + class: "Ranger", + level: 3, + current_hp: 18, + max_hp: 30, + statuses: ["wary"], + sheet: { + stats: { dexterity: 18 }, + abilities: ["Tracker"], + backstory: "Born in the Ashwood.", + race: "Wood Elf", + }, + }, + { + player_id: "lyra-pid", + name: "JamesPlayer", + character_name: "Lyra", + class: "Cleric", + level: 5, + current_hp: 7, + max_hp: 40, + statuses: [], + }, + ]; + + const characters = members.map(partyStatusToSummary); + const localSheet = partyStatusToSheet(members[0]); + + render( + , + ); + + // Header badge: "Edge", not "HP". + const badge = screen.getByTestId("character-edge-badge"); + expect(badge).toHaveTextContent("Edge 18/30"); + expect(screen.queryByTestId("character-hp-badge")).not.toBeInTheDocument(); + + // Inline party rows: "Edge N/M", not "HP N/M". + const kaelRow = screen.getByTestId("party-member-edge-kael-pid"); + expect(kaelRow).toHaveTextContent("Edge 18/30"); + const lyraRow = screen.getByTestId("party-member-edge-lyra-pid"); + expect(lyraRow).toHaveTextContent("Edge 7/40"); + // Lyra at 17.5% should hit the destructive threshold (≤25%). + expect(lyraRow.className).toMatch(/destructive/); + + // Old testids must not exist anywhere — catches a half-rename. + expect( + screen.queryByTestId("party-member-hp-kael-pid"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("party-member-hp-lyra-pid"), + ).not.toBeInTheDocument(); + + // No "HP N/M" text appears anywhere in the panel. + const panel = screen.getByTestId("character-panel"); + expect(panel.textContent ?? "").not.toMatch(/\bHP\s*\d+\s*\/\s*\d+/); + }); +}); diff --git a/src/components/CharacterPanel.tsx b/src/components/CharacterPanel.tsx index e1505f6..8c3389e 100644 --- a/src/components/CharacterPanel.tsx +++ b/src/components/CharacterPanel.tsx @@ -126,13 +126,15 @@ export function CharacterPanel({ > Lv {character.level} - {/* HP/Edge — load-bearing for Sebastien-axis players (mechanical - visibility). Server emits current/max on PARTY_STATUS members - as current_hp/max_hp; App.tsx fans them out into hp/hp_max on - CharacterSheetData. Hidden when both are absent (genres that - don't model HP) so we never render a fake "0/0". */} + {/* Edge badge — load-bearing for Sebastien-axis players (mechanical + visibility). ADR-014 / ADR-078: HP was removed from CreatureCore + in favor of EdgePool (composure currency). Server emits current/max + on PARTY_STATUS members as current_hp/max_hp (legacy wire field + names — protocol rename is a follow-up); App.tsx fans them out + into hp/hp_max on CharacterSheetData. Hidden when both are absent + (genres that don't model edge) so we never render a fake "0/0". */} {typeof character.hp === "number" && typeof character.hp_max === "number" && ( - + )} @@ -242,21 +244,24 @@ export function CharacterPanel({ {toDisplayName(c.class)} Lv.{c.level} - {/* Inline HP for party rows so glance value matches the - CharacterPanel header. Skip when the genre doesn't - report HP at all (both 0 = uninitialized). */} + {/* Inline Edge for party rows so glance value matches the + CharacterPanel header. ADR-014 / ADR-078: HP was + removed in favor of EdgePool — wire field names + (hp/hp_max on CharacterSummary) are kept until a + protocol-level rename. Skip when the genre doesn't + report edge at all (both 0 = uninitialized). */} {(c.hp_max > 0 || c.hp > 0) && ( <> {" · "} 0 && c.hp / c.hp_max <= 0.25 ? "text-destructive font-semibold" : "text-foreground/80" } > - HP {c.hp}/{c.hp_max} + Edge {c.hp}/{c.hp_max} )} @@ -282,11 +287,13 @@ export function CharacterPanel({ } /** - * HP / Edge badge in the CharacterPanel header. Color shifts to destructive - * when the player drops to 1/4 max so a glance is enough to know "I'm in - * trouble". Same threshold rule as the inline party-row HP for consistency. + * Edge badge in the CharacterPanel header. ADR-014 / ADR-078: edge (composure) + * replaced the legacy HP field on CreatureCore — the badge now reflects the + * actual schema. Color shifts to destructive when the player drops to 1/4 max + * so a glance is enough to know "I'm one push from a yield". Same threshold + * rule as the inline party-row edge for consistency. */ -function HpBadge({ current, max }: { current: number; max: number }) { +function EdgeBadge({ current, max }: { current: number; max: number }) { const ratio = max > 0 ? current / max : 1; const tone = ratio <= 0.25 @@ -294,11 +301,11 @@ function HpBadge({ current, max }: { current: number; max: number }) { : "border-[var(--primary)]/40 text-[var(--primary)]"; return (
- HP {current}/{max} + Edge {current}/{max}
); } diff --git a/src/components/CharacterSheet.tsx b/src/components/CharacterSheet.tsx index 5479d74..f14b9cc 100644 --- a/src/components/CharacterSheet.tsx +++ b/src/components/CharacterSheet.tsx @@ -7,11 +7,15 @@ export interface CharacterSheetData { * is the rulebook, not part of character identity). */ race?: string; level: number; - /** Current edge / hit points. Sourced from PARTY_STATUS members[].current_hp. - * Surfaced in the CharacterPanel header so Sebastien-axis (mechanical) - * players can see how close they are to going down. */ + /** Current edge (composure). Sourced from PARTY_STATUS members[].current_hp. + * ADR-014 / ADR-078: HP was removed from CreatureCore in favor of EdgePool; + * the wire field is still named current_hp until the protocol-level rename + * ships, but the value is character.core.edge.current. Surfaced in the + * CharacterPanel header so Sebastien-axis (mechanical) players can see + * how close they are to a yield. */ hp?: number; - /** Maximum edge / hit points. Sourced from PARTY_STATUS members[].max_hp. */ + /** Maximum edge (composure ceiling). Sourced from PARTY_STATUS + * members[].max_hp. See `hp` field doc for the legacy-name caveat. */ hp_max?: number; stats: Record; abilities: string[]; diff --git a/src/components/__tests__/CharacterPanel.test.tsx b/src/components/__tests__/CharacterPanel.test.tsx index 37ffb22..c7cfc64 100644 --- a/src/components/__tests__/CharacterPanel.test.tsx +++ b/src/components/__tests__/CharacterPanel.test.tsx @@ -378,20 +378,31 @@ describe("CharacterPanel — AC-6: integrated party list", () => { expect(screen.queryByTestId("party-section")).not.toBeInTheDocument(); }); - it("renders inline HP per party row sourced from CharacterSummary.hp/hp_max", () => { + it("renders inline Edge per party row sourced from CharacterSummary.hp/hp_max", () => { + // ADR-014 / ADR-078: HP was removed from CreatureCore in favor of EdgePool. + // The wire field on CharacterSummary is still hp/hp_max (legacy name) but + // the value is the edge pool, and the UI label must read "Edge". render(); // Kael at 24/30 — full opacity tone - const kaelHp = screen.getByTestId("party-member-hp-p1"); - expect(kaelHp).toHaveTextContent("HP 24/30"); + const kaelEdge = screen.getByTestId("party-member-edge-p1"); + expect(kaelEdge).toHaveTextContent("Edge 24/30"); // Lyra at 8/40 = 20% — at or below the 25% threshold, should show - // destructive tone class for at-a-glance "in trouble" signaling. - const lyraHp = screen.getByTestId("party-member-hp-p2"); - expect(lyraHp).toHaveTextContent("HP 8/40"); - expect(lyraHp.className).toMatch(/destructive/); + // destructive tone class for at-a-glance "one push from yielding" signal. + const lyraEdge = screen.getByTestId("party-member-edge-p2"); + expect(lyraEdge).toHaveTextContent("Edge 8/40"); + expect(lyraEdge.className).toMatch(/destructive/); }); - it("hides inline HP for genres that don't model HP (both 0)", () => { - const NO_HP_PARTY = [ + it("renders no legacy 'HP N/M' text in any party row (ADR-014 schema lock)", () => { + render(); + const partySection = screen.getByTestId("party-section"); + // Lock the rename: a stray "HP " prefix on a number/number row would mean + // someone re-introduced the legacy label. + expect(partySection.textContent ?? "").not.toMatch(/\bHP\s*\d+\s*\/\s*\d+/); + }); + + it("hides inline Edge for genres that don't model edge (both 0)", () => { + const NO_EDGE_PARTY = [ { ...PARTY[0], player_id: "p3", @@ -399,7 +410,9 @@ describe("CharacterPanel — AC-6: integrated party list", () => { hp_max: 0, }, ]; - render(); + render(); + expect(screen.queryByTestId("party-member-edge-p3")).not.toBeInTheDocument(); + // Old testid must also be gone (catches a partial rename). expect(screen.queryByTestId("party-member-hp-p3")).not.toBeInTheDocument(); }); }); @@ -542,34 +555,54 @@ describe("CharacterPanel — S2-UX: turn-state badges are legible and unambiguou }); // --------------------------------------------------------------------------- -// HP badge in the header — Sebastien-axis (mechanical visibility) +// Edge badge in the header — Sebastien-axis (mechanical visibility) +// +// ADR-014 ("Diamonds and Coal") + ADR-078 ("Edge / Composure") removed the +// hit-points field from CreatureCore. The character-panel header used to +// render an "HP N/M" badge that pulled from edge.current under a misleading +// label; the rename below locks the badge to the actual schema. // --------------------------------------------------------------------------- -describe("CharacterPanel — HP badge in header", () => { - it("renders HP badge when hp + hp_max are present", () => { +describe("CharacterPanel — Edge badge in header (ADR-014 schema)", () => { + it("renders Edge badge when hp + hp_max are present (legacy wire fields, edge value)", () => { render( , ); - const badge = screen.getByTestId("character-hp-badge"); - expect(badge).toHaveTextContent("HP 18/30"); - expect(badge).toHaveAttribute("aria-label", "Hit points 18 of 30"); + const badge = screen.getByTestId("character-edge-badge"); + expect(badge).toHaveTextContent("Edge 18/30"); + expect(badge).toHaveAttribute("aria-label", "Edge 18 of 30"); }); - it("flags HP badge as destructive when current is at/below 25% of max", () => { + it("flags Edge badge as destructive when current is at/below 25% of max", () => { render( , ); - const badge = screen.getByTestId("character-hp-badge"); + const badge = screen.getByTestId("character-edge-badge"); expect(badge.className).toMatch(/destructive/); }); - it("does not render HP badge when hp/hp_max are absent (genres without HP)", () => { + it("does not render Edge badge when hp/hp_max are absent (genres without edge)", () => { render(); + expect(screen.queryByTestId("character-edge-badge")).not.toBeInTheDocument(); + }); + + it("never renders the legacy HP badge testid (rename completeness lock)", () => { + // Wiring test (per CLAUDE.md): even when edge data is present, the + // legacy `character-hp-badge` testid must not render — that's the + // signal a regression has reintroduced the old badge component. + render( + , + ); expect(screen.queryByTestId("character-hp-badge")).not.toBeInTheDocument(); + // And no "HP N/M" text in the panel header. + const header = screen.getByTestId("character-header"); + expect(header.textContent ?? "").not.toMatch(/\bHP\s*\d+\s*\/\s*\d+/); }); });