Implement issue #21: custom combatants can now have a challenge rating assigned via a new breakdown panel, opened by tapping the difficulty indicator. Bestiary-linked combatants show read-only CR with source name; custom combatants get a CR picker with all standard 5e values. CR persists across reloads and round-trips through JSON export/import. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
141 lines
3.4 KiB
TypeScript
141 lines
3.4 KiB
TypeScript
import type {
|
|
Combatant,
|
|
CreatureId,
|
|
DifficultyTier,
|
|
PlayerCharacter,
|
|
} from "@initiative/domain";
|
|
import { calculateEncounterDifficulty, crToXp } from "@initiative/domain";
|
|
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";
|
|
|
|
export interface BreakdownCombatant {
|
|
readonly combatant: Combatant;
|
|
readonly cr: string | null;
|
|
readonly xp: number | null;
|
|
readonly source: string | null;
|
|
readonly editable: boolean;
|
|
}
|
|
|
|
interface DifficultyBreakdown {
|
|
readonly tier: DifficultyTier;
|
|
readonly totalMonsterXp: number;
|
|
readonly partyBudget: {
|
|
readonly low: number;
|
|
readonly moderate: number;
|
|
readonly high: number;
|
|
};
|
|
readonly pcCount: number;
|
|
readonly combatants: readonly BreakdownCombatant[];
|
|
}
|
|
|
|
export function useDifficultyBreakdown(): DifficultyBreakdown | null {
|
|
const { encounter } = useEncounterContext();
|
|
const { characters } = usePlayerCharactersContext();
|
|
const { getCreature } = useBestiaryContext();
|
|
|
|
return useMemo(() => {
|
|
const partyLevels = derivePartyLevels(encounter.combatants, characters);
|
|
const { entries, crs } = classifyCombatants(
|
|
encounter.combatants,
|
|
getCreature,
|
|
);
|
|
|
|
if (partyLevels.length === 0 || crs.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const result = calculateEncounterDifficulty(partyLevels, crs);
|
|
|
|
return {
|
|
...result,
|
|
pcCount: partyLevels.length,
|
|
combatants: entries,
|
|
};
|
|
}, [encounter.combatants, characters, getCreature]);
|
|
}
|
|
|
|
function classifyBestiaryCombatant(
|
|
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) {
|
|
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,
|
|
},
|
|
cr: null,
|
|
};
|
|
}
|
|
|
|
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[] = [];
|
|
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;
|
|
}
|