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

@@ -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,
};
}