// @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"; import { useRulesEdition } from "../use-rules-edition.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 split by side", 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); // PC in party column expect(breakdown?.partyCombatants).toHaveLength(1); expect(breakdown?.partyCombatants[0].combatant.name).toBe("Hero"); expect(breakdown?.partyCombatants[0].side).toBe("party"); expect(breakdown?.partyCombatants[0].level).toBe(5); // Enemies: goblin, thug, bandit expect(breakdown?.enemyCombatants).toHaveLength(3); const goblin = breakdown?.enemyCombatants[0]; expect(goblin?.cr).toBe("1/4"); expect(goblin?.xp).toBe(50); expect(goblin?.source).toBe("SRD"); expect(goblin?.editable).toBe(false); expect(goblin?.side).toBe("enemy"); const thug = breakdown?.enemyCombatants[1]; expect(thug?.cr).toBe("2"); expect(thug?.xp).toBe(450); expect(thug?.source).toBeNull(); expect(thug?.editable).toBe(true); const bandit = breakdown?.enemyCombatants[2]; expect(bandit?.cr).toBeNull(); expect(bandit?.xp).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, }); const breakdown = result.current; expect(breakdown).not.toBeNull(); const ghost = breakdown?.enemyCombatants[0]; expect(ghost?.cr).toBeNull(); expect(ghost?.xp).toBeNull(); expect(ghost?.editable).toBe(false); }); it("PC combatants appear in partyCombatants with level", 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?.partyCombatants).toHaveLength(1); expect(result.current?.partyCombatants[0].combatant.name).toBe("Hero"); expect(result.current?.partyCombatants[0].level).toBe(1); expect(result.current?.partyCombatants[0].side).toBe("party"); }); }); it("combatant with explicit side override is placed correctly", () => { const wrapper = makeWrapper({ encounter: buildEncounter({ combatants: [ buildCombatant({ id: combatantId("c-1"), name: "Hero", playerCharacterId: pcId1, }), buildCombatant({ id: combatantId("c-2"), name: "Allied Guard", creatureId: goblinCreature.id, side: "party", }), buildCombatant({ id: combatantId("c-3"), name: "Thug", cr: "1", }), ], }), playerCharacters: [ { id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 }, ], creatures: new Map([[goblinCreature.id, goblinCreature]]), }); const { result } = renderHook(() => useDifficultyBreakdown(), { wrapper, }); const breakdown = result.current; expect(breakdown).not.toBeNull(); // Allied Guard should be in party column expect(breakdown?.partyCombatants).toHaveLength(2); expect(breakdown?.partyCombatants[1].combatant.name).toBe("Allied Guard"); expect(breakdown?.partyCombatants[1].side).toBe("party"); // Thug in enemy column expect(breakdown?.enemyCombatants).toHaveLength(1); expect(breakdown?.enemyCombatants[0].combatant.name).toBe("Thug"); }); it("exposes encounterMultiplier and adjustedXp for 5e edition", 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: "Thug", cr: "1", }), ], }), playerCharacters: [ { id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 }, ], creatures: new Map([[goblinCreature.id, goblinCreature]]), }); const { result: editionResult } = renderHook(() => useRulesEdition(), { wrapper, }); editionResult.current.setEdition("5e"); try { const { result } = renderHook(() => useDifficultyBreakdown(), { wrapper, }); await waitFor(() => { const breakdown = result.current; expect(breakdown).not.toBeNull(); // 2 enemy monsters, 1 PC (<3) → base x1.5, shift up → x2 expect(breakdown?.encounterMultiplier).toBe(2); // CR 1/4 (50) + CR 1 (200) = 250, x2 = 500 expect(breakdown?.totalMonsterXp).toBe(250); expect(breakdown?.adjustedXp).toBe(500); expect(breakdown?.thresholds).toHaveLength(4); }); } finally { editionResult.current.setEdition("5.5e"); } }); });