Implement issue #21: custom combatants can now have a challenge rating assigned via a new breakdown panel, opened by tapping the difficulty indicator. Bestiary-linked combatants show read-only CR with source name; custom combatants get a CR picker with all standard 5e values. CR persists across reloads and round-trips through JSON export/import. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
174 lines
4.6 KiB
TypeScript
174 lines
4.6 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 { 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<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("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();
|
|
});
|
|
});
|