Add 2014 DMG encounter difficulty calculation
Support the 2014 DMG encounter difficulty as an alternative to the 5.5e system behind the existing Rules Edition toggle. The 2014 system uses Easy/Medium/Hard/Deadly thresholds, an encounter multiplier based on monster count, and party size adjustment (×0.5–×5 range). - Extract RulesEdition to its own domain module - Refactor DifficultyTier to abstract numeric values (0–3) - Restructure DifficultyResult with thresholds array - Add 2014 XP thresholds table and encounter multiplier logic - Wire edition from context into difficulty hooks - Edition-aware labels in indicator and breakdown panel - Show multiplier, adjusted XP, and party size note for 2014 - Rename settings label from "Conditions" to "Rules Edition" - Update spec 008 with issue #23 requirements Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import type { DifficultyTier } from "@initiative/domain";
|
||||
import type { DifficultyTier, RulesEdition } from "@initiative/domain";
|
||||
import { ArrowLeftRight } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||
import {
|
||||
type BreakdownCombatant,
|
||||
@@ -10,13 +11,34 @@ import {
|
||||
import { CrPicker } from "./cr-picker.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
|
||||
const TIER_LABELS: Record<DifficultyTier, { label: string; color: string }> = {
|
||||
trivial: { label: "Trivial", color: "text-muted-foreground" },
|
||||
low: { label: "Low", color: "text-green-500" },
|
||||
moderate: { label: "Moderate", color: "text-yellow-500" },
|
||||
high: { label: "High", color: "text-red-500" },
|
||||
const TIER_LABEL_MAP: Record<
|
||||
RulesEdition,
|
||||
Record<DifficultyTier, { label: string; color: string }>
|
||||
> = {
|
||||
"5.5e": {
|
||||
0: { label: "Trivial", color: "text-muted-foreground" },
|
||||
1: { label: "Low", color: "text-green-500" },
|
||||
2: { label: "Moderate", color: "text-yellow-500" },
|
||||
3: { label: "High", color: "text-red-500" },
|
||||
},
|
||||
"5e": {
|
||||
0: { label: "Easy", color: "text-muted-foreground" },
|
||||
1: { label: "Medium", color: "text-green-500" },
|
||||
2: { label: "Hard", color: "text-yellow-500" },
|
||||
3: { label: "Deadly", color: "text-red-500" },
|
||||
},
|
||||
};
|
||||
|
||||
/** Short labels for threshold display where horizontal space is limited. */
|
||||
const SHORT_LABELS: Readonly<Record<string, string>> = {
|
||||
Moderate: "Mod",
|
||||
Medium: "Med",
|
||||
};
|
||||
|
||||
function shortLabel(label: string): string {
|
||||
return SHORT_LABELS[label] ?? label;
|
||||
}
|
||||
|
||||
function formatXp(xp: number): string {
|
||||
return xp.toLocaleString();
|
||||
}
|
||||
@@ -90,11 +112,12 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, onClose);
|
||||
const { setSide } = useEncounterContext();
|
||||
const { edition } = useRulesEditionContext();
|
||||
|
||||
const breakdown = useDifficultyBreakdown();
|
||||
if (!breakdown) return null;
|
||||
|
||||
const tierConfig = TIER_LABELS[breakdown.tier];
|
||||
const tierConfig = TIER_LABEL_MAP[edition][breakdown.tier];
|
||||
|
||||
const handleToggle = (entry: BreakdownCombatant) => {
|
||||
const newSide = entry.side === "party" ? "enemy" : "party";
|
||||
@@ -120,15 +143,11 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
{breakdown.pcCount === 1 ? "PC" : "PCs"})
|
||||
</div>
|
||||
<div className="flex gap-3 text-xs">
|
||||
<span>
|
||||
Low: <strong>{formatXp(breakdown.partyBudget.low)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Mod: <strong>{formatXp(breakdown.partyBudget.moderate)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
High: <strong>{formatXp(breakdown.partyBudget.high)}</strong>
|
||||
</span>
|
||||
{breakdown.thresholds.map((t) => (
|
||||
<span key={t.label}>
|
||||
{shortLabel(t.label)}: <strong>{formatXp(t.value)}</strong>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -176,12 +195,34 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex justify-between border-border border-t pt-2 font-medium text-xs">
|
||||
<span>Net Monster XP</span>
|
||||
<span className="tabular-nums">
|
||||
{formatXp(breakdown.totalMonsterXp)}
|
||||
</span>
|
||||
</div>
|
||||
{breakdown.encounterMultiplier !== undefined &&
|
||||
breakdown.adjustedXp !== undefined ? (
|
||||
<div className="mt-2 border-border border-t pt-2">
|
||||
<div className="flex justify-between font-medium text-xs">
|
||||
<span>Monster XP</span>
|
||||
<span className="tabular-nums">
|
||||
{formatXp(breakdown.totalMonsterXp)}{" "}
|
||||
<span className="text-muted-foreground">
|
||||
×{breakdown.encounterMultiplier}
|
||||
</span>{" "}
|
||||
= {formatXp(breakdown.adjustedXp)}
|
||||
</span>
|
||||
</div>
|
||||
{breakdown.partySizeAdjusted === true ? (
|
||||
<div className="mt-0.5 text-muted-foreground text-xs italic">
|
||||
Adjusted for {breakdown.pcCount}{" "}
|
||||
{breakdown.pcCount === 1 ? "PC" : "PCs"}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex justify-between border-border border-t pt-2 font-medium text-xs">
|
||||
<span>Net Monster XP</span>
|
||||
<span className="tabular-nums">
|
||||
{formatXp(breakdown.totalMonsterXp)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user