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__/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 && (
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];