Implements PF2e as an alternative game system alongside D&D 5e/5.5e. Settings modal "Game System" selector switches conditions, bestiary, stat block layout, and initiative calculation between systems. - Valued conditions with increment/decrement UX (Clumsy 2, Frightened 3) - 2,502 PF2e creatures from bundled search index (77 sources) - PF2e stat block: level, traits, Perception, Fort/Ref/Will, ability mods - Perception-based initiative rolling - System-scoped source cache (D&D and PF2e sources don't collide) - Backwards-compatible condition rehydration (ConditionId[] → ConditionEntry[]) - Difficulty indicator hidden in PF2e mode (excluded from MVP) Closes dostulata/initiative#19 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
172 lines
4.4 KiB
TypeScript
172 lines
4.4 KiB
TypeScript
import type {
|
|
Combatant,
|
|
CreatureId,
|
|
DifficultyThreshold,
|
|
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";
|
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
|
import { resolveSide } from "./use-difficulty.js";
|
|
|
|
export interface BreakdownCombatant {
|
|
readonly combatant: Combatant;
|
|
readonly cr: string | null;
|
|
readonly xp: number | null;
|
|
readonly source: string | null;
|
|
readonly editable: boolean;
|
|
readonly side: "party" | "enemy";
|
|
readonly level: number | undefined;
|
|
}
|
|
|
|
interface DifficultyBreakdown {
|
|
readonly tier: DifficultyTier;
|
|
readonly totalMonsterXp: number;
|
|
readonly thresholds: readonly DifficultyThreshold[];
|
|
readonly encounterMultiplier: number | undefined;
|
|
readonly adjustedXp: number | undefined;
|
|
readonly partySizeAdjusted: boolean | undefined;
|
|
readonly pcCount: number;
|
|
readonly partyCombatants: readonly BreakdownCombatant[];
|
|
readonly enemyCombatants: readonly BreakdownCombatant[];
|
|
}
|
|
|
|
export function useDifficultyBreakdown(): DifficultyBreakdown | null {
|
|
const { encounter } = useEncounterContext();
|
|
const { characters } = usePlayerCharactersContext();
|
|
const { getCreature } = useBestiaryContext();
|
|
const { edition } = useRulesEditionContext();
|
|
|
|
return useMemo(() => {
|
|
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 (!hasPartyLevel || !hasCr) return null;
|
|
|
|
const result = calculateEncounterDifficulty(descriptors, edition);
|
|
|
|
return {
|
|
...result,
|
|
pcCount,
|
|
partyCombatants,
|
|
enemyCombatants,
|
|
};
|
|
}, [encounter.combatants, characters, getCreature, edition]);
|
|
}
|
|
|
|
type CreatureInfo = {
|
|
cr?: string;
|
|
source: string;
|
|
sourceDisplayName: string;
|
|
};
|
|
|
|
function buildBreakdownEntry(
|
|
c: Combatant,
|
|
side: "party" | "enemy",
|
|
level: number | undefined,
|
|
creature: CreatureInfo | undefined,
|
|
): BreakdownCombatant {
|
|
if (c.playerCharacterId) {
|
|
return {
|
|
combatant: c,
|
|
cr: null,
|
|
xp: null,
|
|
source: null,
|
|
editable: false,
|
|
side,
|
|
level,
|
|
};
|
|
}
|
|
if (creature) {
|
|
const cr = creature.cr ?? null;
|
|
return {
|
|
combatant: c,
|
|
cr,
|
|
xp: cr ? crToXp(cr) : null,
|
|
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?.cr ?? c.cr ?? null;
|
|
return { cr, creature };
|
|
}
|
|
|
|
function classifyCombatants(
|
|
combatants: readonly Combatant[],
|
|
characters: readonly PlayerCharacter[],
|
|
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) {
|
|
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 { partyCombatants, enemyCombatants, descriptors, pcCount };
|
|
}
|