Files
initiative/apps/web/src/hooks/__tests__/use-difficulty.test.tsx
Lukas d9fb271607
All checks were successful
CI / check (push) Successful in 2m39s
CI / build-image (push) Successful in 18s
Add PF2e encounter difficulty calculation with 5-tier budget system
Implements PF2e encounter difficulty alongside the existing D&D system.
PF2e uses creature level vs party level to derive XP, compares against
5-tier budgets (Trivial/Low/Moderate/Severe/Extreme), and adjusts
thresholds for party size. The indicator shows 4 bars in PF2e mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 15:24:18 +02:00

563 lines
15 KiB
TypeScript

// @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<typeof buildEncounter>;
playerCharacters?: PlayerCharacter[];
creatures?: Map<CreatureId, AnyCreature>;
}) {
const adapters = createTestAdapters({
encounter: options.encounter,
playerCharacters: options.playerCharacters ?? [],
creatures: options.creatures,
});
return ({ children }: { children: ReactNode }) => (
<AllProviders adapters={adapters}>{children}</AllProviders>
);
}
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<typeof buildEncounter>;
playerCharacters?: PlayerCharacter[];
creatures?: Map<CreatureId, AnyCreature>;
}) {
const adapters = createTestAdapters({
encounter: options.encounter,
playerCharacters: options.playerCharacters ?? [],
creatures: options.creatures,
});
return ({ children }: { children: ReactNode }) => (
<AllProviders adapters={adapters}>{children}</AllProviders>
);
}
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");
}
});
});
});