Files
initiative/apps/web/src/adapters/bestiary-index-adapter.ts
T
LukasandClaude Opus 4.7 c343fd3cd0 Add bundled-bestiary mechanism for shipping creatures with the app
D&D creatures listed in data/bestiary/dnd-bundled.json are now merged into
the search index and pre-loaded into creatureMap, so they appear alongside
5etools creatures with no "Load source" step. Source codes are derived from
the JSON itself (each creature carries source + sourceDisplayName), so adding
a new book is a pure data change. Bundled sources are excluded from
getAllSourceCodes() so bulk-import skips them, and they never appear in the
source manager (which only lists cached sources).

Includes a reference extractor (scripts/extract-great-labors.py) for the
5.5e revised stat-block format and a /bundle-bestiary skill that future
agents can follow to add monsters from other PDF books.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 15:49:34 +02:00

110 lines
2.8 KiB
TypeScript

import type { BestiaryIndex, BestiaryIndexEntry } from "@initiative/domain";
import rawIndex from "../../../../data/bestiary/index.json";
import {
getBundledDndSources,
loadBundledDndIndexEntries,
} from "./dnd-bundled-adapter.js";
interface CompactCreature {
readonly n: string;
readonly s: string;
readonly ac: number;
readonly hp: number;
readonly dx: number;
readonly cr: string;
readonly ip: number;
readonly sz: string;
readonly tp: string;
}
interface CompactIndex {
readonly sources: Record<string, string>;
readonly creatures: readonly CompactCreature[];
}
function mapCreature(c: CompactCreature): BestiaryIndexEntry {
return {
name: c.n,
source: c.s,
ac: c.ac,
hp: c.hp,
dex: c.dx,
cr: c.cr,
initiativeProficiency: c.ip,
size: c.sz,
type: c.tp,
};
}
// Source codes whose filename on the remote differs from a simple lowercase.
// Plane Shift sources use a hyphen: PSA -> ps-a, etc.
const FILENAME_OVERRIDES: Record<string, string> = {
PSA: "ps-a",
PSD: "ps-d",
PSI: "ps-i",
PSK: "ps-k",
PSX: "ps-x",
PSZ: "ps-z",
};
// Source codes with no corresponding remote bestiary file.
// Excluded from the index entirely so creatures aren't searchable
// without a fetchable source.
const EXCLUDED_SOURCES = new Set<string>([]);
let cachedIndex: BestiaryIndex | undefined;
export function loadBestiaryIndex(): BestiaryIndex {
if (cachedIndex) return cachedIndex;
const compact = rawIndex as unknown as CompactIndex;
const sources: Record<string, string> = Object.fromEntries(
Object.entries(compact.sources).filter(
([code]) => !EXCLUDED_SOURCES.has(code),
),
);
for (const [code, name] of getBundledDndSources()) {
sources[code] = name;
}
cachedIndex = {
sources,
creatures: [
...compact.creatures
.filter((c) => !EXCLUDED_SOURCES.has(c.s))
.map(mapCreature),
...loadBundledDndIndexEntries(),
],
};
return cachedIndex;
}
export function getAllSourceCodes(): string[] {
const index = loadBestiaryIndex();
const bundled = getBundledDndSources();
return Object.keys(index.sources).filter(
(c) => !EXCLUDED_SOURCES.has(c) && !bundled.has(c),
);
}
function sourceCodeToFilename(sourceCode: string): string {
return FILENAME_OVERRIDES[sourceCode] ?? sourceCode.toLowerCase();
}
export function getDefaultFetchUrl(
sourceCode: string,
baseUrl?: string,
): string {
const filename = `bestiary-${sourceCodeToFilename(sourceCode)}.json`;
if (baseUrl !== undefined) {
const normalized = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
return `${normalized}${filename}`;
}
return `https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/${filename}`;
}
export function getSourceDisplayName(sourceCode: string): string {
const index = loadBestiaryIndex();
return index.sources[sourceCode] ?? sourceCode;
}