Show inline on-hit effects on attack lines (e.g., "plus Grab"), frequency limits on abilities (e.g., "(1/day)"), and perception details text alongside senses. Strip redundant frequency lines from Foundry descriptions. Also add resilient PF2e source fetching: batched requests with retry, graceful handling of ad-blocker-blocked creature files (partial success with toast warning and re-fetch prompt for missing creatures). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
184 lines
4.5 KiB
TypeScript
184 lines
4.5 KiB
TypeScript
import type {
|
|
ActivityCost,
|
|
TraitBlock,
|
|
TraitSegment,
|
|
} from "@initiative/domain";
|
|
import { RichDescription } from "./rich-description.js";
|
|
|
|
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 (
|
|
<RichDescription
|
|
key={segmentKey(seg)}
|
|
text={i === 0 ? ` ${seg.value}` : seg.value}
|
|
className="inline whitespace-pre-line"
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<div key={segmentKey(seg)} className="mt-1 space-y-0.5">
|
|
{seg.items.map((item) => (
|
|
<div key={item.label ?? item.text}>
|
|
{item.label != null && (
|
|
<span className="font-semibold">{item.label}. </span>
|
|
)}
|
|
<RichDescription text={item.text} className="inline" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
}
|
|
|
|
const ACTION_DIAMOND = "M50 2 L96 50 L50 98 L4 50 Z M48 27 L71 50 L48 73 Z";
|
|
const ACTION_DIAMOND_SOLID = "M50 2 L96 50 L50 98 L4 50 Z";
|
|
const ACTION_DIAMOND_OUTLINE =
|
|
"M90 2 L136 50 L90 98 L44 50 Z M90 29 L111 50 L90 71 L69 50 Z";
|
|
const FREE_ACTION_DIAMOND =
|
|
"M50 2 L96 50 L50 98 L4 50 Z M50 12 L12 50 L50 88 L88 50 Z";
|
|
const FREE_ACTION_CHEVRON = "M48 27 L71 50 L48 73 Z";
|
|
const REACTION_ARROW =
|
|
"M75 15 A42 42 0 1 0 85 55 L72 55 A30 30 0 1 1 65 25 L65 40 L92 20 L65 0 L65 15 Z";
|
|
|
|
export function ActivityIcon({
|
|
activity,
|
|
}: Readonly<{ activity: ActivityCost }>) {
|
|
const cls = "inline-block h-[1em] align-[-0.1em]";
|
|
if (activity.unit === "free") {
|
|
return (
|
|
<svg aria-hidden="true" className={cls} viewBox="0 0 100 100">
|
|
<path d={FREE_ACTION_DIAMOND} fill="currentColor" fillRule="evenodd" />
|
|
<path d={FREE_ACTION_CHEVRON} fill="currentColor" />
|
|
</svg>
|
|
);
|
|
}
|
|
if (activity.unit === "reaction") {
|
|
return (
|
|
<svg aria-hidden="true" className={cls} viewBox="0 0 100 100">
|
|
<g transform="translate(100,100) rotate(180)">
|
|
<path d={REACTION_ARROW} fill="currentColor" />
|
|
</g>
|
|
</svg>
|
|
);
|
|
}
|
|
const count = activity.number;
|
|
if (count === 1) {
|
|
return (
|
|
<svg aria-hidden="true" className={cls} viewBox="0 0 100 100">
|
|
<path d={ACTION_DIAMOND} fill="currentColor" fillRule="evenodd" />
|
|
</svg>
|
|
);
|
|
}
|
|
if (count === 2) {
|
|
return (
|
|
<svg aria-hidden="true" className={cls} viewBox="0 0 140 100">
|
|
<path d={ACTION_DIAMOND_SOLID} fill="currentColor" />
|
|
<path
|
|
d={ACTION_DIAMOND_OUTLINE}
|
|
fill="currentColor"
|
|
fillRule="evenodd"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
return (
|
|
<svg aria-hidden="true" className={cls} viewBox="0 0 180 100">
|
|
<path d={ACTION_DIAMOND_SOLID} fill="currentColor" />
|
|
<path d="M90 2 L136 50 L90 98 L44 50 Z" fill="currentColor" />
|
|
<path
|
|
d="M130 2 L176 50 L130 98 L84 50 Z M130 29 L151 50 L130 71 L109 50 Z"
|
|
fill="currentColor"
|
|
fillRule="evenodd"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
export function TraitEntry({ trait }: Readonly<{ trait: TraitBlock }>) {
|
|
return (
|
|
<div className="text-sm">
|
|
<span className="font-semibold italic">
|
|
{trait.name}
|
|
{trait.activity ? null : "."}
|
|
{trait.activity ? (
|
|
<>
|
|
{" "}
|
|
<ActivityIcon activity={trait.activity} />
|
|
</>
|
|
) : null}
|
|
</span>
|
|
{trait.frequency ? ` (${trait.frequency})` : null}
|
|
{trait.trigger ? (
|
|
<>
|
|
{" "}
|
|
<span className="font-semibold">Trigger</span> {trait.trigger}
|
|
{trait.segments.length > 0 ? (
|
|
<>
|
|
{" "}
|
|
<span className="font-semibold">Effect</span>
|
|
</>
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
<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>
|
|
</>
|
|
);
|
|
}
|