Add combatant side assignment for encounter difficulty
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:
@@ -9,6 +9,7 @@ import { useMemo } from "react";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||
import { resolveSide } from "./use-difficulty.js";
|
||||
|
||||
export interface BreakdownCombatant {
|
||||
readonly combatant: Combatant;
|
||||
@@ -16,6 +17,8 @@ export interface BreakdownCombatant {
|
||||
readonly xp: number | null;
|
||||
readonly source: string | null;
|
||||
readonly editable: boolean;
|
||||
readonly side: "party" | "enemy";
|
||||
readonly level: number | undefined;
|
||||
}
|
||||
|
||||
interface DifficultyBreakdown {
|
||||
@@ -27,7 +30,8 @@ interface DifficultyBreakdown {
|
||||
readonly high: number;
|
||||
};
|
||||
readonly pcCount: number;
|
||||
readonly combatants: readonly BreakdownCombatant[];
|
||||
readonly partyCombatants: readonly BreakdownCombatant[];
|
||||
readonly enemyCombatants: readonly BreakdownCombatant[];
|
||||
}
|
||||
|
||||
export function useDifficultyBreakdown(): DifficultyBreakdown | null {
|
||||
@@ -36,105 +40,129 @@ export function useDifficultyBreakdown(): DifficultyBreakdown | null {
|
||||
const { getCreature } = useBestiaryContext();
|
||||
|
||||
return useMemo(() => {
|
||||
const partyLevels = derivePartyLevels(encounter.combatants, characters);
|
||||
const { entries, crs } = classifyCombatants(
|
||||
encounter.combatants,
|
||||
getCreature,
|
||||
const { partyCombatants, enemyCombatants, descriptors, pcCount } =
|
||||
classifyCombatants(encounter.combatants, characters, getCreature);
|
||||
|
||||
const hasPartyLevel = descriptors.some(
|
||||
(d) => d.side === "party" && d.level !== undefined,
|
||||
);
|
||||
const hasCr = descriptors.some((d) => d.cr !== undefined);
|
||||
|
||||
if (partyLevels.length === 0 || crs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (!hasPartyLevel || !hasCr) return null;
|
||||
|
||||
const result = calculateEncounterDifficulty(partyLevels, crs);
|
||||
const result = calculateEncounterDifficulty(descriptors);
|
||||
|
||||
return {
|
||||
...result,
|
||||
pcCount: partyLevels.length,
|
||||
combatants: entries,
|
||||
pcCount,
|
||||
partyCombatants,
|
||||
enemyCombatants,
|
||||
};
|
||||
}, [encounter.combatants, characters, getCreature]);
|
||||
}
|
||||
|
||||
function classifyBestiaryCombatant(
|
||||
type CreatureInfo = {
|
||||
cr: string;
|
||||
source: string;
|
||||
sourceDisplayName: string;
|
||||
};
|
||||
|
||||
function buildBreakdownEntry(
|
||||
c: Combatant,
|
||||
getCreature: (
|
||||
id: CreatureId,
|
||||
) => { cr: string; source: string; sourceDisplayName: string } | undefined,
|
||||
): { entry: BreakdownCombatant; cr: string | null } {
|
||||
const creature = c.creatureId ? getCreature(c.creatureId) : undefined;
|
||||
if (creature) {
|
||||
side: "party" | "enemy",
|
||||
level: number | undefined,
|
||||
creature: CreatureInfo | undefined,
|
||||
): BreakdownCombatant {
|
||||
if (c.playerCharacterId) {
|
||||
return {
|
||||
entry: {
|
||||
combatant: c,
|
||||
cr: creature.cr,
|
||||
xp: crToXp(creature.cr),
|
||||
source: creature.sourceDisplayName ?? creature.source,
|
||||
editable: false,
|
||||
},
|
||||
cr: creature.cr,
|
||||
};
|
||||
}
|
||||
return {
|
||||
entry: {
|
||||
combatant: c,
|
||||
cr: null,
|
||||
xp: null,
|
||||
source: null,
|
||||
editable: false,
|
||||
},
|
||||
side,
|
||||
level,
|
||||
};
|
||||
}
|
||||
if (creature) {
|
||||
return {
|
||||
combatant: c,
|
||||
cr: creature.cr,
|
||||
xp: crToXp(creature.cr),
|
||||
source: creature.sourceDisplayName ?? creature.source,
|
||||
editable: false,
|
||||
side,
|
||||
level: undefined,
|
||||
};
|
||||
}
|
||||
if (c.cr) {
|
||||
return {
|
||||
combatant: c,
|
||||
cr: c.cr,
|
||||
xp: crToXp(c.cr),
|
||||
source: null,
|
||||
editable: true,
|
||||
side,
|
||||
level: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
combatant: c,
|
||||
cr: null,
|
||||
xp: null,
|
||||
source: null,
|
||||
editable: !c.creatureId,
|
||||
side,
|
||||
level: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveLevel(
|
||||
c: Combatant,
|
||||
characters: readonly PlayerCharacter[],
|
||||
): number | undefined {
|
||||
if (!c.playerCharacterId) return undefined;
|
||||
return characters.find((p) => p.id === c.playerCharacterId)?.level;
|
||||
}
|
||||
|
||||
function resolveCr(
|
||||
c: Combatant,
|
||||
getCreature: (id: CreatureId) => CreatureInfo | undefined,
|
||||
): { cr: string | null; creature: CreatureInfo | undefined } {
|
||||
const creature = c.creatureId ? getCreature(c.creatureId) : undefined;
|
||||
const cr = creature ? creature.cr : (c.cr ?? null);
|
||||
return { cr, creature };
|
||||
}
|
||||
|
||||
function classifyCombatants(
|
||||
combatants: readonly Combatant[],
|
||||
getCreature: (
|
||||
id: CreatureId,
|
||||
) => { cr: string; source: string; sourceDisplayName: string } | undefined,
|
||||
): { entries: BreakdownCombatant[]; crs: string[] } {
|
||||
const entries: BreakdownCombatant[] = [];
|
||||
const crs: string[] = [];
|
||||
|
||||
for (const c of combatants) {
|
||||
if (c.playerCharacterId) continue;
|
||||
|
||||
if (c.creatureId) {
|
||||
const { entry, cr } = classifyBestiaryCombatant(c, getCreature);
|
||||
entries.push(entry);
|
||||
if (cr) crs.push(cr);
|
||||
} else if (c.cr) {
|
||||
crs.push(c.cr);
|
||||
entries.push({
|
||||
combatant: c,
|
||||
cr: c.cr,
|
||||
xp: crToXp(c.cr),
|
||||
source: null,
|
||||
editable: true,
|
||||
});
|
||||
} else {
|
||||
entries.push({
|
||||
combatant: c,
|
||||
cr: null,
|
||||
xp: null,
|
||||
source: null,
|
||||
editable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { entries, crs };
|
||||
}
|
||||
|
||||
function derivePartyLevels(
|
||||
combatants: readonly Combatant[],
|
||||
characters: readonly PlayerCharacter[],
|
||||
): number[] {
|
||||
const levels: number[] = [];
|
||||
getCreature: (id: CreatureId) => CreatureInfo | undefined,
|
||||
) {
|
||||
const partyCombatants: BreakdownCombatant[] = [];
|
||||
const enemyCombatants: BreakdownCombatant[] = [];
|
||||
const descriptors: {
|
||||
level?: number;
|
||||
cr?: string;
|
||||
side: "party" | "enemy";
|
||||
}[] = [];
|
||||
let pcCount = 0;
|
||||
|
||||
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);
|
||||
const side = resolveSide(c);
|
||||
const level = resolveLevel(c, characters);
|
||||
if (level !== undefined) pcCount++;
|
||||
|
||||
const { cr, creature } = resolveCr(c, getCreature);
|
||||
|
||||
if (level !== undefined || cr != null) {
|
||||
descriptors.push({ level, cr: cr ?? undefined, side });
|
||||
}
|
||||
|
||||
const entry = buildBreakdownEntry(c, side, level, creature);
|
||||
const target = side === "party" ? partyCombatants : enemyCombatants;
|
||||
target.push(entry);
|
||||
}
|
||||
return levels;
|
||||
|
||||
return { partyCombatants, enemyCombatants, descriptors, pcCount };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user