From 052071f9796cccb52b33fcaf3fc95ccbe0932be3 Mon Sep 17 00:00:00 2001 From: Keith Avery Date: Sat, 25 Apr 2026 14:45:03 -0400 Subject: [PATCH 1/2] feat(ui): yield button + /yield command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add YieldButton component, wire it into the confrontation overlay, add /yield slash command that sends a YIELD wire message, and thread the onYield callback from App.tsx through GameBoard → ConfrontationWidget → ConfrontationOverlay. MessageType.YIELD added to protocol.ts to mirror the server-side type added in Task 23. Co-Authored-By: Claude Sonnet 4.6 --- src/App.tsx | 11 ++++++++++ src/__tests__/YieldButton.test.tsx | 20 ++++++++++++++++++ src/components/ConfrontationOverlay.tsx | 11 +++++++++- src/components/GameBoard/GameBoard.tsx | 5 ++++- .../GameBoard/widgets/ConfrontationWidget.tsx | 4 +++- src/components/YieldButton.tsx | 21 +++++++++++++++++++ src/hooks/__tests__/useSlashCommands.test.ts | 11 ++++++++++ src/hooks/useSlashCommands.ts | 13 +++++++++++- src/types/protocol.ts | 1 + 9 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 src/__tests__/YieldButton.test.tsx create mode 100644 src/components/YieldButton.tsx diff --git a/src/App.tsx b/src/App.tsx index ad54d26..b902ac2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -854,6 +854,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 +1255,7 @@ function AppInner() { resourceAlerts={gameState.resourceAlerts} confrontationData={confrontationData} onBeatSelect={handleBeatSelect} + onYield={handleYield} diceRequest={diceRequest} diceResult={diceResult} onDiceThrow={handleDiceThrow} 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/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 && ( 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/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]; From a3de474697764e22a8fa9fb174300b905c710888 Mon Sep 17 00:00:00 2001 From: Keith Avery Date: Sat, 25 Apr 2026 14:53:01 -0400 Subject: [PATCH 2/2] fix(ui): forward slash-command messages to server send pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleSend was appending slash-command-produced messages (e.g. /yield → YIELD) to local state but never calling send(), so the server's _handle_yield never fired. Add send(msg) for-loop inside the slashResult.messages.length > 0 block; intentionally omit setCanType / setThinking — slash commands do not start a narration turn. Add wiring test (slash-command-send-wiring.test.ts) asserting the for-loop and send(msg) are present inside the slashResult.handled branch and that setCanType/setThinking are absent from that branch. Co-Authored-By: Claude Sonnet 4.6 --- src/App.tsx | 3 + .../slash-command-send-wiring.test.ts | 57 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 src/__tests__/slash-command-send-wiring.test.ts diff --git a/src/App.tsx b/src/App.tsx index b902ac2..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; } 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/); + }); +});