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:
@@ -116,6 +116,7 @@ export function createTestAdapters(options?: {
|
||||
`https://example.com/creatures-${sourceCode.toLowerCase()}.json`,
|
||||
getSourceDisplayName: (sourceCode) => sourceCode,
|
||||
getCreaturePathsForSource: () => [],
|
||||
getCreatureNamesByPaths: () => new Map(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,6 +131,39 @@ describe("normalizeFoundryCreature", () => {
|
||||
);
|
||||
expect(creature.senses).toBe("Scent 60 feet");
|
||||
});
|
||||
|
||||
it("extracts perception details", () => {
|
||||
const creature = normalizeFoundryCreature(
|
||||
minimalCreature({
|
||||
system: {
|
||||
...minimalCreature().system,
|
||||
perception: {
|
||||
mod: 35,
|
||||
details: "smoke vision",
|
||||
senses: [{ type: "darkvision" }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(creature.perceptionDetails).toBe("smoke vision");
|
||||
expect(creature.senses).toBe("Darkvision");
|
||||
});
|
||||
|
||||
it("omits perception details when empty", () => {
|
||||
const creature = normalizeFoundryCreature(
|
||||
minimalCreature({
|
||||
system: {
|
||||
...minimalCreature().system,
|
||||
perception: {
|
||||
mod: 8,
|
||||
details: "",
|
||||
senses: [{ type: "darkvision" }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(creature.perceptionDetails).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("languages formatting", () => {
|
||||
@@ -386,6 +419,101 @@ describe("normalizeFoundryCreature", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes attack effects in damage text", () => {
|
||||
const creature = normalizeFoundryCreature(
|
||||
minimalCreature({
|
||||
items: [
|
||||
{
|
||||
_id: "atk1",
|
||||
name: "talon",
|
||||
type: "melee",
|
||||
system: {
|
||||
bonus: { value: 14 },
|
||||
damageRolls: {
|
||||
abc: {
|
||||
damage: "1d10+6",
|
||||
damageType: "piercing",
|
||||
},
|
||||
},
|
||||
traits: { value: [] },
|
||||
attackEffects: { value: ["grab"] },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const attack = creature.attacks?.[0];
|
||||
expect(attack?.segments[0]).toEqual({
|
||||
type: "text",
|
||||
value: "+14, 1d10+6 piercing plus Grab",
|
||||
});
|
||||
});
|
||||
|
||||
it("joins multiple attack effects with 'and'", () => {
|
||||
const creature = normalizeFoundryCreature(
|
||||
minimalCreature({
|
||||
items: [
|
||||
{
|
||||
_id: "atk1",
|
||||
name: "claw",
|
||||
type: "melee",
|
||||
system: {
|
||||
bonus: { value: 18 },
|
||||
damageRolls: {
|
||||
abc: {
|
||||
damage: "2d8+6",
|
||||
damageType: "slashing",
|
||||
},
|
||||
},
|
||||
traits: { value: [] },
|
||||
attackEffects: {
|
||||
value: ["grab", "knockdown"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const attack = creature.attacks?.[0];
|
||||
expect(attack?.segments[0]).toEqual({
|
||||
type: "text",
|
||||
value: "+18, 2d8+6 slashing plus Grab and Knockdown",
|
||||
});
|
||||
});
|
||||
|
||||
it("strips creature-name prefix from attack effect slugs", () => {
|
||||
const creature = normalizeFoundryCreature(
|
||||
minimalCreature({
|
||||
name: "Lich",
|
||||
items: [
|
||||
{
|
||||
_id: "atk1",
|
||||
name: "hand",
|
||||
type: "melee",
|
||||
system: {
|
||||
bonus: { value: 24 },
|
||||
damageRolls: {
|
||||
abc: {
|
||||
damage: "2d12+7",
|
||||
damageType: "negative",
|
||||
},
|
||||
},
|
||||
traits: { value: [] },
|
||||
attackEffects: {
|
||||
value: ["lich-siphon-life"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const attack = creature.attacks?.[0];
|
||||
expect(attack?.segments[0]).toEqual({
|
||||
type: "text",
|
||||
value: "+24, 2d12+7 negative plus Siphon Life",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ability normalization", () => {
|
||||
@@ -539,6 +667,114 @@ describe("normalizeFoundryCreature", () => {
|
||||
: undefined,
|
||||
).toBe("(Concentrate, Polymorph) Takes a new form.");
|
||||
});
|
||||
|
||||
it("extracts frequency from ability", () => {
|
||||
const creature = normalizeFoundryCreature(
|
||||
minimalCreature({
|
||||
items: [
|
||||
{
|
||||
_id: "a1",
|
||||
name: "Drain Soul Cage",
|
||||
type: "action",
|
||||
system: {
|
||||
category: "offensive",
|
||||
actionType: { value: "free" },
|
||||
actions: { value: null },
|
||||
traits: { value: [] },
|
||||
description: { value: "<p>Drains the soul.</p>" },
|
||||
frequency: { max: 1, per: "day" },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(creature.abilitiesBot?.[0]?.frequency).toBe("1/day");
|
||||
});
|
||||
|
||||
it("strips redundant frequency line from description", () => {
|
||||
const creature = normalizeFoundryCreature(
|
||||
minimalCreature({
|
||||
items: [
|
||||
{
|
||||
_id: "a1",
|
||||
name: "Consult the Text",
|
||||
type: "action",
|
||||
system: {
|
||||
category: "offensive",
|
||||
actionType: { value: "action" },
|
||||
actions: { value: 1 },
|
||||
traits: { value: [] },
|
||||
description: {
|
||||
value:
|
||||
"<p><strong>Frequency</strong> once per day</p>\n<hr />\n<p><strong>Effect</strong> The lich opens their spell tome.</p>",
|
||||
},
|
||||
frequency: { max: 1, per: "day" },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const text =
|
||||
creature.abilitiesBot?.[0]?.segments[0]?.type === "text"
|
||||
? creature.abilitiesBot[0].segments[0].value
|
||||
: "";
|
||||
expect(text).not.toContain("Frequency");
|
||||
expect(text).toContain("The lich opens their spell tome.");
|
||||
});
|
||||
|
||||
it("strips frequency line even when preceded by other text", () => {
|
||||
const creature = normalizeFoundryCreature(
|
||||
minimalCreature({
|
||||
items: [
|
||||
{
|
||||
_id: "a1",
|
||||
name: "Drain Soul Cage",
|
||||
type: "action",
|
||||
system: {
|
||||
category: "offensive",
|
||||
actionType: { value: "free" },
|
||||
actions: { value: null },
|
||||
traits: { value: [] },
|
||||
description: {
|
||||
value:
|
||||
"<p>6th rank</p>\n<hr />\n<p><strong>Frequency</strong> once per day</p>\n<hr />\n<p><strong>Effect</strong> The lich taps into their soul cage.</p>",
|
||||
},
|
||||
frequency: { max: 1, per: "day" },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const text =
|
||||
creature.abilitiesBot?.[0]?.segments[0]?.type === "text"
|
||||
? creature.abilitiesBot[0].segments[0].value
|
||||
: "";
|
||||
expect(text).not.toContain("Frequency");
|
||||
expect(text).toContain("6th rank");
|
||||
expect(text).toContain("The lich taps into their soul cage.");
|
||||
});
|
||||
|
||||
it("omits frequency when not present", () => {
|
||||
const creature = normalizeFoundryCreature(
|
||||
minimalCreature({
|
||||
items: [
|
||||
{
|
||||
_id: "a1",
|
||||
name: "Strike",
|
||||
type: "action",
|
||||
system: {
|
||||
category: "offensive",
|
||||
actionType: { value: "action" },
|
||||
actions: { value: 1 },
|
||||
traits: { value: [] },
|
||||
description: { value: "<p>Strikes.</p>" },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(creature.abilitiesBot?.[0]?.frequency).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("equipment normalization", () => {
|
||||
|
||||
@@ -3,8 +3,8 @@ import { type IDBPDatabase, openDB } from "idb";
|
||||
|
||||
const DB_NAME = "initiative-bestiary";
|
||||
const STORE_NAME = "sources";
|
||||
// v7 (2026-04-10): Equipment items added to PF2e creatures; old caches are cleared
|
||||
const DB_VERSION = 7;
|
||||
// v8 (2026-04-10): Attack effects, ability frequency, perception details added to PF2e creatures
|
||||
const DB_VERSION = 8;
|
||||
|
||||
interface CachedSourceInfo {
|
||||
readonly sourceCode: string;
|
||||
|
||||
@@ -63,6 +63,7 @@ interface MeleeSystem {
|
||||
bonus?: { value: number };
|
||||
damageRolls?: Record<string, { damage: string; damageType: string }>;
|
||||
traits?: { value: string[] };
|
||||
attackEffects?: { value: string[] };
|
||||
}
|
||||
|
||||
interface ActionSystem {
|
||||
@@ -71,6 +72,7 @@ interface ActionSystem {
|
||||
actions?: { value: number | null };
|
||||
traits?: { value: string[] };
|
||||
description?: { value: string };
|
||||
frequency?: { max: number; per: string };
|
||||
}
|
||||
|
||||
interface SpellcastingEntrySystem {
|
||||
@@ -342,7 +344,17 @@ function formatSpeed(speed: {
|
||||
|
||||
// -- Attack normalization --
|
||||
|
||||
function normalizeAttack(item: RawFoundryItem): TraitBlock {
|
||||
/** Format an attack effect slug to display text: "grab" → "Grab", "lich-siphon-life" → "Siphon Life". */
|
||||
function formatAttackEffect(slug: string, creatureName: string): string {
|
||||
const prefix = `${creatureName.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-")}-`;
|
||||
const stripped = slug.startsWith(prefix) ? slug.slice(prefix.length) : slug;
|
||||
return stripped.split("-").map(capitalize).join(" ");
|
||||
}
|
||||
|
||||
function normalizeAttack(
|
||||
item: RawFoundryItem,
|
||||
creatureName: string,
|
||||
): TraitBlock {
|
||||
const sys = item.system as unknown as MeleeSystem;
|
||||
const bonus = sys.bonus?.value ?? 0;
|
||||
const traits = sys.traits?.value ?? [];
|
||||
@@ -352,13 +364,18 @@ function normalizeAttack(item: RawFoundryItem): TraitBlock {
|
||||
.join(" plus ");
|
||||
const traitStr =
|
||||
traits.length > 0 ? ` (${traits.map(formatTrait).join(", ")})` : "";
|
||||
const effects = sys.attackEffects?.value ?? [];
|
||||
const effectStr =
|
||||
effects.length > 0
|
||||
? ` plus ${effects.map((e) => formatAttackEffect(e, creatureName)).join(" and ")}`
|
||||
: "";
|
||||
return {
|
||||
name: capitalize(item.name),
|
||||
activity: { number: 1, unit: "action" },
|
||||
segments: [
|
||||
{
|
||||
type: "text",
|
||||
value: `+${bonus}${traitStr}, ${damage}`,
|
||||
value: `+${bonus}${traitStr}, ${damage}${effectStr}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -382,15 +399,31 @@ function parseActivity(
|
||||
|
||||
// -- Ability normalization --
|
||||
|
||||
const FREQUENCY_LINE = /(<strong>)?Frequency(<\/strong>)?\s+[^\n]+\n*/i;
|
||||
|
||||
/** Strip the "Frequency once per day" line from ability descriptions when structured frequency data exists. */
|
||||
function stripFrequencyLine(text: string): string {
|
||||
return text.replace(FREQUENCY_LINE, "").trimStart();
|
||||
}
|
||||
|
||||
function normalizeAbility(item: RawFoundryItem): TraitBlock {
|
||||
const sys = item.system as unknown as ActionSystem;
|
||||
const actionType = sys.actionType?.value;
|
||||
const actionCount = sys.actions?.value;
|
||||
const description = stripFoundryTags(sys.description?.value ?? "");
|
||||
let description = stripFoundryTags(sys.description?.value ?? "");
|
||||
const traits = sys.traits?.value ?? [];
|
||||
|
||||
const activity = parseActivity(actionType, actionCount);
|
||||
|
||||
const frequency =
|
||||
sys.frequency?.max != null && sys.frequency.per
|
||||
? `${sys.frequency.max}/${sys.frequency.per}`
|
||||
: undefined;
|
||||
|
||||
if (frequency) {
|
||||
description = stripFrequencyLine(description);
|
||||
}
|
||||
|
||||
const traitStr =
|
||||
traits.length > 0
|
||||
? `(${traits.map((t) => capitalize(formatTrait(t))).join(", ")}) `
|
||||
@@ -401,7 +434,7 @@ function normalizeAbility(item: RawFoundryItem): TraitBlock {
|
||||
? [{ type: "text", value: text }]
|
||||
: [];
|
||||
|
||||
return { name: item.name, activity, segments };
|
||||
return { name: item.name, activity, frequency, segments };
|
||||
}
|
||||
|
||||
// -- Spellcasting normalization --
|
||||
@@ -684,6 +717,7 @@ export function normalizeFoundryCreature(
|
||||
level: sys.details?.level?.value ?? 0,
|
||||
traits: buildTraits(sys.traits),
|
||||
perception: sys.perception?.mod ?? 0,
|
||||
perceptionDetails: sys.perception?.details || undefined,
|
||||
senses: formatSenses(sys.perception?.senses),
|
||||
languages: formatLanguages(sys.details?.languages),
|
||||
skills: formatSkills(sys.skills),
|
||||
@@ -701,7 +735,9 @@ export function normalizeFoundryCreature(
|
||||
weaknesses: formatWeaknesses(sys.attributes.weaknesses),
|
||||
speed: formatSpeed(sys.attributes.speed),
|
||||
attacks: orUndefined(
|
||||
items.filter((i) => i.type === "melee").map(normalizeAttack),
|
||||
items
|
||||
.filter((i) => i.type === "melee")
|
||||
.map((i) => normalizeAttack(i, r.name)),
|
||||
),
|
||||
abilitiesTop: orUndefined(actionsByCategory(items, "interaction")),
|
||||
abilitiesMid: orUndefined(
|
||||
|
||||
@@ -69,6 +69,18 @@ export function getCreaturePathsForSource(sourceCode: string): string[] {
|
||||
return compact.creatures.filter((c) => c.s === sourceCode).map((c) => c.f);
|
||||
}
|
||||
|
||||
export function getCreatureNamesByPaths(paths: string[]): Map<string, string> {
|
||||
const compact = rawIndex as unknown as CompactIndex;
|
||||
const pathSet = new Set(paths);
|
||||
const result = new Map<string, string>();
|
||||
for (const c of compact.creatures) {
|
||||
if (pathSet.has(c.f)) {
|
||||
result.set(c.f, c.n);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getPf2eSourceDisplayName(sourceCode: string): string {
|
||||
const index = loadPf2eBestiaryIndex();
|
||||
return index.sources[sourceCode] ?? sourceCode;
|
||||
|
||||
@@ -57,4 +57,5 @@ export interface Pf2eBestiaryIndexPort {
|
||||
getDefaultFetchUrl(sourceCode: string, baseUrl?: string): string;
|
||||
getSourceDisplayName(sourceCode: string): string;
|
||||
getCreaturePathsForSource(sourceCode: string): string[];
|
||||
getCreatureNamesByPaths(paths: string[]): Map<string, string>;
|
||||
}
|
||||
|
||||
@@ -48,5 +48,6 @@ export const productionAdapters: Adapters = {
|
||||
getDefaultFetchUrl: pf2eBestiaryIndex.getDefaultPf2eFetchUrl,
|
||||
getSourceDisplayName: pf2eBestiaryIndex.getPf2eSourceDisplayName,
|
||||
getCreaturePathsForSource: pf2eBestiaryIndex.getCreaturePathsForSource,
|
||||
getCreatureNamesByPaths: pf2eBestiaryIndex.getCreatureNamesByPaths,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
{" "}
|
||||
|
||||
@@ -28,7 +28,10 @@ interface BestiaryHook {
|
||||
getCreature: (id: CreatureId) => AnyCreature | undefined;
|
||||
isLoaded: boolean;
|
||||
isSourceCached: (sourceCode: string) => Promise<boolean>;
|
||||
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
|
||||
fetchAndCacheSource: (
|
||||
sourceCode: string,
|
||||
url: string,
|
||||
) => Promise<{ skippedNames: string[] }>;
|
||||
uploadAndCacheSource: (
|
||||
sourceCode: string,
|
||||
jsonData: unknown,
|
||||
@@ -36,6 +39,108 @@ interface BestiaryHook {
|
||||
refreshCache: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface BatchResult {
|
||||
readonly responses: unknown[];
|
||||
readonly failed: string[];
|
||||
}
|
||||
|
||||
async function fetchJson(url: string, path: string): Promise<unknown> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch ${path}: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function fetchWithRetry(
|
||||
url: string,
|
||||
path: string,
|
||||
retries = 2,
|
||||
): Promise<unknown> {
|
||||
try {
|
||||
return await fetchJson(url, path);
|
||||
} catch (error) {
|
||||
if (retries <= 0) throw error;
|
||||
await new Promise<void>((r) => setTimeout(r, 500));
|
||||
return fetchWithRetry(url, path, retries - 1);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBatch(
|
||||
baseUrl: string,
|
||||
paths: string[],
|
||||
): Promise<BatchResult> {
|
||||
const settled = await Promise.allSettled(
|
||||
paths.map((path) => fetchWithRetry(`${baseUrl}${path}`, path)),
|
||||
);
|
||||
const responses: unknown[] = [];
|
||||
const failed: string[] = [];
|
||||
for (let i = 0; i < settled.length; i++) {
|
||||
const result = settled[i];
|
||||
if (result.status === "fulfilled") {
|
||||
responses.push(result.value);
|
||||
} else {
|
||||
failed.push(paths[i]);
|
||||
}
|
||||
}
|
||||
return { responses, failed };
|
||||
}
|
||||
|
||||
async function fetchInBatches(
|
||||
paths: string[],
|
||||
baseUrl: string,
|
||||
concurrency: number,
|
||||
): Promise<BatchResult> {
|
||||
const batches: string[][] = [];
|
||||
for (let i = 0; i < paths.length; i += concurrency) {
|
||||
batches.push(paths.slice(i, i + concurrency));
|
||||
}
|
||||
const accumulated = await batches.reduce<Promise<BatchResult>>(
|
||||
async (prev, batch) => {
|
||||
const acc = await prev;
|
||||
const result = await fetchBatch(baseUrl, batch);
|
||||
return {
|
||||
responses: [...acc.responses, ...result.responses],
|
||||
failed: [...acc.failed, ...result.failed],
|
||||
};
|
||||
},
|
||||
Promise.resolve({ responses: [], failed: [] }),
|
||||
);
|
||||
return accumulated;
|
||||
}
|
||||
|
||||
interface Pf2eFetchResult {
|
||||
creatures: AnyCreature[];
|
||||
skippedNames: string[];
|
||||
}
|
||||
|
||||
async function fetchPf2eSource(
|
||||
paths: string[],
|
||||
url: string,
|
||||
sourceCode: string,
|
||||
displayName: string,
|
||||
resolveNames: (failedPaths: string[]) => Map<string, string>,
|
||||
): Promise<Pf2eFetchResult> {
|
||||
const baseUrl = url.endsWith("/") ? url : `${url}/`;
|
||||
const { responses, failed } = await fetchInBatches(paths, baseUrl, 6);
|
||||
if (responses.length === 0) {
|
||||
throw new Error(
|
||||
`Failed to fetch any creatures (${failed.length} failed). This may be caused by an ad blocker — try disabling it for this site or use file upload instead.`,
|
||||
);
|
||||
}
|
||||
const nameMap = failed.length > 0 ? resolveNames(failed) : new Map();
|
||||
const skippedNames = failed.map((p) => nameMap.get(p) ?? p);
|
||||
if (skippedNames.length > 0) {
|
||||
console.warn("Skipped creatures (ad blocker?):", skippedNames);
|
||||
}
|
||||
return {
|
||||
creatures: normalizeFoundryCreatures(responses, sourceCode, displayName),
|
||||
skippedNames,
|
||||
};
|
||||
}
|
||||
|
||||
export function useBestiary(): BestiaryHook {
|
||||
const { bestiaryCache, bestiaryIndex, pf2eBestiaryIndex } = useAdapters();
|
||||
const { edition } = useRulesEditionContext();
|
||||
@@ -108,30 +213,25 @@ export function useBestiary(): BestiaryHook {
|
||||
);
|
||||
|
||||
const fetchAndCacheSource = useCallback(
|
||||
async (sourceCode: string, url: string): Promise<void> => {
|
||||
async (
|
||||
sourceCode: string,
|
||||
url: string,
|
||||
): Promise<{ skippedNames: string[] }> => {
|
||||
let creatures: AnyCreature[];
|
||||
let skippedNames: string[] = [];
|
||||
|
||||
if (edition === "pf2e") {
|
||||
// PF2e: url is a base URL; fetch each creature file in parallel
|
||||
const paths = pf2eBestiaryIndex.getCreaturePathsForSource(sourceCode);
|
||||
const baseUrl = url.endsWith("/") ? url : `${url}/`;
|
||||
const responses = await Promise.all(
|
||||
paths.map(async (path) => {
|
||||
const response = await fetch(`${baseUrl}${path}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch ${path}: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
return response.json();
|
||||
}),
|
||||
);
|
||||
const displayName = pf2eBestiaryIndex.getSourceDisplayName(sourceCode);
|
||||
creatures = normalizeFoundryCreatures(
|
||||
responses,
|
||||
const result = await fetchPf2eSource(
|
||||
paths,
|
||||
url,
|
||||
sourceCode,
|
||||
displayName,
|
||||
pf2eBestiaryIndex.getCreatureNamesByPaths,
|
||||
);
|
||||
creatures = result.creatures;
|
||||
skippedNames = result.skippedNames;
|
||||
} else {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
@@ -160,6 +260,7 @@ export function useBestiary(): BestiaryHook {
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return { skippedNames };
|
||||
},
|
||||
[bestiaryCache, bestiaryIndex, pf2eBestiaryIndex, edition, system],
|
||||
);
|
||||
|
||||
@@ -22,7 +22,10 @@ interface BulkImportHook {
|
||||
state: BulkImportState;
|
||||
startImport: (
|
||||
baseUrl: string,
|
||||
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>,
|
||||
fetchAndCacheSource: (
|
||||
sourceCode: string,
|
||||
url: string,
|
||||
) => Promise<{ skippedNames: string[] }>,
|
||||
isSourceCached: (sourceCode: string) => Promise<boolean>,
|
||||
refreshCache: () => Promise<void>,
|
||||
) => void;
|
||||
@@ -39,7 +42,10 @@ export function useBulkImport(): BulkImportHook {
|
||||
const startImport = useCallback(
|
||||
(
|
||||
baseUrl: string,
|
||||
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>,
|
||||
fetchAndCacheSource: (
|
||||
sourceCode: string,
|
||||
url: string,
|
||||
) => Promise<{ skippedNames: string[] }>,
|
||||
isSourceCached: (sourceCode: string) => Promise<boolean>,
|
||||
refreshCache: () => Promise<void>,
|
||||
) => {
|
||||
|
||||
Reference in New Issue
Block a user