diff --git a/apps/web/src/__tests__/export-import.test.ts b/apps/web/src/__tests__/export-import.test.ts index 35284ab..0f7ce13 100644 --- a/apps/web/src/__tests__/export-import.test.ts +++ b/apps/web/src/__tests__/export-import.test.ts @@ -209,6 +209,31 @@ describe("round-trip: export then import", () => { expect(imported.playerCharacters).toEqual(bundle.playerCharacters); }); + it("round-trips a combatant with cr field", () => { + const encounterWithCr: Encounter = { + combatants: [ + { + id: combatantId("c-1"), + name: "Custom Thug", + cr: "2", + }, + ], + activeIndex: 0, + roundNumber: 1, + }; + const emptyUndoRedo: UndoRedoState = { + undoStack: [], + redoStack: [], + }; + const bundle = assembleExportBundle(encounterWithCr, emptyUndoRedo, []); + const serialized = JSON.parse(JSON.stringify(bundle)); + const result = validateImportBundle(serialized); + + expect(typeof result).toBe("object"); + const imported = result as ExportBundle; + expect(imported.encounter.combatants[0].cr).toBe("2"); + }); + it("round-trips an empty encounter", () => { const emptyEncounter: Encounter = { combatants: [], diff --git a/apps/web/src/__tests__/factories/build-creature.ts b/apps/web/src/__tests__/factories/build-creature.ts new file mode 100644 index 0000000..63fa975 --- /dev/null +++ b/apps/web/src/__tests__/factories/build-creature.ts @@ -0,0 +1,26 @@ +import type { Creature } from "@initiative/domain"; +import { creatureId } from "@initiative/domain"; + +let counter = 0; + +export function buildCreature(overrides?: Partial): Creature { + const id = ++counter; + return { + id: creatureId(`creature-${id}`), + name: `Creature ${id}`, + source: "srd", + sourceDisplayName: "SRD", + size: "Medium", + type: "humanoid", + alignment: "neutral", + ac: 13, + hp: { average: 7, formula: "2d6" }, + speed: "30 ft.", + abilities: { str: 10, dex: 14, con: 10, int: 10, wis: 10, cha: 10 }, + cr: "1/4", + initiativeProficiency: 0, + proficiencyBonus: 2, + passive: 10, + ...overrides, + }; +} diff --git a/apps/web/src/__tests__/factories/index.ts b/apps/web/src/__tests__/factories/index.ts index 36a19c4..edc9be0 100644 --- a/apps/web/src/__tests__/factories/index.ts +++ b/apps/web/src/__tests__/factories/index.ts @@ -1,2 +1,3 @@ export { buildCombatant } from "./build-combatant.js"; +export { buildCreature } from "./build-creature.js"; export { buildEncounter } from "./build-encounter.js"; diff --git a/apps/web/src/components/__tests__/difficulty-breakdown-panel.test.tsx b/apps/web/src/components/__tests__/difficulty-breakdown-panel.test.tsx new file mode 100644 index 0000000..0f9f2f8 --- /dev/null +++ b/apps/web/src/components/__tests__/difficulty-breakdown-panel.test.tsx @@ -0,0 +1,232 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; + +import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain"; +import { combatantId, creatureId, playerCharacterId } from "@initiative/domain"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js"; +import { + buildCombatant, + buildCreature, + buildEncounter, +} from "../../__tests__/factories/index.js"; +import { AllProviders } from "../../__tests__/test-providers.js"; +import { DifficultyBreakdownPanel } from "../difficulty-breakdown-panel.js"; + +beforeAll(() => { + Object.defineProperty(globalThis, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}); + +afterEach(cleanup); + +const pcId1 = playerCharacterId("pc-1"); +const goblinCreature = buildCreature({ + id: creatureId("srd:goblin"), + name: "Goblin", + cr: "1/4", + source: "srd", + sourceDisplayName: "SRD", +}); + +function renderPanel(options: { + encounter: ReturnType; + playerCharacters?: PlayerCharacter[]; + creatures?: Map; + onClose?: () => void; +}) { + const adapters = createTestAdapters({ + encounter: options.encounter, + playerCharacters: options.playerCharacters ?? [], + creatures: options.creatures, + }); + return render( + + {})} /> + , + ); +} + +function defaultEncounter() { + return buildEncounter({ + combatants: [ + buildCombatant({ + id: combatantId("c-1"), + name: "Hero", + playerCharacterId: pcId1, + }), + buildCombatant({ + id: combatantId("c-2"), + name: "Goblin", + creatureId: goblinCreature.id, + }), + buildCombatant({ + id: combatantId("c-3"), + name: "Custom Thug", + cr: "2", + }), + buildCombatant({ + id: combatantId("c-4"), + name: "Bandit", + }), + ], + }); +} + +const defaultPCs: PlayerCharacter[] = [ + { id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 }, +]; + +describe("DifficultyBreakdownPanel", () => { + it("renders party budget section", async () => { + renderPanel({ + encounter: defaultEncounter(), + playerCharacters: defaultPCs, + creatures: new Map([[goblinCreature.id, goblinCreature]]), + }); + + await waitFor(() => { + expect( + screen.getByText("Party Budget", { exact: false }), + ).toBeInTheDocument(); + expect(screen.getByText("1 PC", { exact: false })).toBeInTheDocument(); + expect(screen.getByText("Low:", { exact: false })).toBeInTheDocument(); + }); + }); + + it("renders tier label", async () => { + renderPanel({ + encounter: defaultEncounter(), + playerCharacters: defaultPCs, + creatures: new Map([[goblinCreature.id, goblinCreature]]), + }); + + await waitFor(() => { + expect( + screen.getByText("Encounter Difficulty:", { exact: false }), + ).toBeInTheDocument(); + }); + }); + + it("renders bestiary combatant as read-only with source name", async () => { + renderPanel({ + encounter: defaultEncounter(), + playerCharacters: defaultPCs, + creatures: new Map([[goblinCreature.id, goblinCreature]]), + }); + + await waitFor(() => { + expect(screen.getByText("Goblin (SRD)")).toBeInTheDocument(); + expect(screen.getAllByText("CR 1/4").length).toBeGreaterThanOrEqual(1); + }); + }); + + it("renders custom combatant with CR picker", async () => { + renderPanel({ + encounter: defaultEncounter(), + playerCharacters: defaultPCs, + creatures: new Map([[goblinCreature.id, goblinCreature]]), + }); + + await waitFor(() => { + const pickers = screen.getAllByLabelText("Challenge rating"); + expect(pickers).toHaveLength(2); + // First picker is "Custom Thug" with CR 2 + expect(pickers[0]).toHaveValue("2"); + }); + }); + + it("renders unassigned combatant with Assign picker and dash for XP", async () => { + renderPanel({ + encounter: defaultEncounter(), + playerCharacters: defaultPCs, + creatures: new Map([[goblinCreature.id, goblinCreature]]), + }); + + await waitFor(() => { + const pickers = screen.getAllByLabelText("Challenge rating"); + // Second picker is "Bandit" with no CR + expect(pickers[1]).toHaveValue(""); + // "—" appears for unassigned XP + expect(screen.getByText("—")).toBeInTheDocument(); + }); + }); + + it("selecting a CR updates the visible XP value", async () => { + const user = userEvent.setup(); + renderPanel({ + encounter: defaultEncounter(), + playerCharacters: defaultPCs, + creatures: new Map([[goblinCreature.id, goblinCreature]]), + }); + + // Wait for the panel to render with bestiary data + await waitFor(() => { + expect(screen.getByText("—")).toBeInTheDocument(); + }); + + // The Bandit (second picker) has no CR — shows "—" for XP + const pickers = screen.getAllByLabelText("Challenge rating"); + + // Select CR 5 (1,800 XP) on Bandit + await user.selectOptions(pickers[1], "5"); + + // XP should update — the "—" should be replaced with an XP value + await waitFor(() => { + expect(screen.getByText("1,800")).toBeInTheDocument(); + }); + }); + + it("renders total monster XP", async () => { + renderPanel({ + encounter: defaultEncounter(), + playerCharacters: defaultPCs, + creatures: new Map([[goblinCreature.id, goblinCreature]]), + }); + + await waitFor(() => { + expect(screen.getByText("Total Monster XP")).toBeInTheDocument(); + }); + }); + + it("renders nothing when breakdown data is insufficient", () => { + // No PCs with level → breakdown returns null + const { container } = renderPanel({ + encounter: buildEncounter({ + combatants: [ + buildCombatant({ id: combatantId("c-1"), name: "Custom" }), + ], + }), + }); + + expect(container.innerHTML).toBe(""); + }); + + it("calls onClose when Escape is pressed", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + renderPanel({ + encounter: defaultEncounter(), + playerCharacters: defaultPCs, + creatures: new Map([[goblinCreature.id, goblinCreature]]), + onClose, + }); + + await user.keyboard("{Escape}"); + + expect(onClose).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/src/components/__tests__/difficulty-indicator.test.tsx b/apps/web/src/components/__tests__/difficulty-indicator.test.tsx index 7c81df5..07c8a14 100644 --- a/apps/web/src/components/__tests__/difficulty-indicator.test.tsx +++ b/apps/web/src/components/__tests__/difficulty-indicator.test.tsx @@ -1,7 +1,8 @@ // @vitest-environment jsdom import type { DifficultyResult } from "@initiative/domain"; import { cleanup, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { DifficultyIndicator } from "../difficulty-indicator.js"; afterEach(cleanup); @@ -56,4 +57,41 @@ describe("DifficultyIndicator", () => { }), ).toBeDefined(); }); + + it("calls onClick when clicked", async () => { + const user = userEvent.setup(); + const handleClick = vi.fn(); + render( + , + ); + + await user.click( + screen.getByRole("img", { + name: "Moderate encounter difficulty", + }), + ); + expect(handleClick).toHaveBeenCalledOnce(); + }); + + it("renders as div when onClick not provided", () => { + const { container } = render( + , + ); + const element = container.querySelector("[role='img']"); + expect(element?.tagName).toBe("DIV"); + }); + + it("renders as button when onClick provided", () => { + const { container } = render( + {}} + />, + ); + const element = container.querySelector("[role='img']"); + expect(element?.tagName).toBe("BUTTON"); + }); }); diff --git a/apps/web/src/components/cr-picker.tsx b/apps/web/src/components/cr-picker.tsx new file mode 100644 index 0000000..650ee1b --- /dev/null +++ b/apps/web/src/components/cr-picker.tsx @@ -0,0 +1,36 @@ +import { VALID_CR_VALUES } from "@initiative/domain"; + +const CR_LABELS: Record = { + "0": "CR 0", + "1/8": "CR 1/8", + "1/4": "CR 1/4", + "1/2": "CR 1/2", +}; + +function formatCr(cr: string): string { + return CR_LABELS[cr] ?? `CR ${cr}`; +} + +export function CrPicker({ + value, + onChange, +}: { + value: string | null; + onChange: (cr: string | undefined) => void; +}) { + return ( + + ); +} diff --git a/apps/web/src/components/difficulty-breakdown-panel.tsx b/apps/web/src/components/difficulty-breakdown-panel.tsx new file mode 100644 index 0000000..72686fe --- /dev/null +++ b/apps/web/src/components/difficulty-breakdown-panel.tsx @@ -0,0 +1,109 @@ +import type { DifficultyTier } from "@initiative/domain"; +import { useRef } from "react"; +import { useEncounterContext } from "../contexts/encounter-context.js"; +import { useClickOutside } from "../hooks/use-click-outside.js"; +import { + type BreakdownCombatant, + useDifficultyBreakdown, +} from "../hooks/use-difficulty-breakdown.js"; +import { CrPicker } from "./cr-picker.js"; + +const TIER_LABELS: Record = { + trivial: { label: "Trivial", color: "text-muted-foreground" }, + low: { label: "Low", color: "text-green-500" }, + moderate: { label: "Moderate", color: "text-yellow-500" }, + high: { label: "High", color: "text-red-500" }, +}; + +function formatXp(xp: number): string { + return xp.toLocaleString(); +} + +function CombatantRow({ entry }: { entry: BreakdownCombatant }) { + const { setCr } = useEncounterContext(); + + const nameLabel = entry.source + ? `${entry.combatant.name} (${entry.source})` + : entry.combatant.name; + + return ( +
+ + {nameLabel} + +
+ {entry.editable ? ( + setCr(entry.combatant.id, cr)} + /> + ) : ( + + {entry.cr ? `CR ${entry.cr}` : "—"} + + )} + + {entry.xp == null ? "—" : formatXp(entry.xp)} + +
+
+ ); +} + +export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) { + const ref = useRef(null); + useClickOutside(ref, onClose); + + const breakdown = useDifficultyBreakdown(); + if (!breakdown) return null; + + const tierConfig = TIER_LABELS[breakdown.tier]; + + return ( +
+
+ Encounter Difficulty:{" "} + {tierConfig.label} +
+ +
+
+ Party Budget ({breakdown.pcCount}{" "} + {breakdown.pcCount === 1 ? "PC" : "PCs"}) +
+
+ + Low: {formatXp(breakdown.partyBudget.low)} + + + Mod: {formatXp(breakdown.partyBudget.moderate)} + + + High: {formatXp(breakdown.partyBudget.high)} + +
+
+ +
+
+ Monsters + XP +
+
+ {breakdown.combatants.map((entry) => ( + + ))} +
+
+ Total Monster XP + + {formatXp(breakdown.totalMonsterXp)} + +
+
+
+ ); +} diff --git a/apps/web/src/components/difficulty-indicator.tsx b/apps/web/src/components/difficulty-indicator.tsx index bab5b87..8bd821f 100644 --- a/apps/web/src/components/difficulty-indicator.tsx +++ b/apps/web/src/components/difficulty-indicator.tsx @@ -13,16 +13,29 @@ const TIER_CONFIG: Record< const BAR_HEIGHTS = ["h-2", "h-3", "h-4"] as const; -export function DifficultyIndicator({ result }: { result: DifficultyResult }) { +export function DifficultyIndicator({ + result, + onClick, +}: { + result: DifficultyResult; + onClick?: () => void; +}) { const config = TIER_CONFIG[result.tier]; const tooltip = `${config.label} encounter difficulty`; + const Element = onClick ? "button" : "div"; + return ( -
{BAR_HEIGHTS.map((height, i) => (
))} -
+ ); } diff --git a/apps/web/src/components/turn-navigation.tsx b/apps/web/src/components/turn-navigation.tsx index e948b1b..aaf32ff 100644 --- a/apps/web/src/components/turn-navigation.tsx +++ b/apps/web/src/components/turn-navigation.tsx @@ -1,6 +1,8 @@ import { Redo2, StepBack, StepForward, Trash2, Undo2 } from "lucide-react"; +import { useState } from "react"; import { useEncounterContext } from "../contexts/encounter-context.js"; import { useDifficulty } from "../hooks/use-difficulty.js"; +import { DifficultyBreakdownPanel } from "./difficulty-breakdown-panel.js"; import { DifficultyIndicator } from "./difficulty-indicator.js"; import { Button } from "./ui/button.js"; import { ConfirmButton } from "./ui/confirm-button.js"; @@ -18,6 +20,7 @@ export function TurnNavigation() { } = useEncounterContext(); const difficulty = useDifficulty(); + const [showBreakdown, setShowBreakdown] = useState(false); const hasCombatants = encounter.combatants.length > 0; const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0; const activeCombatant = encounter.combatants[encounter.activeIndex]; @@ -69,7 +72,19 @@ export function TurnNavigation() { ) : ( No combatants )} - {difficulty && } + {difficulty && ( +
+ setShowBreakdown((prev) => !prev)} + /> + {showBreakdown ? ( + setShowBreakdown(false)} + /> + ) : null} +
+ )}
diff --git a/apps/web/src/hooks/__tests__/use-difficulty-breakdown.test.tsx b/apps/web/src/hooks/__tests__/use-difficulty-breakdown.test.tsx new file mode 100644 index 0000000..5520084 --- /dev/null +++ b/apps/web/src/hooks/__tests__/use-difficulty-breakdown.test.tsx @@ -0,0 +1,246 @@ +// @vitest-environment jsdom +import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain"; +import { combatantId, creatureId, playerCharacterId } from "@initiative/domain"; +import { renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js"; +import { + buildCombatant, + buildCreature, + buildEncounter, +} from "../../__tests__/factories/index.js"; +import { AllProviders } from "../../__tests__/test-providers.js"; +import { useDifficultyBreakdown } from "../use-difficulty-breakdown.js"; + +beforeAll(() => { + Object.defineProperty(globalThis, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}); + +const pcId1 = playerCharacterId("pc-1"); +const goblinCreature = buildCreature({ + id: creatureId("srd:goblin"), + name: "Goblin", + cr: "1/4", + source: "srd", + sourceDisplayName: "SRD", +}); + +function makeWrapper(options: { + encounter: ReturnType; + playerCharacters?: PlayerCharacter[]; + creatures?: Map; +}) { + const adapters = createTestAdapters({ + encounter: options.encounter, + playerCharacters: options.playerCharacters ?? [], + creatures: options.creatures, + }); + return ({ children }: { children: ReactNode }) => ( + {children} + ); +} + +describe("useDifficultyBreakdown", () => { + it("returns null when no leveled PCs", () => { + const wrapper = makeWrapper({ + encounter: buildEncounter({ + combatants: [ + buildCombatant({ + id: combatantId("c-1"), + name: "Hero", + playerCharacterId: pcId1, + }), + buildCombatant({ + id: combatantId("c-2"), + name: "Goblin", + creatureId: goblinCreature.id, + }), + ], + }), + playerCharacters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30 }], + creatures: new Map([[goblinCreature.id, goblinCreature]]), + }); + + const { result } = renderHook(() => useDifficultyBreakdown(), { + wrapper, + }); + expect(result.current).toBeNull(); + }); + + it("returns null when no monsters with CR", () => { + const wrapper = makeWrapper({ + encounter: buildEncounter({ + combatants: [ + buildCombatant({ + id: combatantId("c-1"), + name: "Hero", + playerCharacterId: pcId1, + }), + buildCombatant({ + id: combatantId("c-2"), + name: "Custom", + }), + ], + }), + playerCharacters: [ + { id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 }, + ], + }); + + const { result } = renderHook(() => useDifficultyBreakdown(), { + wrapper, + }); + expect(result.current).toBeNull(); + }); + + it("returns per-combatant entries with correct data", async () => { + const wrapper = makeWrapper({ + encounter: buildEncounter({ + combatants: [ + buildCombatant({ + id: combatantId("c-1"), + name: "Hero", + playerCharacterId: pcId1, + }), + buildCombatant({ + id: combatantId("c-2"), + name: "Goblin", + creatureId: goblinCreature.id, + }), + buildCombatant({ + id: combatantId("c-3"), + name: "Custom Thug", + cr: "2", + }), + buildCombatant({ + id: combatantId("c-4"), + name: "Bandit", + }), + ], + }), + playerCharacters: [ + { id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 }, + ], + creatures: new Map([[goblinCreature.id, goblinCreature]]), + }); + + const { result } = renderHook(() => useDifficultyBreakdown(), { + wrapper, + }); + + await waitFor(() => { + const breakdown = result.current; + expect(breakdown).not.toBeNull(); + expect(breakdown?.pcCount).toBe(1); + // CR 1/4 = 50 + CR 2 = 450 → total 500 + expect(breakdown?.totalMonsterXp).toBe(500); + expect(breakdown?.combatants).toHaveLength(3); + + // Bestiary combatant + const goblin = breakdown?.combatants[0]; + expect(goblin?.cr).toBe("1/4"); + expect(goblin?.xp).toBe(50); + expect(goblin?.source).toBe("SRD"); + expect(goblin?.editable).toBe(false); + + // Custom with CR + const thug = breakdown?.combatants[1]; + expect(thug?.cr).toBe("2"); + expect(thug?.xp).toBe(450); + expect(thug?.source).toBeNull(); + expect(thug?.editable).toBe(true); + + // Custom without CR + const bandit = breakdown?.combatants[2]; + expect(bandit?.cr).toBeNull(); + expect(bandit?.xp).toBeNull(); + expect(bandit?.source).toBeNull(); + expect(bandit?.editable).toBe(true); + }); + }); + + it("bestiary combatant with missing creature is non-editable with null CR", () => { + const missingCreatureId = creatureId("creature-missing"); + const wrapper = makeWrapper({ + encounter: buildEncounter({ + combatants: [ + buildCombatant({ + id: combatantId("c-1"), + name: "Hero", + playerCharacterId: pcId1, + }), + buildCombatant({ + id: combatantId("c-2"), + name: "Ghost", + creatureId: missingCreatureId, + }), + buildCombatant({ + id: combatantId("c-3"), + name: "Thug", + cr: "1", + }), + ], + }), + playerCharacters: [ + { id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 }, + ], + }); + + const { result } = renderHook(() => useDifficultyBreakdown(), { + wrapper, + }); + + // With no bestiary creatures loaded, the Ghost has null CR + const breakdown = result.current; + expect(breakdown).not.toBeNull(); + const ghost = breakdown?.combatants[0]; + expect(ghost?.cr).toBeNull(); + expect(ghost?.xp).toBeNull(); + expect(ghost?.editable).toBe(false); + }); + + it("excludes PC combatants from breakdown entries", async () => { + const wrapper = makeWrapper({ + encounter: buildEncounter({ + combatants: [ + buildCombatant({ + id: combatantId("c-1"), + name: "Hero", + playerCharacterId: pcId1, + }), + buildCombatant({ + id: combatantId("c-2"), + name: "Goblin", + creatureId: goblinCreature.id, + }), + ], + }), + playerCharacters: [ + { id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 }, + ], + creatures: new Map([[goblinCreature.id, goblinCreature]]), + }); + + const { result } = renderHook(() => useDifficultyBreakdown(), { + wrapper, + }); + + await waitFor(() => { + expect(result.current?.combatants).toHaveLength(1); + expect(result.current?.combatants[0].combatant.name).toBe("Goblin"); + }); + }); +}); diff --git a/apps/web/src/hooks/__tests__/use-difficulty-custom-cr.test.tsx b/apps/web/src/hooks/__tests__/use-difficulty-custom-cr.test.tsx new file mode 100644 index 0000000..5980820 --- /dev/null +++ b/apps/web/src/hooks/__tests__/use-difficulty-custom-cr.test.tsx @@ -0,0 +1,173 @@ +// @vitest-environment jsdom +import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain"; +import { combatantId, creatureId, playerCharacterId } from "@initiative/domain"; +import { renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js"; +import { + buildCombatant, + buildCreature, + buildEncounter, +} from "../../__tests__/factories/index.js"; +import { AllProviders } from "../../__tests__/test-providers.js"; +import { useDifficulty } from "../use-difficulty.js"; + +beforeAll(() => { + Object.defineProperty(globalThis, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}); + +const pcId1 = playerCharacterId("pc-1"); +const goblinCreature = buildCreature({ + id: creatureId("srd:goblin"), + name: "Goblin", + cr: "1/4", +}); + +function makeWrapper(options: { + encounter: ReturnType; + playerCharacters?: PlayerCharacter[]; + creatures?: Map; +}) { + const adapters = createTestAdapters({ + encounter: options.encounter, + playerCharacters: options.playerCharacters ?? [], + creatures: options.creatures, + }); + return ({ children }: { children: ReactNode }) => ( + {children} + ); +} + +describe("useDifficulty with custom combatant CRs", () => { + it("includes custom combatant with cr field in monster XP", () => { + const wrapper = makeWrapper({ + encounter: buildEncounter({ + combatants: [ + buildCombatant({ + id: combatantId("c-1"), + name: "Hero", + playerCharacterId: pcId1, + }), + buildCombatant({ + id: combatantId("c-2"), + name: "Custom Thug", + cr: "2", + }), + ], + }), + playerCharacters: [ + { id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 }, + ], + }); + + const { result } = renderHook(() => useDifficulty(), { wrapper }); + + expect(result.current).not.toBeNull(); + expect(result.current?.totalMonsterXp).toBe(450); + }); + + it("uses bestiary CR when combatant has both creatureId and cr", async () => { + const wrapper = makeWrapper({ + encounter: buildEncounter({ + combatants: [ + buildCombatant({ + id: combatantId("c-1"), + name: "Hero", + playerCharacterId: pcId1, + }), + buildCombatant({ + id: combatantId("c-2"), + name: "Goblin", + creatureId: goblinCreature.id, + cr: "5", + }), + ], + }), + playerCharacters: [ + { id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 }, + ], + creatures: new Map([[goblinCreature.id, goblinCreature]]), + }); + + const { result } = renderHook(() => useDifficulty(), { wrapper }); + + await waitFor(() => { + expect(result.current).not.toBeNull(); + // Should use bestiary CR 1/4 (50 XP), not the manual cr "5" (1800 XP) + expect(result.current?.totalMonsterXp).toBe(50); + }); + }); + + it("mixes bestiary and custom-with-CR combatants correctly", async () => { + const wrapper = makeWrapper({ + encounter: buildEncounter({ + combatants: [ + buildCombatant({ + id: combatantId("c-1"), + name: "Hero", + playerCharacterId: pcId1, + }), + buildCombatant({ + id: combatantId("c-2"), + name: "Goblin", + creatureId: goblinCreature.id, + }), + buildCombatant({ + id: combatantId("c-3"), + name: "Custom", + cr: "1", + }), + ], + }), + playerCharacters: [ + { id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 }, + ], + creatures: new Map([[goblinCreature.id, goblinCreature]]), + }); + + const { result } = renderHook(() => useDifficulty(), { wrapper }); + + await waitFor(() => { + expect(result.current).not.toBeNull(); + // CR 1/4 = 50 XP, CR 1 = 200 XP → total 250 + expect(result.current?.totalMonsterXp).toBe(250); + }); + }); + + it("custom combatant without CR is still excluded", () => { + const wrapper = makeWrapper({ + encounter: buildEncounter({ + combatants: [ + buildCombatant({ + id: combatantId("c-1"), + name: "Hero", + playerCharacterId: pcId1, + }), + buildCombatant({ + id: combatantId("c-2"), + name: "Custom Monster", + }), + ], + }), + playerCharacters: [ + { id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 }, + ], + }); + + const { result } = renderHook(() => useDifficulty(), { wrapper }); + expect(result.current).toBeNull(); + }); +}); diff --git a/apps/web/src/hooks/use-difficulty-breakdown.ts b/apps/web/src/hooks/use-difficulty-breakdown.ts new file mode 100644 index 0000000..9515023 --- /dev/null +++ b/apps/web/src/hooks/use-difficulty-breakdown.ts @@ -0,0 +1,140 @@ +import type { + Combatant, + CreatureId, + DifficultyTier, + PlayerCharacter, +} from "@initiative/domain"; +import { calculateEncounterDifficulty, crToXp } from "@initiative/domain"; +import { useMemo } from "react"; +import { useBestiaryContext } from "../contexts/bestiary-context.js"; +import { useEncounterContext } from "../contexts/encounter-context.js"; +import { usePlayerCharactersContext } from "../contexts/player-characters-context.js"; + +export interface BreakdownCombatant { + readonly combatant: Combatant; + readonly cr: string | null; + readonly xp: number | null; + readonly source: string | null; + readonly editable: boolean; +} + +interface DifficultyBreakdown { + readonly tier: DifficultyTier; + readonly totalMonsterXp: number; + readonly partyBudget: { + readonly low: number; + readonly moderate: number; + readonly high: number; + }; + readonly pcCount: number; + readonly combatants: readonly BreakdownCombatant[]; +} + +export function useDifficultyBreakdown(): DifficultyBreakdown | null { + const { encounter } = useEncounterContext(); + const { characters } = usePlayerCharactersContext(); + const { getCreature } = useBestiaryContext(); + + return useMemo(() => { + const partyLevels = derivePartyLevels(encounter.combatants, characters); + const { entries, crs } = classifyCombatants( + encounter.combatants, + getCreature, + ); + + if (partyLevels.length === 0 || crs.length === 0) { + return null; + } + + const result = calculateEncounterDifficulty(partyLevels, crs); + + return { + ...result, + pcCount: partyLevels.length, + combatants: entries, + }; + }, [encounter.combatants, characters, getCreature]); +} + +function classifyBestiaryCombatant( + c: Combatant, + getCreature: ( + id: CreatureId, + ) => { cr: string; source: string; sourceDisplayName: string } | undefined, +): { entry: BreakdownCombatant; cr: string | null } { + const creature = c.creatureId ? getCreature(c.creatureId) : undefined; + if (creature) { + return { + entry: { + combatant: c, + cr: creature.cr, + xp: crToXp(creature.cr), + source: creature.sourceDisplayName ?? creature.source, + editable: false, + }, + cr: creature.cr, + }; + } + return { + entry: { + combatant: c, + cr: null, + xp: null, + source: null, + editable: false, + }, + cr: null, + }; +} + +function classifyCombatants( + combatants: readonly Combatant[], + getCreature: ( + id: CreatureId, + ) => { cr: string; source: string; sourceDisplayName: string } | undefined, +): { entries: BreakdownCombatant[]; crs: string[] } { + const entries: BreakdownCombatant[] = []; + const crs: string[] = []; + + for (const c of combatants) { + if (c.playerCharacterId) continue; + + if (c.creatureId) { + const { entry, cr } = classifyBestiaryCombatant(c, getCreature); + entries.push(entry); + if (cr) crs.push(cr); + } else if (c.cr) { + crs.push(c.cr); + entries.push({ + combatant: c, + cr: c.cr, + xp: crToXp(c.cr), + source: null, + editable: true, + }); + } else { + entries.push({ + combatant: c, + cr: null, + xp: null, + source: null, + editable: true, + }); + } + } + + return { entries, crs }; +} + +function derivePartyLevels( + combatants: readonly Combatant[], + characters: readonly PlayerCharacter[], +): number[] { + const levels: number[] = []; + for (const c of combatants) { + if (!c.playerCharacterId) continue; + const pc = characters.find((p) => p.id === c.playerCharacterId); + if (pc?.level !== undefined) levels.push(pc.level); + } + return levels; +} diff --git a/apps/web/src/hooks/use-difficulty.ts b/apps/web/src/hooks/use-difficulty.ts index c3e856f..360f370 100644 --- a/apps/web/src/hooks/use-difficulty.ts +++ b/apps/web/src/hooks/use-difficulty.ts @@ -29,9 +29,12 @@ function deriveMonsterCrs( ): string[] { const crs: string[] = []; for (const c of combatants) { - if (!c.creatureId) continue; - const creature = getCreature(c.creatureId); - if (creature) crs.push(creature.cr); + if (c.creatureId) { + const creature = getCreature(c.creatureId); + if (creature) crs.push(creature.cr); + } else if (c.cr) { + crs.push(c.cr); + } } return crs; } diff --git a/apps/web/src/hooks/use-encounter.ts b/apps/web/src/hooks/use-encounter.ts index ddb942a..95dfd0f 100644 --- a/apps/web/src/hooks/use-encounter.ts +++ b/apps/web/src/hooks/use-encounter.ts @@ -9,6 +9,7 @@ import { removeCombatantUseCase, retreatTurnUseCase, setAcUseCase, + setCrUseCase, setHpUseCase, setInitiativeUseCase, setTempHpUseCase, @@ -52,6 +53,7 @@ type EncounterAction = | { type: "adjust-hp"; id: CombatantId; delta: number } | { type: "set-temp-hp"; id: CombatantId; tempHp: number | undefined } | { type: "set-ac"; id: CombatantId; value: number | undefined } + | { type: "set-cr"; id: CombatantId; value: string | undefined } | { type: "toggle-condition"; id: CombatantId; @@ -318,6 +320,7 @@ function dispatchEncounterAction( | { type: "adjust-hp" } | { type: "set-temp-hp" } | { type: "set-ac" } + | { type: "set-cr" } | { type: "toggle-condition" } | { type: "toggle-concentration" } >, @@ -358,6 +361,9 @@ function dispatchEncounterAction( case "set-ac": result = setAcUseCase(store, action.id, action.value); break; + case "set-cr": + result = setCrUseCase(store, action.id, action.value); + break; case "toggle-condition": result = toggleConditionUseCase(store, action.id, action.conditionId); break; @@ -495,6 +501,11 @@ export function useEncounter() { dispatch({ type: "set-ac", id, value }), [], ), + setCr: useCallback( + (id: CombatantId, value: string | undefined) => + dispatch({ type: "set-cr", id, value }), + [], + ), toggleCondition: useCallback( (id: CombatantId, conditionId: ConditionId) => dispatch({ type: "toggle-condition", id, conditionId }), diff --git a/apps/web/src/persistence/__tests__/encounter-storage.test.ts b/apps/web/src/persistence/__tests__/encounter-storage.test.ts index e2697a6..791f165 100644 --- a/apps/web/src/persistence/__tests__/encounter-storage.test.ts +++ b/apps/web/src/persistence/__tests__/encounter-storage.test.ts @@ -134,6 +134,26 @@ describe("loadEncounter", () => { expect(loadEncounter()).toBeNull(); }); + it("round-trip preserves combatant cr field", () => { + const result = createEncounter( + [ + { + id: combatantId("c-1"), + name: "Custom Thug", + cr: "2", + }, + ], + 0, + 1, + ); + if (isDomainError(result)) throw new Error("unreachable"); + saveEncounter(result); + const loaded = loadEncounter(); + + expect(loaded).not.toBeNull(); + expect(loaded?.combatants[0].cr).toBe("2"); + }); + it("saving after modifications persists the latest state", () => { const encounter = makeEncounter(); saveEncounter(encounter); diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index c84e697..c2daeaa 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -21,6 +21,7 @@ export { } from "./roll-all-initiative-use-case.js"; export { rollInitiativeUseCase } from "./roll-initiative-use-case.js"; export { setAcUseCase } from "./set-ac-use-case.js"; +export { setCrUseCase } from "./set-cr-use-case.js"; export { setHpUseCase } from "./set-hp-use-case.js"; export { setInitiativeUseCase } from "./set-initiative-use-case.js"; export { setTempHpUseCase } from "./set-temp-hp-use-case.js"; diff --git a/packages/application/src/set-cr-use-case.ts b/packages/application/src/set-cr-use-case.ts new file mode 100644 index 0000000..78b8a12 --- /dev/null +++ b/packages/application/src/set-cr-use-case.ts @@ -0,0 +1,18 @@ +import { + type CombatantId, + type DomainError, + type DomainEvent, + setCr, +} from "@initiative/domain"; +import type { EncounterStore } from "./ports.js"; +import { runEncounterAction } from "./run-encounter-action.js"; + +export function setCrUseCase( + store: EncounterStore, + combatantId: CombatantId, + value: string | undefined, +): DomainEvent[] | DomainError { + return runEncounterAction(store, (encounter) => + setCr(encounter, combatantId, value), + ); +} diff --git a/packages/domain/src/__tests__/rehydrate-combatant.test.ts b/packages/domain/src/__tests__/rehydrate-combatant.test.ts index fe37972..b2bc4ff 100644 --- a/packages/domain/src/__tests__/rehydrate-combatant.test.ts +++ b/packages/domain/src/__tests__/rehydrate-combatant.test.ts @@ -219,6 +219,28 @@ describe("rehydrateCombatant", () => { } }); + it("preserves valid cr field", () => { + for (const cr of ["5", "1/4", "0", "30"]) { + const result = rehydrateCombatant({ ...minimalCombatant(), cr }); + expect(result).not.toBeNull(); + expect(result?.cr).toBe(cr); + } + }); + + it("drops invalid cr field", () => { + for (const cr of ["99", "", 42, null, "abc"]) { + const result = rehydrateCombatant({ ...minimalCombatant(), cr }); + expect(result).not.toBeNull(); + expect(result?.cr).toBeUndefined(); + } + }); + + it("combatant without cr rehydrates as before", () => { + const result = rehydrateCombatant(minimalCombatant()); + expect(result).not.toBeNull(); + expect(result?.cr).toBeUndefined(); + }); + it("drops invalid tempHp — keeps combatant", () => { for (const tempHp of [-1, 1.5, "3"]) { const result = rehydrateCombatant({ diff --git a/packages/domain/src/__tests__/set-cr.test.ts b/packages/domain/src/__tests__/set-cr.test.ts new file mode 100644 index 0000000..b4ece4d --- /dev/null +++ b/packages/domain/src/__tests__/set-cr.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; +import { setCr } from "../set-cr.js"; +import type { Combatant, Encounter } from "../types.js"; +import { combatantId, isDomainError } from "../types.js"; +import { expectDomainError } from "./test-helpers.js"; + +function makeCombatant(name: string, cr?: string): Combatant { + return cr === undefined + ? { id: combatantId(name), name } + : { id: combatantId(name), name, cr }; +} + +function enc( + combatants: Combatant[], + activeIndex = 0, + roundNumber = 1, +): Encounter { + return { combatants, activeIndex, roundNumber }; +} + +function successResult( + encounter: Encounter, + id: string, + value: string | undefined, +) { + const result = setCr(encounter, combatantId(id), value); + if (isDomainError(result)) { + throw new Error(`Expected success, got error: ${result.message}`); + } + return result; +} + +describe("setCr", () => { + it("sets CR to a valid integer value", () => { + const e = enc([makeCombatant("A"), makeCombatant("B")]); + const { encounter, events } = successResult(e, "A", "5"); + + expect(encounter.combatants[0].cr).toBe("5"); + expect(events).toEqual([ + { + type: "CrSet", + combatantId: combatantId("A"), + previousCr: undefined, + newCr: "5", + }, + ]); + }); + + it("sets CR to 0", () => { + const e = enc([makeCombatant("A")]); + const { encounter } = successResult(e, "A", "0"); + + expect(encounter.combatants[0].cr).toBe("0"); + }); + + it("sets CR to fractional values", () => { + for (const cr of ["1/8", "1/4", "1/2"]) { + const e = enc([makeCombatant("A")]); + const { encounter } = successResult(e, "A", cr); + expect(encounter.combatants[0].cr).toBe(cr); + } + }); + + it("sets CR to 30", () => { + const e = enc([makeCombatant("A")]); + const { encounter } = successResult(e, "A", "30"); + + expect(encounter.combatants[0].cr).toBe("30"); + }); + + it("clears CR with undefined", () => { + const e = enc([makeCombatant("A", "5")]); + const { encounter, events } = successResult(e, "A", undefined); + + expect(encounter.combatants[0].cr).toBeUndefined(); + expect(events[0]).toMatchObject({ + previousCr: "5", + newCr: undefined, + }); + }); + + it("returns error for nonexistent combatant", () => { + const e = enc([makeCombatant("A")]); + const result = setCr(e, combatantId("nonexistent"), "1"); + + expectDomainError(result, "combatant-not-found"); + }); + + it("returns error for invalid CR string", () => { + const e = enc([makeCombatant("A")]); + const result = setCr(e, combatantId("A"), "99"); + + expectDomainError(result, "invalid-cr"); + }); + + it("returns error for empty string CR", () => { + const e = enc([makeCombatant("A")]); + const result = setCr(e, combatantId("A"), ""); + + expectDomainError(result, "invalid-cr"); + }); + + it("preserves other fields when setting CR", () => { + const combatant: Combatant = { + id: combatantId("A"), + name: "Aria", + initiative: 15, + maxHp: 20, + currentHp: 18, + ac: 14, + }; + const e = enc([combatant]); + const { encounter } = successResult(e, "A", "2"); + + const updated = encounter.combatants[0]; + expect(updated.cr).toBe("2"); + expect(updated.name).toBe("Aria"); + expect(updated.initiative).toBe(15); + expect(updated.maxHp).toBe(20); + expect(updated.currentHp).toBe(18); + expect(updated.ac).toBe(14); + }); + + it("does not reorder combatants", () => { + const e = enc([makeCombatant("A"), makeCombatant("B")]); + const { encounter } = successResult(e, "B", "1"); + + expect(encounter.combatants[0].id).toBe(combatantId("A")); + expect(encounter.combatants[1].id).toBe(combatantId("B")); + }); + + it("preserves activeIndex and roundNumber", () => { + const e = enc([makeCombatant("A"), makeCombatant("B")], 1, 5); + const { encounter } = successResult(e, "A", "1/4"); + + expect(encounter.activeIndex).toBe(1); + expect(encounter.roundNumber).toBe(5); + }); + + it("does not mutate input encounter", () => { + const e = enc([makeCombatant("A")]); + const original = JSON.parse(JSON.stringify(e)); + setCr(e, combatantId("A"), "10"); + expect(e).toEqual(original); + }); +}); diff --git a/packages/domain/src/encounter-difficulty.ts b/packages/domain/src/encounter-difficulty.ts index e411a9d..012c76e 100644 --- a/packages/domain/src/encounter-difficulty.ts +++ b/packages/domain/src/encounter-difficulty.ts @@ -74,6 +74,9 @@ const XP_BUDGET_PER_CHARACTER: Readonly< 20: { low: 6400, moderate: 13200, high: 22000 }, }; +/** All standard 5e challenge rating strings, in ascending order. */ +export const VALID_CR_VALUES: readonly string[] = Object.keys(CR_TO_XP); + /** Returns the XP value for a given CR string. Returns 0 for unknown CRs. */ export function crToXp(cr: string): number { return CR_TO_XP[cr] ?? 0; diff --git a/packages/domain/src/events.ts b/packages/domain/src/events.ts index bfff32c..7e44562 100644 --- a/packages/domain/src/events.ts +++ b/packages/domain/src/events.ts @@ -94,6 +94,13 @@ export interface AcSet { readonly newAc: number | undefined; } +export interface CrSet { + readonly type: "CrSet"; + readonly combatantId: CombatantId; + readonly previousCr: string | undefined; + readonly newCr: string | undefined; +} + export interface ConditionAdded { readonly type: "ConditionAdded"; readonly combatantId: CombatantId; @@ -153,6 +160,7 @@ export type DomainEvent = | TurnRetreated | RoundRetreated | AcSet + | CrSet | ConditionAdded | ConditionRemoved | ConcentrationStarted diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 9abdacc..702c16b 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -53,6 +53,7 @@ export { crToXp, type DifficultyResult, type DifficultyTier, + VALID_CR_VALUES, } from "./encounter-difficulty.js"; export type { AcSet, @@ -63,6 +64,7 @@ export type { ConcentrationStarted, ConditionAdded, ConditionRemoved, + CrSet, CurrentHpAdjusted, DomainEvent, EncounterCleared, @@ -107,6 +109,7 @@ export { selectRoll, } from "./roll-initiative.js"; export { type SetAcSuccess, setAc } from "./set-ac.js"; +export { type SetCrSuccess, setCr } from "./set-cr.js"; export { type SetHpSuccess, setHp } from "./set-hp.js"; export { type SetInitiativeSuccess, diff --git a/packages/domain/src/rehydrate-combatant.ts b/packages/domain/src/rehydrate-combatant.ts index be04d15..f7bc534 100644 --- a/packages/domain/src/rehydrate-combatant.ts +++ b/packages/domain/src/rehydrate-combatant.ts @@ -1,6 +1,7 @@ import type { ConditionId } from "./conditions.js"; import { VALID_CONDITION_IDS } from "./conditions.js"; import { creatureId } from "./creature-types.js"; +import { VALID_CR_VALUES } from "./encounter-difficulty.js"; import { playerCharacterId, VALID_PLAYER_COLORS, @@ -69,6 +70,12 @@ function validateNonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } +function validateCr(value: unknown): string | undefined { + return typeof value === "string" && VALID_CR_VALUES.includes(value) + ? value + : undefined; +} + function parseOptionalFields(entry: Record) { return { initiative: validateInteger(entry.initiative), @@ -78,6 +85,7 @@ function parseOptionalFields(entry: Record) { creatureId: validateNonEmptyString(entry.creatureId) ? creatureId(entry.creatureId as string) : undefined, + cr: validateCr(entry.cr), color: validateSetMember(entry.color, VALID_PLAYER_COLORS), icon: validateSetMember(entry.icon, VALID_PLAYER_ICONS), playerCharacterId: validateNonEmptyString(entry.playerCharacterId) diff --git a/packages/domain/src/set-cr.ts b/packages/domain/src/set-cr.ts new file mode 100644 index 0000000..160cacf --- /dev/null +++ b/packages/domain/src/set-cr.ts @@ -0,0 +1,53 @@ +import { VALID_CR_VALUES } from "./encounter-difficulty.js"; +import type { DomainEvent } from "./events.js"; +import { + type CombatantId, + type DomainError, + type Encounter, + findCombatant, + isDomainError, +} from "./types.js"; + +export interface SetCrSuccess { + readonly encounter: Encounter; + readonly events: DomainEvent[]; +} + +export function setCr( + encounter: Encounter, + combatantId: CombatantId, + value: string | undefined, +): SetCrSuccess | DomainError { + const found = findCombatant(encounter, combatantId); + if (isDomainError(found)) return found; + + if (value !== undefined && !VALID_CR_VALUES.includes(value)) { + return { + kind: "domain-error", + code: "invalid-cr", + message: `CR must be a valid challenge rating, got "${value}"`, + }; + } + + const previousCr = found.combatant.cr; + + const updatedCombatants = encounter.combatants.map((c) => + c.id === combatantId ? { ...c, cr: value } : c, + ); + + return { + encounter: { + combatants: updatedCombatants, + activeIndex: encounter.activeIndex, + roundNumber: encounter.roundNumber, + }, + events: [ + { + type: "CrSet", + combatantId, + previousCr, + newCr: value, + }, + ], + }; +} diff --git a/packages/domain/src/types.ts b/packages/domain/src/types.ts index b831c1b..0ba9a16 100644 --- a/packages/domain/src/types.ts +++ b/packages/domain/src/types.ts @@ -20,6 +20,7 @@ export interface Combatant { readonly conditions?: readonly ConditionId[]; readonly isConcentrating?: boolean; readonly creatureId?: CreatureId; + readonly cr?: string; readonly color?: string; readonly icon?: string; readonly playerCharacterId?: PlayerCharacterId; diff --git a/specs/008-encounter-difficulty/spec.md b/specs/008-encounter-difficulty/spec.md index a466c8e..7307ea8 100644 --- a/specs/008-encounter-difficulty/spec.md +++ b/specs/008-encounter-difficulty/spec.md @@ -47,7 +47,7 @@ The difficulty indicator only appears when meaningful calculation is possible. I **Acceptance Scenarios**: -1. **Given** an encounter with only custom combatants (no `creatureId`), **When** the top bar renders, **Then** no difficulty indicator is shown. +1. **Given** an encounter with only custom combatants that have no `cr` assigned, **When** the top bar renders, **Then** no difficulty indicator is shown. 2. **Given** an encounter with bestiary-linked monsters but no PC combatants, **When** the top bar renders, **Then** no difficulty indicator is shown. @@ -55,7 +55,7 @@ The difficulty indicator only appears when meaningful calculation is possible. I 4. **Given** an encounter with one leveled PC combatant and one bestiary-linked monster, **When** the last leveled PC is removed, **Then** the indicator disappears. -5. **Given** an encounter with one leveled PC combatant and one bestiary-linked monster, **When** the last bestiary-linked monster is removed (only custom combatants remain), **Then** the indicator disappears. +5. **Given** an encounter with one leveled PC combatant and one bestiary-linked monster, **When** the last bestiary-linked monster is removed and the remaining custom combatants have no `cr` assigned, **Then** the indicator disappears. --- @@ -101,12 +101,82 @@ The difficulty calculation uses the 2024 5.5e XP Budget per Character table and 3. **Given** a party with PCs at different levels (e.g., three level 3 and one level 2), **When** the budget is calculated, **Then** each PC's budget is looked up individually by level and summed (not averaged). -4. **Given** an encounter with both bestiary-linked and custom combatants, **When** the XP total is calculated, **Then** only bestiary-linked combatants contribute XP (custom combatants are excluded). +4. **Given** an encounter with bestiary-linked combatants, custom combatants with CR assigned, and custom combatants without CR, **When** the XP total is calculated, **Then** bestiary-linked combatants contribute XP from their creature CR and custom combatants with CR contribute XP from their assigned CR. Custom combatants without CR are excluded. 5. **Given** a PC combatant whose player character has no level, **When** the budget is calculated, **Then** that PC is excluded from the budget (as if they are not in the party). --- +### Difficulty Breakdown + +**Story ED-5 — View difficulty breakdown details (Priority: P2)** + +The game master taps the difficulty indicator to open a breakdown panel. The panel shows the party XP budget (sum of per-PC budgets with the tier thresholds), a list of all combatants that contribute XP (each showing name, CR, and XP value), and the total monster XP. This gives the GM visibility into how the difficulty tier was calculated. + +**Why this priority**: The indicator alone shows the tier but not the reasoning. The breakdown panel turns the indicator from a black box into a transparent tool the GM can act on. + +**Independent Test**: Can be tested by creating an encounter with leveled PCs and monsters, tapping the indicator, and verifying the panel displays correct budget and per-monster XP values. + +**Acceptance Scenarios**: + +1. **Given** the difficulty indicator is visible, **When** the user taps the indicator, **Then** a breakdown panel opens showing party budget, per-combatant XP contributions, and total monster XP. + +2. **Given** the breakdown panel is open, **When** the user taps outside the panel or taps a close control, **Then** the panel closes. + +3. **Given** an encounter with three leveled PCs at levels 1, 3, and 5, **When** the breakdown panel is open, **Then** the party budget section shows the summed Low, Moderate, and High thresholds for those levels. + +4. **Given** an encounter with two bestiary-linked monsters and one custom combatant with CR assigned, **When** the breakdown panel is open, **Then** all three appear in the combatant list with their name, CR, and XP value. + +5. **Given** an encounter with a custom combatant that has no CR assigned, **When** the breakdown panel is open, **Then** that combatant appears in the list as "unassigned" (no XP contribution shown). + +6. **Given** the breakdown panel is open, **When** a combatant is added or removed from the encounter, **Then** the panel content updates immediately. + +--- + +### Manual CR Assignment + +**Story ED-6 — Assign CR to a custom combatant (Priority: P2)** + +From the difficulty breakdown panel, the game master can assign a challenge rating to any custom (non-bestiary) combatant. A CR picker offers all standard 5e CR values (0, 1/8, 1/4, 1/2, 1–30). Assigning a CR immediately updates that combatant's XP contribution, the total monster XP, and the difficulty tier. + +**Why this priority**: Without CR assignment, custom combatants are invisible to the difficulty calculation. This closes the gap for GMs who don't use the bestiary. + +**Independent Test**: Can be tested by adding a custom combatant, opening the breakdown panel, assigning a CR, and verifying the XP total and difficulty tier update. + +**Acceptance Scenarios**: + +1. **Given** the breakdown panel is open and a custom combatant has no CR, **When** the user taps the "unassigned" CR area for that combatant, **Then** a CR picker appears offering values: 0, 1/8, 1/4, 1/2, 1–30. + +2. **Given** the CR picker is open for a custom combatant, **When** the user selects CR 5, **Then** the combatant's XP updates to 1,800 and the difficulty tier recalculates immediately. + +3. **Given** a custom combatant has CR 2 assigned, **When** the user taps the CR value in the breakdown panel, **Then** the CR picker opens with CR 2 pre-selected, allowing the user to change it. + +4. **Given** a custom combatant has CR 3 assigned, **When** the user selects a different CR from the picker, **Then** the XP contribution updates immediately to match the new CR. + +5. **Given** a custom combatant has CR assigned, **When** the encounter is saved and the page is reloaded, **Then** the CR assignment is restored and the difficulty calculation reflects it. + +6. **Given** a custom combatant has CR assigned, **When** the encounter is exported to JSON and re-imported, **Then** the CR assignment is preserved. + +--- + +**Story ED-7 — Bestiary CR takes precedence over manual CR (Priority: P2)** + +Bestiary-linked combatants derive their CR from the creature data. The breakdown panel shows their CR as read-only with the bestiary source name visible, making the precedence clear. The manual `cr` field on `Combatant` is ignored when `creatureId` is present. + +**Why this priority**: Without clear precedence rules, a combatant could show conflicting CRs from bestiary data and manual assignment, confusing the GM. + +**Independent Test**: Can be tested by adding a bestiary-linked combatant and verifying its CR is read-only in the breakdown panel. + +**Acceptance Scenarios**: + +1. **Given** a bestiary-linked combatant with CR 3 from creature data, **When** the breakdown panel is open, **Then** the combatant shows CR 3 as read-only with the bestiary source name visible. + +2. **Given** a bestiary-linked combatant, **When** the user views it in the breakdown panel, **Then** no CR picker is available — the CR cannot be manually overridden. + +3. **Given** a combatant that was custom but is later linked to a bestiary creature, **When** the breakdown panel is open, **Then** the CR derives from the creature data and any previously assigned manual CR is ignored. + +--- + ### Edge Cases - **All bars empty (trivial)**: When total monster XP is greater than 0 but below the Low threshold, the indicator shows three empty bars. This communicates "we can calculate, but it's trivial." @@ -114,7 +184,9 @@ The difficulty calculation uses the 2024 5.5e XP Budget per Character table and - **Mixed party levels**: PCs at different levels each contribute their own budget — the system handles heterogeneous parties correctly. - **Duplicate PC combatants**: If the same player character is added to the encounter multiple times, each copy contributes to the party budget independently (each counts as a party member). - **CR fractions**: Bestiary creatures can have fractional CRs (e.g., "1/4", "1/2"). The CR-to-XP lookup must handle these string formats. -- **Custom combatants silently excluded**: Custom combatants without `creatureId` do not appear in the XP total and are not flagged as warnings or errors. +- **Custom combatants without CR silently excluded**: Custom combatants without `creatureId` and without a manually assigned `cr` do not appear in the XP total and are not flagged as warnings or errors. They appear in the breakdown panel as "unassigned." +- **Bestiary CR overrides manual CR**: If a combatant has both `creatureId` and a manual `cr` value, the bestiary CR is used and the manual value is ignored. The breakdown panel makes this visible by showing the CR as read-only. +- **CR assignment on combatant later linked to bestiary**: If a custom combatant with a manual CR is subsequently linked to a bestiary creature, the manual CR becomes irrelevant — the creature's CR takes over. - **PCs without level silently excluded**: PC combatants whose player character has no level do not contribute to the budget and are not flagged. - **Indicator with empty encounter**: When the encounter has no combatants, the indicator is hidden (the top bar may not even render per existing behavior). - **Level field on existing player characters**: Existing player characters created before this feature will have no level. They are treated as "no level assigned" — no migration or default is needed. @@ -135,7 +207,7 @@ The system MUST contain a CR-to-XP lookup table mapping all standard 5e challeng The system MUST calculate the party's XP budget by summing the per-character budget for each PC combatant whose player character has a level assigned. PCs without a level are excluded from the sum. #### FR-004 — Monster XP total calculation -The system MUST calculate the total monster XP by summing the XP value (derived from CR) for each combatant that has a `creatureId`. Combatants without `creatureId` are excluded. +The system MUST calculate the total monster XP by summing the XP value (derived from CR) for each combatant that has a CR. For bestiary-linked combatants, CR is derived from the creature data via `creatureId`. For custom combatants, CR comes from the optional `cr` field. Combatants with neither `creatureId` nor `cr` are excluded. #### FR-005 — Difficulty tier determination The system MUST determine the encounter difficulty tier by comparing total monster XP against the party's Low, Moderate, and High thresholds. The tier is the highest threshold that the total XP meets or exceeds. If below Low, the encounter is trivial (no tier label). @@ -153,7 +225,7 @@ The indicator MUST show a tooltip on hover displaying the difficulty label (e.g. The indicator MUST update immediately when combatants are added to or removed from the encounter. #### FR-010 — Hidden when data insufficient -The indicator MUST be hidden when the encounter has no PC combatants with levels OR no bestiary-linked combatants. +The indicator MUST be hidden when the encounter has no PC combatants with levels OR no combatants with CR (neither bestiary-linked nor custom combatants with `cr` assigned). #### FR-011 — Optional level field on PlayerCharacter The `PlayerCharacter` entity MUST support an optional `level` field accepting integer values 1-20. @@ -167,6 +239,24 @@ The player character level MUST be persisted and restored across sessions, consi #### FR-014 — High is the cap When total monster XP exceeds the High threshold, the indicator MUST display the High state (three red bars). There is no tier above High. +#### FR-015 — Optional CR field on Combatant +The `Combatant` entity MUST support an optional `cr` field accepting standard 5e challenge rating strings ("0", "1/8", "1/4", "1/2", "1"–"30"). + +#### FR-016 — Tappable difficulty indicator +The difficulty indicator MUST be tappable, opening a difficulty breakdown panel. + +#### FR-017 — Breakdown panel content +The breakdown panel MUST display: the party XP budget (with Low, Moderate, High thresholds), a list of combatants showing name, CR, and XP contribution, and the total monster XP. + +#### FR-018 — CR picker for custom combatants +The breakdown panel MUST provide a CR picker for custom combatants (those without `creatureId`) offering all standard 5e CR values: 0, 1/8, 1/4, 1/2, 1–30. + +#### FR-019 — Bestiary CR precedence +When a combatant has a `creatureId`, the system MUST derive CR from the linked creature data. The manual `cr` field MUST be ignored. The breakdown panel MUST display bestiary-linked CRs as read-only with the source name visible. + +#### FR-020 — CR persistence +The `cr` field on `Combatant` MUST persist within the encounter across page reloads (via encounter storage) and MUST round-trip through JSON export/import. + ### Key Entities - **XP Budget Table**: A lookup mapping character level (1-20) to three XP thresholds (Low, Moderate, High), sourced from the 2024 5.5e DMG. @@ -174,6 +264,7 @@ When total monster XP exceeds the High threshold, the indicator MUST display the - **DifficultyTier**: An enumeration of difficulty categories: Trivial, Low, Moderate, High. - **DifficultyResult**: The output of the calculation containing the tier, total monster XP, and per-tier budget thresholds. - **PlayerCharacter.level**: An optional integer (1-20) added to the existing `PlayerCharacter` entity defined in spec 005. +- **Combatant.cr**: An optional string field on the existing `Combatant` entity, accepting standard 5e CR values. Used for manual CR assignment on custom combatants. Ignored when the combatant has a `creatureId`. --- @@ -188,6 +279,8 @@ When total monster XP exceeds the High threshold, the indicator MUST display the - **SC-005**: The difficulty calculation is a pure domain function with no I/O, consistent with the project's deterministic domain core. - **SC-006**: The domain module for difficulty calculation has zero imports from application, adapter, or UI layers. - **SC-007**: The optional level field integrates seamlessly into the existing player character create/edit workflow without disrupting existing functionality. +- **SC-008**: The difficulty breakdown panel correctly displays per-combatant XP contributions and party budget that sum to the values used for tier determination. +- **SC-009**: Custom combatants with manually assigned CR contribute correctly to the difficulty calculation, matching the same CR-to-XP mapping used for bestiary creatures. --- @@ -199,7 +292,7 @@ When total monster XP exceeds the High threshold, the indicator MUST display the - The `level` field is added to the existing `PlayerCharacter` type from spec 005. No new entity or storage mechanism is needed. - Existing player characters without a level are treated as "no level assigned" with no migration. - The difficulty indicator occupies minimal horizontal space in the top bar and does not interfere with the combatant name truncation or other controls. -- MVP baseline does not include CR assignment for custom (non-bestiary) combatants. +- The breakdown panel is the sole UI surface for manual CR assignment — there is no CR field in the combatant create/edit forms. +- MVP baseline does not include assigning combatants to party/enemy sides — all combatants with CR are counted as enemies. - MVP baseline does not include the 2014 DMG encounter multiplier mechanic or the four-tier (Easy/Medium/Hard/Deadly) system. -- MVP baseline does not include showing XP totals or budget numbers in the indicator — only the visual bars and tooltip label. - MVP baseline does not include per-combatant level overrides — level is always derived from the player character template.