// @vitest-environment jsdom import type { AnyCreature, 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, buildPf2eCreature, } from "../../__tests__/factories/index.js"; import { AllProviders } from "../../__tests__/test-providers.js"; import { useDifficulty } from "../use-difficulty.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 pcId2 = playerCharacterId("pc-2"); const crId1 = creatureId("srd:goblin"); const goblinCreature = buildCreature({ id: crId1, 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", () => { it("returns difficulty result for leveled PCs and bestiary monsters", async () => { const wrapper = makeWrapper({ encounter: buildEncounter({ combatants: [ buildCombatant({ id: combatantId("c1"), name: "Hero", playerCharacterId: pcId1, }), buildCombatant({ id: combatantId("c2"), name: "Goblin", creatureId: crId1, }), ], }), playerCharacters: [ { id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 }, ], creatures: new Map([[crId1, goblinCreature]]), }); const { result } = renderHook(() => useDifficulty(), { wrapper }); await waitFor(() => { expect(result.current).not.toBeNull(); expect(result.current?.tier).toBe(1); expect(result.current?.totalMonsterXp).toBe(50); }); }); describe("returns null when data is insufficient (ED-2)", () => { it("returns null when encounter has no combatants", () => { const wrapper = makeWrapper({ encounter: buildEncounter({ combatants: [] }), }); const { result } = renderHook(() => useDifficulty(), { wrapper }); expect(result.current).toBeNull(); }); it("returns null when only custom combatants (no creatureId)", () => { const wrapper = makeWrapper({ encounter: buildEncounter({ combatants: [ buildCombatant({ id: combatantId("c1"), name: "Custom", playerCharacterId: pcId1, }), ], }), playerCharacters: [ { id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 }, ], }); const { result } = renderHook(() => useDifficulty(), { wrapper }); expect(result.current).toBeNull(); }); it("returns null when bestiary monsters present but no PC combatants", () => { const wrapper = makeWrapper({ encounter: buildEncounter({ combatants: [ buildCombatant({ id: combatantId("c1"), name: "Goblin", creatureId: crId1, }), ], }), creatures: new Map([[crId1, goblinCreature]]), }); const { result } = renderHook(() => useDifficulty(), { wrapper }); expect(result.current).toBeNull(); }); it("returns null when PC combatants have no level", () => { const wrapper = makeWrapper({ encounter: buildEncounter({ combatants: [ buildCombatant({ id: combatantId("c1"), name: "Hero", playerCharacterId: pcId1, }), buildCombatant({ id: combatantId("c2"), name: "Goblin", creatureId: crId1, }), ], }), playerCharacters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30 }], creatures: new Map([[crId1, goblinCreature]]), }); const { result } = renderHook(() => useDifficulty(), { wrapper }); expect(result.current).toBeNull(); }); it("returns null when PC combatant references unknown player character", () => { const wrapper = makeWrapper({ encounter: buildEncounter({ combatants: [ buildCombatant({ id: combatantId("c1"), name: "Hero", playerCharacterId: pcId2, }), buildCombatant({ id: combatantId("c2"), name: "Goblin", creatureId: crId1, }), ], }), playerCharacters: [ { id: pcId1, name: "Other", ac: 15, maxHp: 30, level: 5 }, ], creatures: new Map([[crId1, goblinCreature]]), }); const { result } = renderHook(() => useDifficulty(), { wrapper }); expect(result.current).toBeNull(); }); }); it("handles mixed combatants: only leveled PCs and CR-bearing monsters contribute", async () => { const wrapper = makeWrapper({ encounter: buildEncounter({ combatants: [ buildCombatant({ id: combatantId("c1"), name: "Leveled", playerCharacterId: pcId1, }), buildCombatant({ id: combatantId("c2"), name: "No Level", playerCharacterId: pcId2, }), buildCombatant({ id: combatantId("c3"), name: "Goblin", creatureId: crId1, }), buildCombatant({ id: combatantId("c4"), name: "Custom Monster", }), ], }), playerCharacters: [ { id: pcId1, name: "Leveled", ac: 15, maxHp: 30, level: 1 }, { id: pcId2, name: "No Level", ac: 12, maxHp: 20 }, ], creatures: new Map([[crId1, goblinCreature]]), }); const { result } = renderHook(() => useDifficulty(), { wrapper }); await waitFor(() => { expect(result.current).not.toBeNull(); // 1 level-1 PC: budget low=50, mod=75, high=100 // CR 1/4 = 50 XP -> low (50 >= 50) expect(result.current?.tier).toBe(1); expect(result.current?.totalMonsterXp).toBe(50); expect(result.current?.thresholds[0].value).toBe(50); }); }); it("includes duplicate PC combatants in budget", async () => { const wrapper = makeWrapper({ encounter: buildEncounter({ combatants: [ buildCombatant({ id: combatantId("c1"), name: "Hero 1", playerCharacterId: pcId1, }), buildCombatant({ id: combatantId("c2"), name: "Hero 2", playerCharacterId: pcId1, }), buildCombatant({ id: combatantId("c3"), name: "Goblin", creatureId: crId1, }), ], }), playerCharacters: [ { id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 }, ], creatures: new Map([[crId1, goblinCreature]]), }); const { result } = renderHook(() => useDifficulty(), { wrapper }); await waitFor(() => { expect(result.current).not.toBeNull(); // 2x level 1: budget low=100 expect(result.current?.thresholds[0].value).toBe(100); }); }); it("combatant toggled to party side subtracts XP", async () => { const bugbear = buildCreature({ id: creatureId("srd:bugbear"), name: "Bugbear", cr: "1", }); const wrapper = makeWrapper({ encounter: buildEncounter({ combatants: [ buildCombatant({ id: combatantId("c1"), name: "Hero", playerCharacterId: pcId1, }), buildCombatant({ id: combatantId("c2"), name: "Allied Guard", creatureId: bugbear.id, side: "party", }), buildCombatant({ id: combatantId("c3"), name: "Thug", cr: "1", }), ], }), playerCharacters: [ { id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 }, ], creatures: new Map([[bugbear.id, bugbear]]), }); const { result } = renderHook(() => useDifficulty(), { wrapper }); await waitFor(() => { expect(result.current).not.toBeNull(); // Thug CR 1 = 200 XP, Allied Guard CR 1 = 200 XP subtracted, net = 0 expect(result.current?.totalMonsterXp).toBe(0); expect(result.current?.tier).toBe(0); }); }); it("default side resolution: PC -> party, non-PC -> enemy", async () => { const wrapper = makeWrapper({ encounter: buildEncounter({ combatants: [ buildCombatant({ id: combatantId("c1"), name: "Hero", playerCharacterId: pcId1, }), buildCombatant({ id: combatantId("c2"), name: "Goblin", creatureId: crId1, }), ], }), playerCharacters: [ { id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 3 }, ], creatures: new Map([[crId1, goblinCreature]]), }); const { result } = renderHook(() => useDifficulty(), { wrapper }); await waitFor(() => { expect(result.current).not.toBeNull(); // Level 3 budget: low=150, mod=225, high=400 // CR 1/4 = 50 XP -> trivial expect(result.current?.thresholds[0].value).toBe(150); expect(result.current?.totalMonsterXp).toBe(50); expect(result.current?.tier).toBe(0); }); }); it("returns 2014 difficulty when edition is 5e", async () => { const wrapper = makeWrapper({ encounter: buildEncounter({ combatants: [ buildCombatant({ id: combatantId("c1"), name: "Hero", playerCharacterId: pcId1, }), buildCombatant({ id: combatantId("c2"), name: "Goblin", creatureId: crId1, }), ], }), playerCharacters: [ { id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 }, ], creatures: new Map([[crId1, goblinCreature]]), }); // Set edition via the hook's external store const { result: editionResult } = renderHook(() => useRulesEdition(), { wrapper, }); editionResult.current.setEdition("5e"); try { const { result } = renderHook(() => useDifficulty(), { wrapper }); await waitFor(() => { expect(result.current).not.toBeNull(); // 2014: 4 thresholds with Easy/Medium/Hard/Deadly labels expect(result.current?.thresholds).toHaveLength(4); expect(result.current?.thresholds[0].label).toBe("Easy"); // CR 1/4 = 50 XP, 1 PC (<3) shifts x1 → x1.5, adjusted = 75 expect(result.current?.encounterMultiplier).toBe(1.5); expect(result.current?.adjustedXp).toBe(75); }); } finally { editionResult.current.setEdition("5.5e"); } }); it("custom combatant with CR on party side subtracts XP", async () => { const wrapper = makeWrapper({ encounter: buildEncounter({ combatants: [ buildCombatant({ id: combatantId("c1"), name: "Hero", playerCharacterId: pcId1, }), buildCombatant({ id: combatantId("c2"), name: "Ally", cr: "2", side: "party", }), buildCombatant({ id: combatantId("c3"), name: "Goblin", creatureId: crId1, }), ], }), playerCharacters: [ { id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 }, ], creatures: new Map([[crId1, goblinCreature]]), }); const { result } = renderHook(() => useDifficulty(), { wrapper }); await waitFor(() => { expect(result.current).not.toBeNull(); // CR 1/4 = 50 XP enemy, CR 2 = 450 XP ally subtracted, net = 0 (floored) expect(result.current?.totalMonsterXp).toBe(0); }); }); describe("PF2e edition", () => { const pf2eCreature = buildPf2eCreature({ id: creatureId("pf2e:orc-warrior"), name: "Orc Warrior", level: 5, }); function makePf2eWrapper(options: { encounter: ReturnType; playerCharacters?: PlayerCharacter[]; creatures?: Map; }) { const adapters = createTestAdapters({ encounter: options.encounter, playerCharacters: options.playerCharacters ?? [], creatures: options.creatures, }); return ({ children }: { children: ReactNode }) => ( {children} ); } it("returns result for PF2e with leveled PCs and PF2e creatures", async () => { const wrapper = makePf2eWrapper({ encounter: buildEncounter({ combatants: [ buildCombatant({ id: combatantId("c1"), name: "Hero", playerCharacterId: pcId1, }), buildCombatant({ id: combatantId("c2"), name: "Orc Warrior", creatureId: pf2eCreature.id, }), ], }), playerCharacters: [ { id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 }, ], creatures: new Map([[pf2eCreature.id, pf2eCreature]]), }); const { result: editionResult } = renderHook(() => useRulesEdition(), { wrapper, }); editionResult.current.setEdition("pf2e"); try { const { result } = renderHook(() => useDifficulty(), { wrapper }); await waitFor(() => { expect(result.current).not.toBeNull(); // Creature level 5, party level 5 → diff 0 → 40 XP expect(result.current?.totalMonsterXp).toBe(40); expect(result.current?.partyLevel).toBe(5); }); } finally { editionResult.current.setEdition("5.5e"); } }); it("returns null for PF2e when no PF2e creatures with level", () => { const wrapper = makePf2eWrapper({ encounter: buildEncounter({ combatants: [ buildCombatant({ id: combatantId("c1"), name: "Hero", playerCharacterId: pcId1, }), buildCombatant({ id: combatantId("c2"), name: "Custom Monster", }), ], }), playerCharacters: [ { id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 }, ], }); const { result: editionResult } = renderHook(() => useRulesEdition(), { wrapper, }); editionResult.current.setEdition("pf2e"); try { const { result } = renderHook(() => useDifficulty(), { wrapper }); expect(result.current).toBeNull(); } finally { editionResult.current.setEdition("5.5e"); } }); it("returns null for PF2e when no PCs with level", () => { const wrapper = makePf2eWrapper({ encounter: buildEncounter({ combatants: [ buildCombatant({ id: combatantId("c1"), name: "Hero", playerCharacterId: pcId1, }), buildCombatant({ id: combatantId("c2"), name: "Orc Warrior", creatureId: pf2eCreature.id, }), ], }), playerCharacters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30 }], creatures: new Map([[pf2eCreature.id, pf2eCreature]]), }); const { result: editionResult } = renderHook(() => useRulesEdition(), { wrapper, }); editionResult.current.setEdition("pf2e"); try { const { result } = renderHook(() => useDifficulty(), { wrapper }); expect(result.current).toBeNull(); } finally { editionResult.current.setEdition("5.5e"); } }); }); });