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

@@ -1,5 +1,6 @@
import type {
Combatant,
CombatantDescriptor,
CreatureId,
DifficultyResult,
PlayerCharacter,
@@ -10,33 +11,31 @@ import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { useEncounterContext } from "../contexts/encounter-context.js";
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
function derivePartyLevels(
combatants: readonly Combatant[],
characters: readonly PlayerCharacter[],
): number[] {
const levels: number[] = [];
for (const c of combatants) {
if (!c.playerCharacterId) continue;
const pc = characters.find((p) => p.id === c.playerCharacterId);
if (pc?.level !== undefined) levels.push(pc.level);
}
return levels;
export function resolveSide(c: Combatant): "party" | "enemy" {
if (c.side) return c.side;
return c.playerCharacterId ? "party" : "enemy";
}
function deriveMonsterCrs(
function buildDescriptors(
combatants: readonly Combatant[],
characters: readonly PlayerCharacter[],
getCreature: (id: CreatureId) => { cr: string } | undefined,
): string[] {
const crs: string[] = [];
): CombatantDescriptor[] {
const descriptors: CombatantDescriptor[] = [];
for (const c of combatants) {
if (c.creatureId) {
const creature = getCreature(c.creatureId);
if (creature) crs.push(creature.cr);
} else if (c.cr) {
crs.push(c.cr);
const side = resolveSide(c);
const level = c.playerCharacterId
? characters.find((p) => p.id === c.playerCharacterId)?.level
: undefined;
const cr = c.creatureId
? getCreature(c.creatureId)?.cr
: (c.cr ?? undefined);
if (level !== undefined || cr !== undefined) {
descriptors.push({ level, cr, side });
}
}
return crs;
return descriptors;
}
export function useDifficulty(): DifficultyResult | null {
@@ -45,13 +44,19 @@ export function useDifficulty(): DifficultyResult | null {
const { getCreature } = useBestiaryContext();
return useMemo(() => {
const partyLevels = derivePartyLevels(encounter.combatants, characters);
const monsterCrs = deriveMonsterCrs(encounter.combatants, getCreature);
const descriptors = buildDescriptors(
encounter.combatants,
characters,
getCreature,
);
if (partyLevels.length === 0 || monsterCrs.length === 0) {
return null;
}
const hasPartyLevel = descriptors.some(
(d) => d.side === "party" && d.level !== undefined,
);
const hasCr = descriptors.some((d) => d.cr !== undefined);
return calculateEncounterDifficulty(partyLevels, monsterCrs);
if (!hasPartyLevel || !hasCr) return null;
return calculateEncounterDifficulty(descriptors);
}, [encounter.combatants, characters, getCreature]);
}