Add PF2e attack effects, ability frequency, and perception details

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>
This commit is contained in:
Lukas
2026-04-10 23:37:03 +02:00
parent 1eaeecad32
commit c3707cf0b6
16 changed files with 488 additions and 55 deletions
@@ -65,7 +65,7 @@ describe("SourceFetchPrompt", () => {
});
it("Load calls fetchAndCacheSource and onSourceLoaded on success", async () => {
mockFetchAndCacheSource.mockResolvedValueOnce(undefined);
mockFetchAndCacheSource.mockResolvedValueOnce({ skippedNames: [] });
const user = userEvent.setup();
const { onSourceLoaded } = renderPrompt();
+3 -1
View File
@@ -207,7 +207,9 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
<div>
<span className="font-semibold">Perception</span>{" "}
{formatInitiativeModifier(creature.perception)}
{creature.senses ? `; ${creature.senses}` : ""}
{creature.senses || creature.perceptionDetails
? `; ${[creature.senses, creature.perceptionDetails].filter(Boolean).join(", ")}`
: ""}
</div>
<PropertyLine label="Languages" value={creature.languages} />
<PropertyLine label="Skills" value={creature.skills} />
@@ -8,7 +8,7 @@ import { Input } from "./ui/input.js";
interface SourceFetchPromptProps {
sourceCode: string;
onSourceLoaded: () => void;
onSourceLoaded: (skippedNames: string[]) => void;
}
export function SourceFetchPrompt({
@@ -32,8 +32,9 @@ export function SourceFetchPrompt({
setStatus("fetching");
setError("");
try {
await fetchAndCacheSource(sourceCode, url);
onSourceLoaded();
const { skippedNames } = await fetchAndCacheSource(sourceCode, url);
setStatus("idle");
onSourceLoaded(skippedNames);
} catch (e) {
setStatus("error");
setError(e instanceof Error ? e.message : "Failed to fetch source data");
@@ -51,7 +52,7 @@ export function SourceFetchPrompt({
const text = await file.text();
const json = JSON.parse(text);
await uploadAndCacheSource(sourceCode, json);
onSourceLoaded();
onSourceLoaded([]);
} catch (err) {
setStatus("error");
setError(
+39 -21
View File
@@ -11,6 +11,7 @@ import { DndStatBlock } from "./dnd-stat-block.js";
import { Pf2eStatBlock } from "./pf2e-stat-block.js";
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
import { SourceManager } from "./source-manager.js";
import { Toast } from "./toast.js";
import { Button } from "./ui/button.js";
interface StatBlockPanelProps {
@@ -260,6 +261,7 @@ export function StatBlockPanel({
);
const [needsFetch, setNeedsFetch] = useState(false);
const [checkingCache, setCheckingCache] = useState(false);
const [skippedToast, setSkippedToast] = useState<string | null>(null);
useEffect(() => {
const mq = globalThis.matchMedia("(min-width: 1024px)");
@@ -280,19 +282,23 @@ export function StatBlockPanel({
return;
}
setCheckingCache(true);
void isSourceCached(sourceCode).then((cached) => {
setNeedsFetch(!cached);
setCheckingCache(false);
});
}, [creatureId, creature, isSourceCached]);
// Show fetch prompt both when source is uncached AND when the source is
// cached but this specific creature is missing (e.g. skipped by ad blocker).
setNeedsFetch(true);
setCheckingCache(false);
}, [creatureId, creature]);
if (!creatureId && !bulkImportMode && !sourceManagerMode) return null;
const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
const handleSourceLoaded = () => {
setNeedsFetch(false);
const handleSourceLoaded = (skippedNames: string[]) => {
if (skippedNames.length > 0) {
const names = skippedNames.join(", ");
setSkippedToast(
`${skippedNames.length} creature(s) skipped (ad blocker?): ${names}`,
);
}
};
const renderContent = () => {
@@ -338,24 +344,36 @@ export function StatBlockPanel({
else if (bulkImportMode) fallbackName = "Import All Sources";
const creatureName = creature?.name ?? fallbackName;
const toast = skippedToast ? (
<Toast message={skippedToast} onDismiss={() => setSkippedToast(null)} />
) : null;
if (isDesktop) {
return (
<DesktopPanel
isCollapsed={isCollapsed}
side={side}
creatureName={creatureName}
panelRole={panelRole}
showPinButton={showPinButton}
onToggleCollapse={onToggleCollapse}
onPin={onPin}
onUnpin={onUnpin}
>
{renderContent()}
</DesktopPanel>
<>
<DesktopPanel
isCollapsed={isCollapsed}
side={side}
creatureName={creatureName}
panelRole={panelRole}
showPinButton={showPinButton}
onToggleCollapse={onToggleCollapse}
onPin={onPin}
onUnpin={onUnpin}
>
{renderContent()}
</DesktopPanel>
{toast}
</>
);
}
if (panelRole === "pinned" || isCollapsed) return null;
return <MobileDrawer onDismiss={onDismiss}>{renderContent()}</MobileDrawer>;
return (
<>
<MobileDrawer onDismiss={onDismiss}>{renderContent()}</MobileDrawer>
{toast}
</>
);
}
@@ -141,6 +141,7 @@ export function TraitEntry({ trait }: Readonly<{ trait: TraitBlock }>) {
</>
) : null}
</span>
{trait.frequency ? ` (${trait.frequency})` : null}
{trait.trigger ? (
<>
{" "}