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>
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||
import type {
|
||||
AnyCreature,
|
||||
CreatureId,
|
||||
PlayerCharacter,
|
||||
} from "@initiative/domain";
|
||||
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||
import {
|
||||
cleanup,
|
||||
@@ -17,6 +21,7 @@ import {
|
||||
buildCombatant,
|
||||
buildCreature,
|
||||
buildEncounter,
|
||||
buildPf2eCreature,
|
||||
} from "../../__tests__/factories/index.js";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import { useRulesEdition } from "../../hooks/use-rules-edition.js";
|
||||
@@ -52,7 +57,7 @@ const goblinCreature = buildCreature({
|
||||
function renderPanel(options: {
|
||||
encounter: ReturnType<typeof buildEncounter>;
|
||||
playerCharacters?: PlayerCharacter[];
|
||||
creatures?: Map<CreatureId, Creature>;
|
||||
creatures?: Map<CreatureId, AnyCreature>;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const adapters = createTestAdapters({
|
||||
@@ -357,4 +362,157 @@ describe("DifficultyBreakdownPanel", () => {
|
||||
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
describe("PF2e edition", () => {
|
||||
const orcWarrior = buildPf2eCreature({
|
||||
id: creatureId("pf2e:orc-warrior"),
|
||||
name: "Orc Warrior",
|
||||
level: 3,
|
||||
source: "crb",
|
||||
sourceDisplayName: "Core Rulebook",
|
||||
});
|
||||
|
||||
function pf2eEncounter() {
|
||||
return buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c-1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId1,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c-2"),
|
||||
name: "Orc Warrior",
|
||||
creatureId: orcWarrior.id,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
it("shows PF2e tier label", async () => {
|
||||
const { result: editionResult } = renderHook(() => useRulesEdition());
|
||||
editionResult.current.setEdition("pf2e");
|
||||
|
||||
try {
|
||||
renderPanel({
|
||||
encounter: pf2eEncounter(),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||
],
|
||||
creatures: new Map([[orcWarrior.id, orcWarrior]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("Encounter Difficulty:", { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
} finally {
|
||||
editionResult.current.setEdition("5.5e");
|
||||
}
|
||||
});
|
||||
|
||||
it("shows party level", async () => {
|
||||
const { result: editionResult } = renderHook(() => useRulesEdition());
|
||||
editionResult.current.setEdition("pf2e");
|
||||
|
||||
try {
|
||||
renderPanel({
|
||||
encounter: pf2eEncounter(),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||
],
|
||||
creatures: new Map([[orcWarrior.id, orcWarrior]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("Party Level: 5", { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
} finally {
|
||||
editionResult.current.setEdition("5.5e");
|
||||
}
|
||||
});
|
||||
|
||||
it("shows creature level and level difference", async () => {
|
||||
const { result: editionResult } = renderHook(() => useRulesEdition());
|
||||
editionResult.current.setEdition("pf2e");
|
||||
|
||||
try {
|
||||
renderPanel({
|
||||
encounter: pf2eEncounter(),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||
],
|
||||
creatures: new Map([[orcWarrior.id, orcWarrior]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Orc Warrior level 3, party level 5 → diff −2
|
||||
expect(
|
||||
screen.getByText("Lv 3 (-2)", { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
} finally {
|
||||
editionResult.current.setEdition("5.5e");
|
||||
}
|
||||
});
|
||||
|
||||
it("shows 5 thresholds with short labels", async () => {
|
||||
const { result: editionResult } = renderHook(() => useRulesEdition());
|
||||
editionResult.current.setEdition("pf2e");
|
||||
|
||||
try {
|
||||
renderPanel({
|
||||
encounter: pf2eEncounter(),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||
],
|
||||
creatures: new Map([[orcWarrior.id, orcWarrior]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("Triv:", { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Low:", { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Mod:", { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Sev:", { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Ext:", { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
} finally {
|
||||
editionResult.current.setEdition("5.5e");
|
||||
}
|
||||
});
|
||||
|
||||
it("shows Net Creature XP label in PF2e mode", async () => {
|
||||
const { result: editionResult } = renderHook(() => useRulesEdition());
|
||||
editionResult.current.setEdition("pf2e");
|
||||
|
||||
try {
|
||||
renderPanel({
|
||||
encounter: pf2eEncounter(),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||
],
|
||||
creatures: new Map([[orcWarrior.id, orcWarrior]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Net Creature XP")).toBeInTheDocument();
|
||||
});
|
||||
} finally {
|
||||
editionResult.current.setEdition("5.5e");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
DifficultyIndicator,
|
||||
TIER_LABELS_5_5E,
|
||||
TIER_LABELS_2014,
|
||||
TIER_LABELS_PF2E,
|
||||
} from "../difficulty-indicator.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
@@ -23,6 +24,7 @@ function makeResult(tier: DifficultyResult["tier"]): DifficultyResult {
|
||||
encounterMultiplier: undefined,
|
||||
adjustedXp: undefined,
|
||||
partySizeAdjusted: undefined,
|
||||
partyLevel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -125,4 +127,64 @@ describe("DifficultyIndicator", () => {
|
||||
const element = container.querySelector("[role='img']");
|
||||
expect(element?.tagName).toBe("BUTTON");
|
||||
});
|
||||
|
||||
it("renders 4 bars when barCount is 4", () => {
|
||||
const { container } = render(
|
||||
<DifficultyIndicator
|
||||
result={makeResult(2)}
|
||||
labels={TIER_LABELS_PF2E}
|
||||
barCount={4}
|
||||
/>,
|
||||
);
|
||||
const bars = container.querySelectorAll("[class*='rounded-sm']");
|
||||
expect(bars).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("shows 0 filled bars for tier 0 with 4 bars", () => {
|
||||
const { container } = render(
|
||||
<DifficultyIndicator
|
||||
result={makeResult(0)}
|
||||
labels={TIER_LABELS_PF2E}
|
||||
barCount={4}
|
||||
/>,
|
||||
);
|
||||
const bars = container.querySelectorAll("[class*='rounded-sm']");
|
||||
for (const bar of bars) {
|
||||
expect(bar.className).toContain("bg-muted");
|
||||
}
|
||||
});
|
||||
|
||||
it("shows correct PF2e tooltip for Severe tier", () => {
|
||||
render(
|
||||
<DifficultyIndicator
|
||||
result={makeResult(3)}
|
||||
labels={TIER_LABELS_PF2E}
|
||||
barCount={4}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("img", { name: "Severe encounter difficulty" }),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows correct PF2e tooltip for Extreme tier", () => {
|
||||
render(
|
||||
<DifficultyIndicator
|
||||
result={makeResult(4)}
|
||||
labels={TIER_LABELS_PF2E}
|
||||
barCount={4}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("img", { name: "Extreme encounter difficulty" }),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("D&D indicator still renders 3 bars (no regression)", () => {
|
||||
const { container } = render(
|
||||
<DifficultyIndicator result={makeResult(3)} labels={TIER_LABELS_5_5E} />,
|
||||
);
|
||||
const bars = container.querySelectorAll("[class*='rounded-sm']");
|
||||
expect(bars).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,12 +19,21 @@ const TIER_LABEL_MAP: Partial<
|
||||
1: { label: "Low", color: "text-green-500" },
|
||||
2: { label: "Moderate", color: "text-yellow-500" },
|
||||
3: { label: "High", color: "text-red-500" },
|
||||
4: { label: "High", color: "text-red-500" },
|
||||
},
|
||||
"5e": {
|
||||
0: { label: "Easy", color: "text-muted-foreground" },
|
||||
1: { label: "Medium", color: "text-green-500" },
|
||||
2: { label: "Hard", color: "text-yellow-500" },
|
||||
3: { label: "Deadly", color: "text-red-500" },
|
||||
4: { label: "Deadly", color: "text-red-500" },
|
||||
},
|
||||
pf2e: {
|
||||
0: { label: "Trivial", color: "text-muted-foreground" },
|
||||
1: { label: "Low", color: "text-green-500" },
|
||||
2: { label: "Moderate", color: "text-yellow-500" },
|
||||
3: { label: "Severe", color: "text-orange-500" },
|
||||
4: { label: "Extreme", color: "text-red-500" },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -32,6 +41,9 @@ const TIER_LABEL_MAP: Partial<
|
||||
const SHORT_LABELS: Readonly<Record<string, string>> = {
|
||||
Moderate: "Mod",
|
||||
Medium: "Med",
|
||||
Trivial: "Triv",
|
||||
Severe: "Sev",
|
||||
Extreme: "Ext",
|
||||
};
|
||||
|
||||
function shortLabel(label: string): string {
|
||||
@@ -107,6 +119,54 @@ function NpcRow({
|
||||
);
|
||||
}
|
||||
|
||||
function Pf2eNpcRow({
|
||||
entry,
|
||||
onToggleSide,
|
||||
}: {
|
||||
entry: BreakdownCombatant;
|
||||
onToggleSide: () => void;
|
||||
}) {
|
||||
const isParty = entry.side === "party";
|
||||
const targetSide = isParty ? "enemy" : "party";
|
||||
|
||||
let xpDisplay: string;
|
||||
if (entry.xp == null) {
|
||||
xpDisplay = "\u2014";
|
||||
} else if (isParty) {
|
||||
xpDisplay = `\u2212${formatXp(entry.xp)}`;
|
||||
} else {
|
||||
xpDisplay = formatXp(entry.xp);
|
||||
}
|
||||
|
||||
let levelDisplay: string;
|
||||
if (entry.creatureLevel === undefined) {
|
||||
levelDisplay = "\u2014";
|
||||
} else if (entry.levelDifference === undefined) {
|
||||
levelDisplay = `Lv ${entry.creatureLevel}`;
|
||||
} else {
|
||||
const sign = entry.levelDifference >= 0 ? "+" : "";
|
||||
levelDisplay = `Lv ${entry.creatureLevel} (${sign}${entry.levelDifference})`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="col-span-4 grid grid-cols-subgrid items-center text-xs">
|
||||
<span className="min-w-0 truncate" title={entry.combatant.name}>
|
||||
{entry.combatant.name}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onToggleSide}
|
||||
aria-label={`Move ${entry.combatant.name} to ${targetSide} side`}
|
||||
>
|
||||
<ArrowLeftRight className="h-3 w-3" />
|
||||
</Button>
|
||||
<span className="text-muted-foreground">{levelDisplay}</span>
|
||||
<span className="text-right tabular-nums">{xpDisplay}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, onClose);
|
||||
@@ -128,6 +188,8 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
const isPC = (entry: BreakdownCombatant) =>
|
||||
entry.combatant.playerCharacterId != null;
|
||||
|
||||
const CreatureRow = edition === "pf2e" ? Pf2eNpcRow : NpcRow;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -142,6 +204,9 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
<div className="mb-1 text-muted-foreground text-xs">
|
||||
Party Budget ({breakdown.pcCount}{" "}
|
||||
{breakdown.pcCount === 1 ? "PC" : "PCs"})
|
||||
{breakdown.partyLevel !== undefined && (
|
||||
<> · Party Level: {breakdown.partyLevel}</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3 text-xs">
|
||||
{breakdown.thresholds.map((t) => (
|
||||
@@ -166,7 +231,7 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
isPC(entry) ? (
|
||||
<PcRow key={entry.combatant.id} entry={entry} />
|
||||
) : (
|
||||
<NpcRow
|
||||
<CreatureRow
|
||||
key={entry.combatant.id}
|
||||
entry={entry}
|
||||
onToggleSide={() => handleToggle(entry)}
|
||||
@@ -186,7 +251,7 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
isPC(entry) ? (
|
||||
<PcRow key={entry.combatant.id} entry={entry} />
|
||||
) : (
|
||||
<NpcRow
|
||||
<CreatureRow
|
||||
key={entry.combatant.id}
|
||||
entry={entry}
|
||||
onToggleSide={() => handleToggle(entry)}
|
||||
@@ -218,7 +283,9 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex justify-between border-border border-t pt-2 font-medium text-xs">
|
||||
<span>Net Monster XP</span>
|
||||
<span>
|
||||
{edition === "pf2e" ? "Net Creature XP" : "Net Monster XP"}
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{formatXp(breakdown.totalMonsterXp)}
|
||||
</span>
|
||||
|
||||
@@ -6,6 +6,7 @@ export const TIER_LABELS_5_5E: Record<DifficultyTier, string> = {
|
||||
1: "Low",
|
||||
2: "Moderate",
|
||||
3: "High",
|
||||
4: "High",
|
||||
};
|
||||
|
||||
export const TIER_LABELS_2014: Record<DifficultyTier, string> = {
|
||||
@@ -13,30 +14,49 @@ export const TIER_LABELS_2014: Record<DifficultyTier, string> = {
|
||||
1: "Medium",
|
||||
2: "Hard",
|
||||
3: "Deadly",
|
||||
4: "Deadly",
|
||||
};
|
||||
|
||||
const TIER_COLORS: Record<
|
||||
DifficultyTier,
|
||||
{ filledBars: number; color: string }
|
||||
> = {
|
||||
0: { filledBars: 0, color: "" },
|
||||
1: { filledBars: 1, color: "bg-green-500" },
|
||||
2: { filledBars: 2, color: "bg-yellow-500" },
|
||||
3: { filledBars: 3, color: "bg-red-500" },
|
||||
export const TIER_LABELS_PF2E: Record<DifficultyTier, string> = {
|
||||
0: "Trivial",
|
||||
1: "Low",
|
||||
2: "Moderate",
|
||||
3: "Severe",
|
||||
4: "Extreme",
|
||||
};
|
||||
|
||||
const BAR_HEIGHTS = ["h-2", "h-3", "h-4"] as const;
|
||||
const BAR_HEIGHTS_3 = ["h-2", "h-3", "h-4"] as const;
|
||||
const BAR_HEIGHTS_4 = ["h-1.5", "h-2", "h-3", "h-4"] as const;
|
||||
|
||||
/** Color for the Nth filled bar (1-indexed) in 4-bar mode. */
|
||||
const BAR_COLORS: Record<number, string> = {
|
||||
1: "bg-green-500",
|
||||
2: "bg-yellow-500",
|
||||
3: "bg-orange-500",
|
||||
4: "bg-red-500",
|
||||
};
|
||||
|
||||
/** For 3-bar mode, bar 3 uses red directly (skip orange). */
|
||||
const BAR_COLORS_3: Record<number, string> = {
|
||||
1: "bg-green-500",
|
||||
2: "bg-yellow-500",
|
||||
3: "bg-red-500",
|
||||
};
|
||||
|
||||
export function DifficultyIndicator({
|
||||
result,
|
||||
labels,
|
||||
barCount = 3,
|
||||
onClick,
|
||||
}: {
|
||||
result: DifficultyResult;
|
||||
labels: Record<DifficultyTier, string>;
|
||||
barCount?: 3 | 4;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const config = TIER_COLORS[result.tier];
|
||||
const barHeights = barCount === 4 ? BAR_HEIGHTS_4 : BAR_HEIGHTS_3;
|
||||
const colorMap = barCount === 4 ? BAR_COLORS : BAR_COLORS_3;
|
||||
const filledBars = result.tier;
|
||||
const label = labels[result.tier];
|
||||
const tooltip = `${label} encounter difficulty`;
|
||||
|
||||
@@ -54,13 +74,13 @@ export function DifficultyIndicator({
|
||||
onClick={onClick}
|
||||
type={onClick ? "button" : undefined}
|
||||
>
|
||||
{BAR_HEIGHTS.map((height, i) => (
|
||||
{barHeights.map((height, i) => (
|
||||
<div
|
||||
key={height}
|
||||
className={cn(
|
||||
"w-1 rounded-sm",
|
||||
height,
|
||||
i < config.filledBars ? config.color : "bg-muted",
|
||||
i < filledBars ? colorMap[i + 1] : "bg-muted",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
DifficultyIndicator,
|
||||
TIER_LABELS_5_5E,
|
||||
TIER_LABELS_2014,
|
||||
TIER_LABELS_PF2E,
|
||||
} from "./difficulty-indicator.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { ConfirmButton } from "./ui/confirm-button.js";
|
||||
@@ -26,7 +27,13 @@ export function TurnNavigation() {
|
||||
|
||||
const difficulty = useDifficulty();
|
||||
const { edition } = useRulesEditionContext();
|
||||
const tierLabels = edition === "5e" ? TIER_LABELS_2014 : TIER_LABELS_5_5E;
|
||||
const TIER_LABELS_BY_EDITION = {
|
||||
pf2e: TIER_LABELS_PF2E,
|
||||
"5e": TIER_LABELS_2014,
|
||||
"5.5e": TIER_LABELS_5_5E,
|
||||
} as const;
|
||||
const tierLabels = TIER_LABELS_BY_EDITION[edition];
|
||||
const barCount = edition === "pf2e" ? 4 : 3;
|
||||
const [showBreakdown, setShowBreakdown] = useState(false);
|
||||
const hasCombatants = encounter.combatants.length > 0;
|
||||
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
||||
@@ -87,6 +94,7 @@ export function TurnNavigation() {
|
||||
<DifficultyIndicator
|
||||
result={difficulty}
|
||||
labels={tierLabels}
|
||||
barCount={barCount}
|
||||
onClick={() => setShowBreakdown((prev) => !prev)}
|
||||
/>
|
||||
{showBreakdown ? (
|
||||
|
||||
Reference in New Issue
Block a user