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>
91 lines
1.9 KiB
TypeScript
91 lines
1.9 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|