Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@
// the player in its `SessionRoom` and broadcasts `SEAT_CONFIRMED` to all
// sockets in the room. We track the seated set here so the UI can render
// a peer-presence indicator and confirm the handshake landed.
const [seatedPlayers, setSeatedPlayers] = useState<

Check failure on line 250 in src/App.tsx

View workflow job for this annotation

GitHub Actions / lint + typecheck + test

'seatedPlayers' is assigned a value but never used
Record<string, string /* character_slot */>
>({});

Expand Down Expand Up @@ -734,6 +734,9 @@
}
if (slashResult.messages.length > 0) {
setMessages((prev) => [...prev, ...slashResult.messages]);
for (const msg of slashResult.messages) {
send(msg);
}
}
return;
}
Expand Down Expand Up @@ -854,6 +857,16 @@
[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.
Expand Down Expand Up @@ -1135,7 +1148,7 @@
});
}
}
}, [readyState, connected, send]);

Check warning on line 1151 in src/App.tsx

View workflow job for this annotation

GitHub Actions / lint + typecheck + test

React Hook useEffect has a missing dependency: 'displayName'. Either include it or remove the dependency array

// If connection fails, clear saved session so we don't loop
useEffect(() => {
Expand Down Expand Up @@ -1245,6 +1258,7 @@
resourceAlerts={gameState.resourceAlerts}
confrontationData={confrontationData}
onBeatSelect={handleBeatSelect}
onYield={handleYield}
diceRequest={diceRequest}
diceResult={diceResult}
onDiceThrow={handleDiceThrow}
Expand Down
20 changes: 20 additions & 0 deletions src/__tests__/YieldButton.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<YieldButton onYield={onYield} disabled={false} />);
fireEvent.click(screen.getByRole("button", { name: /yield/i }));
expect(onYield).toHaveBeenCalledTimes(1);
});

it("disables when no active encounter", () => {
const onYield = vi.fn();
render(<YieldButton onYield={onYield} disabled />);
fireEvent.click(screen.getByRole("button", { name: /yield/i }));
expect(onYield).not.toHaveBeenCalled();
});
});
57 changes: 57 additions & 0 deletions src/__tests__/slash-command-send-wiring.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
11 changes: 10 additions & 1 deletion src/components/ConfrontationOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -67,6 +68,7 @@ interface ConfrontationOverlayProps {
diceResult?: DiceResultPayload | null;
playerId?: string;
onDiceThrow?: (params: DiceThrowParams, face: number[]) => void;
onYield?: () => void;
}

// ═══════════════════════════════════════════════════════════
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -262,6 +264,13 @@ export function ConfrontationOverlay({ data, onBeatSelect, inline, diceRequest,
{/* Beat action buttons */}
<BeatActions beats={data.beats} onBeatSelect={onBeatSelect} />

{/* Yield button — only rendered when parent supplies the handler */}
{onYield !== undefined && (
<div className="mt-2">
<YieldButton onYield={onYield} disabled={false} />
</div>
)}

{/* Inline dice tray — rolls right here when a beat is selected */}
{onDiceThrow && playerId && (
<InlineDiceTray
Expand Down
5 changes: 4 additions & 1 deletion src/components/GameBoard/GameBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -160,6 +161,7 @@ export function GameBoard({
knowledgeEntries,
confrontationData,
onBeatSelect,
onYield,
diceRequest,
diceResult,
onDiceThrow,
Expand Down Expand Up @@ -329,6 +331,7 @@ export function GameBoard({
<ConfrontationWidget
data={confrontationData}
onBeatSelect={onBeatSelect}
onYield={onYield}
diceRequest={diceRequest}
diceResult={diceResult}
playerId={currentPlayerId}
Expand All @@ -351,7 +354,7 @@ export function GameBoard({
return null;
}
}, [messages, thinking, characterSheet, inventoryData, mapData,
knowledgeEntries, confrontationData, onBeatSelect, diceRequest, diceResult,
knowledgeEntries, confrontationData, onBeatSelect, onYield, diceRequest, diceResult,
onDiceThrow, nowPlaying, volumes, muted,
handleVolumeChange, handleMuteToggle, resources, genreSlug,
handleResourceThresholdCrossed, characters, currentPlayerId,
Expand Down
4 changes: 3 additions & 1 deletion src/components/GameBoard/widgets/ConfrontationWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ interface ConfrontationWidgetProps {
diceResult?: DiceResultPayload | null;
playerId?: string;
onDiceThrow?: (params: DiceThrowParams, face: number[]) => 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 (
<ConfrontationOverlay
data={data}
Expand All @@ -20,6 +21,7 @@ export function ConfrontationWidget({ data, onBeatSelect, diceRequest, diceResul
diceResult={diceResult}
playerId={playerId}
onDiceThrow={onDiceThrow}
onYield={onYield}
/>
);
}
21 changes: 21 additions & 0 deletions src/components/YieldButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Button } from "@/components/ui/button";

interface Props {
onYield: () => void;
disabled: boolean;
}

export function YieldButton({ onYield, disabled }: Props) {
return (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => { if (!disabled) onYield(); }}
disabled={disabled}
title="Step out of the fight on your terms. Edge refreshes by 1 + statuses taken."
>
Yield
</Button>
);
}
11 changes: 11 additions & 0 deletions src/hooks/__tests__/useSlashCommands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("");
});
});
13 changes: 12 additions & 1 deletion src/hooks/useSlashCommands.ts
Original file line number Diff line number Diff line change
@@ -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 */
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/types/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
Loading