Support the 2014 DMG encounter difficulty as an alternative to the 5.5e system behind the existing Rules Edition toggle. The 2014 system uses Easy/Medium/Hard/Deadly thresholds, an encounter multiplier based on monster count, and party size adjustment (×0.5–×5 range). - Extract RulesEdition to its own domain module - Refactor DifficultyTier to abstract numeric values (0–3) - Restructure DifficultyResult with thresholds array - Add 2014 XP thresholds table and encounter multiplier logic - Wire edition from context into difficulty hooks - Edition-aware labels in indicator and breakdown panel - Show multiplier, adjusted XP, and party size note for 2014 - Rename settings label from "Conditions" to "Rules Edition" - Update spec 008 with issue #23 requirements Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
349 lines
9.3 KiB
TypeScript
349 lines
9.3 KiB
TypeScript
// @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<typeof buildEncounter>;
|
|
playerCharacters?: PlayerCharacter[];
|
|
creatures?: Map<CreatureId, Creature>;
|
|
}) {
|
|
const adapters = createTestAdapters({
|
|
encounter: options.encounter,
|
|
playerCharacters: options.playerCharacters ?? [],
|
|
creatures: options.creatures,
|
|
});
|
|
return ({ children }: { children: ReactNode }) => (
|
|
<AllProviders adapters={adapters}>{children}</AllProviders>
|
|
);
|
|
}
|
|
|
|
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");
|
|
}
|
|
});
|
|
});
|