Add encounter difficulty indicator (5.5e XP budget)
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
+16 -1
View File
@@ -20,6 +20,7 @@ interface EditFields {
readonly maxHp?: number;
readonly color?: string | null;
readonly icon?: string | null;
readonly level?: number | null;
}
function validateFields(fields: EditFields): DomainError | null {
@@ -72,6 +73,17 @@ function validateFields(fields: EditFields): DomainError | null {
message: `Invalid icon: ${fields.icon}`,
};
}
if (
fields.level !== undefined &&
fields.level !== null &&
(!Number.isInteger(fields.level) || fields.level < 1 || fields.level > 20)
) {
return {
kind: "domain-error",
code: "invalid-level",
message: "Level must be an integer between 1 and 20",
};
}
return null;
}
@@ -92,6 +104,8 @@ function applyFields(
fields.icon === undefined
? existing.icon
: ((fields.icon as PlayerCharacter["icon"]) ?? undefined),
level:
fields.level === undefined ? existing.level : (fields.level ?? undefined),
};
}
@@ -120,7 +134,8 @@ export function editPlayerCharacter(
updated.ac === existing.ac &&
updated.maxHp === existing.maxHp &&
updated.color === existing.color &&
updated.icon === existing.icon
updated.icon === existing.icon &&
updated.level === existing.level
) {
return {
kind: "domain-error",