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:
@@ -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,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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user