From 5335f32f68d9aec694258e8946ace44f3b6938e6 Mon Sep 17 00:00:00 2001 From: Keith Avery Date: Sat, 25 Apr 2026 12:27:35 -0400 Subject: [PATCH] feat(gm-panel): render dual-dial encounter timeline with new event kinds Co-Authored-By: Claude Sonnet 4.6 --- src/__tests__/EncounterTab.test.tsx | 83 ++++++ src/components/Dashboard/DashboardApp.tsx | 16 ++ src/components/Dashboard/DashboardTabs.tsx | 1 + .../Dashboard/tabs/EncounterTab.tsx | 262 ++++++++++++++++++ src/types/payloads.ts | 23 ++ 5 files changed, 385 insertions(+) create mode 100644 src/__tests__/EncounterTab.test.tsx create mode 100644 src/components/Dashboard/tabs/EncounterTab.tsx diff --git a/src/__tests__/EncounterTab.test.tsx b/src/__tests__/EncounterTab.test.tsx new file mode 100644 index 0000000..3ffa635 --- /dev/null +++ b/src/__tests__/EncounterTab.test.tsx @@ -0,0 +1,83 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { EncounterTab, EncounterTimeline } from "../components/Dashboard/tabs/EncounterTab"; +import type { EncounterEvent } from "../types/payloads"; + +const sample: EncounterEvent[] = [ + { + seq: 1, + kind: "ENCOUNTER_STARTED", + payload: { + encounter_type: "combat", + player_metric_threshold: 10, + opponent_metric_threshold: 10, + turn: 1, + }, + created_at: "2026-04-25T00:00:00Z", + }, + { + seq: 2, + kind: "ENCOUNTER_BEAT_APPLIED", + payload: { + actor: "Sam", + actor_side: "player", + beat_id: "attack", + beat_kind: "strike", + outcome_tier: "Success", + own_delta: 2, + opponent_delta: 0, + turn: 1, + }, + created_at: "2026-04-25T00:00:01Z", + }, + { + seq: 3, + kind: "ENCOUNTER_METRIC_ADVANCE", + payload: { side: "player", delta_kind: "own", delta: 2, before: 0, after: 2, turn: 1 }, + created_at: "2026-04-25T00:00:02Z", + }, + { + seq: 4, + kind: "ENCOUNTER_RESOLVED", + payload: { + outcome: "opponent_victory", + final_player_metric: 4, + final_opponent_metric: 11, + triggering_side: "opponent", + turn: 5, + }, + created_at: "2026-04-25T00:00:10Z", + }, +]; + +describe("EncounterTimeline", () => { + it("renders rows for each event kind with side and tier", () => { + render(); + expect(screen.getByText(/Sam/)).toBeInTheDocument(); + expect(screen.getByText(/strike/)).toBeInTheDocument(); + expect(screen.getByText(/Success/)).toBeInTheDocument(); + expect(screen.getByText(/opponent_victory/)).toBeInTheDocument(); + }); + + it("renders dial-pair view from STARTED through RESOLVED", () => { + render(); + expect(screen.getByText(/Player metric:.*0/)).toBeInTheDocument(); + expect(screen.getByText(/Opponent metric:.*0/)).toBeInTheDocument(); + }); +}); + +// Wiring test — confirms EncounterTab is exported and importable (not just +// the pure EncounterTimeline renderer). Any test that passes proves the +// module can be imported and both named exports are accessible. +describe("EncounterTab wiring", () => { + it("EncounterTab is exported from the module", () => { + expect(EncounterTab).toBeDefined(); + expect(typeof EncounterTab).toBe("function"); + }); + + it("renders no-session placeholder when slug is null", () => { + render(); + expect(screen.getByText(/No active session/)).toBeInTheDocument(); + }); +}); diff --git a/src/components/Dashboard/DashboardApp.tsx b/src/components/Dashboard/DashboardApp.tsx index b65d7d4..d04f36a 100644 --- a/src/components/Dashboard/DashboardApp.tsx +++ b/src/components/Dashboard/DashboardApp.tsx @@ -14,6 +14,7 @@ import { TimingTab } from "./tabs/TimingTab"; import { ConsoleTab } from "./tabs/ConsoleTab"; import { PromptTab } from "./tabs/PromptTab"; import { LoreTab } from "./tabs/LoreTab"; +import { EncounterTab } from "./tabs/EncounterTab"; import { THEME } from "./shared/constants"; // --------------------------------------------------------------------------- @@ -307,6 +308,18 @@ export function DashboardApp() { } }, [state.turns.length, loadDebugState]); + // Derive the active session slug from debugState (same sort logic as StateTab). + // Used by EncounterTab to fetch encounter events for the live session. + const activeSlug: string | null = (() => { + if (!state.debugState || state.debugState.length === 0) return null; + const sorted = [...state.debugState].sort((a, b) => { + const aTs = a.last_activity_ts ?? 0; + const bTs = b.last_activity_ts ?? 0; + return bTs - aTs; + }); + return sorted[0].session_key; + })(); + const errorCount = state.allEvents.filter( (e) => e.severity === "error", ).length; @@ -378,6 +391,9 @@ export function DashboardApp() { {state.activeTab === 6 && ( )} + {state.activeTab === 7 && ( + + )} ); diff --git a/src/components/Dashboard/DashboardTabs.tsx b/src/components/Dashboard/DashboardTabs.tsx index 5d83eec..1788d76 100644 --- a/src/components/Dashboard/DashboardTabs.tsx +++ b/src/components/Dashboard/DashboardTabs.tsx @@ -8,6 +8,7 @@ const TAB_LABELS = [ "⑤ Console", "⑥ Prompt", "⑦ Lore", + "⑧ Encounters", ]; interface Props { diff --git a/src/components/Dashboard/tabs/EncounterTab.tsx b/src/components/Dashboard/tabs/EncounterTab.tsx new file mode 100644 index 0000000..02cd883 --- /dev/null +++ b/src/components/Dashboard/tabs/EncounterTab.tsx @@ -0,0 +1,262 @@ +import { useEffect, useState } from "react"; +import type { EncounterEvent } from "@/types/payloads"; +import { THEME } from "../shared/constants"; + +// --------------------------------------------------------------------------- +// Pure row renderers — one per ENCOUNTER_* event kind +// --------------------------------------------------------------------------- + +function startedRow(payload: Record) { + const playerThresh = (payload.player_metric_threshold as number) ?? "?"; + const oppThresh = (payload.opponent_metric_threshold as number) ?? "?"; + return ( + + Encounter started — Player metric: 0 / {playerThresh}, + {" "}Opponent metric: 0 / {oppThresh} + + ); +} + +function beatRow(payload: Record) { + return ( + + {payload.actor as string} + {" (side="}{payload.actor_side as string}{") "} + played {payload.beat_id as string} + {" "}({payload.beat_kind as string}, tier {payload.outcome_tier as string}); + {" "}deltas own={payload.own_delta as number} opp={payload.opponent_delta as number} + + ); +} + +function advanceRow(payload: Record) { + const delta = payload.delta as number; + return ( + + {payload.side as string} dial advanced {delta > 0 ? "+" : ""} + {delta} ({payload.before as number} → {payload.after as number}) + + ); +} + +function tagRow(payload: Record) { + return ( + + tag "{payload.tag_text as string}" on {(payload.target as string) || "(scene)"} + {" "}— leverage {payload.leverage as number}, {(payload.fleeting as boolean) ? "fleeting" : "persistent"} + + ); +} + +function statusRow(payload: Record) { + return ( + + {payload.actor as string} took status {payload.text as string} + {" "}({payload.severity as string}) + + ); +} + +function yieldRow(payload: Record) { + const op = payload.op as string; + if (op === "yield_resolved") { + return ( + + Yield resolved — {payload.yielded_actors as string} (edge refreshed: + {" "}{payload.edge_refreshed as number}) + + ); + } + return Yield received from {payload.actor_name as string}; +} + +function resolvedRow(payload: Record) { + return ( + + RESOLVED — outcome: {payload.outcome as string}; final player_metric= + {payload.final_player_metric as number}, opponent_metric= + {payload.final_opponent_metric as number} + + ); +} + +function skippedRow(payload: Record) { + return ( + + Beat skipped — {payload.actor as string} ({payload.actor_side as string}) / + {" "}{payload.beat_id as string} — reason: {payload.reason as string} + + ); +} + +function renderEventRow(ev: EncounterEvent): React.ReactNode { + switch (ev.kind) { + case "ENCOUNTER_STARTED": + return startedRow(ev.payload); + case "ENCOUNTER_BEAT_APPLIED": + return beatRow(ev.payload); + case "ENCOUNTER_METRIC_ADVANCE": + return advanceRow(ev.payload); + case "ENCOUNTER_TAG_CREATED": + return tagRow(ev.payload); + case "ENCOUNTER_STATUS_ADDED": + return statusRow(ev.payload); + case "ENCOUNTER_YIELD": + return yieldRow(ev.payload); + case "ENCOUNTER_BEAT_SKIPPED": + return skippedRow(ev.payload); + case "ENCOUNTER_RESOLVED": + return resolvedRow(ev.payload); + case "ENCOUNTER_RESOLUTION_SIGNAL": + return null; // internal — no visible row + default: { + // Exhaustiveness guard: surface unknown kinds rather than silently swallowing them. + const unknown = (ev as { kind: string }).kind; + return (unknown event kind: {unknown}); + } + } +} + +// --------------------------------------------------------------------------- +// EncounterTimeline — pure renderer (used by tests and by EncounterTab) +// --------------------------------------------------------------------------- + +interface TimelineProps { + events: EncounterEvent[]; +} + +export function EncounterTimeline({ events }: TimelineProps) { + if (events.length === 0) { + return ( +
+ No encounter events recorded for this session. +
+ ); + } + + return ( +
    + {events.map((ev) => { + const rowContent = renderEventRow(ev); + if (rowContent === null) return null; + const isResolved = ev.kind === "ENCOUNTER_RESOLVED"; + return ( +
  1. + + #{ev.seq} + + + {ev.kind} + + {rowContent} +
  2. + ); + })} +
+ ); +} + +// --------------------------------------------------------------------------- +// EncounterTab — live wrapper that fetches from REST + renders EncounterTimeline +// --------------------------------------------------------------------------- + +interface TabProps { + slug: string | null; +} + +export function EncounterTab({ slug }: TabProps) { + const [events, setEvents] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + if (!slug) return; + let cancelled = false; + + fetch(`/api/sessions/${slug}/encounter_events`) + .then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json(); + }) + .then((data: unknown) => { + if (!cancelled) { + if (!Array.isArray(data)) { + throw new Error(`Expected array, got ${typeof data}`); + } + setEvents(data as EncounterEvent[]); + } + }) + .catch((e: unknown) => { + if (!cancelled) setError(String(e)); + }); + + return () => { + cancelled = true; + }; + }, [slug]); + + if (!slug) { + return ( +
+ No active session. +
+ ); + } + + if (error) { + return ( +
+ Error loading encounter events: {error} +
+ ); + } + + return ( +
+
+ Encounter Timeline — {events.length} event{events.length !== 1 ? "s" : ""} +
+ +
+ ); +} diff --git a/src/types/payloads.ts b/src/types/payloads.ts index 36c8081..66e9209 100644 --- a/src/types/payloads.ts +++ b/src/types/payloads.ts @@ -582,6 +582,29 @@ export function isScrapbookEntry(msg: TypedGameMessage): msg is ScrapbookEntryMe return msg.type === MessageType.SCRAPBOOK_ENTRY; } +// --------------------------------------------------------------------------- +// Encounter event types (GM panel — Task 22) +// REST source: GET /api/sessions/{slug}/encounter_events +// --------------------------------------------------------------------------- + +export type EncounterEventKind = + | "ENCOUNTER_STARTED" + | "ENCOUNTER_BEAT_APPLIED" + | "ENCOUNTER_METRIC_ADVANCE" + | "ENCOUNTER_BEAT_SKIPPED" + | "ENCOUNTER_TAG_CREATED" + | "ENCOUNTER_STATUS_ADDED" + | "ENCOUNTER_YIELD" + | "ENCOUNTER_RESOLVED" + | "ENCOUNTER_RESOLUTION_SIGNAL"; + +export interface EncounterEvent { + seq: number; + kind: EncounterEventKind; + payload: Record; + created_at: string; +} + // --------------------------------------------------------------------------- // Validation helpers (moved from useStateMirror) // ---------------------------------------------------------------------------