Add rules covering bug prevention (noLeakedRender, noFloatingPromises, noImportCycles, noReactForwardRef), security (noScriptUrl, noAlert), performance (noAwaitInLoops, useTopLevelRegex), and code style (noNestedTernary, useGlobalThis, useNullishCoalescing, useSortedClasses, plus ~40 more). Fix all violations: extract top-level regex constants, guard React && renders with boolean coercion, refactor nested ternaries, replace window with globalThis, sort Tailwind classes, and introduce expectDomainError test helper to eliminate conditional expects. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
135 lines
3.5 KiB
TypeScript
135 lines
3.5 KiB
TypeScript
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<boolean>;
|
|
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
|
|
uploadAndCacheSource: (
|
|
sourceCode: string,
|
|
jsonData: unknown,
|
|
) => Promise<void>;
|
|
refreshCache: () => Promise<void>;
|
|
}
|
|
|
|
export function useBestiary(): BestiaryHook {
|
|
const [isLoaded, setIsLoaded] = useState(false);
|
|
const [creatureMap, setCreatureMap] = useState(
|
|
() => new Map<CreatureId, Creature>(),
|
|
);
|
|
|
|
useEffect(() => {
|
|
const index = loadBestiaryIndex();
|
|
setSourceDisplayNames(index.sources as Record<string, string>);
|
|
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<boolean> => {
|
|
return bestiaryCache.isSourceCached(sourceCode);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const fetchAndCacheSource = useCallback(
|
|
async (sourceCode: string, url: string): Promise<void> => {
|
|
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<void> => {
|
|
// 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<void> => {
|
|
const map = await bestiaryCache.loadAllCachedCreatures();
|
|
setCreatureMap(map);
|
|
}, []);
|
|
|
|
return {
|
|
search,
|
|
getCreature,
|
|
isLoaded,
|
|
isSourceCached: isSourceCachedFn,
|
|
fetchAndCacheSource,
|
|
uploadAndCacheSource,
|
|
refreshCache,
|
|
};
|
|
}
|