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>
316 lines
8.4 KiB
TypeScript
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,
|
|
};
|
|
}
|