Files
initiative/apps/web/src/hooks/use-bestiary.ts
Lukas c3707cf0b6 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>
2026-04-10 23:37:03 +02:00

316 lines
8.4 KiB
TypeScript

import type {
AnyCreature,
BestiaryIndexEntry,
CreatureId,
Pf2eBestiaryIndexEntry,
} from "@initiative/domain";
import { useCallback, useEffect, useState } from "react";
import {
normalizeBestiary,
setSourceDisplayNames,
} from "../adapters/bestiary-adapter.js";
import { normalizeFoundryCreatures } from "../adapters/pf2e-bestiary-adapter.js";
import { useAdapters } from "../contexts/adapter-context.js";
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
export type SearchResult =
| (BestiaryIndexEntry & {
readonly system: "dnd";
readonly sourceDisplayName: string;
})
| (Pf2eBestiaryIndexEntry & {
readonly system: "pf2e";
readonly sourceDisplayName: string;
});
interface BestiaryHook {
search: (query: string) => SearchResult[];
getCreature: (id: CreatureId) => AnyCreature | undefined;
isLoaded: boolean;
isSourceCached: (sourceCode: string) => Promise<boolean>;
fetchAndCacheSource: (
sourceCode: string,
url: string,
) => Promise<{ skippedNames: string[] }>;
uploadAndCacheSource: (
sourceCode: string,
jsonData: unknown,
) => Promise<void>;
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();
const [isLoaded, setIsLoaded] = useState(false);
const [creatureMap, setCreatureMap] = useState(
() => new Map<CreatureId, AnyCreature>(),
);
useEffect(() => {
const index = bestiaryIndex.loadIndex();
setSourceDisplayNames(index.sources as Record<string, string>);
const pf2eIndex = pf2eBestiaryIndex.loadIndex();
if (index.creatures.length > 0 || pf2eIndex.creatures.length > 0) {
setIsLoaded(true);
}
void bestiaryCache.loadAllCachedCreatures().then((map) => {
setCreatureMap(map);
});
}, [bestiaryCache, bestiaryIndex, pf2eBestiaryIndex]);
const search = useCallback(
(query: string): SearchResult[] => {
if (query.length < 2) return [];
const lower = query.toLowerCase();
if (edition === "pf2e") {
const index = pf2eBestiaryIndex.loadIndex();
return index.creatures
.filter((c) => c.name.toLowerCase().includes(lower))
.sort((a, b) => a.name.localeCompare(b.name))
.slice(0, 10)
.map((c) => ({
...c,
system: "pf2e" as const,
sourceDisplayName: pf2eBestiaryIndex.getSourceDisplayName(c.source),
}));
}
const index = bestiaryIndex.loadIndex();
return index.creatures
.filter((c) => c.name.toLowerCase().includes(lower))
.sort((a, b) => a.name.localeCompare(b.name))
.slice(0, 10)
.map((c) => ({
...c,
system: "dnd" as const,
sourceDisplayName: bestiaryIndex.getSourceDisplayName(c.source),
}));
},
[bestiaryIndex, pf2eBestiaryIndex, edition],
);
const getCreature = useCallback(
(id: CreatureId): AnyCreature | undefined => {
return creatureMap.get(id);
},
[creatureMap],
);
const system = edition === "pf2e" ? "pf2e" : "dnd";
const isSourceCachedFn = useCallback(
(sourceCode: string): Promise<boolean> => {
return bestiaryCache.isSourceCached(system, sourceCode);
},
[bestiaryCache, system],
);
const fetchAndCacheSource = useCallback(
async (
sourceCode: string,
url: string,
): Promise<{ skippedNames: string[] }> => {
let creatures: AnyCreature[];
let skippedNames: string[] = [];
if (edition === "pf2e") {
const paths = pf2eBestiaryIndex.getCreaturePathsForSource(sourceCode);
const displayName = pf2eBestiaryIndex.getSourceDisplayName(sourceCode);
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) {
throw new Error(
`Failed to fetch: ${response.status} ${response.statusText}`,
);
}
const json = await response.json();
creatures = normalizeBestiary(json);
}
const displayName =
edition === "pf2e"
? pf2eBestiaryIndex.getSourceDisplayName(sourceCode)
: bestiaryIndex.getSourceDisplayName(sourceCode);
await bestiaryCache.cacheSource(
system,
sourceCode,
displayName,
creatures,
);
setCreatureMap((prev) => {
const next = new Map(prev);
for (const c of creatures) {
next.set(c.id, c);
}
return next;
});
return { skippedNames };
},
[bestiaryCache, bestiaryIndex, pf2eBestiaryIndex, edition, system],
);
const uploadAndCacheSource = useCallback(
async (sourceCode: string, jsonData: unknown): Promise<void> => {
const creatures =
edition === "pf2e"
? normalizeFoundryCreatures(
Array.isArray(jsonData) ? jsonData : [jsonData],
sourceCode,
pf2eBestiaryIndex.getSourceDisplayName(sourceCode),
)
: normalizeBestiary(
jsonData as Parameters<typeof normalizeBestiary>[0],
);
const displayName =
edition === "pf2e"
? pf2eBestiaryIndex.getSourceDisplayName(sourceCode)
: bestiaryIndex.getSourceDisplayName(sourceCode);
await bestiaryCache.cacheSource(
system,
sourceCode,
displayName,
creatures,
);
setCreatureMap((prev) => {
const next = new Map(prev);
for (const c of creatures) {
next.set(c.id, c);
}
return next;
});
},
[bestiaryCache, bestiaryIndex, pf2eBestiaryIndex, edition, system],
);
const refreshCache = useCallback(async (): Promise<void> => {
const map = await bestiaryCache.loadAllCachedCreatures();
setCreatureMap(map);
}, [bestiaryCache]);
return {
search,
getCreature,
isLoaded,
isSourceCached: isSourceCachedFn,
fetchAndCacheSource,
uploadAndCacheSource,
refreshCache,
};
}