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>
This commit is contained in:
Lukas
2026-05-27 15:49:34 +02:00
parent d9fb271607
commit c343fd3cd0
8 changed files with 837 additions and 9 deletions
@@ -49,10 +49,9 @@ describe("loadBestiaryIndex", () => {
});
describe("getAllSourceCodes", () => {
it("returns all keys from the index sources", () => {
it("returns all index sources except bundled ones", () => {
const codes = getAllSourceCodes();
const index = loadBestiaryIndex();
expect(codes).toEqual(Object.keys(index.sources));
expect(codes).not.toContain("TGL");
});
it("returns only strings", () => {
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import {
getBundledDndSources,
loadBundledDndCreatures,
loadBundledDndIndexEntries,
} from "../dnd-bundled-adapter.js";
describe("dnd-bundled-adapter", () => {
it("loads bundled creatures with a valid shape", () => {
const creatures = loadBundledDndCreatures();
const sources = getBundledDndSources();
for (const c of creatures) {
expect(sources.has(c.source)).toBe(true);
expect(c.sourceDisplayName).toBe(sources.get(c.source));
expect(c.id.startsWith(`${c.source.toLowerCase()}:`)).toBe(true);
}
});
it("derives source codes from the creature data", () => {
const creatures = loadBundledDndCreatures();
const sources = getBundledDndSources();
const seen = new Set(creatures.map((c) => c.source));
expect(sources.size).toBe(seen.size);
for (const s of seen) {
expect(sources.has(s)).toBe(true);
}
});
it("derives index entries that match the bundled creatures", () => {
const creatures = loadBundledDndCreatures();
const entries = loadBundledDndIndexEntries();
expect(entries.length).toBe(creatures.length);
const entryNames = new Set(entries.map((e) => e.name));
for (const c of creatures) {
expect(entryNames.has(c.name)).toBe(true);
}
});
it("abbreviates sizes to single-letter codes in index entries", () => {
const entries = loadBundledDndIndexEntries();
for (const e of entries) {
expect(["T", "S", "M", "L", "H", "G"]).toContain(e.size);
}
});
});
@@ -1,6 +1,10 @@
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;
@@ -55,23 +59,32 @@ export function loadBestiaryIndex(): BestiaryIndex {
if (cachedIndex) return cachedIndex;
const compact = rawIndex as unknown as CompactIndex;
const sources = Object.fromEntries(
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),
creatures: [
...compact.creatures
.filter((c) => !EXCLUDED_SOURCES.has(c.s))
.map(mapCreature),
...loadBundledDndIndexEntries(),
],
};
return cachedIndex;
}
export function getAllSourceCodes(): string[] {
const index = loadBestiaryIndex();
return Object.keys(index.sources).filter((c) => !EXCLUDED_SOURCES.has(c));
const bundled = getBundledDndSources();
return Object.keys(index.sources).filter(
(c) => !EXCLUDED_SOURCES.has(c) && !bundled.has(c),
);
}
function sourceCodeToFilename(sourceCode: string): string {
@@ -0,0 +1,53 @@
import type { BestiaryIndexEntry, Creature } from "@initiative/domain";
import { creatureId } from "@initiative/domain";
import rawBundled from "../../../../data/bestiary/dnd-bundled.json";
type RawBundledCreature = Omit<Creature, "id"> & { id: string };
const SIZE_TO_CODE: Record<string, string> = {
Tiny: "T",
Small: "S",
Medium: "M",
Large: "L",
Huge: "H",
Gargantuan: "G",
};
/** Full normalized stat blocks for bundled D&D creatures. */
export function loadBundledDndCreatures(): Creature[] {
return (rawBundled as RawBundledCreature[]).map((c) => ({
...c,
id: creatureId(c.id),
}));
}
/** Index entries derived from the bundled creatures, in the compact shape
* used by the search index. */
export function loadBundledDndIndexEntries(): BestiaryIndexEntry[] {
return (rawBundled as RawBundledCreature[]).map((c) => ({
name: c.name,
source: c.source,
ac: c.ac,
hp: c.hp.average,
dex: c.abilities.dex,
cr: c.cr,
initiativeProficiency: c.initiativeProficiency,
size: SIZE_TO_CODE[c.size.split(" ")[0]] ?? "M",
type: c.type.split(" ")[0].toLowerCase(),
}));
}
/** Source codes → display names, derived from the bundled creatures' own
* `source` and `sourceDisplayName` fields. Adding a new book just means
* appending creatures with the right `source` field to dnd-bundled.json;
* no code change is required here. */
export function getBundledDndSources(): ReadonlyMap<string, string> {
const map = new Map<string, string>();
for (const c of rawBundled as RawBundledCreature[]) {
if (!map.has(c.source)) {
map.set(c.source, c.sourceDisplayName);
}
}
return map;
}
+9 -1
View File
@@ -9,6 +9,7 @@ import {
normalizeBestiary,
setSourceDisplayNames,
} from "../adapters/bestiary-adapter.js";
import { loadBundledDndCreatures } from "../adapters/dnd-bundled-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";
@@ -160,7 +161,11 @@ export function useBestiary(): BestiaryHook {
}
void bestiaryCache.loadAllCachedCreatures().then((map) => {
setCreatureMap(map);
const merged = new Map(map);
for (const c of loadBundledDndCreatures()) {
merged.set(c.id, c);
}
setCreatureMap(merged);
});
}, [bestiaryCache, bestiaryIndex, pf2eBestiaryIndex]);
@@ -300,6 +305,9 @@ export function useBestiary(): BestiaryHook {
const refreshCache = useCallback(async (): Promise<void> => {
const map = await bestiaryCache.loadAllCachedCreatures();
for (const c of loadBundledDndCreatures()) {
map.set(c.id, c);
}
setCreatureMap(map);
}, [bestiaryCache]);