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>
190 lines
5.3 KiB
TypeScript
190 lines
5.3 KiB
TypeScript
import type { Creature } from "@initiative/domain";
|
|
import {
|
|
calculateInitiative,
|
|
formatInitiativeModifier,
|
|
} from "@initiative/domain";
|
|
import {
|
|
PropertyLine,
|
|
SectionDivider,
|
|
TraitEntry,
|
|
TraitSection,
|
|
} from "./stat-block-parts.js";
|
|
|
|
interface DndStatBlockProps {
|
|
creature: Creature;
|
|
}
|
|
|
|
function abilityMod(score: number): string {
|
|
const mod = Math.floor((score - 10) / 2);
|
|
return mod >= 0 ? `+${mod}` : `${mod}`;
|
|
}
|
|
|
|
export function DndStatBlock({ creature }: Readonly<DndStatBlockProps>) {
|
|
const abilities = [
|
|
{ label: "STR", score: creature.abilities.str },
|
|
{ label: "DEX", score: creature.abilities.dex },
|
|
{ label: "CON", score: creature.abilities.con },
|
|
{ label: "INT", score: creature.abilities.int },
|
|
{ label: "WIS", score: creature.abilities.wis },
|
|
{ label: "CHA", score: creature.abilities.cha },
|
|
];
|
|
|
|
const initiative = calculateInitiative({
|
|
dexScore: creature.abilities.dex,
|
|
cr: creature.cr,
|
|
initiativeProficiency: creature.initiativeProficiency,
|
|
});
|
|
|
|
return (
|
|
<div className="space-y-1 text-foreground">
|
|
{/* Header */}
|
|
<div>
|
|
<h2 className="font-bold text-stat-heading text-xl">{creature.name}</h2>
|
|
<p className="text-muted-foreground text-sm italic">
|
|
{creature.size} {creature.type}, {creature.alignment}
|
|
</p>
|
|
<p className="text-muted-foreground text-xs">
|
|
{creature.sourceDisplayName}
|
|
</p>
|
|
</div>
|
|
|
|
<SectionDivider />
|
|
|
|
{/* Stats bar */}
|
|
<div className="space-y-0.5 text-sm">
|
|
<div>
|
|
<span className="font-semibold">Armor Class</span> {creature.ac}
|
|
{!!creature.acSource && (
|
|
<span className="text-muted-foreground">
|
|
{" "}
|
|
({creature.acSource})
|
|
</span>
|
|
)}
|
|
<span className="ml-3">
|
|
<span className="font-semibold">Initiative</span>{" "}
|
|
{formatInitiativeModifier(initiative.modifier)} (
|
|
{initiative.passive})
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<span className="font-semibold">Hit Points</span>{" "}
|
|
{creature.hp.average}{" "}
|
|
<span className="text-muted-foreground">({creature.hp.formula})</span>
|
|
</div>
|
|
<div>
|
|
<span className="font-semibold">Speed</span> {creature.speed}
|
|
</div>
|
|
</div>
|
|
|
|
<SectionDivider />
|
|
|
|
{/* Ability scores */}
|
|
<div className="grid grid-cols-6 gap-1 text-center text-sm">
|
|
{abilities.map(({ label, score }) => (
|
|
<div key={label}>
|
|
<div className="font-semibold">{label}</div>
|
|
<div>
|
|
{score}{" "}
|
|
<span className="text-muted-foreground">
|
|
({abilityMod(score)})
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<SectionDivider />
|
|
|
|
{/* Properties */}
|
|
<div className="space-y-0.5">
|
|
<PropertyLine label="Saving Throws" value={creature.savingThrows} />
|
|
<PropertyLine label="Skills" value={creature.skills} />
|
|
<PropertyLine
|
|
label="Damage Vulnerabilities"
|
|
value={creature.vulnerable}
|
|
/>
|
|
<PropertyLine label="Damage Resistances" value={creature.resist} />
|
|
<PropertyLine label="Damage Immunities" value={creature.immune} />
|
|
<PropertyLine
|
|
label="Condition Immunities"
|
|
value={creature.conditionImmune}
|
|
/>
|
|
<PropertyLine label="Senses" value={creature.senses} />
|
|
<PropertyLine label="Languages" value={creature.languages} />
|
|
<div className="text-sm">
|
|
<span className="font-semibold">Challenge</span> {creature.cr}{" "}
|
|
<span className="text-muted-foreground">
|
|
(Proficiency Bonus +{creature.proficiencyBonus})
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<TraitSection entries={creature.traits} />
|
|
|
|
{/* Spellcasting */}
|
|
{creature.spellcasting && creature.spellcasting.length > 0 && (
|
|
<>
|
|
<SectionDivider />
|
|
{creature.spellcasting.map((sc) => (
|
|
<div key={sc.name} className="space-y-1 text-sm">
|
|
<div>
|
|
<span className="font-semibold italic">{sc.name}.</span>{" "}
|
|
{sc.headerText}
|
|
</div>
|
|
{sc.atWill && sc.atWill.length > 0 && (
|
|
<div className="pl-2">
|
|
<span className="font-semibold">At Will:</span>{" "}
|
|
{sc.atWill.join(", ")}
|
|
</div>
|
|
)}
|
|
{sc.daily?.map((d) => (
|
|
<div key={`${d.uses}${d.each ? "e" : ""}`} className="pl-2">
|
|
<span className="font-semibold">
|
|
{d.uses}/day
|
|
{d.each ? " each" : ""}:
|
|
</span>{" "}
|
|
{d.spells.join(", ")}
|
|
</div>
|
|
))}
|
|
{sc.restLong?.map((d) => (
|
|
<div
|
|
key={`rest-${d.uses}${d.each ? "e" : ""}`}
|
|
className="pl-2"
|
|
>
|
|
<span className="font-semibold">
|
|
{d.uses}/long rest
|
|
{d.each ? " each" : ""}:
|
|
</span>{" "}
|
|
{d.spells.join(", ")}
|
|
</div>
|
|
))}
|
|
</div>
|
|
))}
|
|
</>
|
|
)}
|
|
|
|
<TraitSection entries={creature.actions} heading="Actions" />
|
|
<TraitSection entries={creature.bonusActions} heading="Bonus Actions" />
|
|
<TraitSection entries={creature.reactions} heading="Reactions" />
|
|
|
|
{/* Legendary Actions */}
|
|
{!!creature.legendaryActions && (
|
|
<>
|
|
<SectionDivider />
|
|
<h3 className="font-bold text-base text-stat-heading">
|
|
Legendary Actions
|
|
</h3>
|
|
<p className="text-muted-foreground text-sm italic">
|
|
{creature.legendaryActions.preamble}
|
|
</p>
|
|
<div className="space-y-2">
|
|
{creature.legendaryActions.entries.map((a) => (
|
|
<TraitEntry key={a.name} trait={a} />
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|