Add Pathfinder 2e game system mode
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>
This commit is contained in:
189
apps/web/src/components/dnd-stat-block.tsx
Normal file
189
apps/web/src/components/dnd-stat-block.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user