diff --git a/src/components/CharacterPanel.tsx b/src/components/CharacterPanel.tsx index f6c5b45..e1505f6 100644 --- a/src/components/CharacterPanel.tsx +++ b/src/components/CharacterPanel.tsx @@ -204,13 +204,39 @@ export function CharacterPanel({ {isSelf && ( <> {" "} - (YOU) + + (YOU) + )} {isActing && ( <> {" "} - (ACTING) + + + + )} + {isWaiting && ( + <> + {" "} + + Waiting + )} diff --git a/src/components/GameBoard/GameBoard.tsx b/src/components/GameBoard/GameBoard.tsx index 89696b2..56f18e8 100644 --- a/src/components/GameBoard/GameBoard.tsx +++ b/src/components/GameBoard/GameBoard.tsx @@ -272,7 +272,7 @@ export function GameBoard({ [audio, genreSlug], ); - const { chapterTitle } = useRunningHeader(messages); + const { chapterTitle } = useRunningHeader(messages, characters, currentPlayerId); // Story 33-11: content signals drive the mobile tab notification badges. // Each entry is a change-detection scalar for a tab's visible content — @@ -606,23 +606,28 @@ export function GameBoard({ /> - {/* Turn status */} - {characters.length > 1 && activePlayerName && ( + {/* Turn status — canonical multi-PC turn coordination signal. + Renders ONLY the structured TurnStatusPanel ("Waiting on:" widget) + which is load-bearing for sealed-letter chargen turns. The plain + `[ Paul's turn ]` / `[ Your turn ]` chip was removed as part of the + S2-UX banner-cluster dedupe (2026-04-26): the new + MultiplayerTurnBanner above the InputBar already announces whose + turn it is, the CharacterPanel party-section ACTING badge gives + the spatially-located cue, and the InputBar placeholder + ("Waiting for X…") covers input gating. Keeping all four was + quadruple-banner ambiguity. The structured TurnStatusPanel branch + stays because it's a richer per-player roster, not a duplicate + of "whose turn it is". */} + {characters.length > 1 && activePlayerName && turnStatusEntries.length > 0 && (
- {turnStatusEntries.length > 0 ? ( - - ) : ( - waitingForPlayer - ? `[ ${activePlayerName}'s turn ]` - : "[ Your turn ]" - )} +
)} diff --git a/src/components/GameBoard/__tests__/runningHeader-wiring.test.tsx b/src/components/GameBoard/__tests__/runningHeader-wiring.test.tsx new file mode 100644 index 0000000..7ece11a --- /dev/null +++ b/src/components/GameBoard/__tests__/runningHeader-wiring.test.tsx @@ -0,0 +1,162 @@ +/** + * Wiring test — verifies the running header location chip derives from + * PARTY_STATUS (per-PC `current_location`) rather than only from + * CHAPTER_MARKER messages. + * + * Per CLAUDE.md "Every Test Suite Needs a Wiring Test": this drives the chip + * through the actual top-level GameBoard component and asserts the chip text + * updates when the party-status snapshot moves to a new location, *without* + * any page refresh or remount. This guards the S2-UX (c) cache-invalidation + * regression where the chip showed a stale "BRIDGE — OUTER COYOTE REACH" + * even after the prose moved to Docking Crescent. + * + * Same file also asserts the S2-UX (d) banner-cluster dedupe — the redundant + * `[ Paul's turn ]` chip is no longer rendered when there are no per-player + * turn entries. + */ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi, beforeAll, afterAll } from "vitest"; +import { GameBoard, type GameBoardProps } from "../GameBoard"; +import { ImageBusProvider } from "@/providers/ImageBusProvider"; +import type { CharacterSummary } from "@/types/party"; + +const originalMatchMedia = window.matchMedia; + +beforeAll(() => { + // Force desktop breakpoint so the desktop GameBoard layout (with the + // running-header div above the dockview workspace) renders. The default + // test-setup forces mobile, which routes GameBoard through MobileTabView + // and skips the top header entirely. + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: (query: string) => ({ + matches: query.includes("min-width: 1200px"), + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), + }); +}); + +afterAll(() => { + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: originalMatchMedia, + }); +}); + +function makeChar(player_id: string, current_location: string): CharacterSummary { + return { + player_id, + name: player_id, + character_name: player_id, + portrait_url: "", + hp: 10, + hp_max: 10, + status_effects: [], + class: "Pilot", + level: 1, + current_location, + }; +} + +function renderBoard(props: Partial) { + const defaults: GameBoardProps = { + messages: [], + characters: [makeChar("p1", "Bridge")], + onSend: vi.fn(), + disabled: false, + currentPlayerId: "p1", + }; + const merged = { ...defaults, ...props }; + return render( + + + , + ); +} + +describe("GameBoard running header — S2-UX (c) wiring", () => { + it("renders the running-header chip with the local player's current_location", () => { + renderBoard({ + characters: [makeChar("p1", "Docking Crescent")], + currentPlayerId: "p1", + }); + const header = screen.getByTestId("running-header"); + expect(header).toHaveTextContent("Docking Crescent"); + }); + + it("updates the chip when party state moves to a new location (no remount, no refresh)", () => { + const { rerender } = renderBoard({ + characters: [makeChar("p1", "Bridge — Outer Coyote Reach")], + currentPlayerId: "p1", + }); + expect(screen.getByTestId("running-header")).toHaveTextContent( + "Bridge — Outer Coyote Reach", + ); + + // PARTY_STATUS arrives with a new location for the local player. The chip + // must reflect it on the very next render, with no other state changes. + rerender( + + + , + ); + expect(screen.getByTestId("running-header")).toHaveTextContent( + "Docking Crescent", + ); + // And the stale value is gone. + expect(screen.getByTestId("running-header")).not.toHaveTextContent( + "Bridge — Outer Coyote Reach", + ); + }); +}); + +describe("GameBoard turn indicator — S2-UX (d) banner-cluster dedupe", () => { + it("does NOT render the redundant `[ Paul's turn ]` chip when there are no per-player turn entries", () => { + // The bottom turn-indicator strip used to render the plain text + // "[ Paul's turn ]" / "[ Your turn ]" whenever activePlayerName was set. + // That signal was already carried by: + // - the MultiplayerTurnBanner above the InputBar, + // - the CharacterPanel party-section ACTING badge, + // - the InputBar placeholder ("Waiting for X…"). + // Four banners saying the same thing was the bug. The strip is now + // reserved for the structured TurnStatusPanel ("Waiting on:" widget) + // and only renders when turnStatusEntries are present. + renderBoard({ + characters: [makeChar("p1", "Bridge"), makeChar("p2", "Bridge")], + currentPlayerId: "p1", + activePlayerName: "p2", + activePlayerId: "p2", + waitingForPlayer: "p2", + turnStatusEntries: [], + }); + expect(screen.queryByTestId("turn-indicator")).not.toBeInTheDocument(); + }); + + it("structured TurnStatusPanel still renders when entries are present (chargen path preserved)", () => { + renderBoard({ + characters: [makeChar("p1", "Bridge"), makeChar("p2", "Bridge")], + currentPlayerId: "p1", + activePlayerName: "p2", + activePlayerId: "p2", + turnStatusEntries: [ + { player_id: "p1", character_name: "Paul", status: "submitted" }, + { player_id: "p2", character_name: "John", status: "pending" }, + ], + }); + expect(screen.getByTestId("turn-indicator")).toBeInTheDocument(); + }); +}); diff --git a/src/components/__tests__/CharacterPanel.test.tsx b/src/components/__tests__/CharacterPanel.test.tsx index 3bef32d..37ffb22 100644 --- a/src/components/__tests__/CharacterPanel.test.tsx +++ b/src/components/__tests__/CharacterPanel.test.tsx @@ -404,6 +404,143 @@ describe("CharacterPanel — AC-6: integrated party list", () => { }); }); +// --------------------------------------------------------------------------- +// S2-UX (a, b): YOU/ACTING badges legible + non-acting peers show Waiting +// --------------------------------------------------------------------------- + +describe("CharacterPanel — S2-UX: turn-state badges are legible and unambiguous", () => { + const PARTY = [ + { + player_id: "p1", + name: "Kael", + character_name: "Kael", + portrait_url: "/renders/kael.png", + hp: 24, + hp_max: 30, + status_effects: [], + class: "Ranger", + level: 3, + current_location: "The Rusty Cantina", + }, + { + player_id: "p2", + name: "Lyra", + character_name: "Lyra Dawnforge", + portrait_url: "", + hp: 30, + hp_max: 40, + status_effects: [], + class: "Cleric", + level: 5, + current_location: "The Rusty Cantina", + }, + { + player_id: "p3", + name: "Thane", + character_name: "Thane", + portrait_url: "", + hp: 15, + hp_max: 28, + status_effects: [], + class: "Fighter", + level: 4, + current_location: "The Rusty Cantina", + }, + ]; + + it("renders YOU badge on the local player party row", () => { + render( + , + ); + const youBadge = screen.getByTestId("party-member-you-badge-p1"); + expect(youBadge).toBeInTheDocument(); + expect(youBadge).toHaveTextContent("(YOU)"); + }); + + it("renders ACTING badge with a pulse dot on the active player row", () => { + render( + , + ); + const actingBadge = screen.getByTestId("party-member-acting-badge-p2"); + expect(actingBadge).toBeInTheDocument(); + expect(actingBadge).toHaveTextContent(/ACTING/); + // The pulse dot is the kinetic signal Alex needs to see at a glance + // that someone is mid-turn (per S2-UX (b)). + expect(within(actingBadge).getByTestId("party-member-acting-pulse-p2")).toBeInTheDocument(); + }); + + it("ACTING pulse uses animate-pulse so it draws the eye", () => { + render( + , + ); + const pulse = screen.getByTestId("party-member-acting-pulse-p2"); + expect(pulse.className).toMatch(/animate-pulse/); + }); + + it("ACTING badge is bumped from text-[10px] (the prior undersized class) to text-[11px]", () => { + // Regression guard for S2-UX (a) — badge was text-[10px], hard to spot. + render( + , + ); + const actingBadge = screen.getByTestId("party-member-acting-badge-p1"); + expect(actingBadge.className).toMatch(/text-\[11px\]/); + }); + + it("renders Waiting indicator on every non-acting peer when a turn is in flight", () => { + render( + , + ); + // p2 is acting — no waiting badge on its row. + expect( + screen.queryByTestId("party-member-waiting-badge-p2"), + ).not.toBeInTheDocument(); + // p1 (local) and p3 (peer) are waiting — both rows get the badge so + // Alex can see at a glance "the table is waiting on Lyra, not me". + expect(screen.getByTestId("party-member-waiting-badge-p1")).toBeInTheDocument(); + expect(screen.getByTestId("party-member-waiting-badge-p3")).toBeInTheDocument(); + }); + + it("does NOT render any Waiting / ACTING badges when no player is acting", () => { + render( + , + ); + expect(screen.queryByTestId("party-member-waiting-badge-p1")).not.toBeInTheDocument(); + expect(screen.queryByTestId("party-member-waiting-badge-p2")).not.toBeInTheDocument(); + expect(screen.queryByTestId("party-member-waiting-badge-p3")).not.toBeInTheDocument(); + expect(screen.queryByTestId("party-member-acting-badge-p1")).not.toBeInTheDocument(); + expect(screen.queryByTestId("party-member-acting-badge-p2")).not.toBeInTheDocument(); + }); +}); + // --------------------------------------------------------------------------- // HP badge in the header — Sebastien-axis (mechanical visibility) // --------------------------------------------------------------------------- diff --git a/src/hooks/__tests__/useRunningHeader.test.tsx b/src/hooks/__tests__/useRunningHeader.test.tsx new file mode 100644 index 0000000..2e3c669 --- /dev/null +++ b/src/hooks/__tests__/useRunningHeader.test.tsx @@ -0,0 +1,109 @@ +import { renderHook } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { useRunningHeader } from "@/hooks/useRunningHeader"; +import { MessageType, type GameMessage } from "@/types/protocol"; +import type { CharacterSummary } from "@/types/party"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +function chapterMarker(location: string, ts = "2026-04-26T12:00:00Z"): GameMessage { + return { + type: MessageType.CHAPTER_MARKER, + timestamp: ts, + payload: { location }, + } as unknown as GameMessage; +} + +function character(id: string, current_location: string): CharacterSummary { + return { + player_id: id, + name: id, + character_name: id, + portrait_url: "", + hp: 10, + hp_max: 10, + status_effects: [], + class: "", + level: 1, + current_location, + }; +} + +// --------------------------------------------------------------------------- +// S2-UX (c): location chip prefers PARTY_STATUS over stale CHAPTER_MARKER +// --------------------------------------------------------------------------- + +describe("useRunningHeader — S2-UX (c) location chip cache invalidation", () => { + it("falls back to most recent CHAPTER_MARKER when no party data is provided", () => { + const messages = [ + chapterMarker("Bridge — Outer Coyote Reach"), + chapterMarker("Docking Crescent"), + ]; + const { result } = renderHook(() => useRunningHeader(messages)); + expect(result.current.chapterTitle).toBe("Docking Crescent"); + }); + + it("returns null when no chapter markers and no party data", () => { + const { result } = renderHook(() => useRunningHeader([])); + expect(result.current.chapterTitle).toBeNull(); + }); + + it("PREFERS local player's current_location over a stale CHAPTER_MARKER", () => { + // The cache-invalidation bug: chapter marker says BRIDGE but the prose + // (and the party state) has moved to Docking Crescent. The chip should + // follow PARTY_STATUS, not the stale marker. + const messages = [chapterMarker("Bridge — Outer Coyote Reach")]; + const characters = [character("p1", "Docking Crescent")]; + const { result } = renderHook(() => + useRunningHeader(messages, characters, "p1"), + ); + expect(result.current.chapterTitle).toBe("Docking Crescent"); + }); + + it("uses local player's location even when no chapter marker has fired", () => { + const characters = [character("p1", "Engine Room")]; + const { result } = renderHook(() => + useRunningHeader([], characters, "p1"), + ); + expect(result.current.chapterTitle).toBe("Engine Room"); + }); + + it("ignores non-local party members (the chip is per-PC, not party-wide)", () => { + // Split party: I'm at Docking Crescent, my peer is on the Bridge. + // The chip must reflect MY location, not a peer's. + const characters = [ + character("p1", "Docking Crescent"), + character("p2", "Bridge — Outer Coyote Reach"), + ]; + const { result } = renderHook(() => + useRunningHeader([], characters, "p1"), + ); + expect(result.current.chapterTitle).toBe("Docking Crescent"); + }); + + it("falls back to chapter marker when local player's current_location is empty", () => { + const messages = [chapterMarker("Bridge")]; + const characters = [character("p1", "")]; + const { result } = renderHook(() => + useRunningHeader(messages, characters, "p1"), + ); + expect(result.current.chapterTitle).toBe("Bridge"); + }); + + it("updates the chip when party state moves to a new location (no remount, no refresh)", () => { + // The cross-turn refresh case. Mount with one location, then re-render + // with a new one — the chip must update without a page refresh. + const initialChars = [character("p1", "Bridge")]; + const { result, rerender } = renderHook( + ({ chars }: { chars: CharacterSummary[] }) => + useRunningHeader([], chars, "p1"), + { initialProps: { chars: initialChars } }, + ); + expect(result.current.chapterTitle).toBe("Bridge"); + + rerender({ chars: [character("p1", "Docking Crescent")] }); + expect(result.current.chapterTitle).toBe("Docking Crescent"); + }); +}); diff --git a/src/hooks/useRunningHeader.ts b/src/hooks/useRunningHeader.ts index f448691..2cef3f2 100644 --- a/src/hooks/useRunningHeader.ts +++ b/src/hooks/useRunningHeader.ts @@ -1,13 +1,37 @@ import { useMemo } from "react"; import { MessageType, type GameMessage } from "@/types/protocol"; +import type { CharacterSummary } from "@/types/party"; /** - * Derive the most recent chapter/location title from the message stream. - * Used for the running header at the top of the game board so the player - * always knows where they are. + * Derive the most recent chapter/location title for the running header. + * + * Source preference (most fresh → least fresh): + * 1. Local player's `current_location` from PARTY_STATUS — pushed every turn, + * so this is the canonical "where is my PC right now" signal. Fixes the + * cache-invalidation bug (S2-UX (c), 2026-04-26) where the chip showed a + * stale location because no CHAPTER_MARKER had fired yet. + * 2. Most recent `CHAPTER_MARKER.location` in the message stream — useful as + * a fallback before the first PARTY_STATUS arrives or for solo sessions + * that emit chapter markers without a party update. + * + * If neither source has a value, returns `chapterTitle: null` so the caller + * can render a non-breaking-space placeholder. */ -export function useRunningHeader(messages: GameMessage[]) { +export function useRunningHeader( + messages: GameMessage[], + characters: CharacterSummary[] = [], + currentPlayerId?: string, +) { return useMemo(() => { + // Prefer the local player's current_location from PARTY_STATUS. + if (currentPlayerId) { + const me = characters.find((c) => c.player_id === currentPlayerId); + if (me && me.current_location) { + return { chapterTitle: me.current_location }; + } + } + // Solo / pre-PARTY_STATUS fallback: walk the message stream for the + // most recent CHAPTER_MARKER. let chapterTitle: string | null = null; for (const msg of messages) { if (msg.type === MessageType.CHAPTER_MARKER) { @@ -16,5 +40,5 @@ export function useRunningHeader(messages: GameMessage[]) { } } return { chapterTitle }; - }, [messages]); + }, [messages, characters, currentPlayerId]); }