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

@@ -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>