Add encounter difficulty indicator (5.5e XP budget)
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:
@@ -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";
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
39
apps/web/src/components/difficulty-indicator.tsx
Normal file
39
apps/web/src/components/difficulty-indicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user