Compare commits

...

3 Commits

Author SHA1 Message Date
Lukas 1930473753 Bundle The Great Labors bestiary (27 creatures)
CI / check (push) Successful in 2m54s
CI / build-image (push) Successful in 36s
Adds the monsters from appendix B (pages 163-199) of The Great Labors:
Anarch Boar, Blemys, Bronze Automaton/Strategos, Cerberus/Young Cerberus,
Empusa, Goatling/Trickster, Gygan, Keledone, Maenad, Thylean Manticore,
Marble Golem, Minotaur Berserker/Warrior, the three mythic beasts
(White Stag, Golden Lion, Golden Ram), the five nymph lineages
(Aurae, Naiad, Nereid, Oceanid, Oread), Satyr Minstrel, and
Soldier/Soldier Captain.

Generated by scripts/extract-great-labors.py from the source PDF.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 15:50:15 +02:00
Lukas 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
Lukas d9fb271607 Add PF2e encounter difficulty calculation with 5-tier budget system
CI / check (push) Successful in 2m39s
CI / build-image (push) Successful in 18s
Implements PF2e encounter difficulty alongside the existing D&D system.
PF2e uses creature level vs party level to derive XP, compares against
5-tier budgets (Trivial/Low/Moderate/Severe/Extreme), and adjusts
thresholds for party size. The indicator shows 4 bars in PF2e mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 15:24:18 +02:00
22 changed files with 4386 additions and 53 deletions
+148
View File
@@ -0,0 +1,148 @@
---
name: bundle-bestiary
description: Bundle creatures from a third-party PDF into the app's D&D bestiary so they appear in search alongside 5etools creatures, with no "Load source" step. Use when the user asks to add monsters from a PDF book / adventure / supplement to the bundled bestiary.
---
## Instructions
Add the creatures from a PDF to `data/bestiary/dnd-bundled.json` so they appear in the D&D search index and render as normal stat blocks. Bundled creatures bypass the fetch/cache flow — they're shipped in the JS bundle and pre-loaded into `creatureMap` on startup.
### How the bundling works
- `data/bestiary/dnd-bundled.json` is an array of normalized `Creature` objects (the same shape produced by `bestiary-adapter.ts` for 5etools creatures).
- `apps/web/src/adapters/dnd-bundled-adapter.ts` static-imports the JSON and derives:
- `loadBundledDndCreatures()` — full stat blocks for the in-memory creature map
- `loadBundledDndIndexEntries()` — compact summaries for the search index
- `getBundledDndSources()` — source code → display name map, **derived from the JSON itself** (each creature carries its own `source` + `sourceDisplayName`)
- `bestiary-index-adapter.ts` merges the bundled entries into the search index and excludes bundled sources from `getAllSourceCodes()` (so bulk-import skips them).
- `use-bestiary.ts` merges bundled full creatures into `creatureMap` on init/refresh.
This means **adding a new bundled book is purely a data change**: append creatures to `dnd-bundled.json` with the new source's code and display name. No adapter or index code needs editing.
### Step 1 — Confirm scope and source code
Ask the user (don't guess):
1. **PDF path** and the **page range** containing the stat blocks. Many PDFs have hundreds of pages; only a slice has the bestiary.
2. **Source code abbreviation** — short uppercase letters, e.g., `TGL` for *The Great Labors*. Used in creature IDs and the index.
3. **Display name** — the human-readable book title shown in the source column.
4. **Edition / system** — confirm this is D&D (5e or 5.5e). Bundled creatures show in both 5e and 5.5e modes (the bestiary index only differentiates pf2e vs not). PF2e isn't currently supported by the bundled flow — if requested, this would need a parallel `pf2e-bundled-adapter.ts`.
5. **Licensing** — verify the user has the right to bundle the book's content. Don't make assumptions.
### Step 2 — Inspect the PDF
Check Python's PyPDF2 is available:
```bash
python3 -c "from PyPDF2 import PdfReader; print('ok')"
```
If not, the user has `pdftotext`-equivalent tooling configured at `~/Nextcloud/dnd/D&D/PROMPT_prep.md` worth checking.
Then dump and skim the target pages to learn the stat-block format:
```bash
python3 - <<'EOF'
from PyPDF2 import PdfReader
import os
r = PdfReader(os.path.expanduser('PATH/TO/PDF'))
for i in range(START-1, END):
print(f"\n===PAGE {i+1}===\n{r.pages[i].extract_text()}")
EOF
```
Look for the layout — the existing extractor (`scripts/extract-great-labors.py`) assumes the 5.5e/2024 revised format:
- `<Name>` line, then
- `<Size> <Type>(optional subtype), <Alignment>`, then
- `AC X Initiative ±Y (Z)`, then
- `HP N (NdN + N)`, then
- `Speed X ft., …`, then
- A `MOD SAVE MOD SAVE MOD SAVE` header followed by two ability-score rows, then
- Optional meta lines: `Skills`, `Saving Throws`, `Resistances`, `Immunities`, `Vulnerabilities`, `Senses`, `Languages`, then
- `Challenge X (NN XP; PB +N)`, then
- Section blocks: `Traits` / `Actions` / `Bonus Actions` / `Reactions` / `Legendary Actions`, each containing entries shaped like `Name. body...`.
If the PDF format matches, adapt the existing extractor. If it's a different format (5e 2014 with `STR DEX CON …` column layout, an older publisher's layout, a homebrew layout), expect to rework the parser more substantively.
### Step 3 — Adapt or extend the extractor
Copy `scripts/extract-great-labors.py` to a new script per book (e.g., `scripts/extract-<book-slug>.py`) and update:
- `SOURCE_CODE`, `SOURCE_DISPLAY`, `PAGE_START`, `PAGE_END` constants.
- The output path (`data/bestiary/dnd-bundled.json`). **Don't overwrite — merge.** The simplest pattern: read the existing file, drop any entries with the same `source`, then append the new ones.
- The `PROSE_TAIL_PATTERNS` list — every book has its own running headers (`<PageNumber>APPENDIX B … MONSTERS`-style), section-header phrases, and quote-attribution dashes. Run the extractor, audit the output (see Step 4), and add curated trim patterns for any prose tails that bleed in.
Run it:
```bash
python3 scripts/extract-<book-slug>.py PATH/TO/PDF
```
### Step 4 — Audit the output
PyPDF text extraction is messy. Always audit before claiming done:
```bash
python3 - <<'EOF'
import json, re
data = json.load(open('data/bestiary/dnd-bundled.json'))
new = [c for c in data if c['source'] == 'XXX'] # replace XXX with your code
for c in new:
print(f"{c['name']}: CR {c['cr']}, AC {c['ac']}, HP {c['hp']['average']} ({c['hp']['formula']})")
abs_ = c['abilities']
print(f" STR {abs_['str']} DEX {abs_['dex']} CON {abs_['con']} INT {abs_['int']} WIS {abs_['wis']} CHA {abs_['cha']}, PP {c['passive']}")
# Then audit bodies for prose-tail bleed and weird splits.
for c in new:
for sec in ('traits', 'actions', 'bonusActions', 'reactions'):
for e in c.get(sec, []):
body = e['segments'][0]['value']
issues = []
if len(body) > 600: issues.append(f"long({len(body)})")
if re.search(r'\.[A-Z][a-z]', body): issues.append("dot-Capital")
if 'APPENDIX' in body: issues.append("APPENDIX")
if re.search(r'—\s*[A-Z]\w+,\s', body): issues.append("attribution")
if issues:
print(f" {c['name']} [{sec}] {e['name']}: {', '.join(issues)}")
print(f" ...{body[-200:]}")
EOF
```
Common PDF extraction problems to fix in the parser:
- **PDF kerning quirks**: multi-digit values rendered with spaces (e.g., "Passive Perception 1 1" → 11, "Wis 81 1" with no space before negative). The existing parser handles most; check for new ones.
- **Smushed section headers**: lines like `...plants.Actions` where the section header for the next block was concatenated. Handle via `SECTION_HEADER_SMUSH_RE` preprocessing.
- **Cross-page prose bleed**: text from the next page's flavor prose absorbed into the last entry's body. Catch via `PROSE_TAIL_PATTERNS` — add curated phrases observed in this specific book.
- **Sibling-entry inline smush**: `damage.Ram. Melee Attack Roll: …` where two entries got concatenated. Already handled by the mid-line entry boundary regex in the existing parser.
- **Title-cased false positives**: words like `Bloodied.`, `Restrained.`, `Frightened.` at sentence ends would otherwise match the entry-name pattern. Filtered via `NAME_FALSE_POSITIVES` — add to it if the new book uses condition names you haven't seen yet.
### Step 5 — Verify in the app
```bash
pnpm check
```
Then start the dev server and search for one of the new creatures by name:
```bash
pnpm --filter web dev
```
Confirm in the browser:
1. Search finds the creature with the right book name as the source label.
2. Clicking it shows the full stat block immediately — **no "Load source" prompt**.
3. The source manager UI does **not** list the bundled book (it only shows cached sources).
4. Bulk import skips the bundled book.
### Notes for future agents
- **No need to edit `dnd-bundled-adapter.ts` or `bestiary-index-adapter.ts`** when adding a new book — the adapter derives source codes from the JSON.
- `data/bestiary/index.json` is regenerated from 5etools and should **not** be edited to add bundled entries. The merge happens at runtime in `bestiary-index-adapter.ts`.
- Each bundled creature must have:
- A unique `id` like `<sourcecode>:<slug>` (e.g., `tgl:anarch-boar`).
- `source` field matching the source code (e.g., `"TGL"`).
- `sourceDisplayName` field matching the book's display name (e.g., `"The Great Labors"`).
- All the required `Creature` fields from `packages/domain/src/creature-types.ts`.
- The script approach is preferred over hand-editing JSON for >5 creatures. For a single creature or two, hand-editing the JSON is reasonable; just match an existing entry's shape exactly.
- After any change to `dnd-bundled.json`, run `pnpm typecheck` — the static import in the adapter will catch shape mismatches at compile time.
@@ -0,0 +1,28 @@
import type { Pf2eCreature } from "@initiative/domain";
import { creatureId } from "@initiative/domain";
let counter = 0;
export function buildPf2eCreature(
overrides?: Partial<Pf2eCreature>,
): Pf2eCreature {
const id = ++counter;
return {
system: "pf2e",
id: creatureId(`pf2e-creature-${id}`),
name: `PF2e Creature ${id}`,
source: "crb",
sourceDisplayName: "Core Rulebook",
level: 1,
traits: ["humanoid"],
perception: 5,
abilityMods: { str: 2, dex: 1, con: 2, int: 0, wis: 1, cha: -1 },
ac: 15,
saveFort: 7,
saveRef: 4,
saveWill: 5,
hp: 20,
speed: "25 ft.",
...overrides,
};
}
@@ -1,3 +1,4 @@
export { buildCombatant } from "./build-combatant.js"; export { buildCombatant } from "./build-combatant.js";
export { buildCreature } from "./build-creature.js"; export { buildCreature } from "./build-creature.js";
export { buildEncounter } from "./build-encounter.js"; export { buildEncounter } from "./build-encounter.js";
export { buildPf2eCreature } from "./build-pf2e-creature.js";
@@ -49,10 +49,9 @@ describe("loadBestiaryIndex", () => {
}); });
describe("getAllSourceCodes", () => { describe("getAllSourceCodes", () => {
it("returns all keys from the index sources", () => { it("returns all index sources except bundled ones", () => {
const codes = getAllSourceCodes(); const codes = getAllSourceCodes();
const index = loadBestiaryIndex(); expect(codes).not.toContain("TGL");
expect(codes).toEqual(Object.keys(index.sources));
}); });
it("returns only strings", () => { 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 type { BestiaryIndex, BestiaryIndexEntry } from "@initiative/domain";
import rawIndex from "../../../../data/bestiary/index.json"; import rawIndex from "../../../../data/bestiary/index.json";
import {
getBundledDndSources,
loadBundledDndIndexEntries,
} from "./dnd-bundled-adapter.js";
interface CompactCreature { interface CompactCreature {
readonly n: string; readonly n: string;
@@ -55,23 +59,32 @@ export function loadBestiaryIndex(): BestiaryIndex {
if (cachedIndex) return cachedIndex; if (cachedIndex) return cachedIndex;
const compact = rawIndex as unknown as CompactIndex; const compact = rawIndex as unknown as CompactIndex;
const sources = Object.fromEntries( const sources: Record<string, string> = Object.fromEntries(
Object.entries(compact.sources).filter( Object.entries(compact.sources).filter(
([code]) => !EXCLUDED_SOURCES.has(code), ([code]) => !EXCLUDED_SOURCES.has(code),
), ),
); );
for (const [code, name] of getBundledDndSources()) {
sources[code] = name;
}
cachedIndex = { cachedIndex = {
sources, sources,
creatures: compact.creatures creatures: [
...compact.creatures
.filter((c) => !EXCLUDED_SOURCES.has(c.s)) .filter((c) => !EXCLUDED_SOURCES.has(c.s))
.map(mapCreature), .map(mapCreature),
...loadBundledDndIndexEntries(),
],
}; };
return cachedIndex; return cachedIndex;
} }
export function getAllSourceCodes(): string[] { export function getAllSourceCodes(): string[] {
const index = loadBestiaryIndex(); 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 { 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;
}
@@ -1,7 +1,11 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain"; import type {
AnyCreature,
CreatureId,
PlayerCharacter,
} from "@initiative/domain";
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain"; import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
import { import {
cleanup, cleanup,
@@ -17,6 +21,7 @@ import {
buildCombatant, buildCombatant,
buildCreature, buildCreature,
buildEncounter, buildEncounter,
buildPf2eCreature,
} from "../../__tests__/factories/index.js"; } from "../../__tests__/factories/index.js";
import { AllProviders } from "../../__tests__/test-providers.js"; import { AllProviders } from "../../__tests__/test-providers.js";
import { useRulesEdition } from "../../hooks/use-rules-edition.js"; import { useRulesEdition } from "../../hooks/use-rules-edition.js";
@@ -52,7 +57,7 @@ const goblinCreature = buildCreature({
function renderPanel(options: { function renderPanel(options: {
encounter: ReturnType<typeof buildEncounter>; encounter: ReturnType<typeof buildEncounter>;
playerCharacters?: PlayerCharacter[]; playerCharacters?: PlayerCharacter[];
creatures?: Map<CreatureId, Creature>; creatures?: Map<CreatureId, AnyCreature>;
onClose?: () => void; onClose?: () => void;
}) { }) {
const adapters = createTestAdapters({ const adapters = createTestAdapters({
@@ -357,4 +362,157 @@ describe("DifficultyBreakdownPanel", () => {
expect(onClose).toHaveBeenCalledOnce(); expect(onClose).toHaveBeenCalledOnce();
}); });
describe("PF2e edition", () => {
const orcWarrior = buildPf2eCreature({
id: creatureId("pf2e:orc-warrior"),
name: "Orc Warrior",
level: 3,
source: "crb",
sourceDisplayName: "Core Rulebook",
});
function pf2eEncounter() {
return buildEncounter({
combatants: [
buildCombatant({
id: combatantId("c-1"),
name: "Hero",
playerCharacterId: pcId1,
}),
buildCombatant({
id: combatantId("c-2"),
name: "Orc Warrior",
creatureId: orcWarrior.id,
}),
],
});
}
it("shows PF2e tier label", async () => {
const { result: editionResult } = renderHook(() => useRulesEdition());
editionResult.current.setEdition("pf2e");
try {
renderPanel({
encounter: pf2eEncounter(),
playerCharacters: [
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
],
creatures: new Map([[orcWarrior.id, orcWarrior]]),
});
await waitFor(() => {
expect(
screen.getByText("Encounter Difficulty:", { exact: false }),
).toBeInTheDocument();
});
} finally {
editionResult.current.setEdition("5.5e");
}
});
it("shows party level", async () => {
const { result: editionResult } = renderHook(() => useRulesEdition());
editionResult.current.setEdition("pf2e");
try {
renderPanel({
encounter: pf2eEncounter(),
playerCharacters: [
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
],
creatures: new Map([[orcWarrior.id, orcWarrior]]),
});
await waitFor(() => {
expect(
screen.getByText("Party Level: 5", { exact: false }),
).toBeInTheDocument();
});
} finally {
editionResult.current.setEdition("5.5e");
}
});
it("shows creature level and level difference", async () => {
const { result: editionResult } = renderHook(() => useRulesEdition());
editionResult.current.setEdition("pf2e");
try {
renderPanel({
encounter: pf2eEncounter(),
playerCharacters: [
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
],
creatures: new Map([[orcWarrior.id, orcWarrior]]),
});
await waitFor(() => {
// Orc Warrior level 3, party level 5 → diff 2
expect(
screen.getByText("Lv 3 (-2)", { exact: false }),
).toBeInTheDocument();
});
} finally {
editionResult.current.setEdition("5.5e");
}
});
it("shows 5 thresholds with short labels", async () => {
const { result: editionResult } = renderHook(() => useRulesEdition());
editionResult.current.setEdition("pf2e");
try {
renderPanel({
encounter: pf2eEncounter(),
playerCharacters: [
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
],
creatures: new Map([[orcWarrior.id, orcWarrior]]),
});
await waitFor(() => {
expect(
screen.getByText("Triv:", { exact: false }),
).toBeInTheDocument();
expect(
screen.getByText("Low:", { exact: false }),
).toBeInTheDocument();
expect(
screen.getByText("Mod:", { exact: false }),
).toBeInTheDocument();
expect(
screen.getByText("Sev:", { exact: false }),
).toBeInTheDocument();
expect(
screen.getByText("Ext:", { exact: false }),
).toBeInTheDocument();
});
} finally {
editionResult.current.setEdition("5.5e");
}
});
it("shows Net Creature XP label in PF2e mode", async () => {
const { result: editionResult } = renderHook(() => useRulesEdition());
editionResult.current.setEdition("pf2e");
try {
renderPanel({
encounter: pf2eEncounter(),
playerCharacters: [
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
],
creatures: new Map([[orcWarrior.id, orcWarrior]]),
});
await waitFor(() => {
expect(screen.getByText("Net Creature XP")).toBeInTheDocument();
});
} finally {
editionResult.current.setEdition("5.5e");
}
});
});
}); });
@@ -7,6 +7,7 @@ import {
DifficultyIndicator, DifficultyIndicator,
TIER_LABELS_5_5E, TIER_LABELS_5_5E,
TIER_LABELS_2014, TIER_LABELS_2014,
TIER_LABELS_PF2E,
} from "../difficulty-indicator.js"; } from "../difficulty-indicator.js";
afterEach(cleanup); afterEach(cleanup);
@@ -23,6 +24,7 @@ function makeResult(tier: DifficultyResult["tier"]): DifficultyResult {
encounterMultiplier: undefined, encounterMultiplier: undefined,
adjustedXp: undefined, adjustedXp: undefined,
partySizeAdjusted: undefined, partySizeAdjusted: undefined,
partyLevel: undefined,
}; };
} }
@@ -125,4 +127,64 @@ describe("DifficultyIndicator", () => {
const element = container.querySelector("[role='img']"); const element = container.querySelector("[role='img']");
expect(element?.tagName).toBe("BUTTON"); expect(element?.tagName).toBe("BUTTON");
}); });
it("renders 4 bars when barCount is 4", () => {
const { container } = render(
<DifficultyIndicator
result={makeResult(2)}
labels={TIER_LABELS_PF2E}
barCount={4}
/>,
);
const bars = container.querySelectorAll("[class*='rounded-sm']");
expect(bars).toHaveLength(4);
});
it("shows 0 filled bars for tier 0 with 4 bars", () => {
const { container } = render(
<DifficultyIndicator
result={makeResult(0)}
labels={TIER_LABELS_PF2E}
barCount={4}
/>,
);
const bars = container.querySelectorAll("[class*='rounded-sm']");
for (const bar of bars) {
expect(bar.className).toContain("bg-muted");
}
});
it("shows correct PF2e tooltip for Severe tier", () => {
render(
<DifficultyIndicator
result={makeResult(3)}
labels={TIER_LABELS_PF2E}
barCount={4}
/>,
);
expect(
screen.getByRole("img", { name: "Severe encounter difficulty" }),
).toBeDefined();
});
it("shows correct PF2e tooltip for Extreme tier", () => {
render(
<DifficultyIndicator
result={makeResult(4)}
labels={TIER_LABELS_PF2E}
barCount={4}
/>,
);
expect(
screen.getByRole("img", { name: "Extreme encounter difficulty" }),
).toBeDefined();
});
it("D&D indicator still renders 3 bars (no regression)", () => {
const { container } = render(
<DifficultyIndicator result={makeResult(3)} labels={TIER_LABELS_5_5E} />,
);
const bars = container.querySelectorAll("[class*='rounded-sm']");
expect(bars).toHaveLength(3);
});
}); });
@@ -19,12 +19,21 @@ const TIER_LABEL_MAP: Partial<
1: { label: "Low", color: "text-green-500" }, 1: { label: "Low", color: "text-green-500" },
2: { label: "Moderate", color: "text-yellow-500" }, 2: { label: "Moderate", color: "text-yellow-500" },
3: { label: "High", color: "text-red-500" }, 3: { label: "High", color: "text-red-500" },
4: { label: "High", color: "text-red-500" },
}, },
"5e": { "5e": {
0: { label: "Easy", color: "text-muted-foreground" }, 0: { label: "Easy", color: "text-muted-foreground" },
1: { label: "Medium", color: "text-green-500" }, 1: { label: "Medium", color: "text-green-500" },
2: { label: "Hard", color: "text-yellow-500" }, 2: { label: "Hard", color: "text-yellow-500" },
3: { label: "Deadly", color: "text-red-500" }, 3: { label: "Deadly", color: "text-red-500" },
4: { label: "Deadly", color: "text-red-500" },
},
pf2e: {
0: { label: "Trivial", color: "text-muted-foreground" },
1: { label: "Low", color: "text-green-500" },
2: { label: "Moderate", color: "text-yellow-500" },
3: { label: "Severe", color: "text-orange-500" },
4: { label: "Extreme", color: "text-red-500" },
}, },
}; };
@@ -32,6 +41,9 @@ const TIER_LABEL_MAP: Partial<
const SHORT_LABELS: Readonly<Record<string, string>> = { const SHORT_LABELS: Readonly<Record<string, string>> = {
Moderate: "Mod", Moderate: "Mod",
Medium: "Med", Medium: "Med",
Trivial: "Triv",
Severe: "Sev",
Extreme: "Ext",
}; };
function shortLabel(label: string): string { function shortLabel(label: string): string {
@@ -107,6 +119,54 @@ function NpcRow({
); );
} }
function Pf2eNpcRow({
entry,
onToggleSide,
}: {
entry: BreakdownCombatant;
onToggleSide: () => void;
}) {
const isParty = entry.side === "party";
const targetSide = isParty ? "enemy" : "party";
let xpDisplay: string;
if (entry.xp == null) {
xpDisplay = "\u2014";
} else if (isParty) {
xpDisplay = `\u2212${formatXp(entry.xp)}`;
} else {
xpDisplay = formatXp(entry.xp);
}
let levelDisplay: string;
if (entry.creatureLevel === undefined) {
levelDisplay = "\u2014";
} else if (entry.levelDifference === undefined) {
levelDisplay = `Lv ${entry.creatureLevel}`;
} else {
const sign = entry.levelDifference >= 0 ? "+" : "";
levelDisplay = `Lv ${entry.creatureLevel} (${sign}${entry.levelDifference})`;
}
return (
<div className="col-span-4 grid grid-cols-subgrid items-center text-xs">
<span className="min-w-0 truncate" title={entry.combatant.name}>
{entry.combatant.name}
</span>
<Button
variant="ghost"
size="icon-sm"
onClick={onToggleSide}
aria-label={`Move ${entry.combatant.name} to ${targetSide} side`}
>
<ArrowLeftRight className="h-3 w-3" />
</Button>
<span className="text-muted-foreground">{levelDisplay}</span>
<span className="text-right tabular-nums">{xpDisplay}</span>
</div>
);
}
export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) { export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
useClickOutside(ref, onClose); useClickOutside(ref, onClose);
@@ -128,6 +188,8 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
const isPC = (entry: BreakdownCombatant) => const isPC = (entry: BreakdownCombatant) =>
entry.combatant.playerCharacterId != null; entry.combatant.playerCharacterId != null;
const CreatureRow = edition === "pf2e" ? Pf2eNpcRow : NpcRow;
return ( return (
<div <div
ref={ref} ref={ref}
@@ -142,6 +204,9 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
<div className="mb-1 text-muted-foreground text-xs"> <div className="mb-1 text-muted-foreground text-xs">
Party Budget ({breakdown.pcCount}{" "} Party Budget ({breakdown.pcCount}{" "}
{breakdown.pcCount === 1 ? "PC" : "PCs"}) {breakdown.pcCount === 1 ? "PC" : "PCs"})
{breakdown.partyLevel !== undefined && (
<> &middot; Party Level: {breakdown.partyLevel}</>
)}
</div> </div>
<div className="flex gap-3 text-xs"> <div className="flex gap-3 text-xs">
{breakdown.thresholds.map((t) => ( {breakdown.thresholds.map((t) => (
@@ -166,7 +231,7 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
isPC(entry) ? ( isPC(entry) ? (
<PcRow key={entry.combatant.id} entry={entry} /> <PcRow key={entry.combatant.id} entry={entry} />
) : ( ) : (
<NpcRow <CreatureRow
key={entry.combatant.id} key={entry.combatant.id}
entry={entry} entry={entry}
onToggleSide={() => handleToggle(entry)} onToggleSide={() => handleToggle(entry)}
@@ -186,7 +251,7 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
isPC(entry) ? ( isPC(entry) ? (
<PcRow key={entry.combatant.id} entry={entry} /> <PcRow key={entry.combatant.id} entry={entry} />
) : ( ) : (
<NpcRow <CreatureRow
key={entry.combatant.id} key={entry.combatant.id}
entry={entry} entry={entry}
onToggleSide={() => handleToggle(entry)} onToggleSide={() => handleToggle(entry)}
@@ -218,7 +283,9 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
</div> </div>
) : ( ) : (
<div className="mt-2 flex justify-between border-border border-t pt-2 font-medium text-xs"> <div className="mt-2 flex justify-between border-border border-t pt-2 font-medium text-xs">
<span>Net Monster XP</span> <span>
{edition === "pf2e" ? "Net Creature XP" : "Net Monster XP"}
</span>
<span className="tabular-nums"> <span className="tabular-nums">
{formatXp(breakdown.totalMonsterXp)} {formatXp(breakdown.totalMonsterXp)}
</span> </span>
@@ -6,6 +6,7 @@ export const TIER_LABELS_5_5E: Record<DifficultyTier, string> = {
1: "Low", 1: "Low",
2: "Moderate", 2: "Moderate",
3: "High", 3: "High",
4: "High",
}; };
export const TIER_LABELS_2014: Record<DifficultyTier, string> = { export const TIER_LABELS_2014: Record<DifficultyTier, string> = {
@@ -13,30 +14,49 @@ export const TIER_LABELS_2014: Record<DifficultyTier, string> = {
1: "Medium", 1: "Medium",
2: "Hard", 2: "Hard",
3: "Deadly", 3: "Deadly",
4: "Deadly",
}; };
const TIER_COLORS: Record< export const TIER_LABELS_PF2E: Record<DifficultyTier, string> = {
DifficultyTier, 0: "Trivial",
{ filledBars: number; color: string } 1: "Low",
> = { 2: "Moderate",
0: { filledBars: 0, color: "" }, 3: "Severe",
1: { filledBars: 1, color: "bg-green-500" }, 4: "Extreme",
2: { filledBars: 2, color: "bg-yellow-500" },
3: { filledBars: 3, color: "bg-red-500" },
}; };
const BAR_HEIGHTS = ["h-2", "h-3", "h-4"] as const; const BAR_HEIGHTS_3 = ["h-2", "h-3", "h-4"] as const;
const BAR_HEIGHTS_4 = ["h-1.5", "h-2", "h-3", "h-4"] as const;
/** Color for the Nth filled bar (1-indexed) in 4-bar mode. */
const BAR_COLORS: Record<number, string> = {
1: "bg-green-500",
2: "bg-yellow-500",
3: "bg-orange-500",
4: "bg-red-500",
};
/** For 3-bar mode, bar 3 uses red directly (skip orange). */
const BAR_COLORS_3: Record<number, string> = {
1: "bg-green-500",
2: "bg-yellow-500",
3: "bg-red-500",
};
export function DifficultyIndicator({ export function DifficultyIndicator({
result, result,
labels, labels,
barCount = 3,
onClick, onClick,
}: { }: {
result: DifficultyResult; result: DifficultyResult;
labels: Record<DifficultyTier, string>; labels: Record<DifficultyTier, string>;
barCount?: 3 | 4;
onClick?: () => void; onClick?: () => void;
}) { }) {
const config = TIER_COLORS[result.tier]; const barHeights = barCount === 4 ? BAR_HEIGHTS_4 : BAR_HEIGHTS_3;
const colorMap = barCount === 4 ? BAR_COLORS : BAR_COLORS_3;
const filledBars = result.tier;
const label = labels[result.tier]; const label = labels[result.tier];
const tooltip = `${label} encounter difficulty`; const tooltip = `${label} encounter difficulty`;
@@ -54,13 +74,13 @@ export function DifficultyIndicator({
onClick={onClick} onClick={onClick}
type={onClick ? "button" : undefined} type={onClick ? "button" : undefined}
> >
{BAR_HEIGHTS.map((height, i) => ( {barHeights.map((height, i) => (
<div <div
key={height} key={height}
className={cn( className={cn(
"w-1 rounded-sm", "w-1 rounded-sm",
height, height,
i < config.filledBars ? config.color : "bg-muted", i < filledBars ? colorMap[i + 1] : "bg-muted",
)} )}
/> />
))} ))}
+9 -1
View File
@@ -8,6 +8,7 @@ import {
DifficultyIndicator, DifficultyIndicator,
TIER_LABELS_5_5E, TIER_LABELS_5_5E,
TIER_LABELS_2014, TIER_LABELS_2014,
TIER_LABELS_PF2E,
} from "./difficulty-indicator.js"; } from "./difficulty-indicator.js";
import { Button } from "./ui/button.js"; import { Button } from "./ui/button.js";
import { ConfirmButton } from "./ui/confirm-button.js"; import { ConfirmButton } from "./ui/confirm-button.js";
@@ -26,7 +27,13 @@ export function TurnNavigation() {
const difficulty = useDifficulty(); const difficulty = useDifficulty();
const { edition } = useRulesEditionContext(); const { edition } = useRulesEditionContext();
const tierLabels = edition === "5e" ? TIER_LABELS_2014 : TIER_LABELS_5_5E; const TIER_LABELS_BY_EDITION = {
pf2e: TIER_LABELS_PF2E,
"5e": TIER_LABELS_2014,
"5.5e": TIER_LABELS_5_5E,
} as const;
const tierLabels = TIER_LABELS_BY_EDITION[edition];
const barCount = edition === "pf2e" ? 4 : 3;
const [showBreakdown, setShowBreakdown] = useState(false); const [showBreakdown, setShowBreakdown] = useState(false);
const hasCombatants = encounter.combatants.length > 0; const hasCombatants = encounter.combatants.length > 0;
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0; const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
@@ -87,6 +94,7 @@ export function TurnNavigation() {
<DifficultyIndicator <DifficultyIndicator
result={difficulty} result={difficulty}
labels={tierLabels} labels={tierLabels}
barCount={barCount}
onClick={() => setShowBreakdown((prev) => !prev)} onClick={() => setShowBreakdown((prev) => !prev)}
/> />
{showBreakdown ? ( {showBreakdown ? (
@@ -1,5 +1,9 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain"; import type {
AnyCreature,
CreatureId,
PlayerCharacter,
} from "@initiative/domain";
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain"; import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
import { renderHook, waitFor } from "@testing-library/react"; import { renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
@@ -9,6 +13,7 @@ import {
buildCombatant, buildCombatant,
buildCreature, buildCreature,
buildEncounter, buildEncounter,
buildPf2eCreature,
} from "../../__tests__/factories/index.js"; } from "../../__tests__/factories/index.js";
import { AllProviders } from "../../__tests__/test-providers.js"; import { AllProviders } from "../../__tests__/test-providers.js";
import { useDifficultyBreakdown } from "../use-difficulty-breakdown.js"; import { useDifficultyBreakdown } from "../use-difficulty-breakdown.js";
@@ -42,7 +47,7 @@ const goblinCreature = buildCreature({
function makeWrapper(options: { function makeWrapper(options: {
encounter: ReturnType<typeof buildEncounter>; encounter: ReturnType<typeof buildEncounter>;
playerCharacters?: PlayerCharacter[]; playerCharacters?: PlayerCharacter[];
creatures?: Map<CreatureId, Creature>; creatures?: Map<CreatureId, AnyCreature>;
}) { }) {
const adapters = createTestAdapters({ const adapters = createTestAdapters({
encounter: options.encounter, encounter: options.encounter,
@@ -345,4 +350,115 @@ describe("useDifficultyBreakdown", () => {
editionResult.current.setEdition("5.5e"); editionResult.current.setEdition("5.5e");
} }
}); });
describe("PF2e edition", () => {
const orcWarrior = buildPf2eCreature({
id: creatureId("pf2e:orc-warrior"),
name: "Orc Warrior",
level: 3,
source: "crb",
sourceDisplayName: "Core Rulebook",
});
it("returns breakdown with creatureLevel, levelDifference, and XP for PF2e creatures", async () => {
const wrapper = makeWrapper({
encounter: buildEncounter({
combatants: [
buildCombatant({
id: combatantId("c-1"),
name: "Hero",
playerCharacterId: pcId1,
}),
buildCombatant({
id: combatantId("c-2"),
name: "Orc Warrior",
creatureId: orcWarrior.id,
}),
],
}),
playerCharacters: [
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
],
creatures: new Map([[orcWarrior.id, orcWarrior]]),
});
const { result: editionResult } = renderHook(() => useRulesEdition(), {
wrapper,
});
editionResult.current.setEdition("pf2e");
try {
const { result } = renderHook(() => useDifficultyBreakdown(), {
wrapper,
});
await waitFor(() => {
const breakdown = result.current;
expect(breakdown).not.toBeNull();
// Party level should be 5
expect(breakdown?.partyLevel).toBe(5);
// Orc Warrior: level 3, party level 5 → diff 2 → 20 XP
const orc = breakdown?.enemyCombatants[0];
expect(orc?.creatureLevel).toBe(3);
expect(orc?.levelDifference).toBe(-2);
expect(orc?.xp).toBe(20);
expect(orc?.cr).toBeNull();
expect(orc?.source).toBe("Core Rulebook");
// PC should have no creature level
const pc = breakdown?.partyCombatants[0];
expect(pc?.creatureLevel).toBeUndefined();
expect(pc?.levelDifference).toBeUndefined();
});
} finally {
editionResult.current.setEdition("5.5e");
}
});
it("returns partyLevel in result", async () => {
const wrapper = makeWrapper({
encounter: buildEncounter({
combatants: [
buildCombatant({
id: combatantId("c-1"),
name: "Hero",
playerCharacterId: pcId1,
}),
buildCombatant({
id: combatantId("c-2"),
name: "Orc Warrior",
creatureId: orcWarrior.id,
}),
],
}),
playerCharacters: [
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
],
creatures: new Map([[orcWarrior.id, orcWarrior]]),
});
const { result: editionResult } = renderHook(() => useRulesEdition(), {
wrapper,
});
editionResult.current.setEdition("pf2e");
try {
const { result } = renderHook(() => useDifficultyBreakdown(), {
wrapper,
});
await waitFor(() => {
expect(result.current).not.toBeNull();
expect(result.current?.partyLevel).toBe(5);
// 5 thresholds for PF2e
expect(result.current?.thresholds).toHaveLength(5);
expect(result.current?.thresholds[0].label).toBe("Trivial");
});
} finally {
editionResult.current.setEdition("5.5e");
}
});
});
}); });
@@ -1,5 +1,9 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain"; import type {
AnyCreature,
CreatureId,
PlayerCharacter,
} from "@initiative/domain";
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain"; import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
import { renderHook, waitFor } from "@testing-library/react"; import { renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
@@ -9,6 +13,7 @@ import {
buildCombatant, buildCombatant,
buildCreature, buildCreature,
buildEncounter, buildEncounter,
buildPf2eCreature,
} from "../../__tests__/factories/index.js"; } from "../../__tests__/factories/index.js";
import { AllProviders } from "../../__tests__/test-providers.js"; import { AllProviders } from "../../__tests__/test-providers.js";
import { useDifficulty } from "../use-difficulty.js"; import { useDifficulty } from "../use-difficulty.js";
@@ -43,7 +48,7 @@ const goblinCreature = buildCreature({
function makeWrapper(options: { function makeWrapper(options: {
encounter: ReturnType<typeof buildEncounter>; encounter: ReturnType<typeof buildEncounter>;
playerCharacters?: PlayerCharacter[]; playerCharacters?: PlayerCharacter[];
creatures?: Map<CreatureId, Creature>; creatures?: Map<CreatureId, AnyCreature>;
}) { }) {
const adapters = createTestAdapters({ const adapters = createTestAdapters({
encounter: options.encounter, encounter: options.encounter,
@@ -424,4 +429,134 @@ describe("useDifficulty", () => {
expect(result.current?.totalMonsterXp).toBe(0); expect(result.current?.totalMonsterXp).toBe(0);
}); });
}); });
describe("PF2e edition", () => {
const pf2eCreature = buildPf2eCreature({
id: creatureId("pf2e:orc-warrior"),
name: "Orc Warrior",
level: 5,
});
function makePf2eWrapper(options: {
encounter: ReturnType<typeof buildEncounter>;
playerCharacters?: PlayerCharacter[];
creatures?: Map<CreatureId, AnyCreature>;
}) {
const adapters = createTestAdapters({
encounter: options.encounter,
playerCharacters: options.playerCharacters ?? [],
creatures: options.creatures,
});
return ({ children }: { children: ReactNode }) => (
<AllProviders adapters={adapters}>{children}</AllProviders>
);
}
it("returns result for PF2e with leveled PCs and PF2e creatures", async () => {
const wrapper = makePf2eWrapper({
encounter: buildEncounter({
combatants: [
buildCombatant({
id: combatantId("c1"),
name: "Hero",
playerCharacterId: pcId1,
}),
buildCombatant({
id: combatantId("c2"),
name: "Orc Warrior",
creatureId: pf2eCreature.id,
}),
],
}),
playerCharacters: [
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
],
creatures: new Map([[pf2eCreature.id, pf2eCreature]]),
});
const { result: editionResult } = renderHook(() => useRulesEdition(), {
wrapper,
});
editionResult.current.setEdition("pf2e");
try {
const { result } = renderHook(() => useDifficulty(), { wrapper });
await waitFor(() => {
expect(result.current).not.toBeNull();
// Creature level 5, party level 5 → diff 0 → 40 XP
expect(result.current?.totalMonsterXp).toBe(40);
expect(result.current?.partyLevel).toBe(5);
});
} finally {
editionResult.current.setEdition("5.5e");
}
});
it("returns null for PF2e when no PF2e creatures with level", () => {
const wrapper = makePf2eWrapper({
encounter: buildEncounter({
combatants: [
buildCombatant({
id: combatantId("c1"),
name: "Hero",
playerCharacterId: pcId1,
}),
buildCombatant({
id: combatantId("c2"),
name: "Custom Monster",
}),
],
}),
playerCharacters: [
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
],
});
const { result: editionResult } = renderHook(() => useRulesEdition(), {
wrapper,
});
editionResult.current.setEdition("pf2e");
try {
const { result } = renderHook(() => useDifficulty(), { wrapper });
expect(result.current).toBeNull();
} finally {
editionResult.current.setEdition("5.5e");
}
});
it("returns null for PF2e when no PCs with level", () => {
const wrapper = makePf2eWrapper({
encounter: buildEncounter({
combatants: [
buildCombatant({
id: combatantId("c1"),
name: "Hero",
playerCharacterId: pcId1,
}),
buildCombatant({
id: combatantId("c2"),
name: "Orc Warrior",
creatureId: pf2eCreature.id,
}),
],
}),
playerCharacters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30 }],
creatures: new Map([[pf2eCreature.id, pf2eCreature]]),
});
const { result: editionResult } = renderHook(() => useRulesEdition(), {
wrapper,
});
editionResult.current.setEdition("pf2e");
try {
const { result } = renderHook(() => useDifficulty(), { wrapper });
expect(result.current).toBeNull();
} finally {
editionResult.current.setEdition("5.5e");
}
});
});
}); });
+9 -1
View File
@@ -9,6 +9,7 @@ import {
normalizeBestiary, normalizeBestiary,
setSourceDisplayNames, setSourceDisplayNames,
} from "../adapters/bestiary-adapter.js"; } from "../adapters/bestiary-adapter.js";
import { loadBundledDndCreatures } from "../adapters/dnd-bundled-adapter.js";
import { normalizeFoundryCreatures } from "../adapters/pf2e-bestiary-adapter.js"; import { normalizeFoundryCreatures } from "../adapters/pf2e-bestiary-adapter.js";
import { useAdapters } from "../contexts/adapter-context.js"; import { useAdapters } from "../contexts/adapter-context.js";
import { useRulesEditionContext } from "../contexts/rules-edition-context.js"; import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
@@ -160,7 +161,11 @@ export function useBestiary(): BestiaryHook {
} }
void bestiaryCache.loadAllCachedCreatures().then((map) => { 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]); }, [bestiaryCache, bestiaryIndex, pf2eBestiaryIndex]);
@@ -300,6 +305,9 @@ export function useBestiary(): BestiaryHook {
const refreshCache = useCallback(async (): Promise<void> => { const refreshCache = useCallback(async (): Promise<void> => {
const map = await bestiaryCache.loadAllCachedCreatures(); const map = await bestiaryCache.loadAllCachedCreatures();
for (const c of loadBundledDndCreatures()) {
map.set(c.id, c);
}
setCreatureMap(map); setCreatureMap(map);
}, [bestiaryCache]); }, [bestiaryCache]);
+112 -13
View File
@@ -1,11 +1,17 @@
import type { import type {
AnyCreature,
Combatant, Combatant,
CreatureId, CreatureId,
DifficultyThreshold, DifficultyThreshold,
DifficultyTier, DifficultyTier,
PlayerCharacter, PlayerCharacter,
} from "@initiative/domain"; } from "@initiative/domain";
import { calculateEncounterDifficulty, crToXp } from "@initiative/domain"; import {
calculateEncounterDifficulty,
crToXp,
derivePartyLevel,
pf2eCreatureXp,
} from "@initiative/domain";
import { useMemo } from "react"; import { useMemo } from "react";
import { useBestiaryContext } from "../contexts/bestiary-context.js"; import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { useEncounterContext } from "../contexts/encounter-context.js"; import { useEncounterContext } from "../contexts/encounter-context.js";
@@ -21,6 +27,10 @@ export interface BreakdownCombatant {
readonly editable: boolean; readonly editable: boolean;
readonly side: "party" | "enemy"; readonly side: "party" | "enemy";
readonly level: number | undefined; readonly level: number | undefined;
/** PF2e only: the creature's level from bestiary data. */
readonly creatureLevel: number | undefined;
/** PF2e only: creature level minus party level. */
readonly levelDifference: number | undefined;
} }
interface DifficultyBreakdown { interface DifficultyBreakdown {
@@ -30,6 +40,7 @@ interface DifficultyBreakdown {
readonly encounterMultiplier: number | undefined; readonly encounterMultiplier: number | undefined;
readonly adjustedXp: number | undefined; readonly adjustedXp: number | undefined;
readonly partySizeAdjusted: boolean | undefined; readonly partySizeAdjusted: boolean | undefined;
readonly partyLevel: number | undefined;
readonly pcCount: number; readonly pcCount: number;
readonly partyCombatants: readonly BreakdownCombatant[]; readonly partyCombatants: readonly BreakdownCombatant[];
readonly enemyCombatants: readonly BreakdownCombatant[]; readonly enemyCombatants: readonly BreakdownCombatant[];
@@ -48,9 +59,16 @@ export function useDifficultyBreakdown(): DifficultyBreakdown | null {
const hasPartyLevel = descriptors.some( const hasPartyLevel = descriptors.some(
(d) => d.side === "party" && d.level !== undefined, (d) => d.side === "party" && d.level !== undefined,
); );
const hasCr = descriptors.some((d) => d.cr !== undefined);
if (edition === "pf2e") {
const hasCreatureLevel = descriptors.some(
(d) => d.creatureLevel !== undefined,
);
if (!hasPartyLevel || !hasCreatureLevel) return null;
} else {
const hasCr = descriptors.some((d) => d.cr !== undefined);
if (!hasPartyLevel || !hasCr) return null; if (!hasPartyLevel || !hasCr) return null;
}
const result = calculateEncounterDifficulty(descriptors, edition); const result = calculateEncounterDifficulty(descriptors, edition);
@@ -65,6 +83,7 @@ export function useDifficultyBreakdown(): DifficultyBreakdown | null {
type CreatureInfo = { type CreatureInfo = {
cr?: string; cr?: string;
creatureLevel?: number;
source: string; source: string;
sourceDisplayName: string; sourceDisplayName: string;
}; };
@@ -74,6 +93,7 @@ function buildBreakdownEntry(
side: "party" | "enemy", side: "party" | "enemy",
level: number | undefined, level: number | undefined,
creature: CreatureInfo | undefined, creature: CreatureInfo | undefined,
partyLevel: number | undefined,
): BreakdownCombatant { ): BreakdownCombatant {
if (c.playerCharacterId) { if (c.playerCharacterId) {
return { return {
@@ -84,6 +104,29 @@ function buildBreakdownEntry(
editable: false, editable: false,
side, side,
level, level,
creatureLevel: undefined,
levelDifference: undefined,
};
}
if (creature && creature.creatureLevel !== undefined) {
const levelDiff =
partyLevel === undefined
? undefined
: creature.creatureLevel - partyLevel;
const xp =
partyLevel === undefined
? null
: pf2eCreatureXp(creature.creatureLevel, partyLevel);
return {
combatant: c,
cr: null,
xp,
source: creature.sourceDisplayName ?? creature.source,
editable: false,
side,
level: undefined,
creatureLevel: creature.creatureLevel,
levelDifference: levelDiff,
}; };
} }
if (creature) { if (creature) {
@@ -96,6 +139,8 @@ function buildBreakdownEntry(
editable: false, editable: false,
side, side,
level: undefined, level: undefined,
creatureLevel: undefined,
levelDifference: undefined,
}; };
} }
if (c.cr) { if (c.cr) {
@@ -107,6 +152,8 @@ function buildBreakdownEntry(
editable: true, editable: true,
side, side,
level: undefined, level: undefined,
creatureLevel: undefined,
levelDifference: undefined,
}; };
} }
return { return {
@@ -117,6 +164,8 @@ function buildBreakdownEntry(
editable: !c.creatureId, editable: !c.creatureId,
side, side,
level: undefined, level: undefined,
creatureLevel: undefined,
levelDifference: undefined,
}; };
} }
@@ -128,41 +177,91 @@ function resolveLevel(
return characters.find((p) => p.id === c.playerCharacterId)?.level; return characters.find((p) => p.id === c.playerCharacterId)?.level;
} }
function resolveCr( function resolveCreatureInfo(
c: Combatant, c: Combatant,
getCreature: (id: CreatureId) => CreatureInfo | undefined, getCreature: (id: CreatureId) => AnyCreature | undefined,
): { cr: string | null; creature: CreatureInfo | undefined } { ): {
const creature = c.creatureId ? getCreature(c.creatureId) : undefined; cr: string | null;
const cr = creature?.cr ?? c.cr ?? null; creatureLevel: number | undefined;
return { cr, creature }; creature: CreatureInfo | undefined;
} {
const rawCreature = c.creatureId ? getCreature(c.creatureId) : undefined;
if (!rawCreature) {
return {
cr: c.cr ?? null,
creatureLevel: undefined,
creature: undefined,
};
}
if ("system" in rawCreature && rawCreature.system === "pf2e") {
return {
cr: null,
creatureLevel: rawCreature.level,
creature: {
creatureLevel: rawCreature.level,
source: rawCreature.source,
sourceDisplayName: rawCreature.sourceDisplayName,
},
};
}
const cr = "cr" in rawCreature ? rawCreature.cr : undefined;
return {
cr: cr ?? c.cr ?? null,
creatureLevel: undefined,
creature: {
cr,
source: rawCreature.source,
sourceDisplayName: rawCreature.sourceDisplayName,
},
};
}
function collectPartyLevel(
combatants: readonly Combatant[],
characters: readonly PlayerCharacter[],
): number | undefined {
const partyLevels: number[] = [];
for (const c of combatants) {
if (resolveSide(c) !== "party") continue;
const level = resolveLevel(c, characters);
if (level !== undefined) partyLevels.push(level);
}
return partyLevels.length > 0 ? derivePartyLevel(partyLevels) : undefined;
} }
function classifyCombatants( function classifyCombatants(
combatants: readonly Combatant[], combatants: readonly Combatant[],
characters: readonly PlayerCharacter[], characters: readonly PlayerCharacter[],
getCreature: (id: CreatureId) => CreatureInfo | undefined, getCreature: (id: CreatureId) => AnyCreature | undefined,
) { ) {
const partyCombatants: BreakdownCombatant[] = []; const partyCombatants: BreakdownCombatant[] = [];
const enemyCombatants: BreakdownCombatant[] = []; const enemyCombatants: BreakdownCombatant[] = [];
const descriptors: { const descriptors: {
level?: number; level?: number;
cr?: string; cr?: string;
creatureLevel?: number;
side: "party" | "enemy"; side: "party" | "enemy";
}[] = []; }[] = [];
let pcCount = 0; let pcCount = 0;
const partyLevel = collectPartyLevel(combatants, characters);
for (const c of combatants) { for (const c of combatants) {
const side = resolveSide(c); const side = resolveSide(c);
const level = resolveLevel(c, characters); const level = resolveLevel(c, characters);
if (level !== undefined) pcCount++; if (level !== undefined) pcCount++;
const { cr, creature } = resolveCr(c, getCreature); const { cr, creatureLevel, creature } = resolveCreatureInfo(c, getCreature);
if (level !== undefined || cr != null) { if (level !== undefined || cr != null || creatureLevel !== undefined) {
descriptors.push({ level, cr: cr ?? undefined, side }); descriptors.push({
level,
cr: cr ?? undefined,
creatureLevel,
side,
});
} }
const entry = buildBreakdownEntry(c, side, level, creature); const entry = buildBreakdownEntry(c, side, level, creature, partyLevel);
const target = side === "party" ? partyCombatants : enemyCombatants; const target = side === "party" ? partyCombatants : enemyCombatants;
target.push(entry); target.push(entry);
} }
+18 -5
View File
@@ -33,9 +33,17 @@ function buildDescriptors(
const creatureCr = const creatureCr =
creature && !("system" in creature) ? creature.cr : undefined; creature && !("system" in creature) ? creature.cr : undefined;
const cr = creatureCr ?? c.cr ?? undefined; const cr = creatureCr ?? c.cr ?? undefined;
const creatureLevel =
creature && "system" in creature && creature.system === "pf2e"
? creature.level
: undefined;
if (level !== undefined || cr !== undefined) { if (
descriptors.push({ level, cr, side }); level !== undefined ||
cr !== undefined ||
creatureLevel !== undefined
) {
descriptors.push({ level, cr, creatureLevel, side });
} }
} }
return descriptors; return descriptors;
@@ -48,8 +56,6 @@ export function useDifficulty(): DifficultyResult | null {
const { edition } = useRulesEditionContext(); const { edition } = useRulesEditionContext();
return useMemo(() => { return useMemo(() => {
if (edition === "pf2e") return null;
const descriptors = buildDescriptors( const descriptors = buildDescriptors(
encounter.combatants, encounter.combatants,
characters, characters,
@@ -59,9 +65,16 @@ export function useDifficulty(): DifficultyResult | null {
const hasPartyLevel = descriptors.some( const hasPartyLevel = descriptors.some(
(d) => d.side === "party" && d.level !== undefined, (d) => d.side === "party" && d.level !== undefined,
); );
const hasCr = descriptors.some((d) => d.cr !== undefined);
if (edition === "pf2e") {
const hasCreatureLevel = descriptors.some(
(d) => d.creatureLevel !== undefined,
);
if (!hasPartyLevel || !hasCreatureLevel) return null;
} else {
const hasCr = descriptors.some((d) => d.cr !== undefined);
if (!hasPartyLevel || !hasCr) return null; if (!hasPartyLevel || !hasCr) return null;
}
return calculateEncounterDifficulty(descriptors, edition); return calculateEncounterDifficulty(descriptors, edition);
}, [encounter.combatants, characters, getCreature, edition]); }, [encounter.combatants, characters, getCreature, edition]);
File diff suppressed because it is too large Load Diff
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
import { import {
calculateEncounterDifficulty, calculateEncounterDifficulty,
crToXp, crToXp,
derivePartyLevel,
pf2eCreatureXp,
} from "../encounter-difficulty.js"; } from "../encounter-difficulty.js";
describe("crToXp", () => { describe("crToXp", () => {
@@ -386,3 +388,234 @@ describe("calculateEncounterDifficulty — 2014 edition", () => {
expect(result.adjustedXp).toBeUndefined(); expect(result.adjustedXp).toBeUndefined();
}); });
}); });
/** Helper to build a PF2e enemy-side descriptor with creature level. */
function pf2eEnemy(creatureLevel: number) {
return { creatureLevel, side: "enemy" as const };
}
/** Helper to build a PF2e party-side creature descriptor. */
function pf2eAlly(creatureLevel: number) {
return { creatureLevel, side: "party" as const };
}
describe("derivePartyLevel", () => {
it("returns 0 for empty array", () => {
expect(derivePartyLevel([])).toBe(0);
});
it("returns the level for a single PC", () => {
expect(derivePartyLevel([7])).toBe(7);
});
it("returns the unanimous level", () => {
expect(derivePartyLevel([5, 5, 5, 5])).toBe(5);
});
it("returns the mode when one level is most common", () => {
expect(derivePartyLevel([3, 3, 3, 5])).toBe(3);
});
it("returns rounded average when mode is tied", () => {
// 3,3,5,5 → average 4
expect(derivePartyLevel([3, 3, 5, 5])).toBe(4);
});
it("returns rounded average when all levels are different", () => {
// 2,4,6,8 → average 5
expect(derivePartyLevel([2, 4, 6, 8])).toBe(5);
});
it("rounds average to nearest integer", () => {
// 1,2 → average 1.5 → rounds to 2
expect(derivePartyLevel([1, 2])).toBe(2);
});
});
describe("pf2eCreatureXp", () => {
it.each([
[-4, 10],
[-3, 15],
[-2, 20],
[-1, 30],
[0, 40],
[1, 60],
[2, 80],
[3, 120],
[4, 160],
])("level diff %i returns %i XP", (diff, expectedXp) => {
// partyLevel 5, creatureLevel = 5 + diff
expect(pf2eCreatureXp(5 + diff, 5)).toBe(expectedXp);
});
it("clamps level diff below 4 to 4 (10 XP)", () => {
expect(pf2eCreatureXp(0, 10)).toBe(10);
});
it("clamps level diff above +4 to +4 (160 XP)", () => {
expect(pf2eCreatureXp(15, 5)).toBe(160);
});
});
describe("calculateEncounterDifficulty — pf2e edition", () => {
it("returns Trivial (tier 0) for 40 XP with party of 4", () => {
// 1 creature at party level = 40 XP, below Low (60)
const result = calculateEncounterDifficulty(
[party(5), party(5), party(5), party(5), pf2eEnemy(5)],
"pf2e",
);
expect(result.tier).toBe(0);
expect(result.totalMonsterXp).toBe(40);
expect(result.partyLevel).toBe(5);
expect(result.thresholds).toEqual([
{ label: "Trivial", value: 40 },
{ label: "Low", value: 60 },
{ label: "Moderate", value: 80 },
{ label: "Severe", value: 120 },
{ label: "Extreme", value: 160 },
]);
});
it("returns Low (tier 1) for 60 XP", () => {
// 1 creature at party level +1 = 60 XP
const result = calculateEncounterDifficulty(
[party(5), party(5), party(5), party(5), pf2eEnemy(6)],
"pf2e",
);
expect(result.tier).toBe(1);
expect(result.totalMonsterXp).toBe(60);
});
it("returns Moderate (tier 2) for 80 XP", () => {
// 1 creature at +2 = 80 XP
const result = calculateEncounterDifficulty(
[party(5), party(5), party(5), party(5), pf2eEnemy(7)],
"pf2e",
);
expect(result.tier).toBe(2);
expect(result.totalMonsterXp).toBe(80);
});
it("returns Severe (tier 3) for 120 XP", () => {
// 1 creature at +3 = 120 XP
const result = calculateEncounterDifficulty(
[party(5), party(5), party(5), party(5), pf2eEnemy(8)],
"pf2e",
);
expect(result.tier).toBe(3);
expect(result.totalMonsterXp).toBe(120);
});
it("returns Extreme (tier 4) for 160 XP", () => {
// 1 creature at +4 = 160 XP
const result = calculateEncounterDifficulty(
[party(5), party(5), party(5), party(5), pf2eEnemy(9)],
"pf2e",
);
expect(result.tier).toBe(4);
expect(result.totalMonsterXp).toBe(160);
});
it("returns tier 0 when XP is below Low threshold", () => {
// 1 creature at 4 = 10 XP, Low = 60
const result = calculateEncounterDifficulty(
[party(5), party(5), party(5), party(5), pf2eEnemy(1)],
"pf2e",
);
expect(result.tier).toBe(0);
expect(result.totalMonsterXp).toBe(10);
});
it("adjusts thresholds for 5 PCs (increases by adjustment)", () => {
const result = calculateEncounterDifficulty(
[party(5), party(5), party(5), party(5), party(5), pf2eEnemy(5)],
"pf2e",
);
expect(result.thresholds).toEqual([
{ label: "Trivial", value: 50 },
{ label: "Low", value: 75 },
{ label: "Moderate", value: 100 },
{ label: "Severe", value: 150 },
{ label: "Extreme", value: 200 },
]);
});
it("adjusts thresholds for 3 PCs (decreases by adjustment)", () => {
const result = calculateEncounterDifficulty(
[party(5), party(5), party(5), pf2eEnemy(5)],
"pf2e",
);
expect(result.thresholds).toEqual([
{ label: "Trivial", value: 30 },
{ label: "Low", value: 45 },
{ label: "Moderate", value: 60 },
{ label: "Severe", value: 90 },
{ label: "Extreme", value: 120 },
]);
});
it("floors thresholds at 0 for very small parties", () => {
const result = calculateEncounterDifficulty(
[party(5), pf2eEnemy(5)],
"pf2e",
);
// 1 PC: adjustment = 3
// Trivial: 40 + (3 * 10) = 10
// Low: 60 + (3 * 15) = 15
expect(result.thresholds[0].value).toBe(10);
expect(result.thresholds[1].value).toBe(15);
expect(result.thresholds[2].value).toBe(20); // 80 60
expect(result.thresholds[3].value).toBe(30); // 120 90
expect(result.thresholds[4].value).toBe(40); // 160 120
});
it("subtracts XP for party-side creatures", () => {
// 2 enemies at party level = 80 XP, 1 ally at party level = 40 XP
// Net = 80 40 = 40 XP
const result = calculateEncounterDifficulty(
[
party(5),
party(5),
party(5),
party(5),
pf2eEnemy(5),
pf2eEnemy(5),
pf2eAlly(5),
],
"pf2e",
);
expect(result.totalMonsterXp).toBe(40);
});
it("floors net creature XP at 0", () => {
const result = calculateEncounterDifficulty(
[party(5), party(5), party(5), party(5), pf2eEnemy(1), pf2eAlly(9)],
"pf2e",
);
expect(result.totalMonsterXp).toBe(0);
});
it("derives party level using mode", () => {
// 3x level 3, 1x level 5 → mode is 3
const result = calculateEncounterDifficulty(
[party(3), party(3), party(3), party(5), pf2eEnemy(3)],
"pf2e",
);
expect(result.partyLevel).toBe(3);
});
it("has no encounterMultiplier, adjustedXp, or partySizeAdjusted", () => {
const result = calculateEncounterDifficulty(
[party(5), party(5), party(5), party(5), pf2eEnemy(5)],
"pf2e",
);
expect(result.encounterMultiplier).toBeUndefined();
expect(result.adjustedXp).toBeUndefined();
expect(result.partySizeAdjusted).toBeUndefined();
});
it("returns partyLevel undefined for D&D editions", () => {
const result = calculateEncounterDifficulty([party(1), enemy("1")], "5.5e");
expect(result.partyLevel).toBeUndefined();
});
});
+169 -2
View File
@@ -1,7 +1,7 @@
import type { RulesEdition } from "./rules-edition.js"; import type { RulesEdition } from "./rules-edition.js";
/** Abstract difficulty severity: 0 = negligible, 3 = maximum. Maps to filled bar count. */ /** Abstract difficulty severity: 0 = negligible, up to 4 (PF2e Extreme). Maps to filled bar count. */
export type DifficultyTier = 0 | 1 | 2 | 3; export type DifficultyTier = 0 | 1 | 2 | 3 | 4;
export interface DifficultyThreshold { export interface DifficultyThreshold {
readonly label: string; readonly label: string;
@@ -18,6 +18,8 @@ export interface DifficultyResult {
readonly adjustedXp: number | undefined; readonly adjustedXp: number | undefined;
/** 2014 only: true when the multiplier was shifted due to party size (<3 or 6+). */ /** 2014 only: true when the multiplier was shifted due to party size (<3 or 6+). */
readonly partySizeAdjusted: boolean | undefined; readonly partySizeAdjusted: boolean | undefined;
/** PF2e only: the derived party level used for XP calculation. */
readonly partyLevel: number | undefined;
} }
/** Maps challenge rating strings to XP values (standard 5e). */ /** Maps challenge rating strings to XP values (standard 5e). */
@@ -160,6 +162,133 @@ function getEncounterMultiplier(
}; };
} }
/**
* PF2e: XP granted by a creature based on its level relative to party level.
* Key is (creature level party level), clamped to [4, +4].
*/
const PF2E_LEVEL_DIFF_XP: Readonly<Record<number, number>> = {
[-4]: 10,
[-3]: 15,
[-2]: 20,
[-1]: 30,
0: 40,
1: 60,
2: 80,
3: 120,
4: 160,
};
/** PF2e base encounter budget thresholds for a party of 4. */
const PF2E_THRESHOLDS_BASE = {
trivial: 40,
low: 60,
moderate: 80,
severe: 120,
extreme: 160,
} as const;
/** PF2e per-PC adjustment to each threshold (added per PC beyond 4, subtracted per PC fewer). */
const PF2E_THRESHOLD_ADJUSTMENTS = {
trivial: 10,
low: 15,
moderate: 20,
severe: 30,
extreme: 40,
} as const;
/**
* Derives PF2e party level from PC levels.
* Returns the mode (most common level). If no unique mode, returns
* the average rounded to the nearest integer.
*/
export function derivePartyLevel(levels: readonly number[]): number {
if (levels.length === 0) return 0;
if (levels.length === 1) return levels[0];
const counts = new Map<number, number>();
for (const l of levels) {
counts.set(l, (counts.get(l) ?? 0) + 1);
}
let maxCount = 0;
let mode: number | undefined;
let isTied = false;
for (const [level, count] of counts) {
if (count > maxCount) {
maxCount = count;
mode = level;
isTied = false;
} else if (count === maxCount) {
isTied = true;
}
}
if (!isTied && mode !== undefined) return mode;
const sum = levels.reduce((a, b) => a + b, 0);
return Math.round(sum / levels.length);
}
/** Returns PF2e XP for a creature given its level and the party level. */
export function pf2eCreatureXp(
creatureLevel: number,
partyLevel: number,
): number {
const diff = Math.max(-4, Math.min(4, creatureLevel - partyLevel));
return PF2E_LEVEL_DIFF_XP[diff] ?? 0;
}
function calculatePf2eBudget(partySize: number) {
const adjustment = partySize - 4;
return {
trivial: Math.max(
0,
PF2E_THRESHOLDS_BASE.trivial +
adjustment * PF2E_THRESHOLD_ADJUSTMENTS.trivial,
),
low: Math.max(
0,
PF2E_THRESHOLDS_BASE.low + adjustment * PF2E_THRESHOLD_ADJUSTMENTS.low,
),
moderate: Math.max(
0,
PF2E_THRESHOLDS_BASE.moderate +
adjustment * PF2E_THRESHOLD_ADJUSTMENTS.moderate,
),
severe: Math.max(
0,
PF2E_THRESHOLDS_BASE.severe +
adjustment * PF2E_THRESHOLD_ADJUSTMENTS.severe,
),
extreme: Math.max(
0,
PF2E_THRESHOLDS_BASE.extreme +
adjustment * PF2E_THRESHOLD_ADJUSTMENTS.extreme,
),
};
}
function scanCombatantsPf2e(
combatants: readonly CombatantDescriptor[],
partyLevel: number,
) {
let totalCreatureXp = 0;
for (const c of combatants) {
if (c.creatureLevel !== undefined) {
const xp = pf2eCreatureXp(c.creatureLevel, partyLevel);
if (c.side === "enemy") {
totalCreatureXp += xp;
} else {
totalCreatureXp -= xp;
}
}
}
return { totalCreatureXp: Math.max(0, totalCreatureXp) };
}
/** All standard 5e challenge rating strings, in ascending order. */ /** All standard 5e challenge rating strings, in ascending order. */
export const VALID_CR_VALUES: readonly string[] = Object.keys(CR_TO_XP); export const VALID_CR_VALUES: readonly string[] = Object.keys(CR_TO_XP);
@@ -171,6 +300,7 @@ export function crToXp(cr: string): number {
export interface CombatantDescriptor { export interface CombatantDescriptor {
readonly level?: number; readonly level?: number;
readonly cr?: string; readonly cr?: string;
readonly creatureLevel?: number;
readonly side: "party" | "enemy"; readonly side: "party" | "enemy";
} }
@@ -247,6 +377,41 @@ export function calculateEncounterDifficulty(
combatants: readonly CombatantDescriptor[], combatants: readonly CombatantDescriptor[],
edition: RulesEdition, edition: RulesEdition,
): DifficultyResult { ): DifficultyResult {
if (edition === "pf2e") {
const partyLevels: number[] = [];
for (const c of combatants) {
if (c.level !== undefined && c.side === "party") {
partyLevels.push(c.level);
}
}
const partyLevel = derivePartyLevel(partyLevels);
const { totalCreatureXp } = scanCombatantsPf2e(combatants, partyLevel);
const budget = calculatePf2eBudget(partyLevels.length);
const thresholds: DifficultyThreshold[] = [
{ label: "Trivial", value: budget.trivial },
{ label: "Low", value: budget.low },
{ label: "Moderate", value: budget.moderate },
{ label: "Severe", value: budget.severe },
{ label: "Extreme", value: budget.extreme },
];
return {
tier: determineTier(totalCreatureXp, [
budget.low,
budget.moderate,
budget.severe,
budget.extreme,
]),
totalMonsterXp: totalCreatureXp,
thresholds,
encounterMultiplier: undefined,
adjustedXp: undefined,
partySizeAdjusted: undefined,
partyLevel,
};
}
const { totalMonsterXp, monsterCount, partyLevels } = const { totalMonsterXp, monsterCount, partyLevels } =
scanCombatants(combatants); scanCombatants(combatants);
@@ -268,6 +433,7 @@ export function calculateEncounterDifficulty(
encounterMultiplier: undefined, encounterMultiplier: undefined,
adjustedXp: undefined, adjustedXp: undefined,
partySizeAdjusted: undefined, partySizeAdjusted: undefined,
partyLevel: undefined,
}; };
} }
@@ -294,5 +460,6 @@ export function calculateEncounterDifficulty(
encounterMultiplier, encounterMultiplier,
adjustedXp, adjustedXp,
partySizeAdjusted, partySizeAdjusted,
partyLevel: undefined,
}; };
} }
+2
View File
@@ -64,6 +64,8 @@ export {
type DifficultyResult, type DifficultyResult,
type DifficultyThreshold, type DifficultyThreshold,
type DifficultyTier, type DifficultyTier,
derivePartyLevel,
pf2eCreatureXp,
VALID_CR_VALUES, VALID_CR_VALUES,
} from "./encounter-difficulty.js"; } from "./encounter-difficulty.js";
export type { export type {
+561
View File
@@ -0,0 +1,561 @@
#!/usr/bin/env python3
"""Extract D&D 5.5e stat blocks from The Great Labors PDF.
Usage:
python3 scripts/extract-great-labors.py <path-to-pdf>
Reads pages 163-199 (Appendix B: Monsters) and emits
data/bestiary/dnd-bundled.json in the Creature[] shape from
packages/domain/src/creature-types.ts.
Requires: PyPDF2 (pip install PyPDF2)
"""
import json
import os
import re
import sys
from pathlib import Path
from PyPDF2 import PdfReader
# --- Constants ---
SOURCE_CODE = "TGL"
SOURCE_DISPLAY = "The Great Labors"
PAGE_START = 163 # 1-indexed
PAGE_END = 199
SIZE_RE = r"(Tiny|Small|Medium|Large|Huge|Gargantuan)"
TYPE_PIECE = r"[A-Za-z][A-Za-z\- ]*?"
ALIGN_PIECE = r"[A-Za-z][A-Za-z ()]*?"
HEADER_RE = re.compile(
rf"^{SIZE_RE}\s+({TYPE_PIECE}(?:\s+\([^)]+\))?),\s+({ALIGN_PIECE})\s*$"
)
AC_RE = re.compile(r"^AC\s+(\d+)\s+Initiative\s+([+\-]\s*\d+|[+\-]?\d+)")
HP_RE = re.compile(r"^HP\s+(\d+)\s*\(([^)]+)\)")
SPEED_RE = re.compile(r"^Speed\s+(.+?)\s*$")
ABILITY_ROW_RE = re.compile(
r"^(Str|Dex|Con|Int|Wis|Cha)\s+(\d+)\s*([+\-]?\s*\d+)\s+([+\-]?\s*\d+)\s+"
r"(Str|Dex|Con|Int|Wis|Cha)\s+(\d+)\s*([+\-]?\s*\d+)\s+([+\-]?\s*\d+)\s+"
r"(Str|Dex|Con|Int|Wis|Cha)\s+(\d+)\s*([+\-]?\s*\d+)\s+([+\-]?\s*\d+)\s*$"
)
CR_RE = re.compile(
r"^Challenge\s+([\d/]+)\s*\(([\d,]+)\s*XP;\s*PB\s+\+(\d+)\)"
)
SECTION_HEADERS = ("Traits", "Actions", "Bonus Actions", "Reactions",
"Legendary Actions", "Mythic Actions")
# Page running header like "166APPENDIX B MONSTERS..." -- marks the
# transition from stat-block content into prose on the next page.
RUNNING_HEADER_RE = re.compile(r"^\d+APPENDIX B\b")
# Condition / status-word false positives that the title-case entry regex
# would otherwise mistake for a new entry name. These names commonly end a
# sentence inside an entry's body (e.g. "...while it is Bloodied.").
NAME_FALSE_POSITIVES = {
"Bloodied", "Restrained", "Grappled", "Charmed", "Frightened",
"Prone", "Incapacitated", "Stunned", "Paralyzed", "Petrified",
"Poisoned", "Blinded", "Deafened", "Invisible", "Unconscious",
"Exhaustion", "Surprised", "Furious",
"Failure", "Success", "Trigger", "Response", "Hit", "Miss",
"Habitat", "Treasure", "Bonus Actions", "Reactions", "Traits", "Actions",
"Disadvantage", "Advantage",
}
# --- Helpers ---
def norm_dash(s: str) -> str:
return s.replace("", "-").replace("", "-").replace("", "-")
def proficiency_bonus(cr_str: str) -> int:
if "/" in cr_str:
n, d = cr_str.split("/")
cr = int(n) / int(d)
else:
cr = int(cr_str)
if cr <= 4:
return 2
if cr <= 8:
return 3
if cr <= 12:
return 4
if cr <= 16:
return 5
if cr <= 20:
return 6
if cr <= 24:
return 7
if cr <= 28:
return 8
return 9
def make_creature_id(source: str, name: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
return f"{source.lower()}:{slug}"
def parse_passive_perception(senses_text: str) -> int | None:
# The PDF sometimes renders multi-digit values with a kerning space
# (e.g. "Passive Perception 1 1" meaning 11). Collapse those.
m = re.search(r"Passive Perception\s+(\d(?:\s*\d)*)\s*$", senses_text)
if not m:
m = re.search(r"Passive Perception\s+(\d+)", senses_text)
return int(m.group(1).replace(" ", "")) if m else None
# --- Page extraction ---
def extract_pages(pdf_path: Path) -> str:
reader = PdfReader(str(pdf_path))
parts = []
for i in range(PAGE_START - 1, PAGE_END):
parts.append(reader.pages[i].extract_text())
return "\n".join(parts)
# --- Block splitting ---
def find_stat_block_starts(lines: list[str]) -> list[int]:
starts = []
for i, line in enumerate(lines):
if AC_RE.match(line.strip()):
header_idx = None
for j in range(i - 1, max(-1, i - 5), -1):
if HEADER_RE.match(lines[j].strip()):
header_idx = j
break
if header_idx is None:
continue
name_idx = header_idx - 1
if name_idx >= 0 and lines[name_idx].strip():
starts.append(name_idx)
return starts
SECTION_HEADER_SMUSH_RE = re.compile(
r"^(?P<body>.+?)\.(?P<hdr>Actions|Bonus Actions|Reactions|Legendary Actions|Traits)\s*$"
)
def block_for(lines: list[str], start: int, next_start: int | None) -> list[str]:
"""Build the line list for one stat block.
Drops page markers and everything from the first running-header line
onward (which marks the transition to a new prose page). Splits PDF
smush lines like "...plants.Actions" into two lines so section header
detection works.
"""
end = next_start if next_start is not None else len(lines)
out: list[str] = []
for ln in lines[start:end]:
if ln.startswith("===PAGE"):
continue
if RUNNING_HEADER_RE.match(ln.strip()):
break
m = SECTION_HEADER_SMUSH_RE.match(ln.strip())
if m:
out.append(m.group("body") + ".")
out.append(m.group("hdr"))
else:
out.append(ln)
return out
# --- Vitals parsing ---
def parse_header(block: list[str]) -> dict:
name = block[0].strip()
header = block[1].strip()
m = HEADER_RE.match(header)
if not m:
raise ValueError(f"Bad header for {name!r}: {header!r}")
size, ctype, alignment = m.group(1), m.group(2).strip(), m.group(3).strip()
return {"name": name, "size": size, "type": ctype, "alignment": alignment}
def parse_ac(line: str) -> int:
m = AC_RE.match(line.strip())
if not m:
raise ValueError(f"Bad AC line: {line!r}")
return int(m.group(1))
def parse_hp(line: str) -> dict:
m = HP_RE.match(line.strip())
if not m:
raise ValueError(f"Bad HP line: {line!r}")
return {"average": int(m.group(1)), "formula": m.group(2).strip()}
def parse_speed(line: str) -> str:
m = SPEED_RE.match(line.strip())
if not m:
raise ValueError(f"Bad Speed line: {line!r}")
speed = m.group(1).rstrip(".").strip()
# Normalize "30 ft" → "30 ft." to match 5etools adapter output style.
speed = re.sub(r"(\d+)\s+ft\b\.?", r"\1 ft.", speed)
return speed
def parse_abilities(row1: str, row2: str) -> dict:
out = {}
for row in (row1, row2):
m = ABILITY_ROW_RE.match(row.strip())
if not m:
raise ValueError(f"Bad ability row: {row!r}")
for off in (0, 4, 8):
ab = m.group(off + 1).lower()
score = int(m.group(off + 2))
out[ab] = score
return out
# --- Meta lines ---
META_KEYS = ("Skills", "Saving Throws", "Resistances", "Immunities",
"Vulnerabilities", "Senses", "Languages", "Gear")
def is_meta_start(line: str) -> str | None:
for key in META_KEYS:
if line.startswith(key + " ") or line.startswith(key + " "):
return key
return None
def parse_meta(lines: list[str], start: int) -> tuple[dict, int]:
meta: dict[str, str] = {}
i = start
current_key: str | None = None
current_val_parts: list[str] = []
def flush() -> None:
nonlocal current_key, current_val_parts
if current_key is not None:
meta[current_key] = " ".join(p.strip() for p in current_val_parts).strip()
current_key = None
current_val_parts = []
while i < len(lines):
line = lines[i].strip()
if not line:
i += 1
continue
if line.startswith("Challenge "):
flush()
return meta, i
key = is_meta_start(line)
if key:
flush()
current_key = key
current_val_parts.append(line[len(key):].strip())
elif current_key is not None:
current_val_parts.append(line)
i += 1
flush()
return meta, i
# --- Section discovery ---
def find_section_starts(block: list[str], start_idx: int) -> list[tuple[str, int]]:
starts = []
for i in range(start_idx, len(block)):
ln = block[i].strip()
if ln in SECTION_HEADERS:
starts.append((ln, i))
return starts
def collect_section_lines(block: list[str], start: int, end: int) -> list[str]:
"""Collect the raw lines for one section (between header indices)."""
out: list[str] = []
for line in block[start:end]:
if not line.strip():
continue
out.append(line.rstrip())
return out
def join_section_text(lines: list[str]) -> str:
"""Join section lines into a single text blob, repairing wrap hyphens."""
text = " ".join(line.strip() for line in lines if line.strip())
text = re.sub(r"\s+", " ", text)
# Repair "civi -li zation" → "civilization" (PDF column-wrap hyphens).
text = re.sub(r"(\w)\s*-\s+(\w)", r"\1\2", text)
return text.strip()
# --- Entry splitting ---
# Entry name: title-case phrase, where each "word" is either a Capitalized
# word, a lowercase connector (of/the/and/or/in/at/on/to/with/from), a roman
# numeral, etc. Optionally followed by parenthesized modifier.
ENTRY_NAME_INNER = (
r"[A-Z][A-Za-z']*"
r"(?:[ \-](?:[A-Z][A-Za-z']*|of|the|and|or|in|at|on|to|with|from))*"
r"(?:\s*\([^)]+\))?"
)
# An entry boundary occurs at the start of the joined section text, or
# immediately after a sentence-ending punctuation. The PDF sometimes drops
# the space between the period and the new entry name, so `\s*` is fine.
ENTRY_BOUNDARY = re.compile(
rf"(?:^|(?<=[\.\?\!]))\s*(?P<name>{ENTRY_NAME_INNER})\.\s+(?=[A-Z“\"(])"
)
# Trim attribution quotes / page-header bleed-through from entry bodies.
PROSE_TAIL_PATTERNS = (
# Em-dash attribution: " —Chondrus, Priest of Lutheria"
re.compile(r"\s+—\s*[A-Z][^—]*$"),
# Smushed section header at end ("...plants.Actions").
re.compile(
r"\.\s*(?:Actions|Bonus\s+Actions|Reactions|Legendary\s+Actions|Traits)\s*$"
),
# Curated prose subheadings / phrase markers that follow stat blocks in
# this book. PDF reflow often merges prose onto the same logical line
# as the last action body, so the leading whitespace is optional.
re.compile(
r"\.?\s*(?:Random Trapped Creature|Maenad Bacchanal|The Phalanx Formation"
r"|Reinforced Portal|TRAPPED|HUNGER FOR|PURSUIT OF|RITUAL|MyTHIC|BRON"
r"|GOlDEN|NyMPH|MARBlE|KElEDONE|SOlDIER|MINOTAUR|SATyRS|GOATlING|EMPUS"
r"|ANARCH|GyGAN|CERBERUS|WHITE STAG|STORM|FEy|VOlKAN).*",
re.DOTALL,
),
# Specific prose sentence-starts observed leaking in.
re.compile(
r"\.(?:will gleefully|Some report that|Storm Dory|This magic weapon"
r"|Thylean soldiers|Some claim|These leaders).*",
re.DOTALL,
),
# All-caps run of 3+ uppercase letters in a word, then a space, then
# another word with 3+ uppercase letters (PDF small-caps section header
# like "BRON zE STRATEGOS", "MyTHIC BEAST", "GOlDEN RAM").
re.compile(r"(?<=[\.\s])[A-Z]{2}\w*\s+[\w ]{0,12}[A-Z]{3}[A-Z\w ]*"),
)
def trim_prose_tail(body: str) -> str:
out = body
for pat in PROSE_TAIL_PATTERNS:
m = pat.search(out)
if m:
out = out[:m.start()].rstrip().rstrip(".") + "."
return out.strip()
def is_valid_entry_name(name: str) -> bool:
"""Filter false-positive matches that aren't really entry names."""
if name in NAME_FALSE_POSITIVES:
return False
# Single short capitalized word that's a common condition or noun is
# usually a false positive when followed by a period. Real entry names
# almost always have either multiple words or a parenthesized modifier.
bare = re.sub(r"\s*\([^)]+\)\s*", "", name).strip()
if bare in NAME_FALSE_POSITIVES:
return False
return True
def split_text_into_entries(text: str) -> list[tuple[str, str]]:
"""Split section text into (name, body) entries by scanning for entry-name
boundaries (start-of-text or after a sentence period)."""
matches: list[tuple[int, int, str]] = []
for m in ENTRY_BOUNDARY.finditer(text):
name = m.group("name").strip()
if is_valid_entry_name(name):
matches.append((m.start(), m.end(), name))
if not matches:
return []
entries: list[tuple[str, str]] = []
for i, (_, body_start, name) in enumerate(matches):
body_end = matches[i + 1][0] if i + 1 < len(matches) else len(text)
body = text[body_start:body_end].strip()
entries.append((name, body))
return entries
def parse_section_traits(lines: list[str]) -> list[dict]:
text = join_section_text(lines)
entries = split_text_into_entries(text)
out = []
for name, body in entries:
body = trim_prose_tail(body)
if body or name:
out.append({"name": name,
"segments": [{"type": "text", "value": body}]})
return out
def parse_legendary(lines: list[str], creature_name: str) -> dict | None:
"""Parse the Legendary Actions section. Text before the first entry whose
body contains action vocabulary forms the preamble.
"""
text = join_section_text(lines)
all_matches: list[tuple[int, int, str]] = []
for m in ENTRY_BOUNDARY.finditer(text):
name = m.group("name").strip()
if is_valid_entry_name(name):
all_matches.append((m.start(), m.end(), name))
action_anchors = ("Saving Throw", "Attack Roll", "Trigger", "Recharge",
"Melee", "Ranged", "Constitution", "Dexterity",
"Strength", "Intelligence", "Wisdom", "Charisma")
first_action_idx = None
for i, (_, body_start, _) in enumerate(all_matches):
body_end = all_matches[i + 1][0] if i + 1 < len(all_matches) else len(text)
body_head = text[body_start:min(body_end, body_start + 100)]
if any(a in body_head for a in action_anchors):
first_action_idx = i
break
if first_action_idx is None:
return None
preamble = text[:all_matches[first_action_idx][0]].strip()
if not preamble:
preamble = f"{creature_name} can take Legendary Actions."
entries = []
for i in range(first_action_idx, len(all_matches)):
_, body_start, name = all_matches[i]
body_end = all_matches[i + 1][0] if i + 1 < len(all_matches) else len(text)
body = text[body_start:body_end].strip()
entries.append((name, body))
if not entries:
return None
return {
"preamble": preamble,
"entries": [
{"name": name,
"segments": [{"type": "text", "value": trim_prose_tail(body)}]}
for name, body in entries if body
],
}
# --- Top-level parse ---
def parse_block(block: list[str]) -> dict:
head = parse_header(block)
ac = parse_ac(block[2])
hp = parse_hp(block[3])
speed = parse_speed(block[4])
if not block[5].strip().startswith("MOD"):
raise ValueError(f"Expected MOD header, got: {block[5]!r}")
abilities = parse_abilities(block[6], block[7])
meta, ch_idx = parse_meta(block, 8)
cr_match = CR_RE.match(block[ch_idx].strip())
if not cr_match:
raise ValueError(f"Bad Challenge line: {block[ch_idx]!r}")
cr_str = cr_match.group(1)
section_starts = find_section_starts(block, ch_idx + 1)
sections: dict[str, list[str]] = {}
for i, (name, idx) in enumerate(section_starts):
end = section_starts[i + 1][1] if i + 1 < len(section_starts) else len(block)
sections[name] = collect_section_lines(block, idx + 1, end)
creature: dict = {
"id": make_creature_id(SOURCE_CODE, head["name"]),
"name": head["name"],
"source": SOURCE_CODE,
"sourceDisplayName": SOURCE_DISPLAY,
"size": head["size"],
"type": head["type"],
"alignment": head["alignment"],
"ac": ac,
"hp": hp,
"speed": speed,
"abilities": abilities,
"cr": cr_str,
"initiativeProficiency": 0,
"proficiencyBonus": proficiency_bonus(cr_str),
"passive": parse_passive_perception(meta.get("Senses", "")) or 10,
}
if "Saving Throws" in meta:
creature["savingThrows"] = meta["Saving Throws"]
if "Skills" in meta:
creature["skills"] = meta["Skills"]
if "Resistances" in meta:
creature["resist"] = meta["Resistances"]
if "Immunities" in meta:
creature["immune"] = meta["Immunities"]
if "Vulnerabilities" in meta:
creature["vulnerable"] = meta["Vulnerabilities"]
if "Senses" in meta:
senses = re.sub(r"[;,]?\s*Passive Perception\s+\d+\s*$", "", meta["Senses"])
senses = senses.strip().rstrip(";").strip()
if senses:
creature["senses"] = senses
if "Languages" in meta:
creature["languages"] = meta["Languages"]
if "Traits" in sections:
creature["traits"] = parse_section_traits(sections["Traits"])
if "Actions" in sections:
creature["actions"] = parse_section_traits(sections["Actions"])
if "Bonus Actions" in sections:
creature["bonusActions"] = parse_section_traits(sections["Bonus Actions"])
if "Reactions" in sections:
creature["reactions"] = parse_section_traits(sections["Reactions"])
if "Legendary Actions" in sections:
leg = parse_legendary(sections["Legendary Actions"], head["name"])
if leg:
creature["legendaryActions"] = leg
return creature
def main() -> int:
if len(sys.argv) != 2:
print("Usage: python3 extract-great-labors.py <path-to-pdf>",
file=sys.stderr)
return 1
pdf_path = Path(os.path.expanduser(sys.argv[1]))
if not pdf_path.exists():
print(f"PDF not found: {pdf_path}", file=sys.stderr)
return 1
text = extract_pages(pdf_path)
lines = text.split("\n")
starts = find_stat_block_starts(lines)
print(f"Detected {len(starts)} stat blocks", file=sys.stderr)
creatures = []
failures = []
for i, s in enumerate(starts):
next_s = starts[i + 1] if i + 1 < len(starts) else None
block = block_for(lines, s, next_s)
try:
creatures.append(parse_block(block))
except Exception as e:
failures.append((block[0] if block else "<empty>", str(e)))
if failures:
print(f"\n{len(failures)} parse failures:", file=sys.stderr)
for name, err in failures:
print(f" - {name}: {err}", file=sys.stderr)
out_path = Path(__file__).resolve().parent.parent / "data" / "bestiary" / "dnd-bundled.json"
out_path.parent.mkdir(parents=True, exist_ok=True)
with out_path.open("w") as f:
json.dump(creatures, f, indent="\t", ensure_ascii=False)
f.write("\n")
print(f"Wrote {len(creatures)} creatures to {out_path}", file=sys.stderr)
return 0 if not failures else 2
if __name__ == "__main__":
sys.exit(main())