Implements PF2e as an alternative game system alongside D&D 5e/5.5e. Settings modal "Game System" selector switches conditions, bestiary, stat block layout, and initiative calculation between systems. - Valued conditions with increment/decrement UX (Clumsy 2, Frightened 3) - 2,502 PF2e creatures from bundled search index (77 sources) - PF2e stat block: level, traits, Perception, Fort/Ref/Will, ability mods - Perception-based initiative rolling - System-scoped source cache (D&D and PF2e sources don't collide) - Backwards-compatible condition rehydration (ConditionId[] → ConditionEntry[]) - Difficulty indicator hidden in PF2e mode (excluded from MVP) Closes dostulata/initiative#19 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
216 lines
5.9 KiB
JavaScript
216 lines
5.9 KiB
JavaScript
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
|
|
// Usage: node scripts/generate-pf2e-bestiary-index.mjs <path-to-Pf2eTools>
|
|
//
|
|
// Requires a local clone/checkout of https://github.com/Pf2eToolsOrg/Pf2eTools (dev branch)
|
|
// with at least data/bestiary/.
|
|
//
|
|
// Example:
|
|
// git clone --depth 1 --branch dev --sparse https://github.com/Pf2eToolsOrg/Pf2eTools.git /tmp/pf2etools
|
|
// cd /tmp/pf2etools && git sparse-checkout set data/bestiary data
|
|
// node scripts/generate-pf2e-bestiary-index.mjs /tmp/pf2etools
|
|
|
|
const TOOLS_ROOT = process.argv[2];
|
|
if (!TOOLS_ROOT) {
|
|
console.error(
|
|
"Usage: node scripts/generate-pf2e-bestiary-index.mjs <Pf2eTools-path>",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const PROJECT_ROOT = join(import.meta.dirname, "..");
|
|
const BESTIARY_DIR = join(TOOLS_ROOT, "data/bestiary");
|
|
const OUTPUT_PATH = join(PROJECT_ROOT, "data/bestiary/pf2e-index.json");
|
|
|
|
// --- Source display names ---
|
|
// Pf2eTools doesn't have a single books.json with all adventure paths.
|
|
// We map known source codes to display names here.
|
|
const SOURCE_NAMES = {
|
|
B1: "Bestiary",
|
|
B2: "Bestiary 2",
|
|
B3: "Bestiary 3",
|
|
CRB: "Core Rulebook",
|
|
GMG: "Gamemastery Guide",
|
|
LOME: "Lost Omens: The Mwangi Expanse",
|
|
LOMM: "Lost Omens: Monsters of Myth",
|
|
LOIL: "Lost Omens: Impossible Lands",
|
|
LOCG: "Lost Omens: Character Guide",
|
|
LOSK: "Lost Omens: Knights of Lastwall",
|
|
LOTXWG: "Lost Omens: Travel Guide",
|
|
LOACLO: "Lost Omens: Absalom, City of Lost Omens",
|
|
LOHh: "Lost Omens: Highhelm",
|
|
AoA1: "Age of Ashes #1: Hellknight Hill",
|
|
AoA2: "Age of Ashes #2: Cult of Cinders",
|
|
AoA3: "Age of Ashes #3: Tomorrow Must Burn",
|
|
AoA4: "Age of Ashes #4: Fires of the Haunted City",
|
|
AoA5: "Age of Ashes #5: Against the Scarlet Triad",
|
|
AoA6: "Age of Ashes #6: Broken Promises",
|
|
AoE1: "Agents of Edgewatch #1",
|
|
AoE2: "Agents of Edgewatch #2",
|
|
AoE3: "Agents of Edgewatch #3",
|
|
AoE4: "Agents of Edgewatch #4",
|
|
AoE5: "Agents of Edgewatch #5",
|
|
AoE6: "Agents of Edgewatch #6",
|
|
EC1: "Extinction Curse #1",
|
|
EC2: "Extinction Curse #2",
|
|
EC3: "Extinction Curse #3",
|
|
EC4: "Extinction Curse #4",
|
|
EC5: "Extinction Curse #5",
|
|
EC6: "Extinction Curse #6",
|
|
AV1: "Abomination Vaults #1",
|
|
AV2: "Abomination Vaults #2",
|
|
AV3: "Abomination Vaults #3",
|
|
FRP1: "Fists of the Ruby Phoenix #1",
|
|
FRP2: "Fists of the Ruby Phoenix #2",
|
|
FRP3: "Fists of the Ruby Phoenix #3",
|
|
SoT1: "Strength of Thousands #1",
|
|
SoT2: "Strength of Thousands #2",
|
|
SoT3: "Strength of Thousands #3",
|
|
SoT4: "Strength of Thousands #4",
|
|
SoT5: "Strength of Thousands #5",
|
|
SoT6: "Strength of Thousands #6",
|
|
OoA1: "Outlaws of Alkenstar #1",
|
|
OoA2: "Outlaws of Alkenstar #2",
|
|
OoA3: "Outlaws of Alkenstar #3",
|
|
BotD: "Book of the Dead",
|
|
DA: "Dark Archive",
|
|
FoP: "The Fall of Plaguestone",
|
|
LTiBA: "Little Trouble in Big Absalom",
|
|
Sli: "The Slithering",
|
|
TiO: "Troubles in Otari",
|
|
NGD: "Night of the Gray Death",
|
|
BB: "Beginner Box",
|
|
SoG1: "Sky King's Tomb #1",
|
|
SoG2: "Sky King's Tomb #2",
|
|
SoG3: "Sky King's Tomb #3",
|
|
GW1: "Gatewalkers #1",
|
|
GW2: "Gatewalkers #2",
|
|
GW3: "Gatewalkers #3",
|
|
WoW1: "Wardens of Wildwood #1",
|
|
WoW2: "Wardens of Wildwood #2",
|
|
WoW3: "Wardens of Wildwood #3",
|
|
SF1: "Season of Ghosts #1",
|
|
SF2: "Season of Ghosts #2",
|
|
SF3: "Season of Ghosts #3",
|
|
POS1: "Pathfinder One-Shots",
|
|
AFoF: "A Fistful of Flowers",
|
|
TaL: "Threshold of Knowledge",
|
|
ToK: "Threshold of Knowledge",
|
|
DaLl: "Dinner at Lionlodge",
|
|
MotM: "Monsters of the Multiverse",
|
|
Mal: "Malevolence",
|
|
TEC: "The Enmity Cycle",
|
|
SaS: "Shadows at Sundown",
|
|
Rust: "Rusthenge",
|
|
CotT: "Crown of the Kobold King",
|
|
SoM: "Secrets of Magic",
|
|
};
|
|
|
|
// --- Size extraction from traits ---
|
|
const SIZES = new Set([
|
|
"tiny",
|
|
"small",
|
|
"medium",
|
|
"large",
|
|
"huge",
|
|
"gargantuan",
|
|
]);
|
|
|
|
// Creature type traits (PF2e types are lowercase in the traits array)
|
|
const CREATURE_TYPES = new Set([
|
|
"aberration",
|
|
"animal",
|
|
"astral",
|
|
"beast",
|
|
"celestial",
|
|
"construct",
|
|
"dragon",
|
|
"dream",
|
|
"elemental",
|
|
"ethereal",
|
|
"fey",
|
|
"fiend",
|
|
"fungus",
|
|
"giant",
|
|
"humanoid",
|
|
"monitor",
|
|
"ooze",
|
|
"petitioner",
|
|
"plant",
|
|
"spirit",
|
|
"time",
|
|
"undead",
|
|
]);
|
|
|
|
function extractSize(traits) {
|
|
if (!Array.isArray(traits)) return "medium";
|
|
const found = traits.find((t) => SIZES.has(t.toLowerCase()));
|
|
return found ? found.toLowerCase() : "medium";
|
|
}
|
|
|
|
function extractType(traits) {
|
|
if (!Array.isArray(traits)) return "";
|
|
const found = traits.find((t) => CREATURE_TYPES.has(t.toLowerCase()));
|
|
return found ? found.toLowerCase() : "";
|
|
}
|
|
|
|
// --- Main ---
|
|
|
|
const files = readdirSync(BESTIARY_DIR).filter(
|
|
(f) => f.startsWith("creatures-") && f.endsWith(".json"),
|
|
);
|
|
|
|
const creatures = [];
|
|
const seenSources = new Set();
|
|
|
|
for (const file of files.sort()) {
|
|
const raw = JSON.parse(readFileSync(join(BESTIARY_DIR, file), "utf-8"));
|
|
const entries = raw.creature ?? [];
|
|
|
|
for (const c of entries) {
|
|
// Skip copies/references
|
|
if (c._copy) continue;
|
|
|
|
const source = c.source ?? "";
|
|
seenSources.add(source);
|
|
|
|
const ac = c.defenses?.ac?.std ?? 0;
|
|
const hp = c.defenses?.hp?.[0]?.hp ?? 0;
|
|
const perception = c.perception?.std ?? 0;
|
|
|
|
creatures.push({
|
|
n: c.name,
|
|
s: source,
|
|
lv: c.level ?? 0,
|
|
ac,
|
|
hp,
|
|
pc: perception,
|
|
sz: extractSize(c.traits),
|
|
tp: extractType(c.traits),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Sort by name then source for stable output
|
|
creatures.sort((a, b) => a.n.localeCompare(b.n) || a.s.localeCompare(b.s));
|
|
|
|
// Build source map from seen sources
|
|
const sources = {};
|
|
for (const code of [...seenSources].sort()) {
|
|
sources[code] = SOURCE_NAMES[code] ?? code;
|
|
}
|
|
|
|
const output = { sources, creatures };
|
|
writeFileSync(OUTPUT_PATH, JSON.stringify(output));
|
|
|
|
const rawSize = Buffer.byteLength(JSON.stringify(output));
|
|
console.log(`Sources: ${Object.keys(sources).length}`);
|
|
console.log(`Creatures: ${creatures.length}`);
|
|
console.log(`Output size: ${(rawSize / 1024).toFixed(1)} KB`);
|
|
|
|
const unmapped = [...seenSources].filter((s) => !SOURCE_NAMES[s]);
|
|
if (unmapped.length > 0) {
|
|
console.log(`Unmapped sources: ${unmapped.sort().join(", ")}`);
|
|
}
|