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 @@ -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;
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1245,6 +1258,7 @@ function AppInner() {
resourceAlerts={gameState.resourceAlerts}
confrontationData={confrontationData}
onBeatSelect={handleBeatSelect}
onYield={handleYield}
diceRequest={diceRequest}
diceResult={diceResult}
onDiceThrow={handleDiceThrow}
Expand Down
83 changes: 83 additions & 0 deletions src/__tests__/EncounterTab.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<EncounterTimeline events={sample} />);
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(<EncounterTimeline events={sample} />);
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(<EncounterTab slug={null} />);
expect(screen.getByText(/No active session/)).toBeInTheDocument();
});
});
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
16 changes: 16 additions & 0 deletions src/components/Dashboard/DashboardApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -378,6 +391,9 @@ export function DashboardApp() {
{state.activeTab === 6 && (
<LoreTab loreEvents={state.loreEvents} />
)}
{state.activeTab === 7 && (
<EncounterTab slug={activeSlug} />
)}
</div>
</div>
);
Expand Down
1 change: 1 addition & 0 deletions src/components/Dashboard/DashboardTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const TAB_LABELS = [
"⑤ Console",
"⑥ Prompt",
"⑦ Lore",
"⑧ Encounters",
];

interface Props {
Expand Down
Loading