Add Pathfinder 2e game system mode
All checks were successful
CI / check (push) Successful in 2m21s
CI / build-image (push) Successful in 24s

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>
This commit is contained in:
Lukas
2026-04-07 01:26:22 +02:00
parent 8f6eebc43b
commit e62c49434c
67 changed files with 27758 additions and 527 deletions

View File

@@ -0,0 +1,215 @@
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(", ")}`);
}