Replace the stagnant Pf2eTools bestiary with Foundry VTT PF2e system data (github.com/foundryvtt/pf2e, v13-dev branch). This gives us 4,355 remaster-era creatures across 49 sources including Monster Core 1+2 and all adventure paths. Changes: - Rewrite index generation script to walk Foundry pack directories - Rewrite PF2e normalization adapter for Foundry JSON shape (system.* fields, items[] for attacks/abilities/spells) - Add stripFoundryTags utility for Foundry HTML + enrichment syntax - Implement multi-file source fetching (one request per creature file) - Add spellcasting section to PF2e stat block (ranked spells + cantrips) - Add saveConditional and hpDetails to PF2e domain type and stat block - Add size and rarity to PF2e trait tags - Filter redundant glossary abilities (healing when in hp.details, spell mechanic reminders, allSaves duplicates) - Add PF2e stat block component tests (22 tests) - Bump IndexedDB cache version to 5 for clean migration Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
215 lines
5.9 KiB
TypeScript
215 lines
5.9 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<void>;
|
|
uploadAndCacheSource: (
|
|
sourceCode: string,
|
|
jsonData: unknown,
|
|
) => Promise<void>;
|
|
refreshCache: () => Promise<void>;
|
|
}
|
|
|
|
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<void> => {
|
|
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<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,
|
|
};
|
|
}
|