Add encounter difficulty indicator (5.5e XP budget)
All checks were successful
CI / check (push) Successful in 1m13s
CI / build-image (push) Successful in 16s

Live 3-bar difficulty indicator in the top bar showing encounter
difficulty (Trivial/Low/Moderate/High) based on the 2024 5.5e XP
budget system. Automatically derived from PC levels and bestiary
creature CRs.

- Add optional level field (1-20) to PlayerCharacter
- Add CR-to-XP and XP Budget per Character lookup tables in domain
- Add calculateEncounterDifficulty pure function
- Add DifficultyIndicator component with color-coded bars and tooltip
- Add useDifficulty hook composing encounter, PC, and bestiary contexts
- Indicator hidden when no PCs with levels or no bestiary-linked monsters
- Level field in PC create/edit forms, persisted in storage

Closes #18

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-27 22:55:48 +01:00
parent 36122b500b
commit ef76b9c90b
32 changed files with 1648 additions and 11 deletions

View File

@@ -6,11 +6,19 @@ import { combatantId } from "@initiative/domain";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
// Mock the context module
// Mock the context modules
vi.mock("../../contexts/encounter-context.js", () => ({
useEncounterContext: vi.fn(),
}));
vi.mock("../../contexts/player-characters-context.js", () => ({
usePlayerCharactersContext: vi.fn().mockReturnValue({ characters: [] }),
}));
vi.mock("../../contexts/bestiary-context.js", () => ({
useBestiaryContext: vi.fn().mockReturnValue({ getCreature: () => undefined }),
}));
import { useEncounterContext } from "../../contexts/encounter-context.js";
import { TurnNavigation } from "../turn-navigation.js";

View File

@@ -7,6 +7,13 @@ import { Button } from "./ui/button";
import { Dialog } from "./ui/dialog";
import { Input } from "./ui/input";
function parseLevel(value: string): number | undefined | "invalid" {
if (value.trim() === "") return undefined;
const n = Number.parseInt(value, 10);
if (Number.isNaN(n) || n < 1 || n > 20) return "invalid";
return n;
}
interface CreatePlayerModalProps {
open: boolean;
onClose: () => void;
@@ -16,6 +23,7 @@ interface CreatePlayerModalProps {
maxHp: number,
color: string | undefined,
icon: string | undefined,
level: number | undefined,
) => void;
playerCharacter?: PlayerCharacter;
}
@@ -31,6 +39,7 @@ export function CreatePlayerModal({
const [maxHp, setMaxHp] = useState("10");
const [color, setColor] = useState("blue");
const [icon, setIcon] = useState("sword");
const [level, setLevel] = useState("");
const [error, setError] = useState("");
const isEdit = !!playerCharacter;
@@ -43,12 +52,18 @@ export function CreatePlayerModal({
setMaxHp(String(playerCharacter.maxHp));
setColor(playerCharacter.color ?? "");
setIcon(playerCharacter.icon ?? "");
setLevel(
playerCharacter.level === undefined
? ""
: String(playerCharacter.level),
);
} else {
setName("");
setAc("10");
setMaxHp("10");
setColor("");
setIcon("");
setLevel("");
}
setError("");
}
@@ -71,7 +86,19 @@ export function CreatePlayerModal({
setError("Max HP must be at least 1");
return;
}
onSave(trimmed, acNum, hpNum, color || undefined, icon || undefined);
const levelNum = parseLevel(level);
if (levelNum === "invalid") {
setError("Level must be between 1 and 20");
return;
}
onSave(
trimmed,
acNum,
hpNum,
color || undefined,
icon || undefined,
levelNum,
);
onClose();
};
@@ -135,6 +162,20 @@ export function CreatePlayerModal({
className="text-center"
/>
</div>
<div className="flex-1">
<span className="mb-1 block text-muted-foreground text-sm">
Level
</span>
<Input
type="text"
inputMode="numeric"
value={level}
onChange={(e) => setLevel(e.target.value)}
placeholder="1-20"
aria-label="Level"
className="text-center"
/>
</div>
</div>
<div>

View File

@@ -0,0 +1,39 @@
import type { DifficultyResult, DifficultyTier } from "@initiative/domain";
import { cn } from "../lib/utils.js";
const TIER_CONFIG: Record<
DifficultyTier,
{ filledBars: number; color: string; label: string }
> = {
trivial: { filledBars: 0, color: "", label: "Trivial" },
low: { filledBars: 1, color: "bg-green-500", label: "Low" },
moderate: { filledBars: 2, color: "bg-yellow-500", label: "Moderate" },
high: { filledBars: 3, color: "bg-red-500", label: "High" },
};
const BAR_HEIGHTS = ["h-2", "h-3", "h-4"] as const;
export function DifficultyIndicator({ result }: { result: DifficultyResult }) {
const config = TIER_CONFIG[result.tier];
const tooltip = `${config.label} encounter difficulty`;
return (
<div
className="flex items-end gap-0.5"
title={tooltip}
role="img"
aria-label={tooltip}
>
{BAR_HEIGHTS.map((height, i) => (
<div
key={height}
className={cn(
"w-1 rounded-sm",
height,
i < config.filledBars ? config.color : "bg-muted",
)}
/>
))}
</div>
);
}

View File

@@ -35,7 +35,7 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
setEditingPlayer(undefined);
setManagementOpen(true);
}}
onSave={(name, ac, maxHp, color, icon) => {
onSave={(name, ac, maxHp, color, icon, level) => {
if (editingPlayer) {
editCharacter(editingPlayer.id, {
name,
@@ -43,9 +43,10 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
maxHp,
color: color ?? null,
icon: icon ?? null,
level: level ?? null,
});
} else {
createCharacter(name, ac, maxHp, color, icon);
createCharacter(name, ac, maxHp, color, icon, level);
}
}}
playerCharacter={editingPlayer}

View File

@@ -68,6 +68,11 @@ export function PlayerManagement({
<span className="text-muted-foreground text-xs tabular-nums">
HP {pc.maxHp}
</span>
{pc.level !== undefined && (
<span className="text-muted-foreground text-xs tabular-nums">
Lv {pc.level}
</span>
)}
<Button
variant="ghost"
size="icon-sm"

View File

@@ -1,5 +1,7 @@
import { Redo2, StepBack, StepForward, Trash2, Undo2 } from "lucide-react";
import { useEncounterContext } from "../contexts/encounter-context.js";
import { useDifficulty } from "../hooks/use-difficulty.js";
import { DifficultyIndicator } from "./difficulty-indicator.js";
import { Button } from "./ui/button.js";
import { ConfirmButton } from "./ui/confirm-button.js";
@@ -15,6 +17,7 @@ export function TurnNavigation() {
canRedo,
} = useEncounterContext();
const difficulty = useDifficulty();
const hasCombatants = encounter.combatants.length > 0;
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
const activeCombatant = encounter.combatants[encounter.activeIndex];
@@ -66,6 +69,7 @@ export function TurnNavigation() {
) : (
<span className="text-muted-foreground">No combatants</span>
)}
{difficulty && <DifficultyIndicator result={difficulty} />}
</div>
<div className="flex flex-shrink-0 items-center gap-3">

View File

@@ -0,0 +1,220 @@
// @vitest-environment jsdom
import type {
Combatant,
CreatureId,
Encounter,
PlayerCharacter,
} from "@initiative/domain";
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../contexts/encounter-context.js", () => ({
useEncounterContext: vi.fn(),
}));
vi.mock("../../contexts/player-characters-context.js", () => ({
usePlayerCharactersContext: vi.fn(),
}));
vi.mock("../../contexts/bestiary-context.js", () => ({
useBestiaryContext: vi.fn(),
}));
import { useBestiaryContext } from "../../contexts/bestiary-context.js";
import { useEncounterContext } from "../../contexts/encounter-context.js";
import { usePlayerCharactersContext } from "../../contexts/player-characters-context.js";
import { useDifficulty } from "../use-difficulty.js";
const mockEncounterContext = vi.mocked(useEncounterContext);
const mockPlayerCharactersContext = vi.mocked(usePlayerCharactersContext);
const mockBestiaryContext = vi.mocked(useBestiaryContext);
const pcId1 = playerCharacterId("pc-1");
const pcId2 = playerCharacterId("pc-2");
const crId1 = creatureId("creature-1");
const _crId2 = creatureId("creature-2");
function setup(options: {
combatants: Combatant[];
characters: PlayerCharacter[];
creatures: Map<CreatureId, { cr: string }>;
}) {
const encounter = {
combatants: options.combatants,
activeIndex: 0,
roundNumber: 1,
} as Encounter;
mockEncounterContext.mockReturnValue({
encounter,
} as ReturnType<typeof useEncounterContext>);
mockPlayerCharactersContext.mockReturnValue({
characters: options.characters,
} as ReturnType<typeof usePlayerCharactersContext>);
mockBestiaryContext.mockReturnValue({
getCreature: (id: CreatureId) => options.creatures.get(id),
} as ReturnType<typeof useBestiaryContext>);
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("useDifficulty", () => {
it("returns difficulty result for leveled PCs and bestiary monsters", () => {
setup({
combatants: [
{ id: combatantId("c1"), name: "Hero", playerCharacterId: pcId1 },
{ id: combatantId("c2"), name: "Goblin", creatureId: crId1 },
],
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 }],
creatures: new Map([[crId1, { cr: "1/4" }]]),
});
const { result } = renderHook(() => useDifficulty());
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", () => {
setup({ combatants: [], characters: [], creatures: new Map() });
const { result } = renderHook(() => useDifficulty());
expect(result.current).toBeNull();
});
it("returns null when only custom combatants (no creatureId)", () => {
setup({
combatants: [
{
id: combatantId("c1"),
name: "Custom",
playerCharacterId: pcId1,
},
],
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 }],
creatures: new Map(),
});
const { result } = renderHook(() => useDifficulty());
expect(result.current).toBeNull();
});
it("returns null when bestiary monsters present but no PC combatants", () => {
setup({
combatants: [
{ id: combatantId("c1"), name: "Goblin", creatureId: crId1 },
],
characters: [],
creatures: new Map([[crId1, { cr: "1" }]]),
});
const { result } = renderHook(() => useDifficulty());
expect(result.current).toBeNull();
});
it("returns null when PC combatants have no level", () => {
setup({
combatants: [
{
id: combatantId("c1"),
name: "Hero",
playerCharacterId: pcId1,
},
{ id: combatantId("c2"), name: "Goblin", creatureId: crId1 },
],
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30 }],
creatures: new Map([[crId1, { cr: "1" }]]),
});
const { result } = renderHook(() => useDifficulty());
expect(result.current).toBeNull();
});
it("returns null when PC combatant references unknown player character", () => {
setup({
combatants: [
{
id: combatantId("c1"),
name: "Hero",
playerCharacterId: pcId2,
},
{ id: combatantId("c2"), name: "Goblin", creatureId: crId1 },
],
characters: [{ id: pcId1, name: "Other", ac: 15, maxHp: 30, level: 5 }],
creatures: new Map([[crId1, { cr: "1" }]]),
});
const { result } = renderHook(() => useDifficulty());
expect(result.current).toBeNull();
});
});
it("handles mixed combatants: only leveled PCs and bestiary monsters contribute", () => {
// Party: one leveled PC, one without level (excluded)
// Monsters: one bestiary creature, one custom (excluded)
setup({
combatants: [
{
id: combatantId("c1"),
name: "Leveled",
playerCharacterId: pcId1,
},
{
id: combatantId("c2"),
name: "No Level",
playerCharacterId: pcId2,
},
{ id: combatantId("c3"), name: "Goblin", creatureId: crId1 },
{ id: combatantId("c4"), name: "Custom Monster" },
],
characters: [
{ id: pcId1, name: "Leveled", ac: 15, maxHp: 30, level: 1 },
{ id: pcId2, name: "No Level", ac: 12, maxHp: 20 },
],
creatures: new Map([[crId1, { cr: "1" }]]),
});
const { result } = renderHook(() => useDifficulty());
expect(result.current).not.toBeNull();
// 1 level-1 PC: budget low=50, mod=75, high=100
// 1 CR 1 monster: 200 XP → high (200 >= 100)
expect(result.current?.tier).toBe("high");
expect(result.current?.totalMonsterXp).toBe(200);
expect(result.current?.partyBudget.low).toBe(50);
});
it("includes duplicate PC combatants in budget", () => {
// Same PC added twice → counts twice
setup({
combatants: [
{
id: combatantId("c1"),
name: "Hero 1",
playerCharacterId: pcId1,
},
{
id: combatantId("c2"),
name: "Hero 2",
playerCharacterId: pcId1,
},
{ id: combatantId("c3"), name: "Goblin", creatureId: crId1 },
],
characters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 1 }],
creatures: new Map([[crId1, { cr: "1/4" }]]),
});
const { result } = renderHook(() => useDifficulty());
expect(result.current).not.toBeNull();
// 2x level 1: budget low=100
expect(result.current?.partyBudget.low).toBe(100);
});
});

View File

@@ -42,7 +42,14 @@ describe("usePlayerCharacters", () => {
const { result } = renderHook(() => usePlayerCharacters());
act(() => {
result.current.createCharacter("Vex", 15, 28, undefined, undefined);
result.current.createCharacter(
"Vex",
15,
28,
undefined,
undefined,
undefined,
);
});
expect(result.current.characters).toHaveLength(1);
@@ -57,7 +64,14 @@ describe("usePlayerCharacters", () => {
let error: unknown;
act(() => {
error = result.current.createCharacter("", 15, 28, undefined, undefined);
error = result.current.createCharacter(
"",
15,
28,
undefined,
undefined,
undefined,
);
});
expect(error).toMatchObject({ kind: "domain-error" });
@@ -68,7 +82,14 @@ describe("usePlayerCharacters", () => {
const { result } = renderHook(() => usePlayerCharacters());
act(() => {
result.current.createCharacter("Vex", 15, 28, undefined, undefined);
result.current.createCharacter(
"Vex",
15,
28,
undefined,
undefined,
undefined,
);
});
const id = result.current.characters[0].id;
@@ -85,7 +106,14 @@ describe("usePlayerCharacters", () => {
const { result } = renderHook(() => usePlayerCharacters());
act(() => {
result.current.createCharacter("Vex", 15, 28, undefined, undefined);
result.current.createCharacter(
"Vex",
15,
28,
undefined,
undefined,
undefined,
);
});
const id = result.current.characters[0].id;

View File

@@ -0,0 +1,54 @@
import type {
Combatant,
CreatureId,
DifficultyResult,
PlayerCharacter,
} from "@initiative/domain";
import { calculateEncounterDifficulty } from "@initiative/domain";
import { useMemo } from "react";
import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { useEncounterContext } from "../contexts/encounter-context.js";
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
function derivePartyLevels(
combatants: readonly Combatant[],
characters: readonly PlayerCharacter[],
): number[] {
const levels: number[] = [];
for (const c of combatants) {
if (!c.playerCharacterId) continue;
const pc = characters.find((p) => p.id === c.playerCharacterId);
if (pc?.level !== undefined) levels.push(pc.level);
}
return levels;
}
function deriveMonsterCrs(
combatants: readonly Combatant[],
getCreature: (id: CreatureId) => { cr: string } | undefined,
): string[] {
const crs: string[] = [];
for (const c of combatants) {
if (!c.creatureId) continue;
const creature = getCreature(c.creatureId);
if (creature) crs.push(creature.cr);
}
return crs;
}
export function useDifficulty(): DifficultyResult | null {
const { encounter } = useEncounterContext();
const { characters } = usePlayerCharactersContext();
const { getCreature } = useBestiaryContext();
return useMemo(() => {
const partyLevels = derivePartyLevels(encounter.combatants, characters);
const monsterCrs = deriveMonsterCrs(encounter.combatants, getCreature);
if (partyLevels.length === 0 || monsterCrs.length === 0) {
return null;
}
return calculateEncounterDifficulty(partyLevels, monsterCrs);
}, [encounter.combatants, characters, getCreature]);
}

View File

@@ -28,6 +28,7 @@ interface EditFields {
readonly maxHp?: number;
readonly color?: string | null;
readonly icon?: string | null;
readonly level?: number | null;
}
export function usePlayerCharacters() {
@@ -57,6 +58,7 @@ export function usePlayerCharacters() {
maxHp: number,
color: string | undefined,
icon: string | undefined,
level: number | undefined,
) => {
const id = generatePcId();
const result = createPlayerCharacterUseCase(
@@ -67,6 +69,7 @@ export function usePlayerCharacters() {
maxHp,
color,
icon,
level,
);
if (isDomainError(result)) {
return result;

View File

@@ -186,6 +186,38 @@ describe("player-character-storage", () => {
expect(loadPlayerCharacters()).toEqual([]);
});
it("preserves level through save/load round-trip", () => {
const pc = makePC({ level: 5 });
savePlayerCharacters([pc]);
const loaded = loadPlayerCharacters();
expect(loaded[0].level).toBe(5);
});
it("preserves undefined level through save/load round-trip", () => {
const pc = makePC();
savePlayerCharacters([pc]);
const loaded = loadPlayerCharacters();
expect(loaded[0].level).toBeUndefined();
});
it("discards character with invalid level", () => {
mockStorage.setItem(
STORAGE_KEY,
JSON.stringify([
{
id: "pc-1",
name: "Test",
ac: 10,
maxHp: 50,
color: "blue",
icon: "sword",
level: 25,
},
]),
);
expect(loadPlayerCharacters()).toEqual([]);
});
it("keeps valid characters and discards invalid ones", () => {
mockStorage.setItem(
STORAGE_KEY,

View File

@@ -44,6 +44,14 @@ export function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
return null;
if (!isValidOptionalMember(entry.color, VALID_PLAYER_COLORS)) return null;
if (!isValidOptionalMember(entry.icon, VALID_PLAYER_ICONS)) return null;
if (
entry.level !== undefined &&
(typeof entry.level !== "number" ||
!Number.isInteger(entry.level) ||
entry.level < 1 ||
entry.level > 20)
)
return null;
return {
id: playerCharacterId(entry.id),
@@ -52,6 +60,7 @@ export function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
maxHp: entry.maxHp,
color: entry.color as PlayerCharacter["color"],
icon: entry.icon as PlayerCharacter["icon"],
level: entry.level,
};
}