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; fetchAndCacheSource: ( sourceCode: string, url: string, ) => Promise<{ skippedNames: string[] }>; uploadAndCacheSource: ( sourceCode: string, jsonData: unknown, ) => Promise; refreshCache: () => Promise; } interface BatchResult { readonly responses: unknown[]; readonly failed: string[]; } async function fetchJson(url: string, path: string): Promise { 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 { try { return await fetchJson(url, path); } catch (error) { if (retries <= 0) throw error; await new Promise((r) => setTimeout(r, 500)); return fetchWithRetry(url, path, retries - 1); } } async function fetchBatch( baseUrl: string, paths: string[], ): Promise { 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 { const batches: string[][] = []; for (let i = 0; i < paths.length; i += concurrency) { batches.push(paths.slice(i, i + concurrency)); } const accumulated = await batches.reduce>( 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, ): Promise { 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(), ); useEffect(() => { const index = bestiaryIndex.loadIndex(); setSourceDisplayNames(index.sources as Record); 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 => { 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 => { const creatures = edition === "pf2e" ? normalizeFoundryCreatures( Array.isArray(jsonData) ? jsonData : [jsonData], sourceCode, pf2eBestiaryIndex.getSourceDisplayName(sourceCode), ) : normalizeBestiary( jsonData as Parameters[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 => { const map = await bestiaryCache.loadAllCachedCreatures(); setCreatureMap(map); }, [bestiaryCache]); return { search, getCreature, isLoaded, isSourceCached: isSourceCachedFn, fetchAndCacheSource, uploadAndCacheSource, refreshCache, }; }