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; uploadAndCacheSource: ( sourceCode: string, jsonData: unknown, ) => Promise; refreshCache: () => Promise; } 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 => { let creatures: AnyCreature[]; 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, sourceCode, displayName, ); } 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; }); }, [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, }; }