import type { BestiaryIndexEntry, Creature, CreatureId, } from "@initiative/domain"; import { useCallback, useEffect, useState } from "react"; import { normalizeBestiary, setSourceDisplayNames, } from "../adapters/bestiary-adapter.js"; import * as bestiaryCache from "../adapters/bestiary-cache.js"; import { getSourceDisplayName, loadBestiaryIndex, } from "../adapters/bestiary-index-adapter.js"; export interface SearchResult extends BestiaryIndexEntry { readonly sourceDisplayName: string; } interface BestiaryHook { search: (query: string) => SearchResult[]; getCreature: (id: CreatureId) => Creature | 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 [isLoaded, setIsLoaded] = useState(false); const [creatureMap, setCreatureMap] = useState( () => new Map(), ); useEffect(() => { const index = loadBestiaryIndex(); setSourceDisplayNames(index.sources as Record); if (index.creatures.length > 0) { setIsLoaded(true); } void bestiaryCache.loadAllCachedCreatures().then((map) => { setCreatureMap(map); }); }, []); const search = useCallback((query: string): SearchResult[] => { if (query.length < 2) return []; const lower = query.toLowerCase(); const index = loadBestiaryIndex(); return index.creatures .filter((c) => c.name.toLowerCase().includes(lower)) .sort((a, b) => a.name.localeCompare(b.name)) .slice(0, 10) .map((c) => ({ ...c, sourceDisplayName: getSourceDisplayName(c.source), })); }, []); const getCreature = useCallback( (id: CreatureId): Creature | undefined => { return creatureMap.get(id); }, [creatureMap], ); const isSourceCachedFn = useCallback( (sourceCode: string): Promise => { return bestiaryCache.isSourceCached(sourceCode); }, [], ); const fetchAndCacheSource = useCallback( async (sourceCode: string, url: string): Promise => { const response = await fetch(url); if (!response.ok) { throw new Error( `Failed to fetch: ${response.status} ${response.statusText}`, ); } const json = await response.json(); const creatures = normalizeBestiary(json); const displayName = getSourceDisplayName(sourceCode); await bestiaryCache.cacheSource(sourceCode, displayName, creatures); setCreatureMap((prev) => { const next = new Map(prev); for (const c of creatures) { next.set(c.id, c); } return next; }); }, [], ); const uploadAndCacheSource = useCallback( async (sourceCode: string, jsonData: unknown): Promise => { // biome-ignore lint/suspicious/noExplicitAny: raw JSON shape varies const creatures = normalizeBestiary(jsonData as any); const displayName = getSourceDisplayName(sourceCode); await bestiaryCache.cacheSource(sourceCode, displayName, creatures); setCreatureMap((prev) => { const next = new Map(prev); for (const c of creatures) { next.set(c.id, c); } return next; }); }, [], ); const refreshCache = useCallback(async (): Promise => { const map = await bestiaryCache.loadAllCachedCreatures(); setCreatureMap(map); }, []); return { search, getCreature, isLoaded, isSourceCached: isSourceCachedFn, fetchAndCacheSource, uploadAndCacheSource, refreshCache, }; }