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:
90
apps/web/src/components/stat-block-parts.tsx
Normal file
90
apps/web/src/components/stat-block-parts.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { TraitBlock, TraitSegment } from "@initiative/domain";
|
||||
|
||||
export function PropertyLine({
|
||||
label,
|
||||
value,
|
||||
}: Readonly<{
|
||||
label: string;
|
||||
value: string | undefined;
|
||||
}>) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<span className="font-semibold">{label}</span> {value}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionDivider() {
|
||||
return (
|
||||
<div className="my-2 h-px bg-gradient-to-r from-stat-divider-from via-stat-divider-via to-transparent" />
|
||||
);
|
||||
}
|
||||
|
||||
function segmentKey(seg: TraitSegment): string {
|
||||
return seg.type === "text"
|
||||
? seg.value.slice(0, 40)
|
||||
: seg.items.map((i) => i.label ?? i.text.slice(0, 20)).join(",");
|
||||
}
|
||||
|
||||
function TraitSegments({
|
||||
segments,
|
||||
}: Readonly<{ segments: readonly TraitSegment[] }>) {
|
||||
return (
|
||||
<>
|
||||
{segments.map((seg, i) => {
|
||||
if (seg.type === "text") {
|
||||
return (
|
||||
<span key={segmentKey(seg)}>
|
||||
{i === 0 ? ` ${seg.value}` : seg.value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={segmentKey(seg)} className="mt-1 space-y-0.5">
|
||||
{seg.items.map((item) => (
|
||||
<p key={item.label ?? item.text}>
|
||||
{item.label != null && (
|
||||
<span className="font-semibold">{item.label}. </span>
|
||||
)}
|
||||
{item.text}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function TraitEntry({ trait }: Readonly<{ trait: TraitBlock }>) {
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<span className="font-semibold italic">{trait.name}.</span>
|
||||
<TraitSegments segments={trait.segments} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TraitSection({
|
||||
entries,
|
||||
heading,
|
||||
}: Readonly<{
|
||||
entries: readonly TraitBlock[] | undefined;
|
||||
heading?: string;
|
||||
}>) {
|
||||
if (!entries || entries.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
<SectionDivider />
|
||||
{heading ? (
|
||||
<h3 className="font-bold text-base text-stat-heading">{heading}</h3>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
{entries.map((e) => (
|
||||
<TraitEntry key={e.name} trait={e} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user