Add combatant side assignment for encounter difficulty
Combatants can now be assigned to party or enemy side via a toggle in the difficulty breakdown panel. Party-side NPCs subtract their XP from the encounter total, letting allied NPCs reduce difficulty. PCs default to party, non-PCs to enemy — users who don't use sides see no change. Side persists across reload and export/import. Closes #22 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
381
apps/web/src/hooks/__tests__/use-difficulty.test.tsx
Normal file
381
apps/web/src/hooks/__tests__/use-difficulty.test.tsx
Normal file
@@ -0,0 +1,381 @@
|
||||
// @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 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, Creature>;
|
||||
}) {
|
||||
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("low");
|
||||
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("low");
|
||||
expect(result.current?.totalMonsterXp).toBe(50);
|
||||
expect(result.current?.partyBudget.low).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?.partyBudget.low).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("trivial");
|
||||
});
|
||||
});
|
||||
|
||||
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?.partyBudget.low).toBe(150);
|
||||
expect(result.current?.totalMonsterXp).toBe(50);
|
||||
expect(result.current?.tier).toBe("trivial");
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user