diff --git a/src/App.tsx b/src/App.tsx index ad54d26..9ae2b60 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -734,6 +734,9 @@ function AppInner() { } if (slashResult.messages.length > 0) { setMessages((prev) => [...prev, ...slashResult.messages]); + for (const msg of slashResult.messages) { + send(msg); + } } return; } @@ -854,6 +857,16 @@ function AppInner() { [diceRequest, send], ); + // Yield action — player steps out of an active confrontation on their terms. + // Sends a YIELD message to the server; server refreshes edge by 1 + statuses taken. + const handleYield = useCallback(() => { + send({ + type: MessageType.YIELD, + payload: {}, + player_id: "", + }); + }, [send]); + const navigate = useNavigate(); // Bug 6: Leave game — disconnect, clear state, return to lobby. @@ -1245,6 +1258,7 @@ function AppInner() { resourceAlerts={gameState.resourceAlerts} confrontationData={confrontationData} onBeatSelect={handleBeatSelect} + onYield={handleYield} diceRequest={diceRequest} diceResult={diceResult} onDiceThrow={handleDiceThrow} 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/__tests__/YieldButton.test.tsx b/src/__tests__/YieldButton.test.tsx new file mode 100644 index 0000000..0921ada --- /dev/null +++ b/src/__tests__/YieldButton.test.tsx @@ -0,0 +1,20 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { YieldButton } from "../components/YieldButton"; + +describe("YieldButton", () => { + it("calls onYield when clicked", () => { + const onYield = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: /yield/i })); + expect(onYield).toHaveBeenCalledTimes(1); + }); + + it("disables when no active encounter", () => { + const onYield = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: /yield/i })); + expect(onYield).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/slash-command-send-wiring.test.ts b/src/__tests__/slash-command-send-wiring.test.ts new file mode 100644 index 0000000..6e60c3b --- /dev/null +++ b/src/__tests__/slash-command-send-wiring.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from "vitest"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +// ── Wiring test: slash-command messages reach the server send pipeline ──────── +// +// The bug (Task 26 code review): handleSend in App.tsx intercepted slash +// commands, appended their messages to local state, but never called send(). +// The result: /yield appended a YIELD entry to the narrative log client-side +// but transmitted nothing to the server — _handle_yield never fired. +// +// This test reads App.tsx source to assert that the fix is present and wired: +// send(msg) must appear inside the slashResult.messages.length > 0 block, +// not just setMessages. +// ───────────────────────────────────────────────────────────────────────────── + +const appSrc = fs.readFileSync( + path.resolve(__dirname, "../App.tsx"), + "utf-8", +); + +describe("Wiring: slash-command messages forwarded to server via send()", () => { + it("App.tsx calls send(msg) for each slash-command message", () => { + // The fix block must contain both setMessages and send(msg) inside the + // slashResult.messages.length > 0 branch. We verify by asserting the + // for-loop pattern is present immediately after setMessages. + expect(appSrc).toMatch( + /slashResult\.messages\.length > 0[\s\S]*?send\(msg\)/, + ); + }); + + it("App.tsx iterates slashResult.messages and calls send for each", () => { + // The for-loop construct must be present: `for (const msg of slashResult.messages)` + expect(appSrc).toMatch(/for\s*\(\s*const\s+msg\s+of\s+slashResult\.messages\s*\)/); + }); + + it("send(msg) appears in the slashResult.handled branch (not only the PLAYER_ACTION path)", () => { + // Capture everything from `if (slashResult.handled)` to its closing `return;` + // and assert send(msg) appears inside it. + const handledBranch = appSrc.match( + /if\s*\(slashResult\.handled\)\s*\{([\s\S]*?)\n\s+return;/, + ); + expect(handledBranch).not.toBeNull(); + expect(handledBranch?.[1]).toMatch(/send\(msg\)/); + }); + + it("the slash-command send path does NOT call setCanType or setThinking", () => { + // Per spec: slash commands must not seal the input bar or show a thinking + // indicator — that lifecycle only applies to PLAYER_ACTION narration turns. + const handledBranch = appSrc.match( + /if\s*\(slashResult\.handled\)\s*\{([\s\S]*?)\n\s+return;/, + ); + expect(handledBranch).not.toBeNull(); + expect(handledBranch?.[1]).not.toMatch(/setCanType/); + expect(handledBranch?.[1]).not.toMatch(/setThinking/); + }); +}); diff --git a/src/components/ConfrontationOverlay.tsx b/src/components/ConfrontationOverlay.tsx index ce6dafb..dd72a21 100644 --- a/src/components/ConfrontationOverlay.tsx +++ b/src/components/ConfrontationOverlay.tsx @@ -1,5 +1,6 @@ import type { DiceRequestPayload, DiceResultPayload, DiceThrowParams } from "@/types/payloads"; import { InlineDiceTray } from "@/dice/InlineDiceTray"; +import { YieldButton } from "@/components/YieldButton"; // ═══════════════════════════════════════════════════════════ // Types — exported for tests and consumers @@ -67,6 +68,7 @@ interface ConfrontationOverlayProps { diceResult?: DiceResultPayload | null; playerId?: string; onDiceThrow?: (params: DiceThrowParams, face: number[]) => void; + onYield?: () => void; } // ═══════════════════════════════════════════════════════════ @@ -222,7 +224,7 @@ function SecondaryStatsPanel({ stats }: { stats: SecondaryStats }) { // Main component // ═══════════════════════════════════════════════════════════ -export function ConfrontationOverlay({ data, onBeatSelect, inline, diceRequest, diceResult, playerId, onDiceThrow }: ConfrontationOverlayProps) { +export function ConfrontationOverlay({ data, onBeatSelect, inline, diceRequest, diceResult, playerId, onDiceThrow, onYield }: ConfrontationOverlayProps) { if (!data) return null; const isStandoff = data.type === 'standoff'; @@ -262,6 +264,13 @@ export function ConfrontationOverlay({ data, onBeatSelect, inline, diceRequest, {/* Beat action buttons */} + {/* Yield button — only rendered when parent supplies the handler */} + {onYield !== undefined && ( +
+ +
+ )} + {/* Inline dice tray — rolls right here when a beat is selected */} {onDiceThrow && playerId && ( { + 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/components/GameBoard/GameBoard.tsx b/src/components/GameBoard/GameBoard.tsx index c297c48..89696b2 100644 --- a/src/components/GameBoard/GameBoard.tsx +++ b/src/components/GameBoard/GameBoard.tsx @@ -130,6 +130,7 @@ export interface GameBoardProps { knowledgeEntries?: KnowledgeEntry[]; confrontationData?: ConfrontationData | null; onBeatSelect?: (beatId: string) => void; + onYield?: () => void; diceRequest?: DiceRequestPayload | null; diceResult?: DiceResultPayload | null; onDiceThrow?: (params: DiceThrowParams, face: number[]) => void; @@ -160,6 +161,7 @@ export function GameBoard({ knowledgeEntries, confrontationData, onBeatSelect, + onYield, diceRequest, diceResult, onDiceThrow, @@ -329,6 +331,7 @@ export function GameBoard({ void; + onYield?: () => void; } -export function ConfrontationWidget({ data, onBeatSelect, diceRequest, diceResult, playerId, onDiceThrow }: ConfrontationWidgetProps) { +export function ConfrontationWidget({ data, onBeatSelect, diceRequest, diceResult, playerId, onDiceThrow, onYield }: ConfrontationWidgetProps) { return ( ); } diff --git a/src/components/YieldButton.tsx b/src/components/YieldButton.tsx new file mode 100644 index 0000000..f325c1d --- /dev/null +++ b/src/components/YieldButton.tsx @@ -0,0 +1,21 @@ +import { Button } from "@/components/ui/button"; + +interface Props { + onYield: () => void; + disabled: boolean; +} + +export function YieldButton({ onYield, disabled }: Props) { + return ( + + ); +} diff --git a/src/hooks/__tests__/useSlashCommands.test.ts b/src/hooks/__tests__/useSlashCommands.test.ts index 249f1a9..fb0ee71 100644 --- a/src/hooks/__tests__/useSlashCommands.test.ts +++ b/src/hooks/__tests__/useSlashCommands.test.ts @@ -119,4 +119,15 @@ describe("useSlashCommands", () => { expect(out.handled).toBe(true); }); + + it("/yield returns handled=true and a single YIELD-typed message", () => { + const { result } = renderSlashCommands(); + const out = result.current.execute("/yield"); + + expect(out.handled).toBe(true); + expect(out.widget).toBeUndefined(); + expect(out.messages).toHaveLength(1); + expect(out.messages[0].type).toBe("YIELD"); + expect(out.messages[0].player_id).toBe(""); + }); }); diff --git a/src/hooks/useSlashCommands.ts b/src/hooks/useSlashCommands.ts index 804a3b1..d94e77f 100644 --- a/src/hooks/useSlashCommands.ts +++ b/src/hooks/useSlashCommands.ts @@ -1,5 +1,5 @@ import { useCallback } from 'react'; -import type { GameMessage } from '../types/protocol'; +import { MessageType, type GameMessage } from '../types/protocol'; import type { WidgetId } from '@/components/GameBoard/widgetRegistry'; /** @deprecated Use WidgetId instead */ @@ -35,6 +35,17 @@ export function useSlashCommands() { return { handled: true, messages: [], widget: 'knowledge' }; case '/gallery': return { handled: true, messages: [], widget: 'gallery' }; + case '/yield': + return { + handled: true, + messages: [ + { + type: MessageType.YIELD, + payload: {}, + player_id: "", + } satisfies GameMessage, + ], + }; default: // Unknown slash command — swallow it client-side. The backend cannot // receive slash text as PLAYER_ACTION without erroring. If a command 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) // --------------------------------------------------------------------------- diff --git a/src/types/protocol.ts b/src/types/protocol.ts index d9b47bd..319baf5 100644 --- a/src/types/protocol.ts +++ b/src/types/protocol.ts @@ -48,6 +48,7 @@ export const MessageType = { SEAT_CONFIRMED: "SEAT_CONFIRMED", GAME_PAUSED: "GAME_PAUSED", GAME_RESUMED: "GAME_RESUMED", + YIELD: "YIELD", } as const; export type MessageType = (typeof MessageType)[keyof typeof MessageType];