Add combatant side assignment for encounter difficulty
All checks were successful
CI / check (push) Successful in 2m18s
CI / build-image (push) Successful in 17s

Combatants can now be assigned to party or enemy side via a toggle
in the difficulty breakdown panel. Party-side NPCs subtract their XP
from the encounter total, letting allied NPCs reduce difficulty.
PCs default to party, non-PCs to enemy — users who don't use sides
see no change. Side persists across reload and export/import.

Closes #22

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-04-03 14:15:12 +02:00
parent 30e7ed4121
commit 94e1806112
23 changed files with 1359 additions and 455 deletions

View File

@@ -82,43 +82,61 @@ export function crToXp(cr: string): number {
return CR_TO_XP[cr] ?? 0;
}
export interface CombatantDescriptor {
readonly level?: number;
readonly cr?: string;
readonly side: "party" | "enemy";
}
function determineTier(
xp: number,
low: number,
moderate: number,
high: number,
): DifficultyTier {
if (xp >= high) return "high";
if (xp >= moderate) return "moderate";
if (xp >= low) return "low";
return "trivial";
}
/**
* Calculates encounter difficulty from party levels and monster CRs.
* Both arrays should be pre-filtered (only PCs with levels, only bestiary-linked monsters).
* Calculates encounter difficulty from combatant descriptors.
* Party-side combatants with level contribute to the budget.
* Enemy-side combatants with CR add XP; party-side with CR subtract XP (floored at 0).
*/
export function calculateEncounterDifficulty(
partyLevels: readonly number[],
monsterCrs: readonly string[],
combatants: readonly CombatantDescriptor[],
): DifficultyResult {
let budgetLow = 0;
let budgetModerate = 0;
let budgetHigh = 0;
let totalMonsterXp = 0;
for (const level of partyLevels) {
const budget = XP_BUDGET_PER_CHARACTER[level];
if (budget) {
budgetLow += budget.low;
budgetModerate += budget.moderate;
budgetHigh += budget.high;
for (const c of combatants) {
if (c.level !== undefined && c.side === "party") {
const budget = XP_BUDGET_PER_CHARACTER[c.level];
if (budget) {
budgetLow += budget.low;
budgetModerate += budget.moderate;
budgetHigh += budget.high;
}
}
if (c.cr !== undefined) {
const xp = crToXp(c.cr);
if (c.side === "enemy") {
totalMonsterXp += xp;
} else {
totalMonsterXp -= xp;
}
}
}
let totalMonsterXp = 0;
for (const cr of monsterCrs) {
totalMonsterXp += crToXp(cr);
}
let tier: DifficultyTier = "trivial";
if (totalMonsterXp >= budgetHigh) {
tier = "high";
} else if (totalMonsterXp >= budgetModerate) {
tier = "moderate";
} else if (totalMonsterXp >= budgetLow) {
tier = "low";
}
totalMonsterXp = Math.max(0, totalMonsterXp);
return {
tier,
tier: determineTier(totalMonsterXp, budgetLow, budgetModerate, budgetHigh),
totalMonsterXp,
partyBudget: {
low: budgetLow,