Compare commits
5 Commits
0.9.38
..
1930473753
| Author | SHA1 | Date | |
|---|---|---|---|
| 1930473753 | |||
| c343fd3cd0 | |||
| d9fb271607 | |||
| 064af16f95 | |||
| 0f640601b6 |
@@ -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 8–1 –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 { buildCreature } from "./build-creature.js";
|
||||
export { buildEncounter } from "./build-encounter.js";
|
||||
export { buildPf2eCreature } from "./build-pf2e-creature.js";
|
||||
|
||||
@@ -49,10 +49,9 @@ describe("loadBestiaryIndex", () => {
|
||||
});
|
||||
|
||||
describe("getAllSourceCodes", () => {
|
||||
it("returns all keys from the index sources", () => {
|
||||
it("returns all index sources except bundled ones", () => {
|
||||
const codes = getAllSourceCodes();
|
||||
const index = loadBestiaryIndex();
|
||||
expect(codes).toEqual(Object.keys(index.sources));
|
||||
expect(codes).not.toContain("TGL");
|
||||
});
|
||||
|
||||
it("returns only strings", () => {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getBundledDndSources,
|
||||
loadBundledDndCreatures,
|
||||
loadBundledDndIndexEntries,
|
||||
} from "../dnd-bundled-adapter.js";
|
||||
|
||||
describe("dnd-bundled-adapter", () => {
|
||||
it("loads bundled creatures with a valid shape", () => {
|
||||
const creatures = loadBundledDndCreatures();
|
||||
const sources = getBundledDndSources();
|
||||
for (const c of creatures) {
|
||||
expect(sources.has(c.source)).toBe(true);
|
||||
expect(c.sourceDisplayName).toBe(sources.get(c.source));
|
||||
expect(c.id.startsWith(`${c.source.toLowerCase()}:`)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("derives source codes from the creature data", () => {
|
||||
const creatures = loadBundledDndCreatures();
|
||||
const sources = getBundledDndSources();
|
||||
const seen = new Set(creatures.map((c) => c.source));
|
||||
expect(sources.size).toBe(seen.size);
|
||||
for (const s of seen) {
|
||||
expect(sources.has(s)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("derives index entries that match the bundled creatures", () => {
|
||||
const creatures = loadBundledDndCreatures();
|
||||
const entries = loadBundledDndIndexEntries();
|
||||
expect(entries.length).toBe(creatures.length);
|
||||
const entryNames = new Set(entries.map((e) => e.name));
|
||||
for (const c of creatures) {
|
||||
expect(entryNames.has(c.name)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("abbreviates sizes to single-letter codes in index entries", () => {
|
||||
const entries = loadBundledDndIndexEntries();
|
||||
for (const e of entries) {
|
||||
expect(["T", "S", "M", "L", "H", "G"]).toContain(e.size);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { BestiaryIndex, BestiaryIndexEntry } from "@initiative/domain";
|
||||
|
||||
import rawIndex from "../../../../data/bestiary/index.json";
|
||||
import {
|
||||
getBundledDndSources,
|
||||
loadBundledDndIndexEntries,
|
||||
} from "./dnd-bundled-adapter.js";
|
||||
|
||||
interface CompactCreature {
|
||||
readonly n: string;
|
||||
@@ -55,23 +59,32 @@ export function loadBestiaryIndex(): BestiaryIndex {
|
||||
if (cachedIndex) return cachedIndex;
|
||||
|
||||
const compact = rawIndex as unknown as CompactIndex;
|
||||
const sources = Object.fromEntries(
|
||||
const sources: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(compact.sources).filter(
|
||||
([code]) => !EXCLUDED_SOURCES.has(code),
|
||||
),
|
||||
);
|
||||
for (const [code, name] of getBundledDndSources()) {
|
||||
sources[code] = name;
|
||||
}
|
||||
cachedIndex = {
|
||||
sources,
|
||||
creatures: compact.creatures
|
||||
.filter((c) => !EXCLUDED_SOURCES.has(c.s))
|
||||
.map(mapCreature),
|
||||
creatures: [
|
||||
...compact.creatures
|
||||
.filter((c) => !EXCLUDED_SOURCES.has(c.s))
|
||||
.map(mapCreature),
|
||||
...loadBundledDndIndexEntries(),
|
||||
],
|
||||
};
|
||||
return cachedIndex;
|
||||
}
|
||||
|
||||
export function getAllSourceCodes(): string[] {
|
||||
const index = loadBestiaryIndex();
|
||||
return Object.keys(index.sources).filter((c) => !EXCLUDED_SOURCES.has(c));
|
||||
const bundled = getBundledDndSources();
|
||||
return Object.keys(index.sources).filter(
|
||||
(c) => !EXCLUDED_SOURCES.has(c) && !bundled.has(c),
|
||||
);
|
||||
}
|
||||
|
||||
function sourceCodeToFilename(sourceCode: string): string {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { BestiaryIndexEntry, Creature } from "@initiative/domain";
|
||||
import { creatureId } from "@initiative/domain";
|
||||
|
||||
import rawBundled from "../../../../data/bestiary/dnd-bundled.json";
|
||||
|
||||
type RawBundledCreature = Omit<Creature, "id"> & { id: string };
|
||||
|
||||
const SIZE_TO_CODE: Record<string, string> = {
|
||||
Tiny: "T",
|
||||
Small: "S",
|
||||
Medium: "M",
|
||||
Large: "L",
|
||||
Huge: "H",
|
||||
Gargantuan: "G",
|
||||
};
|
||||
|
||||
/** Full normalized stat blocks for bundled D&D creatures. */
|
||||
export function loadBundledDndCreatures(): Creature[] {
|
||||
return (rawBundled as RawBundledCreature[]).map((c) => ({
|
||||
...c,
|
||||
id: creatureId(c.id),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Index entries derived from the bundled creatures, in the compact shape
|
||||
* used by the search index. */
|
||||
export function loadBundledDndIndexEntries(): BestiaryIndexEntry[] {
|
||||
return (rawBundled as RawBundledCreature[]).map((c) => ({
|
||||
name: c.name,
|
||||
source: c.source,
|
||||
ac: c.ac,
|
||||
hp: c.hp.average,
|
||||
dex: c.abilities.dex,
|
||||
cr: c.cr,
|
||||
initiativeProficiency: c.initiativeProficiency,
|
||||
size: SIZE_TO_CODE[c.size.split(" ")[0]] ?? "M",
|
||||
type: c.type.split(" ")[0].toLowerCase(),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Source codes → display names, derived from the bundled creatures' own
|
||||
* `source` and `sourceDisplayName` fields. Adding a new book just means
|
||||
* appending creatures with the right `source` field to dnd-bundled.json;
|
||||
* no code change is required here. */
|
||||
export function getBundledDndSources(): ReadonlyMap<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
for (const c of rawBundled as RawBundledCreature[]) {
|
||||
if (!map.has(c.source)) {
|
||||
map.set(c.source, c.sourceDisplayName);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
// @vitest-environment jsdom
|
||||
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 {
|
||||
cleanup,
|
||||
@@ -17,6 +21,7 @@ import {
|
||||
buildCombatant,
|
||||
buildCreature,
|
||||
buildEncounter,
|
||||
buildPf2eCreature,
|
||||
} from "../../__tests__/factories/index.js";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import { useRulesEdition } from "../../hooks/use-rules-edition.js";
|
||||
@@ -52,7 +57,7 @@ const goblinCreature = buildCreature({
|
||||
function renderPanel(options: {
|
||||
encounter: ReturnType<typeof buildEncounter>;
|
||||
playerCharacters?: PlayerCharacter[];
|
||||
creatures?: Map<CreatureId, Creature>;
|
||||
creatures?: Map<CreatureId, AnyCreature>;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const adapters = createTestAdapters({
|
||||
@@ -357,4 +362,157 @@ describe("DifficultyBreakdownPanel", () => {
|
||||
|
||||
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,
|
||||
TIER_LABELS_5_5E,
|
||||
TIER_LABELS_2014,
|
||||
TIER_LABELS_PF2E,
|
||||
} from "../difficulty-indicator.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
@@ -23,6 +24,7 @@ function makeResult(tier: DifficultyResult["tier"]): DifficultyResult {
|
||||
encounterMultiplier: undefined,
|
||||
adjustedXp: undefined,
|
||||
partySizeAdjusted: undefined,
|
||||
partyLevel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -125,4 +127,64 @@ describe("DifficultyIndicator", () => {
|
||||
const element = container.querySelector("[role='img']");
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -618,14 +618,17 @@ export function CombatantRow({
|
||||
onRemove={(conditionId) => toggleCondition(id, conditionId)}
|
||||
onDecrement={(conditionId) => decrementCondition(id, conditionId)}
|
||||
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
||||
/>
|
||||
>
|
||||
{isPf2e && (
|
||||
<PersistentDamageTags
|
||||
entries={combatant.persistentDamage}
|
||||
onRemove={(damageType) =>
|
||||
removePersistentDamage(id, damageType)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</ConditionTags>
|
||||
</div>
|
||||
{isPf2e && (
|
||||
<PersistentDamageTags
|
||||
entries={combatant.persistentDamage}
|
||||
onRemove={(damageType) => removePersistentDamage(id, damageType)}
|
||||
/>
|
||||
)}
|
||||
{!!pickerOpen && (
|
||||
<ConditionPicker
|
||||
anchorRef={conditionAnchorRef}
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
Droplet,
|
||||
Droplets,
|
||||
EarOff,
|
||||
Eclipse,
|
||||
Eye,
|
||||
EyeClosed,
|
||||
EyeOff,
|
||||
Flame,
|
||||
FlaskConical,
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
HeartPulse,
|
||||
Link,
|
||||
Moon,
|
||||
Orbit,
|
||||
PersonStanding,
|
||||
ShieldMinus,
|
||||
ShieldOff,
|
||||
@@ -31,9 +34,12 @@ import {
|
||||
Skull,
|
||||
Snail,
|
||||
Snowflake,
|
||||
Sparkle,
|
||||
Sparkles,
|
||||
Sun,
|
||||
Sword,
|
||||
TrendingDown,
|
||||
Wind,
|
||||
Zap,
|
||||
ZapOff,
|
||||
} from "lucide-react";
|
||||
@@ -50,7 +56,9 @@ export const CONDITION_ICON_MAP: Record<string, LucideIcon> = {
|
||||
Droplet,
|
||||
Droplets,
|
||||
EarOff,
|
||||
Eclipse,
|
||||
Eye,
|
||||
EyeClosed,
|
||||
EyeOff,
|
||||
Flame,
|
||||
FlaskConical,
|
||||
@@ -63,6 +71,7 @@ export const CONDITION_ICON_MAP: Record<string, LucideIcon> = {
|
||||
HeartPulse,
|
||||
Link,
|
||||
Moon,
|
||||
Orbit,
|
||||
PersonStanding,
|
||||
ShieldMinus,
|
||||
ShieldOff,
|
||||
@@ -70,9 +79,12 @@ export const CONDITION_ICON_MAP: Record<string, LucideIcon> = {
|
||||
Skull,
|
||||
Snail,
|
||||
Snowflake,
|
||||
Sparkle,
|
||||
Sparkles,
|
||||
Sun,
|
||||
Sword,
|
||||
TrendingDown,
|
||||
Wind,
|
||||
Zap,
|
||||
ZapOff,
|
||||
};
|
||||
@@ -82,6 +94,7 @@ export const CONDITION_COLOR_CLASSES: Record<string, string> = {
|
||||
pink: "text-pink-400",
|
||||
amber: "text-amber-400",
|
||||
orange: "text-orange-400",
|
||||
purple: "text-purple-400",
|
||||
gray: "text-gray-400",
|
||||
violet: "text-violet-400",
|
||||
yellow: "text-yellow-400",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getConditionDescription,
|
||||
} from "@initiative/domain";
|
||||
import { Plus } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import {
|
||||
@@ -18,6 +19,7 @@ interface ConditionTagsProps {
|
||||
onRemove: (conditionId: ConditionId) => void;
|
||||
onDecrement: (conditionId: ConditionId) => void;
|
||||
onOpenPicker: () => void;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function ConditionTags({
|
||||
@@ -25,6 +27,7 @@ export function ConditionTags({
|
||||
onRemove,
|
||||
onDecrement,
|
||||
onOpenPicker,
|
||||
children,
|
||||
}: Readonly<ConditionTagsProps>) {
|
||||
const { edition } = useRulesEditionContext();
|
||||
return (
|
||||
@@ -69,6 +72,7 @@ export function ConditionTags({
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
{children}
|
||||
<button
|
||||
type="button"
|
||||
title="Add condition"
|
||||
|
||||
@@ -19,12 +19,21 @@ const TIER_LABEL_MAP: Partial<
|
||||
1: { label: "Low", color: "text-green-500" },
|
||||
2: { label: "Moderate", color: "text-yellow-500" },
|
||||
3: { label: "High", color: "text-red-500" },
|
||||
4: { label: "High", color: "text-red-500" },
|
||||
},
|
||||
"5e": {
|
||||
0: { label: "Easy", color: "text-muted-foreground" },
|
||||
1: { label: "Medium", color: "text-green-500" },
|
||||
2: { label: "Hard", color: "text-yellow-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>> = {
|
||||
Moderate: "Mod",
|
||||
Medium: "Med",
|
||||
Trivial: "Triv",
|
||||
Severe: "Sev",
|
||||
Extreme: "Ext",
|
||||
};
|
||||
|
||||
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 }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, onClose);
|
||||
@@ -128,6 +188,8 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
const isPC = (entry: BreakdownCombatant) =>
|
||||
entry.combatant.playerCharacterId != null;
|
||||
|
||||
const CreatureRow = edition === "pf2e" ? Pf2eNpcRow : NpcRow;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -142,6 +204,9 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
<div className="mb-1 text-muted-foreground text-xs">
|
||||
Party Budget ({breakdown.pcCount}{" "}
|
||||
{breakdown.pcCount === 1 ? "PC" : "PCs"})
|
||||
{breakdown.partyLevel !== undefined && (
|
||||
<> · Party Level: {breakdown.partyLevel}</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3 text-xs">
|
||||
{breakdown.thresholds.map((t) => (
|
||||
@@ -166,7 +231,7 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
isPC(entry) ? (
|
||||
<PcRow key={entry.combatant.id} entry={entry} />
|
||||
) : (
|
||||
<NpcRow
|
||||
<CreatureRow
|
||||
key={entry.combatant.id}
|
||||
entry={entry}
|
||||
onToggleSide={() => handleToggle(entry)}
|
||||
@@ -186,7 +251,7 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
isPC(entry) ? (
|
||||
<PcRow key={entry.combatant.id} entry={entry} />
|
||||
) : (
|
||||
<NpcRow
|
||||
<CreatureRow
|
||||
key={entry.combatant.id}
|
||||
entry={entry}
|
||||
onToggleSide={() => handleToggle(entry)}
|
||||
@@ -218,7 +283,9 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
) : (
|
||||
<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">
|
||||
{formatXp(breakdown.totalMonsterXp)}
|
||||
</span>
|
||||
|
||||
@@ -6,6 +6,7 @@ export const TIER_LABELS_5_5E: Record<DifficultyTier, string> = {
|
||||
1: "Low",
|
||||
2: "Moderate",
|
||||
3: "High",
|
||||
4: "High",
|
||||
};
|
||||
|
||||
export const TIER_LABELS_2014: Record<DifficultyTier, string> = {
|
||||
@@ -13,30 +14,49 @@ export const TIER_LABELS_2014: Record<DifficultyTier, string> = {
|
||||
1: "Medium",
|
||||
2: "Hard",
|
||||
3: "Deadly",
|
||||
4: "Deadly",
|
||||
};
|
||||
|
||||
const TIER_COLORS: Record<
|
||||
DifficultyTier,
|
||||
{ filledBars: number; color: string }
|
||||
> = {
|
||||
0: { filledBars: 0, color: "" },
|
||||
1: { filledBars: 1, color: "bg-green-500" },
|
||||
2: { filledBars: 2, color: "bg-yellow-500" },
|
||||
3: { filledBars: 3, color: "bg-red-500" },
|
||||
export const TIER_LABELS_PF2E: Record<DifficultyTier, string> = {
|
||||
0: "Trivial",
|
||||
1: "Low",
|
||||
2: "Moderate",
|
||||
3: "Severe",
|
||||
4: "Extreme",
|
||||
};
|
||||
|
||||
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({
|
||||
result,
|
||||
labels,
|
||||
barCount = 3,
|
||||
onClick,
|
||||
}: {
|
||||
result: DifficultyResult;
|
||||
labels: Record<DifficultyTier, string>;
|
||||
barCount?: 3 | 4;
|
||||
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 tooltip = `${label} encounter difficulty`;
|
||||
|
||||
@@ -54,13 +74,13 @@ export function DifficultyIndicator({
|
||||
onClick={onClick}
|
||||
type={onClick ? "button" : undefined}
|
||||
>
|
||||
{BAR_HEIGHTS.map((height, i) => (
|
||||
{barHeights.map((height, i) => (
|
||||
<div
|
||||
key={height}
|
||||
className={cn(
|
||||
"w-1 rounded-sm",
|
||||
height,
|
||||
i < config.filledBars ? config.color : "bg-muted",
|
||||
i < filledBars ? colorMap[i + 1] : "bg-muted",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
DifficultyIndicator,
|
||||
TIER_LABELS_5_5E,
|
||||
TIER_LABELS_2014,
|
||||
TIER_LABELS_PF2E,
|
||||
} from "./difficulty-indicator.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { ConfirmButton } from "./ui/confirm-button.js";
|
||||
@@ -26,7 +27,13 @@ export function TurnNavigation() {
|
||||
|
||||
const difficulty = useDifficulty();
|
||||
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 hasCombatants = encounter.combatants.length > 0;
|
||||
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
||||
@@ -87,6 +94,7 @@ export function TurnNavigation() {
|
||||
<DifficultyIndicator
|
||||
result={difficulty}
|
||||
labels={tierLabels}
|
||||
barCount={barCount}
|
||||
onClick={() => setShowBreakdown((prev) => !prev)}
|
||||
/>
|
||||
{showBreakdown ? (
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
// @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 { renderHook, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
@@ -9,6 +13,7 @@ import {
|
||||
buildCombatant,
|
||||
buildCreature,
|
||||
buildEncounter,
|
||||
buildPf2eCreature,
|
||||
} from "../../__tests__/factories/index.js";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import { useDifficultyBreakdown } from "../use-difficulty-breakdown.js";
|
||||
@@ -42,7 +47,7 @@ const goblinCreature = buildCreature({
|
||||
function makeWrapper(options: {
|
||||
encounter: ReturnType<typeof buildEncounter>;
|
||||
playerCharacters?: PlayerCharacter[];
|
||||
creatures?: Map<CreatureId, Creature>;
|
||||
creatures?: Map<CreatureId, AnyCreature>;
|
||||
}) {
|
||||
const adapters = createTestAdapters({
|
||||
encounter: options.encounter,
|
||||
@@ -345,4 +350,115 @@ describe("useDifficultyBreakdown", () => {
|
||||
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
|
||||
import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||
import type {
|
||||
AnyCreature,
|
||||
CreatureId,
|
||||
PlayerCharacter,
|
||||
} from "@initiative/domain";
|
||||
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
@@ -9,6 +13,7 @@ import {
|
||||
buildCombatant,
|
||||
buildCreature,
|
||||
buildEncounter,
|
||||
buildPf2eCreature,
|
||||
} from "../../__tests__/factories/index.js";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import { useDifficulty } from "../use-difficulty.js";
|
||||
@@ -43,7 +48,7 @@ const goblinCreature = buildCreature({
|
||||
function makeWrapper(options: {
|
||||
encounter: ReturnType<typeof buildEncounter>;
|
||||
playerCharacters?: PlayerCharacter[];
|
||||
creatures?: Map<CreatureId, Creature>;
|
||||
creatures?: Map<CreatureId, AnyCreature>;
|
||||
}) {
|
||||
const adapters = createTestAdapters({
|
||||
encounter: options.encounter,
|
||||
@@ -424,4 +429,134 @@ describe("useDifficulty", () => {
|
||||
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,6 +9,7 @@ import {
|
||||
normalizeBestiary,
|
||||
setSourceDisplayNames,
|
||||
} from "../adapters/bestiary-adapter.js";
|
||||
import { loadBundledDndCreatures } from "../adapters/dnd-bundled-adapter.js";
|
||||
import { normalizeFoundryCreatures } from "../adapters/pf2e-bestiary-adapter.js";
|
||||
import { useAdapters } from "../contexts/adapter-context.js";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
@@ -160,7 +161,11 @@ export function useBestiary(): BestiaryHook {
|
||||
}
|
||||
|
||||
void bestiaryCache.loadAllCachedCreatures().then((map) => {
|
||||
setCreatureMap(map);
|
||||
const merged = new Map(map);
|
||||
for (const c of loadBundledDndCreatures()) {
|
||||
merged.set(c.id, c);
|
||||
}
|
||||
setCreatureMap(merged);
|
||||
});
|
||||
}, [bestiaryCache, bestiaryIndex, pf2eBestiaryIndex]);
|
||||
|
||||
@@ -300,6 +305,9 @@ export function useBestiary(): BestiaryHook {
|
||||
|
||||
const refreshCache = useCallback(async (): Promise<void> => {
|
||||
const map = await bestiaryCache.loadAllCachedCreatures();
|
||||
for (const c of loadBundledDndCreatures()) {
|
||||
map.set(c.id, c);
|
||||
}
|
||||
setCreatureMap(map);
|
||||
}, [bestiaryCache]);
|
||||
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import type {
|
||||
AnyCreature,
|
||||
Combatant,
|
||||
CreatureId,
|
||||
DifficultyThreshold,
|
||||
DifficultyTier,
|
||||
PlayerCharacter,
|
||||
} from "@initiative/domain";
|
||||
import { calculateEncounterDifficulty, crToXp } from "@initiative/domain";
|
||||
import {
|
||||
calculateEncounterDifficulty,
|
||||
crToXp,
|
||||
derivePartyLevel,
|
||||
pf2eCreatureXp,
|
||||
} from "@initiative/domain";
|
||||
import { useMemo } from "react";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
@@ -21,6 +27,10 @@ export interface BreakdownCombatant {
|
||||
readonly editable: boolean;
|
||||
readonly side: "party" | "enemy";
|
||||
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 {
|
||||
@@ -30,6 +40,7 @@ interface DifficultyBreakdown {
|
||||
readonly encounterMultiplier: number | undefined;
|
||||
readonly adjustedXp: number | undefined;
|
||||
readonly partySizeAdjusted: boolean | undefined;
|
||||
readonly partyLevel: number | undefined;
|
||||
readonly pcCount: number;
|
||||
readonly partyCombatants: readonly BreakdownCombatant[];
|
||||
readonly enemyCombatants: readonly BreakdownCombatant[];
|
||||
@@ -48,9 +59,16 @@ export function useDifficultyBreakdown(): DifficultyBreakdown | null {
|
||||
const hasPartyLevel = descriptors.some(
|
||||
(d) => d.side === "party" && d.level !== undefined,
|
||||
);
|
||||
const hasCr = descriptors.some((d) => d.cr !== undefined);
|
||||
|
||||
if (!hasPartyLevel || !hasCr) return null;
|
||||
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;
|
||||
}
|
||||
|
||||
const result = calculateEncounterDifficulty(descriptors, edition);
|
||||
|
||||
@@ -65,6 +83,7 @@ export function useDifficultyBreakdown(): DifficultyBreakdown | null {
|
||||
|
||||
type CreatureInfo = {
|
||||
cr?: string;
|
||||
creatureLevel?: number;
|
||||
source: string;
|
||||
sourceDisplayName: string;
|
||||
};
|
||||
@@ -74,6 +93,7 @@ function buildBreakdownEntry(
|
||||
side: "party" | "enemy",
|
||||
level: number | undefined,
|
||||
creature: CreatureInfo | undefined,
|
||||
partyLevel: number | undefined,
|
||||
): BreakdownCombatant {
|
||||
if (c.playerCharacterId) {
|
||||
return {
|
||||
@@ -84,6 +104,29 @@ function buildBreakdownEntry(
|
||||
editable: false,
|
||||
side,
|
||||
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) {
|
||||
@@ -96,6 +139,8 @@ function buildBreakdownEntry(
|
||||
editable: false,
|
||||
side,
|
||||
level: undefined,
|
||||
creatureLevel: undefined,
|
||||
levelDifference: undefined,
|
||||
};
|
||||
}
|
||||
if (c.cr) {
|
||||
@@ -107,6 +152,8 @@ function buildBreakdownEntry(
|
||||
editable: true,
|
||||
side,
|
||||
level: undefined,
|
||||
creatureLevel: undefined,
|
||||
levelDifference: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -117,6 +164,8 @@ function buildBreakdownEntry(
|
||||
editable: !c.creatureId,
|
||||
side,
|
||||
level: undefined,
|
||||
creatureLevel: undefined,
|
||||
levelDifference: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,41 +177,91 @@ function resolveLevel(
|
||||
return characters.find((p) => p.id === c.playerCharacterId)?.level;
|
||||
}
|
||||
|
||||
function resolveCr(
|
||||
function resolveCreatureInfo(
|
||||
c: Combatant,
|
||||
getCreature: (id: CreatureId) => CreatureInfo | undefined,
|
||||
): { cr: string | null; creature: CreatureInfo | undefined } {
|
||||
const creature = c.creatureId ? getCreature(c.creatureId) : undefined;
|
||||
const cr = creature?.cr ?? c.cr ?? null;
|
||||
return { cr, creature };
|
||||
getCreature: (id: CreatureId) => AnyCreature | undefined,
|
||||
): {
|
||||
cr: string | null;
|
||||
creatureLevel: number | undefined;
|
||||
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(
|
||||
combatants: readonly Combatant[],
|
||||
characters: readonly PlayerCharacter[],
|
||||
getCreature: (id: CreatureId) => CreatureInfo | undefined,
|
||||
getCreature: (id: CreatureId) => AnyCreature | undefined,
|
||||
) {
|
||||
const partyCombatants: BreakdownCombatant[] = [];
|
||||
const enemyCombatants: BreakdownCombatant[] = [];
|
||||
const descriptors: {
|
||||
level?: number;
|
||||
cr?: string;
|
||||
creatureLevel?: number;
|
||||
side: "party" | "enemy";
|
||||
}[] = [];
|
||||
let pcCount = 0;
|
||||
const partyLevel = collectPartyLevel(combatants, characters);
|
||||
|
||||
for (const c of combatants) {
|
||||
const side = resolveSide(c);
|
||||
const level = resolveLevel(c, characters);
|
||||
if (level !== undefined) pcCount++;
|
||||
|
||||
const { cr, creature } = resolveCr(c, getCreature);
|
||||
const { cr, creatureLevel, creature } = resolveCreatureInfo(c, getCreature);
|
||||
|
||||
if (level !== undefined || cr != null) {
|
||||
descriptors.push({ level, cr: cr ?? undefined, side });
|
||||
if (level !== undefined || cr != null || creatureLevel !== undefined) {
|
||||
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;
|
||||
target.push(entry);
|
||||
}
|
||||
|
||||
@@ -33,9 +33,17 @@ function buildDescriptors(
|
||||
const creatureCr =
|
||||
creature && !("system" in creature) ? creature.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) {
|
||||
descriptors.push({ level, cr, side });
|
||||
if (
|
||||
level !== undefined ||
|
||||
cr !== undefined ||
|
||||
creatureLevel !== undefined
|
||||
) {
|
||||
descriptors.push({ level, cr, creatureLevel, side });
|
||||
}
|
||||
}
|
||||
return descriptors;
|
||||
@@ -48,8 +56,6 @@ export function useDifficulty(): DifficultyResult | null {
|
||||
const { edition } = useRulesEditionContext();
|
||||
|
||||
return useMemo(() => {
|
||||
if (edition === "pf2e") return null;
|
||||
|
||||
const descriptors = buildDescriptors(
|
||||
encounter.combatants,
|
||||
characters,
|
||||
@@ -59,9 +65,16 @@ export function useDifficulty(): DifficultyResult | null {
|
||||
const hasPartyLevel = descriptors.some(
|
||||
(d) => d.side === "party" && d.level !== undefined,
|
||||
);
|
||||
const hasCr = descriptors.some((d) => d.cr !== undefined);
|
||||
|
||||
if (!hasPartyLevel || !hasCr) return null;
|
||||
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;
|
||||
}
|
||||
|
||||
return calculateEncounterDifficulty(descriptors, 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 {
|
||||
calculateEncounterDifficulty,
|
||||
crToXp,
|
||||
derivePartyLevel,
|
||||
pf2eCreatureXp,
|
||||
} from "../encounter-difficulty.js";
|
||||
|
||||
describe("crToXp", () => {
|
||||
@@ -386,3 +388,234 @@ describe("calculateEncounterDifficulty — 2014 edition", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,13 +60,13 @@ describe("toggleCondition", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("maintains definition order when adding conditions", () => {
|
||||
it("appends new conditions to the end (insertion order)", () => {
|
||||
const e = enc([makeCombatant("A", [{ id: "poisoned" }])]);
|
||||
const { encounter } = success(e, "A", "blinded");
|
||||
|
||||
expect(encounter.combatants[0].conditions).toEqual([
|
||||
{ id: "blinded" },
|
||||
{ id: "poisoned" },
|
||||
{ id: "blinded" },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -109,15 +109,16 @@ describe("toggleCondition", () => {
|
||||
expect(encounter.combatants[0].conditions).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves order across all conditions", () => {
|
||||
it("preserves insertion order across all conditions", () => {
|
||||
const order = CONDITION_DEFINITIONS.map((d) => d.id);
|
||||
// Add in reverse order
|
||||
// Add in reverse order — result should be reverse order (insertion order)
|
||||
const reversed = [...order].reverse();
|
||||
let e = enc([makeCombatant("A")]);
|
||||
for (const cond of [...order].reverse()) {
|
||||
for (const cond of reversed) {
|
||||
const result = success(e, "A", cond);
|
||||
e = result.encounter;
|
||||
}
|
||||
expect(e.combatants[0].conditions).toEqual(order.map((id) => ({ id })));
|
||||
expect(e.combatants[0].conditions).toEqual(reversed.map((id) => ({ id })));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -500,8 +500,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
||||
description5e: "",
|
||||
descriptionPf2e:
|
||||
"Location unknown. Must pick a square to target; DC 11 flat check. Attacker is off-guard against your attacks.",
|
||||
iconName: "Ghost",
|
||||
color: "violet",
|
||||
iconName: "EyeClosed",
|
||||
color: "slate",
|
||||
systems: ["pf2e"],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { RulesEdition } from "./rules-edition.js";
|
||||
|
||||
/** Abstract difficulty severity: 0 = negligible, 3 = maximum. Maps to filled bar count. */
|
||||
export type DifficultyTier = 0 | 1 | 2 | 3;
|
||||
/** Abstract difficulty severity: 0 = negligible, up to 4 (PF2e Extreme). Maps to filled bar count. */
|
||||
export type DifficultyTier = 0 | 1 | 2 | 3 | 4;
|
||||
|
||||
export interface DifficultyThreshold {
|
||||
readonly label: string;
|
||||
@@ -18,6 +18,8 @@ export interface DifficultyResult {
|
||||
readonly adjustedXp: number | undefined;
|
||||
/** 2014 only: true when the multiplier was shifted due to party size (<3 or 6+). */
|
||||
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). */
|
||||
@@ -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. */
|
||||
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 {
|
||||
readonly level?: number;
|
||||
readonly cr?: string;
|
||||
readonly creatureLevel?: number;
|
||||
readonly side: "party" | "enemy";
|
||||
}
|
||||
|
||||
@@ -247,6 +377,41 @@ export function calculateEncounterDifficulty(
|
||||
combatants: readonly CombatantDescriptor[],
|
||||
edition: RulesEdition,
|
||||
): 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 } =
|
||||
scanCombatants(combatants);
|
||||
|
||||
@@ -268,6 +433,7 @@ export function calculateEncounterDifficulty(
|
||||
encounterMultiplier: undefined,
|
||||
adjustedXp: undefined,
|
||||
partySizeAdjusted: undefined,
|
||||
partyLevel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -294,5 +460,6 @@ export function calculateEncounterDifficulty(
|
||||
encounterMultiplier,
|
||||
adjustedXp,
|
||||
partySizeAdjusted,
|
||||
partyLevel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -64,6 +64,8 @@ export {
|
||||
type DifficultyResult,
|
||||
type DifficultyThreshold,
|
||||
type DifficultyTier,
|
||||
derivePartyLevel,
|
||||
pf2eCreatureXp,
|
||||
VALID_CR_VALUES,
|
||||
} from "./encounter-difficulty.js";
|
||||
export type {
|
||||
|
||||
@@ -15,6 +15,11 @@ export const PERSISTENT_DAMAGE_TYPES = [
|
||||
"electricity",
|
||||
"poison",
|
||||
"mental",
|
||||
"force",
|
||||
"void",
|
||||
"spirit",
|
||||
"vitality",
|
||||
"piercing",
|
||||
] as const;
|
||||
|
||||
export type PersistentDamageType = (typeof PERSISTENT_DAMAGE_TYPES)[number];
|
||||
@@ -64,6 +69,21 @@ export const PERSISTENT_DAMAGE_DEFINITIONS: readonly PersistentDamageDefinition[
|
||||
iconName: "BrainCog",
|
||||
color: "pink",
|
||||
},
|
||||
{ type: "force", label: "Force", iconName: "Orbit", color: "indigo" },
|
||||
{ type: "void", label: "Void", iconName: "Eclipse", color: "purple" },
|
||||
{ type: "spirit", label: "Spirit", iconName: "Wind", color: "neutral" },
|
||||
{
|
||||
type: "vitality",
|
||||
label: "Vitality",
|
||||
iconName: "Sparkle",
|
||||
color: "amber",
|
||||
},
|
||||
{
|
||||
type: "piercing",
|
||||
label: "Piercing",
|
||||
iconName: "Sword",
|
||||
color: "neutral",
|
||||
},
|
||||
];
|
||||
|
||||
export interface PersistentDamageSuccess {
|
||||
|
||||
@@ -14,12 +14,6 @@ export interface ToggleConditionSuccess {
|
||||
readonly events: DomainEvent[];
|
||||
}
|
||||
|
||||
function sortByDefinitionOrder(entries: ConditionEntry[]): ConditionEntry[] {
|
||||
const order = CONDITION_DEFINITIONS.map((d) => d.id);
|
||||
entries.sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id));
|
||||
return entries;
|
||||
}
|
||||
|
||||
function validateConditionId(conditionId: ConditionId): DomainError | null {
|
||||
if (!VALID_CONDITION_IDS.has(conditionId)) {
|
||||
return {
|
||||
@@ -67,8 +61,7 @@ export function toggleCondition(
|
||||
newConditions = filtered.length > 0 ? filtered : undefined;
|
||||
event = { type: "ConditionRemoved", combatantId, condition: conditionId };
|
||||
} else {
|
||||
const added = sortByDefinitionOrder([...current, { id: conditionId }]);
|
||||
newConditions = added;
|
||||
newConditions = [...current, { id: conditionId }];
|
||||
event = { type: "ConditionAdded", combatantId, condition: conditionId };
|
||||
}
|
||||
|
||||
@@ -125,10 +118,7 @@ export function setConditionValue(
|
||||
};
|
||||
}
|
||||
|
||||
const added = sortByDefinitionOrder([
|
||||
...current,
|
||||
{ id: conditionId, value: clampedValue },
|
||||
]);
|
||||
const added = [...current, { id: conditionId, value: clampedValue }];
|
||||
return {
|
||||
encounter: applyConditions(encounter, combatantId, added),
|
||||
events: [
|
||||
|
||||
@@ -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())
|
||||
@@ -356,7 +356,7 @@ Acceptance scenarios:
|
||||
As a DM running a PF2e encounter, I want to apply persistent damage to a combatant as a compact tag showing a damage type icon and formula so I can track ongoing damage effects without manual bookkeeping.
|
||||
|
||||
Acceptance scenarios:
|
||||
1. **Given** the game system is Pathfinder 2e and the condition picker is open, **When** the user clicks "Persistent Damage", **Then** a sub-picker opens with a damage type dropdown (fire, bleed, acid, cold, electricity, poison, mental) and a formula text input.
|
||||
1. **Given** the game system is Pathfinder 2e and the condition picker is open, **When** the user clicks "Persistent Damage", **Then** a sub-picker opens with a damage type dropdown (fire, bleed, acid, cold, electricity, poison, mental, force, void, spirit, vitality, piercing) and a formula text input.
|
||||
2. **Given** the sub-picker is open, **When** the user selects "fire" and types "2d6" and confirms, **Then** a compact tag appears on the combatant row showing a fire icon and "2d6".
|
||||
3. **Given** a combatant has persistent fire 2d6, **When** the user adds persistent bleed 1d4, **Then** both tags appear on the row simultaneously.
|
||||
4. **Given** a combatant has persistent fire 2d6, **When** the user adds persistent fire 3d6, **Then** the existing fire entry is replaced with 3d6 (one instance per type).
|
||||
@@ -421,7 +421,7 @@ Acceptance scenarios:
|
||||
- **FR-111**: When Pathfinder 2e is the active game system, the concentration UI (Brain icon toggle, purple left border accent, damage pulse animation) MUST be hidden entirely. The Brain icon MUST NOT be shown on hover or at rest, and the concentration toggle MUST NOT be interactive.
|
||||
- **FR-112**: Switching the game system MUST NOT clear or modify `isConcentrating` state on any combatant. The state MUST be preserved in storage and restored to the UI when switching back to a D&D game system.
|
||||
- **FR-117**: When Pathfinder 2e is active, the condition picker MUST include a "Persistent Damage" entry that opens a sub-picker instead of toggling directly.
|
||||
- **FR-118**: The persistent damage sub-picker MUST contain a dropdown of common PF2e damage types (fire, bleed, acid, cold, electricity, poison, mental) and a text input for the damage formula (e.g., "2d6").
|
||||
- **FR-118**: The persistent damage sub-picker MUST contain a dropdown of PF2e damage types (fire, bleed, acid, cold, electricity, poison, mental, force, void, spirit, vitality, piercing) and a text input for the damage formula (e.g., "2d6").
|
||||
- **FR-119**: Each persistent damage entry MUST be displayed as a compact tag on the combatant row showing a damage type icon and the formula text (e.g., fire icon + "2d6").
|
||||
- **FR-120**: Only one persistent damage entry per damage type is allowed per combatant. Adding the same damage type MUST replace the existing formula.
|
||||
- **FR-121**: Clicking a persistent damage tag on the combatant row MUST remove that entry.
|
||||
|
||||
Reference in New Issue
Block a user