Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a045e3a0f9 | |||
| 934d98025e | |||
| 3b2fb99b37 | |||
| 111b464da5 | |||
| a97ffe5ed1 | |||
| 1930473753 | |||
| c343fd3cd0 | |||
| d9fb271607 | |||
| 064af16f95 |
@@ -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.
|
||||||
@@ -27,8 +27,8 @@
|
|||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"jsdom": "^29.0.1",
|
"jsdom": "^29.1.1",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
"vite": "^8.0.5"
|
"vite": "^8.0.16"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { Pf2eCreature } from "@initiative/domain";
|
||||||
|
import { creatureId } from "@initiative/domain";
|
||||||
|
|
||||||
|
let counter = 0;
|
||||||
|
|
||||||
|
export function buildPf2eCreature(
|
||||||
|
overrides?: Partial<Pf2eCreature>,
|
||||||
|
): Pf2eCreature {
|
||||||
|
const id = ++counter;
|
||||||
|
return {
|
||||||
|
system: "pf2e",
|
||||||
|
id: creatureId(`pf2e-creature-${id}`),
|
||||||
|
name: `PF2e Creature ${id}`,
|
||||||
|
source: "crb",
|
||||||
|
sourceDisplayName: "Core Rulebook",
|
||||||
|
level: 1,
|
||||||
|
traits: ["humanoid"],
|
||||||
|
perception: 5,
|
||||||
|
abilityMods: { str: 2, dex: 1, con: 2, int: 0, wis: 1, cha: -1 },
|
||||||
|
ac: 15,
|
||||||
|
saveFort: 7,
|
||||||
|
saveRef: 4,
|
||||||
|
saveWill: 5,
|
||||||
|
hp: 20,
|
||||||
|
speed: "25 ft.",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export { buildCombatant } from "./build-combatant.js";
|
export { buildCombatant } from "./build-combatant.js";
|
||||||
export { buildCreature } from "./build-creature.js";
|
export { buildCreature } from "./build-creature.js";
|
||||||
export { buildEncounter } from "./build-encounter.js";
|
export { buildEncounter } from "./build-encounter.js";
|
||||||
|
export { buildPf2eCreature } from "./build-pf2e-creature.js";
|
||||||
|
|||||||
@@ -49,10 +49,9 @@ describe("loadBestiaryIndex", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("getAllSourceCodes", () => {
|
describe("getAllSourceCodes", () => {
|
||||||
it("returns all keys from the index sources", () => {
|
it("returns all index sources except bundled ones", () => {
|
||||||
const codes = getAllSourceCodes();
|
const codes = getAllSourceCodes();
|
||||||
const index = loadBestiaryIndex();
|
expect(codes).not.toContain("TGL");
|
||||||
expect(codes).toEqual(Object.keys(index.sources));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns only strings", () => {
|
it("returns only strings", () => {
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
getBundledDndSources,
|
||||||
|
loadBundledDndCreatures,
|
||||||
|
loadBundledDndIndexEntries,
|
||||||
|
} from "../dnd-bundled-adapter.js";
|
||||||
|
|
||||||
|
describe("dnd-bundled-adapter", () => {
|
||||||
|
it("loads bundled creatures with a valid shape", () => {
|
||||||
|
const creatures = loadBundledDndCreatures();
|
||||||
|
const sources = getBundledDndSources();
|
||||||
|
for (const c of creatures) {
|
||||||
|
expect(sources.has(c.source)).toBe(true);
|
||||||
|
expect(c.sourceDisplayName).toBe(sources.get(c.source));
|
||||||
|
expect(c.id.startsWith(`${c.source.toLowerCase()}:`)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives source codes from the creature data", () => {
|
||||||
|
const creatures = loadBundledDndCreatures();
|
||||||
|
const sources = getBundledDndSources();
|
||||||
|
const seen = new Set(creatures.map((c) => c.source));
|
||||||
|
expect(sources.size).toBe(seen.size);
|
||||||
|
for (const s of seen) {
|
||||||
|
expect(sources.has(s)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives index entries that match the bundled creatures", () => {
|
||||||
|
const creatures = loadBundledDndCreatures();
|
||||||
|
const entries = loadBundledDndIndexEntries();
|
||||||
|
expect(entries.length).toBe(creatures.length);
|
||||||
|
const entryNames = new Set(entries.map((e) => e.name));
|
||||||
|
for (const c of creatures) {
|
||||||
|
expect(entryNames.has(c.name)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("abbreviates sizes to single-letter codes in index entries", () => {
|
||||||
|
const entries = loadBundledDndIndexEntries();
|
||||||
|
for (const e of entries) {
|
||||||
|
expect(["T", "S", "M", "L", "H", "G"]).toContain(e.size);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { BestiaryIndex, BestiaryIndexEntry } from "@initiative/domain";
|
import type { BestiaryIndex, BestiaryIndexEntry } from "@initiative/domain";
|
||||||
|
|
||||||
import rawIndex from "../../../../data/bestiary/index.json";
|
import rawIndex from "../../../../data/bestiary/index.json";
|
||||||
|
import {
|
||||||
|
getBundledDndSources,
|
||||||
|
loadBundledDndIndexEntries,
|
||||||
|
} from "./dnd-bundled-adapter.js";
|
||||||
|
|
||||||
interface CompactCreature {
|
interface CompactCreature {
|
||||||
readonly n: string;
|
readonly n: string;
|
||||||
@@ -55,23 +59,32 @@ export function loadBestiaryIndex(): BestiaryIndex {
|
|||||||
if (cachedIndex) return cachedIndex;
|
if (cachedIndex) return cachedIndex;
|
||||||
|
|
||||||
const compact = rawIndex as unknown as CompactIndex;
|
const compact = rawIndex as unknown as CompactIndex;
|
||||||
const sources = Object.fromEntries(
|
const sources: Record<string, string> = Object.fromEntries(
|
||||||
Object.entries(compact.sources).filter(
|
Object.entries(compact.sources).filter(
|
||||||
([code]) => !EXCLUDED_SOURCES.has(code),
|
([code]) => !EXCLUDED_SOURCES.has(code),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
for (const [code, name] of getBundledDndSources()) {
|
||||||
|
sources[code] = name;
|
||||||
|
}
|
||||||
cachedIndex = {
|
cachedIndex = {
|
||||||
sources,
|
sources,
|
||||||
creatures: compact.creatures
|
creatures: [
|
||||||
|
...compact.creatures
|
||||||
.filter((c) => !EXCLUDED_SOURCES.has(c.s))
|
.filter((c) => !EXCLUDED_SOURCES.has(c.s))
|
||||||
.map(mapCreature),
|
.map(mapCreature),
|
||||||
|
...loadBundledDndIndexEntries(),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
return cachedIndex;
|
return cachedIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAllSourceCodes(): string[] {
|
export function getAllSourceCodes(): string[] {
|
||||||
const index = loadBestiaryIndex();
|
const index = loadBestiaryIndex();
|
||||||
return Object.keys(index.sources).filter((c) => !EXCLUDED_SOURCES.has(c));
|
const bundled = getBundledDndSources();
|
||||||
|
return Object.keys(index.sources).filter(
|
||||||
|
(c) => !EXCLUDED_SOURCES.has(c) && !bundled.has(c),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sourceCodeToFilename(sourceCode: string): string {
|
function sourceCodeToFilename(sourceCode: string): string {
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { BestiaryIndexEntry, Creature } from "@initiative/domain";
|
||||||
|
import { creatureId } from "@initiative/domain";
|
||||||
|
|
||||||
|
import rawBundled from "../../../../data/bestiary/dnd-bundled.json";
|
||||||
|
|
||||||
|
type RawBundledCreature = Omit<Creature, "id"> & { id: string };
|
||||||
|
|
||||||
|
const SIZE_TO_CODE: Record<string, string> = {
|
||||||
|
Tiny: "T",
|
||||||
|
Small: "S",
|
||||||
|
Medium: "M",
|
||||||
|
Large: "L",
|
||||||
|
Huge: "H",
|
||||||
|
Gargantuan: "G",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Full normalized stat blocks for bundled D&D creatures. */
|
||||||
|
export function loadBundledDndCreatures(): Creature[] {
|
||||||
|
return (rawBundled as RawBundledCreature[]).map((c) => ({
|
||||||
|
...c,
|
||||||
|
id: creatureId(c.id),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index entries derived from the bundled creatures, in the compact shape
|
||||||
|
* used by the search index. */
|
||||||
|
export function loadBundledDndIndexEntries(): BestiaryIndexEntry[] {
|
||||||
|
return (rawBundled as RawBundledCreature[]).map((c) => ({
|
||||||
|
name: c.name,
|
||||||
|
source: c.source,
|
||||||
|
ac: c.ac,
|
||||||
|
hp: c.hp.average,
|
||||||
|
dex: c.abilities.dex,
|
||||||
|
cr: c.cr,
|
||||||
|
initiativeProficiency: c.initiativeProficiency,
|
||||||
|
size: SIZE_TO_CODE[c.size.split(" ")[0]] ?? "M",
|
||||||
|
type: c.type.split(" ")[0].toLowerCase(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Source codes → display names, derived from the bundled creatures' own
|
||||||
|
* `source` and `sourceDisplayName` fields. Adding a new book just means
|
||||||
|
* appending creatures with the right `source` field to dnd-bundled.json;
|
||||||
|
* no code change is required here. */
|
||||||
|
export function getBundledDndSources(): ReadonlyMap<string, string> {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const c of rawBundled as RawBundledCreature[]) {
|
||||||
|
if (!map.has(c.source)) {
|
||||||
|
map.set(c.source, c.sourceDisplayName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
@@ -38,6 +38,22 @@ describe("Dialog", () => {
|
|||||||
expect(dialog?.hasAttribute("open")).toBe(false);
|
expect(dialog?.hasAttribute("open")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("unmounts children when closed so internal state does not persist", () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<Dialog open={true} onClose={() => {}}>
|
||||||
|
<span>Body</span>
|
||||||
|
</Dialog>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("Body")).not.toBeNull();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<Dialog open={false} onClose={() => {}}>
|
||||||
|
<span>Body</span>
|
||||||
|
</Dialog>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("Body")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("calls onClose on cancel event", () => {
|
it("calls onClose on cancel event", () => {
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain";
|
import type {
|
||||||
|
AnyCreature,
|
||||||
|
CreatureId,
|
||||||
|
PlayerCharacter,
|
||||||
|
} from "@initiative/domain";
|
||||||
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||||
import {
|
import {
|
||||||
cleanup,
|
cleanup,
|
||||||
@@ -17,6 +21,7 @@ import {
|
|||||||
buildCombatant,
|
buildCombatant,
|
||||||
buildCreature,
|
buildCreature,
|
||||||
buildEncounter,
|
buildEncounter,
|
||||||
|
buildPf2eCreature,
|
||||||
} from "../../__tests__/factories/index.js";
|
} from "../../__tests__/factories/index.js";
|
||||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
import { useRulesEdition } from "../../hooks/use-rules-edition.js";
|
import { useRulesEdition } from "../../hooks/use-rules-edition.js";
|
||||||
@@ -52,7 +57,7 @@ const goblinCreature = buildCreature({
|
|||||||
function renderPanel(options: {
|
function renderPanel(options: {
|
||||||
encounter: ReturnType<typeof buildEncounter>;
|
encounter: ReturnType<typeof buildEncounter>;
|
||||||
playerCharacters?: PlayerCharacter[];
|
playerCharacters?: PlayerCharacter[];
|
||||||
creatures?: Map<CreatureId, Creature>;
|
creatures?: Map<CreatureId, AnyCreature>;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const adapters = createTestAdapters({
|
const adapters = createTestAdapters({
|
||||||
@@ -357,4 +362,157 @@ describe("DifficultyBreakdownPanel", () => {
|
|||||||
|
|
||||||
expect(onClose).toHaveBeenCalledOnce();
|
expect(onClose).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("PF2e edition", () => {
|
||||||
|
const orcWarrior = buildPf2eCreature({
|
||||||
|
id: creatureId("pf2e:orc-warrior"),
|
||||||
|
name: "Orc Warrior",
|
||||||
|
level: 3,
|
||||||
|
source: "crb",
|
||||||
|
sourceDisplayName: "Core Rulebook",
|
||||||
|
});
|
||||||
|
|
||||||
|
function pf2eEncounter() {
|
||||||
|
return buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-2"),
|
||||||
|
name: "Orc Warrior",
|
||||||
|
creatureId: orcWarrior.id,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("shows PF2e tier label", async () => {
|
||||||
|
const { result: editionResult } = renderHook(() => useRulesEdition());
|
||||||
|
editionResult.current.setEdition("pf2e");
|
||||||
|
|
||||||
|
try {
|
||||||
|
renderPanel({
|
||||||
|
encounter: pf2eEncounter(),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[orcWarrior.id, orcWarrior]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText("Encounter Difficulty:", { exact: false }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
editionResult.current.setEdition("5.5e");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows party level", async () => {
|
||||||
|
const { result: editionResult } = renderHook(() => useRulesEdition());
|
||||||
|
editionResult.current.setEdition("pf2e");
|
||||||
|
|
||||||
|
try {
|
||||||
|
renderPanel({
|
||||||
|
encounter: pf2eEncounter(),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[orcWarrior.id, orcWarrior]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText("Party Level: 5", { exact: false }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
editionResult.current.setEdition("5.5e");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows creature level and level difference", async () => {
|
||||||
|
const { result: editionResult } = renderHook(() => useRulesEdition());
|
||||||
|
editionResult.current.setEdition("pf2e");
|
||||||
|
|
||||||
|
try {
|
||||||
|
renderPanel({
|
||||||
|
encounter: pf2eEncounter(),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[orcWarrior.id, orcWarrior]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Orc Warrior level 3, party level 5 → diff −2
|
||||||
|
expect(
|
||||||
|
screen.getByText("Lv 3 (-2)", { exact: false }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
editionResult.current.setEdition("5.5e");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 5 thresholds with short labels", async () => {
|
||||||
|
const { result: editionResult } = renderHook(() => useRulesEdition());
|
||||||
|
editionResult.current.setEdition("pf2e");
|
||||||
|
|
||||||
|
try {
|
||||||
|
renderPanel({
|
||||||
|
encounter: pf2eEncounter(),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[orcWarrior.id, orcWarrior]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText("Triv:", { exact: false }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Low:", { exact: false }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Mod:", { exact: false }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Sev:", { exact: false }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Ext:", { exact: false }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
editionResult.current.setEdition("5.5e");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Net Creature XP label in PF2e mode", async () => {
|
||||||
|
const { result: editionResult } = renderHook(() => useRulesEdition());
|
||||||
|
editionResult.current.setEdition("pf2e");
|
||||||
|
|
||||||
|
try {
|
||||||
|
renderPanel({
|
||||||
|
encounter: pf2eEncounter(),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[orcWarrior.id, orcWarrior]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Net Creature XP")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
editionResult.current.setEdition("5.5e");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
DifficultyIndicator,
|
DifficultyIndicator,
|
||||||
TIER_LABELS_5_5E,
|
TIER_LABELS_5_5E,
|
||||||
TIER_LABELS_2014,
|
TIER_LABELS_2014,
|
||||||
|
TIER_LABELS_PF2E,
|
||||||
} from "../difficulty-indicator.js";
|
} from "../difficulty-indicator.js";
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
@@ -23,6 +24,7 @@ function makeResult(tier: DifficultyResult["tier"]): DifficultyResult {
|
|||||||
encounterMultiplier: undefined,
|
encounterMultiplier: undefined,
|
||||||
adjustedXp: undefined,
|
adjustedXp: undefined,
|
||||||
partySizeAdjusted: undefined,
|
partySizeAdjusted: undefined,
|
||||||
|
partyLevel: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,4 +127,64 @@ describe("DifficultyIndicator", () => {
|
|||||||
const element = container.querySelector("[role='img']");
|
const element = container.querySelector("[role='img']");
|
||||||
expect(element?.tagName).toBe("BUTTON");
|
expect(element?.tagName).toBe("BUTTON");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders 4 bars when barCount is 4", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<DifficultyIndicator
|
||||||
|
result={makeResult(2)}
|
||||||
|
labels={TIER_LABELS_PF2E}
|
||||||
|
barCount={4}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const bars = container.querySelectorAll("[class*='rounded-sm']");
|
||||||
|
expect(bars).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 0 filled bars for tier 0 with 4 bars", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<DifficultyIndicator
|
||||||
|
result={makeResult(0)}
|
||||||
|
labels={TIER_LABELS_PF2E}
|
||||||
|
barCount={4}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const bars = container.querySelectorAll("[class*='rounded-sm']");
|
||||||
|
for (const bar of bars) {
|
||||||
|
expect(bar.className).toContain("bg-muted");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows correct PF2e tooltip for Severe tier", () => {
|
||||||
|
render(
|
||||||
|
<DifficultyIndicator
|
||||||
|
result={makeResult(3)}
|
||||||
|
labels={TIER_LABELS_PF2E}
|
||||||
|
barCount={4}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("img", { name: "Severe encounter difficulty" }),
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows correct PF2e tooltip for Extreme tier", () => {
|
||||||
|
render(
|
||||||
|
<DifficultyIndicator
|
||||||
|
result={makeResult(4)}
|
||||||
|
labels={TIER_LABELS_PF2E}
|
||||||
|
barCount={4}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("img", { name: "Extreme encounter difficulty" }),
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("D&D indicator still renders 3 bars (no regression)", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<DifficultyIndicator result={makeResult(3)} labels={TIER_LABELS_5_5E} />,
|
||||||
|
);
|
||||||
|
const bars = container.querySelectorAll("[class*='rounded-sm']");
|
||||||
|
expect(bars).toHaveLength(3);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -618,14 +618,17 @@ export function CombatantRow({
|
|||||||
onRemove={(conditionId) => toggleCondition(id, conditionId)}
|
onRemove={(conditionId) => toggleCondition(id, conditionId)}
|
||||||
onDecrement={(conditionId) => decrementCondition(id, conditionId)}
|
onDecrement={(conditionId) => decrementCondition(id, conditionId)}
|
||||||
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
||||||
/>
|
>
|
||||||
</div>
|
|
||||||
{isPf2e && (
|
{isPf2e && (
|
||||||
<PersistentDamageTags
|
<PersistentDamageTags
|
||||||
entries={combatant.persistentDamage}
|
entries={combatant.persistentDamage}
|
||||||
onRemove={(damageType) => removePersistentDamage(id, damageType)}
|
onRemove={(damageType) =>
|
||||||
|
removePersistentDamage(id, damageType)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</ConditionTags>
|
||||||
|
</div>
|
||||||
{!!pickerOpen && (
|
{!!pickerOpen && (
|
||||||
<ConditionPicker
|
<ConditionPicker
|
||||||
anchorRef={conditionAnchorRef}
|
anchorRef={conditionAnchorRef}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
EarOff,
|
EarOff,
|
||||||
Eclipse,
|
Eclipse,
|
||||||
Eye,
|
Eye,
|
||||||
|
EyeClosed,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Flame,
|
Flame,
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
@@ -57,6 +58,7 @@ export const CONDITION_ICON_MAP: Record<string, LucideIcon> = {
|
|||||||
EarOff,
|
EarOff,
|
||||||
Eclipse,
|
Eclipse,
|
||||||
Eye,
|
Eye,
|
||||||
|
EyeClosed,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Flame,
|
Flame,
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
@@ -92,6 +94,7 @@ export const CONDITION_COLOR_CLASSES: Record<string, string> = {
|
|||||||
pink: "text-pink-400",
|
pink: "text-pink-400",
|
||||||
amber: "text-amber-400",
|
amber: "text-amber-400",
|
||||||
orange: "text-orange-400",
|
orange: "text-orange-400",
|
||||||
|
purple: "text-purple-400",
|
||||||
gray: "text-gray-400",
|
gray: "text-gray-400",
|
||||||
violet: "text-violet-400",
|
violet: "text-violet-400",
|
||||||
yellow: "text-yellow-400",
|
yellow: "text-yellow-400",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
getConditionDescription,
|
getConditionDescription,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
import { cn } from "../lib/utils.js";
|
import { cn } from "../lib/utils.js";
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +19,7 @@ interface ConditionTagsProps {
|
|||||||
onRemove: (conditionId: ConditionId) => void;
|
onRemove: (conditionId: ConditionId) => void;
|
||||||
onDecrement: (conditionId: ConditionId) => void;
|
onDecrement: (conditionId: ConditionId) => void;
|
||||||
onOpenPicker: () => void;
|
onOpenPicker: () => void;
|
||||||
|
children?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConditionTags({
|
export function ConditionTags({
|
||||||
@@ -25,6 +27,7 @@ export function ConditionTags({
|
|||||||
onRemove,
|
onRemove,
|
||||||
onDecrement,
|
onDecrement,
|
||||||
onOpenPicker,
|
onOpenPicker,
|
||||||
|
children,
|
||||||
}: Readonly<ConditionTagsProps>) {
|
}: Readonly<ConditionTagsProps>) {
|
||||||
const { edition } = useRulesEditionContext();
|
const { edition } = useRulesEditionContext();
|
||||||
return (
|
return (
|
||||||
@@ -69,6 +72,7 @@ export function ConditionTags({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{children}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="Add condition"
|
title="Add condition"
|
||||||
|
|||||||
@@ -19,12 +19,21 @@ const TIER_LABEL_MAP: Partial<
|
|||||||
1: { label: "Low", color: "text-green-500" },
|
1: { label: "Low", color: "text-green-500" },
|
||||||
2: { label: "Moderate", color: "text-yellow-500" },
|
2: { label: "Moderate", color: "text-yellow-500" },
|
||||||
3: { label: "High", color: "text-red-500" },
|
3: { label: "High", color: "text-red-500" },
|
||||||
|
4: { label: "High", color: "text-red-500" },
|
||||||
},
|
},
|
||||||
"5e": {
|
"5e": {
|
||||||
0: { label: "Easy", color: "text-muted-foreground" },
|
0: { label: "Easy", color: "text-muted-foreground" },
|
||||||
1: { label: "Medium", color: "text-green-500" },
|
1: { label: "Medium", color: "text-green-500" },
|
||||||
2: { label: "Hard", color: "text-yellow-500" },
|
2: { label: "Hard", color: "text-yellow-500" },
|
||||||
3: { label: "Deadly", color: "text-red-500" },
|
3: { label: "Deadly", color: "text-red-500" },
|
||||||
|
4: { label: "Deadly", color: "text-red-500" },
|
||||||
|
},
|
||||||
|
pf2e: {
|
||||||
|
0: { label: "Trivial", color: "text-muted-foreground" },
|
||||||
|
1: { label: "Low", color: "text-green-500" },
|
||||||
|
2: { label: "Moderate", color: "text-yellow-500" },
|
||||||
|
3: { label: "Severe", color: "text-orange-500" },
|
||||||
|
4: { label: "Extreme", color: "text-red-500" },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -32,6 +41,9 @@ const TIER_LABEL_MAP: Partial<
|
|||||||
const SHORT_LABELS: Readonly<Record<string, string>> = {
|
const SHORT_LABELS: Readonly<Record<string, string>> = {
|
||||||
Moderate: "Mod",
|
Moderate: "Mod",
|
||||||
Medium: "Med",
|
Medium: "Med",
|
||||||
|
Trivial: "Triv",
|
||||||
|
Severe: "Sev",
|
||||||
|
Extreme: "Ext",
|
||||||
};
|
};
|
||||||
|
|
||||||
function shortLabel(label: string): string {
|
function shortLabel(label: string): string {
|
||||||
@@ -107,6 +119,54 @@ function NpcRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Pf2eNpcRow({
|
||||||
|
entry,
|
||||||
|
onToggleSide,
|
||||||
|
}: {
|
||||||
|
entry: BreakdownCombatant;
|
||||||
|
onToggleSide: () => void;
|
||||||
|
}) {
|
||||||
|
const isParty = entry.side === "party";
|
||||||
|
const targetSide = isParty ? "enemy" : "party";
|
||||||
|
|
||||||
|
let xpDisplay: string;
|
||||||
|
if (entry.xp == null) {
|
||||||
|
xpDisplay = "\u2014";
|
||||||
|
} else if (isParty) {
|
||||||
|
xpDisplay = `\u2212${formatXp(entry.xp)}`;
|
||||||
|
} else {
|
||||||
|
xpDisplay = formatXp(entry.xp);
|
||||||
|
}
|
||||||
|
|
||||||
|
let levelDisplay: string;
|
||||||
|
if (entry.creatureLevel === undefined) {
|
||||||
|
levelDisplay = "\u2014";
|
||||||
|
} else if (entry.levelDifference === undefined) {
|
||||||
|
levelDisplay = `Lv ${entry.creatureLevel}`;
|
||||||
|
} else {
|
||||||
|
const sign = entry.levelDifference >= 0 ? "+" : "";
|
||||||
|
levelDisplay = `Lv ${entry.creatureLevel} (${sign}${entry.levelDifference})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="col-span-4 grid grid-cols-subgrid items-center text-xs">
|
||||||
|
<span className="min-w-0 truncate" title={entry.combatant.name}>
|
||||||
|
{entry.combatant.name}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onToggleSide}
|
||||||
|
aria-label={`Move ${entry.combatant.name} to ${targetSide} side`}
|
||||||
|
>
|
||||||
|
<ArrowLeftRight className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-muted-foreground">{levelDisplay}</span>
|
||||||
|
<span className="text-right tabular-nums">{xpDisplay}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
useClickOutside(ref, onClose);
|
useClickOutside(ref, onClose);
|
||||||
@@ -128,6 +188,8 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
|||||||
const isPC = (entry: BreakdownCombatant) =>
|
const isPC = (entry: BreakdownCombatant) =>
|
||||||
entry.combatant.playerCharacterId != null;
|
entry.combatant.playerCharacterId != null;
|
||||||
|
|
||||||
|
const CreatureRow = edition === "pf2e" ? Pf2eNpcRow : NpcRow;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -142,6 +204,9 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
|||||||
<div className="mb-1 text-muted-foreground text-xs">
|
<div className="mb-1 text-muted-foreground text-xs">
|
||||||
Party Budget ({breakdown.pcCount}{" "}
|
Party Budget ({breakdown.pcCount}{" "}
|
||||||
{breakdown.pcCount === 1 ? "PC" : "PCs"})
|
{breakdown.pcCount === 1 ? "PC" : "PCs"})
|
||||||
|
{breakdown.partyLevel !== undefined && (
|
||||||
|
<> · Party Level: {breakdown.partyLevel}</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 text-xs">
|
<div className="flex gap-3 text-xs">
|
||||||
{breakdown.thresholds.map((t) => (
|
{breakdown.thresholds.map((t) => (
|
||||||
@@ -166,7 +231,7 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
|||||||
isPC(entry) ? (
|
isPC(entry) ? (
|
||||||
<PcRow key={entry.combatant.id} entry={entry} />
|
<PcRow key={entry.combatant.id} entry={entry} />
|
||||||
) : (
|
) : (
|
||||||
<NpcRow
|
<CreatureRow
|
||||||
key={entry.combatant.id}
|
key={entry.combatant.id}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
onToggleSide={() => handleToggle(entry)}
|
onToggleSide={() => handleToggle(entry)}
|
||||||
@@ -186,7 +251,7 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
|||||||
isPC(entry) ? (
|
isPC(entry) ? (
|
||||||
<PcRow key={entry.combatant.id} entry={entry} />
|
<PcRow key={entry.combatant.id} entry={entry} />
|
||||||
) : (
|
) : (
|
||||||
<NpcRow
|
<CreatureRow
|
||||||
key={entry.combatant.id}
|
key={entry.combatant.id}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
onToggleSide={() => handleToggle(entry)}
|
onToggleSide={() => handleToggle(entry)}
|
||||||
@@ -218,7 +283,9 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-2 flex justify-between border-border border-t pt-2 font-medium text-xs">
|
<div className="mt-2 flex justify-between border-border border-t pt-2 font-medium text-xs">
|
||||||
<span>Net Monster XP</span>
|
<span>
|
||||||
|
{edition === "pf2e" ? "Net Creature XP" : "Net Monster XP"}
|
||||||
|
</span>
|
||||||
<span className="tabular-nums">
|
<span className="tabular-nums">
|
||||||
{formatXp(breakdown.totalMonsterXp)}
|
{formatXp(breakdown.totalMonsterXp)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const TIER_LABELS_5_5E: Record<DifficultyTier, string> = {
|
|||||||
1: "Low",
|
1: "Low",
|
||||||
2: "Moderate",
|
2: "Moderate",
|
||||||
3: "High",
|
3: "High",
|
||||||
|
4: "High",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TIER_LABELS_2014: Record<DifficultyTier, string> = {
|
export const TIER_LABELS_2014: Record<DifficultyTier, string> = {
|
||||||
@@ -13,30 +14,49 @@ export const TIER_LABELS_2014: Record<DifficultyTier, string> = {
|
|||||||
1: "Medium",
|
1: "Medium",
|
||||||
2: "Hard",
|
2: "Hard",
|
||||||
3: "Deadly",
|
3: "Deadly",
|
||||||
|
4: "Deadly",
|
||||||
};
|
};
|
||||||
|
|
||||||
const TIER_COLORS: Record<
|
export const TIER_LABELS_PF2E: Record<DifficultyTier, string> = {
|
||||||
DifficultyTier,
|
0: "Trivial",
|
||||||
{ filledBars: number; color: string }
|
1: "Low",
|
||||||
> = {
|
2: "Moderate",
|
||||||
0: { filledBars: 0, color: "" },
|
3: "Severe",
|
||||||
1: { filledBars: 1, color: "bg-green-500" },
|
4: "Extreme",
|
||||||
2: { filledBars: 2, color: "bg-yellow-500" },
|
|
||||||
3: { filledBars: 3, color: "bg-red-500" },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const BAR_HEIGHTS = ["h-2", "h-3", "h-4"] as const;
|
const BAR_HEIGHTS_3 = ["h-2", "h-3", "h-4"] as const;
|
||||||
|
const BAR_HEIGHTS_4 = ["h-1.5", "h-2", "h-3", "h-4"] as const;
|
||||||
|
|
||||||
|
/** Color for the Nth filled bar (1-indexed) in 4-bar mode. */
|
||||||
|
const BAR_COLORS: Record<number, string> = {
|
||||||
|
1: "bg-green-500",
|
||||||
|
2: "bg-yellow-500",
|
||||||
|
3: "bg-orange-500",
|
||||||
|
4: "bg-red-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** For 3-bar mode, bar 3 uses red directly (skip orange). */
|
||||||
|
const BAR_COLORS_3: Record<number, string> = {
|
||||||
|
1: "bg-green-500",
|
||||||
|
2: "bg-yellow-500",
|
||||||
|
3: "bg-red-500",
|
||||||
|
};
|
||||||
|
|
||||||
export function DifficultyIndicator({
|
export function DifficultyIndicator({
|
||||||
result,
|
result,
|
||||||
labels,
|
labels,
|
||||||
|
barCount = 3,
|
||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
result: DifficultyResult;
|
result: DifficultyResult;
|
||||||
labels: Record<DifficultyTier, string>;
|
labels: Record<DifficultyTier, string>;
|
||||||
|
barCount?: 3 | 4;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const config = TIER_COLORS[result.tier];
|
const barHeights = barCount === 4 ? BAR_HEIGHTS_4 : BAR_HEIGHTS_3;
|
||||||
|
const colorMap = barCount === 4 ? BAR_COLORS : BAR_COLORS_3;
|
||||||
|
const filledBars = result.tier;
|
||||||
const label = labels[result.tier];
|
const label = labels[result.tier];
|
||||||
const tooltip = `${label} encounter difficulty`;
|
const tooltip = `${label} encounter difficulty`;
|
||||||
|
|
||||||
@@ -54,13 +74,13 @@ export function DifficultyIndicator({
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
type={onClick ? "button" : undefined}
|
type={onClick ? "button" : undefined}
|
||||||
>
|
>
|
||||||
{BAR_HEIGHTS.map((height, i) => (
|
{barHeights.map((height, i) => (
|
||||||
<div
|
<div
|
||||||
key={height}
|
key={height}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-1 rounded-sm",
|
"w-1 rounded-sm",
|
||||||
height,
|
height,
|
||||||
i < config.filledBars ? config.color : "bg-muted",
|
i < filledBars ? colorMap[i + 1] : "bg-muted",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
DifficultyIndicator,
|
DifficultyIndicator,
|
||||||
TIER_LABELS_5_5E,
|
TIER_LABELS_5_5E,
|
||||||
TIER_LABELS_2014,
|
TIER_LABELS_2014,
|
||||||
|
TIER_LABELS_PF2E,
|
||||||
} from "./difficulty-indicator.js";
|
} from "./difficulty-indicator.js";
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
import { ConfirmButton } from "./ui/confirm-button.js";
|
import { ConfirmButton } from "./ui/confirm-button.js";
|
||||||
@@ -26,7 +27,13 @@ export function TurnNavigation() {
|
|||||||
|
|
||||||
const difficulty = useDifficulty();
|
const difficulty = useDifficulty();
|
||||||
const { edition } = useRulesEditionContext();
|
const { edition } = useRulesEditionContext();
|
||||||
const tierLabels = edition === "5e" ? TIER_LABELS_2014 : TIER_LABELS_5_5E;
|
const TIER_LABELS_BY_EDITION = {
|
||||||
|
pf2e: TIER_LABELS_PF2E,
|
||||||
|
"5e": TIER_LABELS_2014,
|
||||||
|
"5.5e": TIER_LABELS_5_5E,
|
||||||
|
} as const;
|
||||||
|
const tierLabels = TIER_LABELS_BY_EDITION[edition];
|
||||||
|
const barCount = edition === "pf2e" ? 4 : 3;
|
||||||
const [showBreakdown, setShowBreakdown] = useState(false);
|
const [showBreakdown, setShowBreakdown] = useState(false);
|
||||||
const hasCombatants = encounter.combatants.length > 0;
|
const hasCombatants = encounter.combatants.length > 0;
|
||||||
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
||||||
@@ -87,6 +94,7 @@ export function TurnNavigation() {
|
|||||||
<DifficultyIndicator
|
<DifficultyIndicator
|
||||||
result={difficulty}
|
result={difficulty}
|
||||||
labels={tierLabels}
|
labels={tierLabels}
|
||||||
|
barCount={barCount}
|
||||||
onClick={() => setShowBreakdown((prev) => !prev)}
|
onClick={() => setShowBreakdown((prev) => !prev)}
|
||||||
/>
|
/>
|
||||||
{showBreakdown ? (
|
{showBreakdown ? (
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function Dialog({ open, onClose, className, children }: DialogProps) {
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="p-6">{children}</div>
|
{open ? <div className="p-6">{children}</div> : null}
|
||||||
</dialog>
|
</dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain";
|
import type {
|
||||||
|
AnyCreature,
|
||||||
|
CreatureId,
|
||||||
|
PlayerCharacter,
|
||||||
|
} from "@initiative/domain";
|
||||||
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||||
import { renderHook, waitFor } from "@testing-library/react";
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
@@ -9,6 +13,7 @@ import {
|
|||||||
buildCombatant,
|
buildCombatant,
|
||||||
buildCreature,
|
buildCreature,
|
||||||
buildEncounter,
|
buildEncounter,
|
||||||
|
buildPf2eCreature,
|
||||||
} from "../../__tests__/factories/index.js";
|
} from "../../__tests__/factories/index.js";
|
||||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
import { useDifficultyBreakdown } from "../use-difficulty-breakdown.js";
|
import { useDifficultyBreakdown } from "../use-difficulty-breakdown.js";
|
||||||
@@ -42,7 +47,7 @@ const goblinCreature = buildCreature({
|
|||||||
function makeWrapper(options: {
|
function makeWrapper(options: {
|
||||||
encounter: ReturnType<typeof buildEncounter>;
|
encounter: ReturnType<typeof buildEncounter>;
|
||||||
playerCharacters?: PlayerCharacter[];
|
playerCharacters?: PlayerCharacter[];
|
||||||
creatures?: Map<CreatureId, Creature>;
|
creatures?: Map<CreatureId, AnyCreature>;
|
||||||
}) {
|
}) {
|
||||||
const adapters = createTestAdapters({
|
const adapters = createTestAdapters({
|
||||||
encounter: options.encounter,
|
encounter: options.encounter,
|
||||||
@@ -345,4 +350,115 @@ describe("useDifficultyBreakdown", () => {
|
|||||||
editionResult.current.setEdition("5.5e");
|
editionResult.current.setEdition("5.5e");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("PF2e edition", () => {
|
||||||
|
const orcWarrior = buildPf2eCreature({
|
||||||
|
id: creatureId("pf2e:orc-warrior"),
|
||||||
|
name: "Orc Warrior",
|
||||||
|
level: 3,
|
||||||
|
source: "crb",
|
||||||
|
sourceDisplayName: "Core Rulebook",
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns breakdown with creatureLevel, levelDifference, and XP for PF2e creatures", async () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-2"),
|
||||||
|
name: "Orc Warrior",
|
||||||
|
creatureId: orcWarrior.id,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[orcWarrior.id, orcWarrior]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result: editionResult } = renderHook(() => useRulesEdition(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
editionResult.current.setEdition("pf2e");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { result } = renderHook(() => useDifficultyBreakdown(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const breakdown = result.current;
|
||||||
|
expect(breakdown).not.toBeNull();
|
||||||
|
|
||||||
|
// Party level should be 5
|
||||||
|
expect(breakdown?.partyLevel).toBe(5);
|
||||||
|
|
||||||
|
// Orc Warrior: level 3, party level 5 → diff −2 → 20 XP
|
||||||
|
const orc = breakdown?.enemyCombatants[0];
|
||||||
|
expect(orc?.creatureLevel).toBe(3);
|
||||||
|
expect(orc?.levelDifference).toBe(-2);
|
||||||
|
expect(orc?.xp).toBe(20);
|
||||||
|
expect(orc?.cr).toBeNull();
|
||||||
|
expect(orc?.source).toBe("Core Rulebook");
|
||||||
|
|
||||||
|
// PC should have no creature level
|
||||||
|
const pc = breakdown?.partyCombatants[0];
|
||||||
|
expect(pc?.creatureLevel).toBeUndefined();
|
||||||
|
expect(pc?.levelDifference).toBeUndefined();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
editionResult.current.setEdition("5.5e");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns partyLevel in result", async () => {
|
||||||
|
const wrapper = makeWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c-2"),
|
||||||
|
name: "Orc Warrior",
|
||||||
|
creatureId: orcWarrior.id,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[orcWarrior.id, orcWarrior]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result: editionResult } = renderHook(() => useRulesEdition(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
editionResult.current.setEdition("pf2e");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { result } = renderHook(() => useDifficultyBreakdown(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current).not.toBeNull();
|
||||||
|
expect(result.current?.partyLevel).toBe(5);
|
||||||
|
// 5 thresholds for PF2e
|
||||||
|
expect(result.current?.thresholds).toHaveLength(5);
|
||||||
|
expect(result.current?.thresholds[0].label).toBe("Trivial");
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
editionResult.current.setEdition("5.5e");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain";
|
import type {
|
||||||
|
AnyCreature,
|
||||||
|
CreatureId,
|
||||||
|
PlayerCharacter,
|
||||||
|
} from "@initiative/domain";
|
||||||
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||||
import { renderHook, waitFor } from "@testing-library/react";
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
@@ -9,6 +13,7 @@ import {
|
|||||||
buildCombatant,
|
buildCombatant,
|
||||||
buildCreature,
|
buildCreature,
|
||||||
buildEncounter,
|
buildEncounter,
|
||||||
|
buildPf2eCreature,
|
||||||
} from "../../__tests__/factories/index.js";
|
} from "../../__tests__/factories/index.js";
|
||||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
import { useDifficulty } from "../use-difficulty.js";
|
import { useDifficulty } from "../use-difficulty.js";
|
||||||
@@ -43,7 +48,7 @@ const goblinCreature = buildCreature({
|
|||||||
function makeWrapper(options: {
|
function makeWrapper(options: {
|
||||||
encounter: ReturnType<typeof buildEncounter>;
|
encounter: ReturnType<typeof buildEncounter>;
|
||||||
playerCharacters?: PlayerCharacter[];
|
playerCharacters?: PlayerCharacter[];
|
||||||
creatures?: Map<CreatureId, Creature>;
|
creatures?: Map<CreatureId, AnyCreature>;
|
||||||
}) {
|
}) {
|
||||||
const adapters = createTestAdapters({
|
const adapters = createTestAdapters({
|
||||||
encounter: options.encounter,
|
encounter: options.encounter,
|
||||||
@@ -424,4 +429,134 @@ describe("useDifficulty", () => {
|
|||||||
expect(result.current?.totalMonsterXp).toBe(0);
|
expect(result.current?.totalMonsterXp).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("PF2e edition", () => {
|
||||||
|
const pf2eCreature = buildPf2eCreature({
|
||||||
|
id: creatureId("pf2e:orc-warrior"),
|
||||||
|
name: "Orc Warrior",
|
||||||
|
level: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
function makePf2eWrapper(options: {
|
||||||
|
encounter: ReturnType<typeof buildEncounter>;
|
||||||
|
playerCharacters?: PlayerCharacter[];
|
||||||
|
creatures?: Map<CreatureId, AnyCreature>;
|
||||||
|
}) {
|
||||||
|
const adapters = createTestAdapters({
|
||||||
|
encounter: options.encounter,
|
||||||
|
playerCharacters: options.playerCharacters ?? [],
|
||||||
|
creatures: options.creatures,
|
||||||
|
});
|
||||||
|
return ({ children }: { children: ReactNode }) => (
|
||||||
|
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("returns result for PF2e with leveled PCs and PF2e creatures", async () => {
|
||||||
|
const wrapper = makePf2eWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c2"),
|
||||||
|
name: "Orc Warrior",
|
||||||
|
creatureId: pf2eCreature.id,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
creatures: new Map([[pf2eCreature.id, pf2eCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result: editionResult } = renderHook(() => useRulesEdition(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
editionResult.current.setEdition("pf2e");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current).not.toBeNull();
|
||||||
|
// Creature level 5, party level 5 → diff 0 → 40 XP
|
||||||
|
expect(result.current?.totalMonsterXp).toBe(40);
|
||||||
|
expect(result.current?.partyLevel).toBe(5);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
editionResult.current.setEdition("5.5e");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for PF2e when no PF2e creatures with level", () => {
|
||||||
|
const wrapper = makePf2eWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c2"),
|
||||||
|
name: "Custom Monster",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [
|
||||||
|
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result: editionResult } = renderHook(() => useRulesEdition(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
editionResult.current.setEdition("pf2e");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
expect(result.current).toBeNull();
|
||||||
|
} finally {
|
||||||
|
editionResult.current.setEdition("5.5e");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for PF2e when no PCs with level", () => {
|
||||||
|
const wrapper = makePf2eWrapper({
|
||||||
|
encounter: buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c1"),
|
||||||
|
name: "Hero",
|
||||||
|
playerCharacterId: pcId1,
|
||||||
|
}),
|
||||||
|
buildCombatant({
|
||||||
|
id: combatantId("c2"),
|
||||||
|
name: "Orc Warrior",
|
||||||
|
creatureId: pf2eCreature.id,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
playerCharacters: [{ id: pcId1, name: "Hero", ac: 15, maxHp: 30 }],
|
||||||
|
creatures: new Map([[pf2eCreature.id, pf2eCreature]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result: editionResult } = renderHook(() => useRulesEdition(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
editionResult.current.setEdition("pf2e");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { result } = renderHook(() => useDifficulty(), { wrapper });
|
||||||
|
expect(result.current).toBeNull();
|
||||||
|
} finally {
|
||||||
|
editionResult.current.setEdition("5.5e");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -112,6 +112,49 @@ describe("usePlayerCharacters", () => {
|
|||||||
expect(result.current.characters[0].name).toBe("Vex'ahlia");
|
expect(result.current.characters[0].name).toBe("Vex'ahlia");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("createCharacter assigns a fresh id after rehydration from persistence", () => {
|
||||||
|
const stored = [
|
||||||
|
{
|
||||||
|
id: playerCharacterId("pc-1"),
|
||||||
|
name: "Mikka",
|
||||||
|
ac: 12,
|
||||||
|
maxHp: 58,
|
||||||
|
color: undefined,
|
||||||
|
icon: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: playerCharacterId("pc-3"),
|
||||||
|
name: "Bob",
|
||||||
|
ac: 14,
|
||||||
|
maxHp: 40,
|
||||||
|
color: undefined,
|
||||||
|
icon: undefined,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const adapters = createTestAdapters({ playerCharacters: stored });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => usePlayerCharacters(), {
|
||||||
|
wrapper: ({ children }: { children: ReactNode }) => (
|
||||||
|
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.createCharacter(
|
||||||
|
"Charlie",
|
||||||
|
13,
|
||||||
|
25,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ids = result.current.characters.map((pc) => pc.id);
|
||||||
|
expect(new Set(ids).size).toBe(ids.length);
|
||||||
|
expect(ids).toContain(playerCharacterId("pc-4"));
|
||||||
|
});
|
||||||
|
|
||||||
it("deleteCharacter removes character and persists", () => {
|
it("deleteCharacter removes character and persists", () => {
|
||||||
const { result } = renderHook(() => usePlayerCharacters(), { wrapper });
|
const { result } = renderHook(() => usePlayerCharacters(), { wrapper });
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
normalizeBestiary,
|
normalizeBestiary,
|
||||||
setSourceDisplayNames,
|
setSourceDisplayNames,
|
||||||
} from "../adapters/bestiary-adapter.js";
|
} from "../adapters/bestiary-adapter.js";
|
||||||
|
import { loadBundledDndCreatures } from "../adapters/dnd-bundled-adapter.js";
|
||||||
import { normalizeFoundryCreatures } from "../adapters/pf2e-bestiary-adapter.js";
|
import { normalizeFoundryCreatures } from "../adapters/pf2e-bestiary-adapter.js";
|
||||||
import { useAdapters } from "../contexts/adapter-context.js";
|
import { useAdapters } from "../contexts/adapter-context.js";
|
||||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
@@ -160,7 +161,11 @@ export function useBestiary(): BestiaryHook {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void bestiaryCache.loadAllCachedCreatures().then((map) => {
|
void bestiaryCache.loadAllCachedCreatures().then((map) => {
|
||||||
setCreatureMap(map);
|
const merged = new Map(map);
|
||||||
|
for (const c of loadBundledDndCreatures()) {
|
||||||
|
merged.set(c.id, c);
|
||||||
|
}
|
||||||
|
setCreatureMap(merged);
|
||||||
});
|
});
|
||||||
}, [bestiaryCache, bestiaryIndex, pf2eBestiaryIndex]);
|
}, [bestiaryCache, bestiaryIndex, pf2eBestiaryIndex]);
|
||||||
|
|
||||||
@@ -300,6 +305,9 @@ export function useBestiary(): BestiaryHook {
|
|||||||
|
|
||||||
const refreshCache = useCallback(async (): Promise<void> => {
|
const refreshCache = useCallback(async (): Promise<void> => {
|
||||||
const map = await bestiaryCache.loadAllCachedCreatures();
|
const map = await bestiaryCache.loadAllCachedCreatures();
|
||||||
|
for (const c of loadBundledDndCreatures()) {
|
||||||
|
map.set(c.id, c);
|
||||||
|
}
|
||||||
setCreatureMap(map);
|
setCreatureMap(map);
|
||||||
}, [bestiaryCache]);
|
}, [bestiaryCache]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AnyCreature,
|
||||||
Combatant,
|
Combatant,
|
||||||
CreatureId,
|
CreatureId,
|
||||||
DifficultyThreshold,
|
DifficultyThreshold,
|
||||||
DifficultyTier,
|
DifficultyTier,
|
||||||
PlayerCharacter,
|
PlayerCharacter,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { calculateEncounterDifficulty, crToXp } from "@initiative/domain";
|
import {
|
||||||
|
calculateEncounterDifficulty,
|
||||||
|
crToXp,
|
||||||
|
derivePartyLevel,
|
||||||
|
pf2eCreatureXp,
|
||||||
|
} from "@initiative/domain";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
@@ -21,6 +27,10 @@ export interface BreakdownCombatant {
|
|||||||
readonly editable: boolean;
|
readonly editable: boolean;
|
||||||
readonly side: "party" | "enemy";
|
readonly side: "party" | "enemy";
|
||||||
readonly level: number | undefined;
|
readonly level: number | undefined;
|
||||||
|
/** PF2e only: the creature's level from bestiary data. */
|
||||||
|
readonly creatureLevel: number | undefined;
|
||||||
|
/** PF2e only: creature level minus party level. */
|
||||||
|
readonly levelDifference: number | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DifficultyBreakdown {
|
interface DifficultyBreakdown {
|
||||||
@@ -30,6 +40,7 @@ interface DifficultyBreakdown {
|
|||||||
readonly encounterMultiplier: number | undefined;
|
readonly encounterMultiplier: number | undefined;
|
||||||
readonly adjustedXp: number | undefined;
|
readonly adjustedXp: number | undefined;
|
||||||
readonly partySizeAdjusted: boolean | undefined;
|
readonly partySizeAdjusted: boolean | undefined;
|
||||||
|
readonly partyLevel: number | undefined;
|
||||||
readonly pcCount: number;
|
readonly pcCount: number;
|
||||||
readonly partyCombatants: readonly BreakdownCombatant[];
|
readonly partyCombatants: readonly BreakdownCombatant[];
|
||||||
readonly enemyCombatants: readonly BreakdownCombatant[];
|
readonly enemyCombatants: readonly BreakdownCombatant[];
|
||||||
@@ -48,9 +59,16 @@ export function useDifficultyBreakdown(): DifficultyBreakdown | null {
|
|||||||
const hasPartyLevel = descriptors.some(
|
const hasPartyLevel = descriptors.some(
|
||||||
(d) => d.side === "party" && d.level !== undefined,
|
(d) => d.side === "party" && d.level !== undefined,
|
||||||
);
|
);
|
||||||
const hasCr = descriptors.some((d) => d.cr !== undefined);
|
|
||||||
|
|
||||||
|
if (edition === "pf2e") {
|
||||||
|
const hasCreatureLevel = descriptors.some(
|
||||||
|
(d) => d.creatureLevel !== undefined,
|
||||||
|
);
|
||||||
|
if (!hasPartyLevel || !hasCreatureLevel) return null;
|
||||||
|
} else {
|
||||||
|
const hasCr = descriptors.some((d) => d.cr !== undefined);
|
||||||
if (!hasPartyLevel || !hasCr) return null;
|
if (!hasPartyLevel || !hasCr) return null;
|
||||||
|
}
|
||||||
|
|
||||||
const result = calculateEncounterDifficulty(descriptors, edition);
|
const result = calculateEncounterDifficulty(descriptors, edition);
|
||||||
|
|
||||||
@@ -65,6 +83,7 @@ export function useDifficultyBreakdown(): DifficultyBreakdown | null {
|
|||||||
|
|
||||||
type CreatureInfo = {
|
type CreatureInfo = {
|
||||||
cr?: string;
|
cr?: string;
|
||||||
|
creatureLevel?: number;
|
||||||
source: string;
|
source: string;
|
||||||
sourceDisplayName: string;
|
sourceDisplayName: string;
|
||||||
};
|
};
|
||||||
@@ -74,6 +93,7 @@ function buildBreakdownEntry(
|
|||||||
side: "party" | "enemy",
|
side: "party" | "enemy",
|
||||||
level: number | undefined,
|
level: number | undefined,
|
||||||
creature: CreatureInfo | undefined,
|
creature: CreatureInfo | undefined,
|
||||||
|
partyLevel: number | undefined,
|
||||||
): BreakdownCombatant {
|
): BreakdownCombatant {
|
||||||
if (c.playerCharacterId) {
|
if (c.playerCharacterId) {
|
||||||
return {
|
return {
|
||||||
@@ -84,6 +104,29 @@ function buildBreakdownEntry(
|
|||||||
editable: false,
|
editable: false,
|
||||||
side,
|
side,
|
||||||
level,
|
level,
|
||||||
|
creatureLevel: undefined,
|
||||||
|
levelDifference: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (creature && creature.creatureLevel !== undefined) {
|
||||||
|
const levelDiff =
|
||||||
|
partyLevel === undefined
|
||||||
|
? undefined
|
||||||
|
: creature.creatureLevel - partyLevel;
|
||||||
|
const xp =
|
||||||
|
partyLevel === undefined
|
||||||
|
? null
|
||||||
|
: pf2eCreatureXp(creature.creatureLevel, partyLevel);
|
||||||
|
return {
|
||||||
|
combatant: c,
|
||||||
|
cr: null,
|
||||||
|
xp,
|
||||||
|
source: creature.sourceDisplayName ?? creature.source,
|
||||||
|
editable: false,
|
||||||
|
side,
|
||||||
|
level: undefined,
|
||||||
|
creatureLevel: creature.creatureLevel,
|
||||||
|
levelDifference: levelDiff,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (creature) {
|
if (creature) {
|
||||||
@@ -96,6 +139,8 @@ function buildBreakdownEntry(
|
|||||||
editable: false,
|
editable: false,
|
||||||
side,
|
side,
|
||||||
level: undefined,
|
level: undefined,
|
||||||
|
creatureLevel: undefined,
|
||||||
|
levelDifference: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (c.cr) {
|
if (c.cr) {
|
||||||
@@ -107,6 +152,8 @@ function buildBreakdownEntry(
|
|||||||
editable: true,
|
editable: true,
|
||||||
side,
|
side,
|
||||||
level: undefined,
|
level: undefined,
|
||||||
|
creatureLevel: undefined,
|
||||||
|
levelDifference: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -117,6 +164,8 @@ function buildBreakdownEntry(
|
|||||||
editable: !c.creatureId,
|
editable: !c.creatureId,
|
||||||
side,
|
side,
|
||||||
level: undefined,
|
level: undefined,
|
||||||
|
creatureLevel: undefined,
|
||||||
|
levelDifference: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,41 +177,91 @@ function resolveLevel(
|
|||||||
return characters.find((p) => p.id === c.playerCharacterId)?.level;
|
return characters.find((p) => p.id === c.playerCharacterId)?.level;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveCr(
|
function resolveCreatureInfo(
|
||||||
c: Combatant,
|
c: Combatant,
|
||||||
getCreature: (id: CreatureId) => CreatureInfo | undefined,
|
getCreature: (id: CreatureId) => AnyCreature | undefined,
|
||||||
): { cr: string | null; creature: CreatureInfo | undefined } {
|
): {
|
||||||
const creature = c.creatureId ? getCreature(c.creatureId) : undefined;
|
cr: string | null;
|
||||||
const cr = creature?.cr ?? c.cr ?? null;
|
creatureLevel: number | undefined;
|
||||||
return { cr, creature };
|
creature: CreatureInfo | undefined;
|
||||||
|
} {
|
||||||
|
const rawCreature = c.creatureId ? getCreature(c.creatureId) : undefined;
|
||||||
|
if (!rawCreature) {
|
||||||
|
return {
|
||||||
|
cr: c.cr ?? null,
|
||||||
|
creatureLevel: undefined,
|
||||||
|
creature: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if ("system" in rawCreature && rawCreature.system === "pf2e") {
|
||||||
|
return {
|
||||||
|
cr: null,
|
||||||
|
creatureLevel: rawCreature.level,
|
||||||
|
creature: {
|
||||||
|
creatureLevel: rawCreature.level,
|
||||||
|
source: rawCreature.source,
|
||||||
|
sourceDisplayName: rawCreature.sourceDisplayName,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const cr = "cr" in rawCreature ? rawCreature.cr : undefined;
|
||||||
|
return {
|
||||||
|
cr: cr ?? c.cr ?? null,
|
||||||
|
creatureLevel: undefined,
|
||||||
|
creature: {
|
||||||
|
cr,
|
||||||
|
source: rawCreature.source,
|
||||||
|
sourceDisplayName: rawCreature.sourceDisplayName,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectPartyLevel(
|
||||||
|
combatants: readonly Combatant[],
|
||||||
|
characters: readonly PlayerCharacter[],
|
||||||
|
): number | undefined {
|
||||||
|
const partyLevels: number[] = [];
|
||||||
|
for (const c of combatants) {
|
||||||
|
if (resolveSide(c) !== "party") continue;
|
||||||
|
const level = resolveLevel(c, characters);
|
||||||
|
if (level !== undefined) partyLevels.push(level);
|
||||||
|
}
|
||||||
|
return partyLevels.length > 0 ? derivePartyLevel(partyLevels) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function classifyCombatants(
|
function classifyCombatants(
|
||||||
combatants: readonly Combatant[],
|
combatants: readonly Combatant[],
|
||||||
characters: readonly PlayerCharacter[],
|
characters: readonly PlayerCharacter[],
|
||||||
getCreature: (id: CreatureId) => CreatureInfo | undefined,
|
getCreature: (id: CreatureId) => AnyCreature | undefined,
|
||||||
) {
|
) {
|
||||||
const partyCombatants: BreakdownCombatant[] = [];
|
const partyCombatants: BreakdownCombatant[] = [];
|
||||||
const enemyCombatants: BreakdownCombatant[] = [];
|
const enemyCombatants: BreakdownCombatant[] = [];
|
||||||
const descriptors: {
|
const descriptors: {
|
||||||
level?: number;
|
level?: number;
|
||||||
cr?: string;
|
cr?: string;
|
||||||
|
creatureLevel?: number;
|
||||||
side: "party" | "enemy";
|
side: "party" | "enemy";
|
||||||
}[] = [];
|
}[] = [];
|
||||||
let pcCount = 0;
|
let pcCount = 0;
|
||||||
|
const partyLevel = collectPartyLevel(combatants, characters);
|
||||||
|
|
||||||
for (const c of combatants) {
|
for (const c of combatants) {
|
||||||
const side = resolveSide(c);
|
const side = resolveSide(c);
|
||||||
const level = resolveLevel(c, characters);
|
const level = resolveLevel(c, characters);
|
||||||
if (level !== undefined) pcCount++;
|
if (level !== undefined) pcCount++;
|
||||||
|
|
||||||
const { cr, creature } = resolveCr(c, getCreature);
|
const { cr, creatureLevel, creature } = resolveCreatureInfo(c, getCreature);
|
||||||
|
|
||||||
if (level !== undefined || cr != null) {
|
if (level !== undefined || cr != null || creatureLevel !== undefined) {
|
||||||
descriptors.push({ level, cr: cr ?? undefined, side });
|
descriptors.push({
|
||||||
|
level,
|
||||||
|
cr: cr ?? undefined,
|
||||||
|
creatureLevel,
|
||||||
|
side,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const entry = buildBreakdownEntry(c, side, level, creature);
|
const entry = buildBreakdownEntry(c, side, level, creature, partyLevel);
|
||||||
const target = side === "party" ? partyCombatants : enemyCombatants;
|
const target = side === "party" ? partyCombatants : enemyCombatants;
|
||||||
target.push(entry);
|
target.push(entry);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,9 +33,17 @@ function buildDescriptors(
|
|||||||
const creatureCr =
|
const creatureCr =
|
||||||
creature && !("system" in creature) ? creature.cr : undefined;
|
creature && !("system" in creature) ? creature.cr : undefined;
|
||||||
const cr = creatureCr ?? c.cr ?? undefined;
|
const cr = creatureCr ?? c.cr ?? undefined;
|
||||||
|
const creatureLevel =
|
||||||
|
creature && "system" in creature && creature.system === "pf2e"
|
||||||
|
? creature.level
|
||||||
|
: undefined;
|
||||||
|
|
||||||
if (level !== undefined || cr !== undefined) {
|
if (
|
||||||
descriptors.push({ level, cr, side });
|
level !== undefined ||
|
||||||
|
cr !== undefined ||
|
||||||
|
creatureLevel !== undefined
|
||||||
|
) {
|
||||||
|
descriptors.push({ level, cr, creatureLevel, side });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return descriptors;
|
return descriptors;
|
||||||
@@ -48,8 +56,6 @@ export function useDifficulty(): DifficultyResult | null {
|
|||||||
const { edition } = useRulesEditionContext();
|
const { edition } = useRulesEditionContext();
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
if (edition === "pf2e") return null;
|
|
||||||
|
|
||||||
const descriptors = buildDescriptors(
|
const descriptors = buildDescriptors(
|
||||||
encounter.combatants,
|
encounter.combatants,
|
||||||
characters,
|
characters,
|
||||||
@@ -59,9 +65,16 @@ export function useDifficulty(): DifficultyResult | null {
|
|||||||
const hasPartyLevel = descriptors.some(
|
const hasPartyLevel = descriptors.some(
|
||||||
(d) => d.side === "party" && d.level !== undefined,
|
(d) => d.side === "party" && d.level !== undefined,
|
||||||
);
|
);
|
||||||
const hasCr = descriptors.some((d) => d.cr !== undefined);
|
|
||||||
|
|
||||||
|
if (edition === "pf2e") {
|
||||||
|
const hasCreatureLevel = descriptors.some(
|
||||||
|
(d) => d.creatureLevel !== undefined,
|
||||||
|
);
|
||||||
|
if (!hasPartyLevel || !hasCreatureLevel) return null;
|
||||||
|
} else {
|
||||||
|
const hasCr = descriptors.some((d) => d.cr !== undefined);
|
||||||
if (!hasPartyLevel || !hasCr) return null;
|
if (!hasPartyLevel || !hasCr) return null;
|
||||||
|
}
|
||||||
|
|
||||||
return calculateEncounterDifficulty(descriptors, edition);
|
return calculateEncounterDifficulty(descriptors, edition);
|
||||||
}, [encounter.combatants, characters, getCreature, edition]);
|
}, [encounter.combatants, characters, getCreature, edition]);
|
||||||
|
|||||||
@@ -9,10 +9,18 @@ import { isDomainError, playerCharacterId } from "@initiative/domain";
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useAdapters } from "../contexts/adapter-context.js";
|
import { useAdapters } from "../contexts/adapter-context.js";
|
||||||
|
|
||||||
let nextPcId = 0;
|
const PC_ID_PATTERN = /^pc-(\d+)$/;
|
||||||
|
|
||||||
function generatePcId(): PlayerCharacterId {
|
function generatePcId(existing: readonly PlayerCharacter[]): PlayerCharacterId {
|
||||||
return playerCharacterId(`pc-${++nextPcId}`);
|
let max = 0;
|
||||||
|
for (const pc of existing) {
|
||||||
|
const match = PC_ID_PATTERN.exec(pc.id);
|
||||||
|
if (match) {
|
||||||
|
const n = Number(match[1]);
|
||||||
|
if (n > max) max = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return playerCharacterId(`pc-${max + 1}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EditFields {
|
interface EditFields {
|
||||||
@@ -55,7 +63,7 @@ export function usePlayerCharacters() {
|
|||||||
icon: string | undefined,
|
icon: string | undefined,
|
||||||
level: number | undefined,
|
level: number | undefined,
|
||||||
) => {
|
) => {
|
||||||
const id = generatePcId();
|
const id = generatePcId(charactersRef.current);
|
||||||
const result = createPlayerCharacterUseCase(
|
const result = createPlayerCharacterUseCase(
|
||||||
makeStore(),
|
makeStore(),
|
||||||
id,
|
id,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+14
-1
@@ -3,8 +3,21 @@
|
|||||||
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be",
|
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"undici": ">=7.24.0",
|
"undici": "~7.24.0",
|
||||||
"picomatch": ">=4.0.4"
|
"picomatch": ">=4.0.4"
|
||||||
|
},
|
||||||
|
"auditConfig": {
|
||||||
|
"ignoreGhsas": [
|
||||||
|
"GHSA-vmh5-mc38-953g",
|
||||||
|
"GHSA-vxpw-j846-p89q",
|
||||||
|
"GHSA-hm92-r4w5-c3mj"
|
||||||
|
],
|
||||||
|
"_ignoreGhsasNotes": {
|
||||||
|
"_shared": "All three advisories sit in undici, are reached only via jsdom in test runs, and are fixed in undici>=7.28.0. We can't move there because jsdom@29.1.1 reaches into undici 7's private module layout and crashes on the 7.28+ restructure. None of the vulnerable code paths run in our tests (no SOCKS5 proxy, no WebSocket client). Drop these entries when jsdom updates its undici pin.",
|
||||||
|
"GHSA-vmh5-mc38-953g": "SOCKS5 ProxyAgent TLS bypass — unreachable, no SOCKS5 proxy in tests.",
|
||||||
|
"GHSA-vxpw-j846-p89q": "WebSocket client DoS via fragment-count bypass — unreachable, no WS client in tests.",
|
||||||
|
"GHSA-hm92-r4w5-c3mj": "SOCKS5 proxy pool cross-origin reuse — unreachable, no SOCKS5 proxy in tests."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
calculateEncounterDifficulty,
|
calculateEncounterDifficulty,
|
||||||
crToXp,
|
crToXp,
|
||||||
|
derivePartyLevel,
|
||||||
|
pf2eCreatureXp,
|
||||||
} from "../encounter-difficulty.js";
|
} from "../encounter-difficulty.js";
|
||||||
|
|
||||||
describe("crToXp", () => {
|
describe("crToXp", () => {
|
||||||
@@ -386,3 +388,234 @@ describe("calculateEncounterDifficulty — 2014 edition", () => {
|
|||||||
expect(result.adjustedXp).toBeUndefined();
|
expect(result.adjustedXp).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Helper to build a PF2e enemy-side descriptor with creature level. */
|
||||||
|
function pf2eEnemy(creatureLevel: number) {
|
||||||
|
return { creatureLevel, side: "enemy" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper to build a PF2e party-side creature descriptor. */
|
||||||
|
function pf2eAlly(creatureLevel: number) {
|
||||||
|
return { creatureLevel, side: "party" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("derivePartyLevel", () => {
|
||||||
|
it("returns 0 for empty array", () => {
|
||||||
|
expect(derivePartyLevel([])).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the level for a single PC", () => {
|
||||||
|
expect(derivePartyLevel([7])).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the unanimous level", () => {
|
||||||
|
expect(derivePartyLevel([5, 5, 5, 5])).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the mode when one level is most common", () => {
|
||||||
|
expect(derivePartyLevel([3, 3, 3, 5])).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns rounded average when mode is tied", () => {
|
||||||
|
// 3,3,5,5 → average 4
|
||||||
|
expect(derivePartyLevel([3, 3, 5, 5])).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns rounded average when all levels are different", () => {
|
||||||
|
// 2,4,6,8 → average 5
|
||||||
|
expect(derivePartyLevel([2, 4, 6, 8])).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rounds average to nearest integer", () => {
|
||||||
|
// 1,2 → average 1.5 → rounds to 2
|
||||||
|
expect(derivePartyLevel([1, 2])).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pf2eCreatureXp", () => {
|
||||||
|
it.each([
|
||||||
|
[-4, 10],
|
||||||
|
[-3, 15],
|
||||||
|
[-2, 20],
|
||||||
|
[-1, 30],
|
||||||
|
[0, 40],
|
||||||
|
[1, 60],
|
||||||
|
[2, 80],
|
||||||
|
[3, 120],
|
||||||
|
[4, 160],
|
||||||
|
])("level diff %i returns %i XP", (diff, expectedXp) => {
|
||||||
|
// partyLevel 5, creatureLevel = 5 + diff
|
||||||
|
expect(pf2eCreatureXp(5 + diff, 5)).toBe(expectedXp);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps level diff below −4 to −4 (10 XP)", () => {
|
||||||
|
expect(pf2eCreatureXp(0, 10)).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps level diff above +4 to +4 (160 XP)", () => {
|
||||||
|
expect(pf2eCreatureXp(15, 5)).toBe(160);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("calculateEncounterDifficulty — pf2e edition", () => {
|
||||||
|
it("returns Trivial (tier 0) for 40 XP with party of 4", () => {
|
||||||
|
// 1 creature at party level = 40 XP, below Low (60)
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(5), party(5), party(5), party(5), pf2eEnemy(5)],
|
||||||
|
"pf2e",
|
||||||
|
);
|
||||||
|
expect(result.tier).toBe(0);
|
||||||
|
expect(result.totalMonsterXp).toBe(40);
|
||||||
|
expect(result.partyLevel).toBe(5);
|
||||||
|
expect(result.thresholds).toEqual([
|
||||||
|
{ label: "Trivial", value: 40 },
|
||||||
|
{ label: "Low", value: 60 },
|
||||||
|
{ label: "Moderate", value: 80 },
|
||||||
|
{ label: "Severe", value: 120 },
|
||||||
|
{ label: "Extreme", value: 160 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns Low (tier 1) for 60 XP", () => {
|
||||||
|
// 1 creature at party level +1 = 60 XP
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(5), party(5), party(5), party(5), pf2eEnemy(6)],
|
||||||
|
"pf2e",
|
||||||
|
);
|
||||||
|
expect(result.tier).toBe(1);
|
||||||
|
expect(result.totalMonsterXp).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns Moderate (tier 2) for 80 XP", () => {
|
||||||
|
// 1 creature at +2 = 80 XP
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(5), party(5), party(5), party(5), pf2eEnemy(7)],
|
||||||
|
"pf2e",
|
||||||
|
);
|
||||||
|
expect(result.tier).toBe(2);
|
||||||
|
expect(result.totalMonsterXp).toBe(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns Severe (tier 3) for 120 XP", () => {
|
||||||
|
// 1 creature at +3 = 120 XP
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(5), party(5), party(5), party(5), pf2eEnemy(8)],
|
||||||
|
"pf2e",
|
||||||
|
);
|
||||||
|
expect(result.tier).toBe(3);
|
||||||
|
expect(result.totalMonsterXp).toBe(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns Extreme (tier 4) for 160 XP", () => {
|
||||||
|
// 1 creature at +4 = 160 XP
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(5), party(5), party(5), party(5), pf2eEnemy(9)],
|
||||||
|
"pf2e",
|
||||||
|
);
|
||||||
|
expect(result.tier).toBe(4);
|
||||||
|
expect(result.totalMonsterXp).toBe(160);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns tier 0 when XP is below Low threshold", () => {
|
||||||
|
// 1 creature at −4 = 10 XP, Low = 60
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(5), party(5), party(5), party(5), pf2eEnemy(1)],
|
||||||
|
"pf2e",
|
||||||
|
);
|
||||||
|
expect(result.tier).toBe(0);
|
||||||
|
expect(result.totalMonsterXp).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adjusts thresholds for 5 PCs (increases by adjustment)", () => {
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(5), party(5), party(5), party(5), party(5), pf2eEnemy(5)],
|
||||||
|
"pf2e",
|
||||||
|
);
|
||||||
|
expect(result.thresholds).toEqual([
|
||||||
|
{ label: "Trivial", value: 50 },
|
||||||
|
{ label: "Low", value: 75 },
|
||||||
|
{ label: "Moderate", value: 100 },
|
||||||
|
{ label: "Severe", value: 150 },
|
||||||
|
{ label: "Extreme", value: 200 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adjusts thresholds for 3 PCs (decreases by adjustment)", () => {
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(5), party(5), party(5), pf2eEnemy(5)],
|
||||||
|
"pf2e",
|
||||||
|
);
|
||||||
|
expect(result.thresholds).toEqual([
|
||||||
|
{ label: "Trivial", value: 30 },
|
||||||
|
{ label: "Low", value: 45 },
|
||||||
|
{ label: "Moderate", value: 60 },
|
||||||
|
{ label: "Severe", value: 90 },
|
||||||
|
{ label: "Extreme", value: 120 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("floors thresholds at 0 for very small parties", () => {
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(5), pf2eEnemy(5)],
|
||||||
|
"pf2e",
|
||||||
|
);
|
||||||
|
// 1 PC: adjustment = −3
|
||||||
|
// Trivial: 40 + (−3 * 10) = 10
|
||||||
|
// Low: 60 + (−3 * 15) = 15
|
||||||
|
expect(result.thresholds[0].value).toBe(10);
|
||||||
|
expect(result.thresholds[1].value).toBe(15);
|
||||||
|
expect(result.thresholds[2].value).toBe(20); // 80 − 60
|
||||||
|
expect(result.thresholds[3].value).toBe(30); // 120 − 90
|
||||||
|
expect(result.thresholds[4].value).toBe(40); // 160 − 120
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subtracts XP for party-side creatures", () => {
|
||||||
|
// 2 enemies at party level = 80 XP, 1 ally at party level = 40 XP
|
||||||
|
// Net = 80 − 40 = 40 XP
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[
|
||||||
|
party(5),
|
||||||
|
party(5),
|
||||||
|
party(5),
|
||||||
|
party(5),
|
||||||
|
pf2eEnemy(5),
|
||||||
|
pf2eEnemy(5),
|
||||||
|
pf2eAlly(5),
|
||||||
|
],
|
||||||
|
"pf2e",
|
||||||
|
);
|
||||||
|
expect(result.totalMonsterXp).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("floors net creature XP at 0", () => {
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(5), party(5), party(5), party(5), pf2eEnemy(1), pf2eAlly(9)],
|
||||||
|
"pf2e",
|
||||||
|
);
|
||||||
|
expect(result.totalMonsterXp).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives party level using mode", () => {
|
||||||
|
// 3x level 3, 1x level 5 → mode is 3
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(3), party(3), party(3), party(5), pf2eEnemy(3)],
|
||||||
|
"pf2e",
|
||||||
|
);
|
||||||
|
expect(result.partyLevel).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no encounterMultiplier, adjustedXp, or partySizeAdjusted", () => {
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(5), party(5), party(5), party(5), pf2eEnemy(5)],
|
||||||
|
"pf2e",
|
||||||
|
);
|
||||||
|
expect(result.encounterMultiplier).toBeUndefined();
|
||||||
|
expect(result.adjustedXp).toBeUndefined();
|
||||||
|
expect(result.partySizeAdjusted).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns partyLevel undefined for D&D editions", () => {
|
||||||
|
const result = calculateEncounterDifficulty([party(1), enemy("1")], "5.5e");
|
||||||
|
expect(result.partyLevel).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 e = enc([makeCombatant("A", [{ id: "poisoned" }])]);
|
||||||
const { encounter } = success(e, "A", "blinded");
|
const { encounter } = success(e, "A", "blinded");
|
||||||
|
|
||||||
expect(encounter.combatants[0].conditions).toEqual([
|
expect(encounter.combatants[0].conditions).toEqual([
|
||||||
{ id: "blinded" },
|
|
||||||
{ id: "poisoned" },
|
{ id: "poisoned" },
|
||||||
|
{ id: "blinded" },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -109,15 +109,16 @@ describe("toggleCondition", () => {
|
|||||||
expect(encounter.combatants[0].conditions).toBeUndefined();
|
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);
|
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")]);
|
let e = enc([makeCombatant("A")]);
|
||||||
for (const cond of [...order].reverse()) {
|
for (const cond of reversed) {
|
||||||
const result = success(e, "A", cond);
|
const result = success(e, "A", cond);
|
||||||
e = result.encounter;
|
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: "",
|
description5e: "",
|
||||||
descriptionPf2e:
|
descriptionPf2e:
|
||||||
"Location unknown. Must pick a square to target; DC 11 flat check. Attacker is off-guard against your attacks.",
|
"Location unknown. Must pick a square to target; DC 11 flat check. Attacker is off-guard against your attacks.",
|
||||||
iconName: "Ghost",
|
iconName: "EyeClosed",
|
||||||
color: "violet",
|
color: "slate",
|
||||||
systems: ["pf2e"],
|
systems: ["pf2e"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { RulesEdition } from "./rules-edition.js";
|
import type { RulesEdition } from "./rules-edition.js";
|
||||||
|
|
||||||
/** Abstract difficulty severity: 0 = negligible, 3 = maximum. Maps to filled bar count. */
|
/** Abstract difficulty severity: 0 = negligible, up to 4 (PF2e Extreme). Maps to filled bar count. */
|
||||||
export type DifficultyTier = 0 | 1 | 2 | 3;
|
export type DifficultyTier = 0 | 1 | 2 | 3 | 4;
|
||||||
|
|
||||||
export interface DifficultyThreshold {
|
export interface DifficultyThreshold {
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
@@ -18,6 +18,8 @@ export interface DifficultyResult {
|
|||||||
readonly adjustedXp: number | undefined;
|
readonly adjustedXp: number | undefined;
|
||||||
/** 2014 only: true when the multiplier was shifted due to party size (<3 or 6+). */
|
/** 2014 only: true when the multiplier was shifted due to party size (<3 or 6+). */
|
||||||
readonly partySizeAdjusted: boolean | undefined;
|
readonly partySizeAdjusted: boolean | undefined;
|
||||||
|
/** PF2e only: the derived party level used for XP calculation. */
|
||||||
|
readonly partyLevel: number | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Maps challenge rating strings to XP values (standard 5e). */
|
/** Maps challenge rating strings to XP values (standard 5e). */
|
||||||
@@ -160,6 +162,133 @@ function getEncounterMultiplier(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PF2e: XP granted by a creature based on its level relative to party level.
|
||||||
|
* Key is (creature level − party level), clamped to [−4, +4].
|
||||||
|
*/
|
||||||
|
const PF2E_LEVEL_DIFF_XP: Readonly<Record<number, number>> = {
|
||||||
|
[-4]: 10,
|
||||||
|
[-3]: 15,
|
||||||
|
[-2]: 20,
|
||||||
|
[-1]: 30,
|
||||||
|
0: 40,
|
||||||
|
1: 60,
|
||||||
|
2: 80,
|
||||||
|
3: 120,
|
||||||
|
4: 160,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** PF2e base encounter budget thresholds for a party of 4. */
|
||||||
|
const PF2E_THRESHOLDS_BASE = {
|
||||||
|
trivial: 40,
|
||||||
|
low: 60,
|
||||||
|
moderate: 80,
|
||||||
|
severe: 120,
|
||||||
|
extreme: 160,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** PF2e per-PC adjustment to each threshold (added per PC beyond 4, subtracted per PC fewer). */
|
||||||
|
const PF2E_THRESHOLD_ADJUSTMENTS = {
|
||||||
|
trivial: 10,
|
||||||
|
low: 15,
|
||||||
|
moderate: 20,
|
||||||
|
severe: 30,
|
||||||
|
extreme: 40,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives PF2e party level from PC levels.
|
||||||
|
* Returns the mode (most common level). If no unique mode, returns
|
||||||
|
* the average rounded to the nearest integer.
|
||||||
|
*/
|
||||||
|
export function derivePartyLevel(levels: readonly number[]): number {
|
||||||
|
if (levels.length === 0) return 0;
|
||||||
|
if (levels.length === 1) return levels[0];
|
||||||
|
|
||||||
|
const counts = new Map<number, number>();
|
||||||
|
for (const l of levels) {
|
||||||
|
counts.set(l, (counts.get(l) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let maxCount = 0;
|
||||||
|
let mode: number | undefined;
|
||||||
|
let isTied = false;
|
||||||
|
|
||||||
|
for (const [level, count] of counts) {
|
||||||
|
if (count > maxCount) {
|
||||||
|
maxCount = count;
|
||||||
|
mode = level;
|
||||||
|
isTied = false;
|
||||||
|
} else if (count === maxCount) {
|
||||||
|
isTied = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isTied && mode !== undefined) return mode;
|
||||||
|
|
||||||
|
const sum = levels.reduce((a, b) => a + b, 0);
|
||||||
|
return Math.round(sum / levels.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns PF2e XP for a creature given its level and the party level. */
|
||||||
|
export function pf2eCreatureXp(
|
||||||
|
creatureLevel: number,
|
||||||
|
partyLevel: number,
|
||||||
|
): number {
|
||||||
|
const diff = Math.max(-4, Math.min(4, creatureLevel - partyLevel));
|
||||||
|
return PF2E_LEVEL_DIFF_XP[diff] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculatePf2eBudget(partySize: number) {
|
||||||
|
const adjustment = partySize - 4;
|
||||||
|
return {
|
||||||
|
trivial: Math.max(
|
||||||
|
0,
|
||||||
|
PF2E_THRESHOLDS_BASE.trivial +
|
||||||
|
adjustment * PF2E_THRESHOLD_ADJUSTMENTS.trivial,
|
||||||
|
),
|
||||||
|
low: Math.max(
|
||||||
|
0,
|
||||||
|
PF2E_THRESHOLDS_BASE.low + adjustment * PF2E_THRESHOLD_ADJUSTMENTS.low,
|
||||||
|
),
|
||||||
|
moderate: Math.max(
|
||||||
|
0,
|
||||||
|
PF2E_THRESHOLDS_BASE.moderate +
|
||||||
|
adjustment * PF2E_THRESHOLD_ADJUSTMENTS.moderate,
|
||||||
|
),
|
||||||
|
severe: Math.max(
|
||||||
|
0,
|
||||||
|
PF2E_THRESHOLDS_BASE.severe +
|
||||||
|
adjustment * PF2E_THRESHOLD_ADJUSTMENTS.severe,
|
||||||
|
),
|
||||||
|
extreme: Math.max(
|
||||||
|
0,
|
||||||
|
PF2E_THRESHOLDS_BASE.extreme +
|
||||||
|
adjustment * PF2E_THRESHOLD_ADJUSTMENTS.extreme,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanCombatantsPf2e(
|
||||||
|
combatants: readonly CombatantDescriptor[],
|
||||||
|
partyLevel: number,
|
||||||
|
) {
|
||||||
|
let totalCreatureXp = 0;
|
||||||
|
|
||||||
|
for (const c of combatants) {
|
||||||
|
if (c.creatureLevel !== undefined) {
|
||||||
|
const xp = pf2eCreatureXp(c.creatureLevel, partyLevel);
|
||||||
|
if (c.side === "enemy") {
|
||||||
|
totalCreatureXp += xp;
|
||||||
|
} else {
|
||||||
|
totalCreatureXp -= xp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { totalCreatureXp: Math.max(0, totalCreatureXp) };
|
||||||
|
}
|
||||||
|
|
||||||
/** All standard 5e challenge rating strings, in ascending order. */
|
/** All standard 5e challenge rating strings, in ascending order. */
|
||||||
export const VALID_CR_VALUES: readonly string[] = Object.keys(CR_TO_XP);
|
export const VALID_CR_VALUES: readonly string[] = Object.keys(CR_TO_XP);
|
||||||
|
|
||||||
@@ -171,6 +300,7 @@ export function crToXp(cr: string): number {
|
|||||||
export interface CombatantDescriptor {
|
export interface CombatantDescriptor {
|
||||||
readonly level?: number;
|
readonly level?: number;
|
||||||
readonly cr?: string;
|
readonly cr?: string;
|
||||||
|
readonly creatureLevel?: number;
|
||||||
readonly side: "party" | "enemy";
|
readonly side: "party" | "enemy";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,6 +377,41 @@ export function calculateEncounterDifficulty(
|
|||||||
combatants: readonly CombatantDescriptor[],
|
combatants: readonly CombatantDescriptor[],
|
||||||
edition: RulesEdition,
|
edition: RulesEdition,
|
||||||
): DifficultyResult {
|
): DifficultyResult {
|
||||||
|
if (edition === "pf2e") {
|
||||||
|
const partyLevels: number[] = [];
|
||||||
|
for (const c of combatants) {
|
||||||
|
if (c.level !== undefined && c.side === "party") {
|
||||||
|
partyLevels.push(c.level);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const partyLevel = derivePartyLevel(partyLevels);
|
||||||
|
const { totalCreatureXp } = scanCombatantsPf2e(combatants, partyLevel);
|
||||||
|
const budget = calculatePf2eBudget(partyLevels.length);
|
||||||
|
const thresholds: DifficultyThreshold[] = [
|
||||||
|
{ label: "Trivial", value: budget.trivial },
|
||||||
|
{ label: "Low", value: budget.low },
|
||||||
|
{ label: "Moderate", value: budget.moderate },
|
||||||
|
{ label: "Severe", value: budget.severe },
|
||||||
|
{ label: "Extreme", value: budget.extreme },
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
tier: determineTier(totalCreatureXp, [
|
||||||
|
budget.low,
|
||||||
|
budget.moderate,
|
||||||
|
budget.severe,
|
||||||
|
budget.extreme,
|
||||||
|
]),
|
||||||
|
totalMonsterXp: totalCreatureXp,
|
||||||
|
thresholds,
|
||||||
|
encounterMultiplier: undefined,
|
||||||
|
adjustedXp: undefined,
|
||||||
|
partySizeAdjusted: undefined,
|
||||||
|
partyLevel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const { totalMonsterXp, monsterCount, partyLevels } =
|
const { totalMonsterXp, monsterCount, partyLevels } =
|
||||||
scanCombatants(combatants);
|
scanCombatants(combatants);
|
||||||
|
|
||||||
@@ -268,6 +433,7 @@ export function calculateEncounterDifficulty(
|
|||||||
encounterMultiplier: undefined,
|
encounterMultiplier: undefined,
|
||||||
adjustedXp: undefined,
|
adjustedXp: undefined,
|
||||||
partySizeAdjusted: undefined,
|
partySizeAdjusted: undefined,
|
||||||
|
partyLevel: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,5 +460,6 @@ export function calculateEncounterDifficulty(
|
|||||||
encounterMultiplier,
|
encounterMultiplier,
|
||||||
adjustedXp,
|
adjustedXp,
|
||||||
partySizeAdjusted,
|
partySizeAdjusted,
|
||||||
|
partyLevel: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ export {
|
|||||||
type DifficultyResult,
|
type DifficultyResult,
|
||||||
type DifficultyThreshold,
|
type DifficultyThreshold,
|
||||||
type DifficultyTier,
|
type DifficultyTier,
|
||||||
|
derivePartyLevel,
|
||||||
|
pf2eCreatureXp,
|
||||||
VALID_CR_VALUES,
|
VALID_CR_VALUES,
|
||||||
} from "./encounter-difficulty.js";
|
} from "./encounter-difficulty.js";
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export const PERSISTENT_DAMAGE_DEFINITIONS: readonly PersistentDamageDefinition[
|
|||||||
color: "pink",
|
color: "pink",
|
||||||
},
|
},
|
||||||
{ type: "force", label: "Force", iconName: "Orbit", color: "indigo" },
|
{ type: "force", label: "Force", iconName: "Orbit", color: "indigo" },
|
||||||
{ type: "void", label: "Void", iconName: "Eclipse", color: "slate" },
|
{ type: "void", label: "Void", iconName: "Eclipse", color: "purple" },
|
||||||
{ type: "spirit", label: "Spirit", iconName: "Wind", color: "neutral" },
|
{ type: "spirit", label: "Spirit", iconName: "Wind", color: "neutral" },
|
||||||
{
|
{
|
||||||
type: "vitality",
|
type: "vitality",
|
||||||
|
|||||||
@@ -14,12 +14,6 @@ export interface ToggleConditionSuccess {
|
|||||||
readonly events: DomainEvent[];
|
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 {
|
function validateConditionId(conditionId: ConditionId): DomainError | null {
|
||||||
if (!VALID_CONDITION_IDS.has(conditionId)) {
|
if (!VALID_CONDITION_IDS.has(conditionId)) {
|
||||||
return {
|
return {
|
||||||
@@ -67,8 +61,7 @@ export function toggleCondition(
|
|||||||
newConditions = filtered.length > 0 ? filtered : undefined;
|
newConditions = filtered.length > 0 ? filtered : undefined;
|
||||||
event = { type: "ConditionRemoved", combatantId, condition: conditionId };
|
event = { type: "ConditionRemoved", combatantId, condition: conditionId };
|
||||||
} else {
|
} else {
|
||||||
const added = sortByDefinitionOrder([...current, { id: conditionId }]);
|
newConditions = [...current, { id: conditionId }];
|
||||||
newConditions = added;
|
|
||||||
event = { type: "ConditionAdded", combatantId, condition: conditionId };
|
event = { type: "ConditionAdded", combatantId, condition: conditionId };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,10 +118,7 @@ export function setConditionValue(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const added = sortByDefinitionOrder([
|
const added = [...current, { id: conditionId, value: clampedValue }];
|
||||||
...current,
|
|
||||||
{ id: conditionId, value: clampedValue },
|
|
||||||
]);
|
|
||||||
return {
|
return {
|
||||||
encounter: applyConditions(encounter, combatantId, added),
|
encounter: applyConditions(encounter, combatantId, added),
|
||||||
events: [
|
events: [
|
||||||
|
|||||||
Generated
+224
-177
@@ -5,7 +5,7 @@ settings:
|
|||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
overrides:
|
overrides:
|
||||||
undici: '>=7.24.0'
|
undici: ~7.24.0
|
||||||
picomatch: '>=4.0.4'
|
picomatch: '>=4.0.4'
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
@@ -17,7 +17,7 @@ importers:
|
|||||||
version: 2.4.8
|
version: 2.4.8
|
||||||
'@vitest/coverage-v8':
|
'@vitest/coverage-v8':
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.1.0(vitest@4.1.0(@types/node@25.3.3)(jsdom@29.0.1)(vite@8.0.5(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.3)))
|
version: 4.1.0(vitest@4.1.0(@types/node@25.3.3)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.3)))
|
||||||
jscpd:
|
jscpd:
|
||||||
specifier: ^4.0.8
|
specifier: ^4.0.8
|
||||||
version: 4.0.8
|
version: 4.0.8
|
||||||
@@ -41,7 +41,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.1.0(@types/node@25.3.3)(jsdom@29.0.1)(vite@8.0.5(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.3))
|
version: 4.1.0(@types/node@25.3.3)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.3))
|
||||||
|
|
||||||
apps/web:
|
apps/web:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -75,7 +75,7 @@ importers:
|
|||||||
devDependencies:
|
devDependencies:
|
||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.2.2
|
specifier: ^4.2.2
|
||||||
version: 4.2.2(vite@8.0.5(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.3))
|
version: 4.2.2(vite@8.0.16(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.3))
|
||||||
'@testing-library/jest-dom':
|
'@testing-library/jest-dom':
|
||||||
specifier: ^6.9.1
|
specifier: ^6.9.1
|
||||||
version: 6.9.1
|
version: 6.9.1
|
||||||
@@ -93,16 +93,16 @@ importers:
|
|||||||
version: 19.2.3(@types/react@19.2.14)
|
version: 19.2.3(@types/react@19.2.14)
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^6.0.1
|
specifier: ^6.0.1
|
||||||
version: 6.0.1(vite@8.0.5(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.3))
|
version: 6.0.1(vite@8.0.16(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.3))
|
||||||
jsdom:
|
jsdom:
|
||||||
specifier: ^29.0.1
|
specifier: ^29.1.1
|
||||||
version: 29.0.1
|
version: 29.1.1
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.2.2
|
specifier: ^4.2.2
|
||||||
version: 4.2.2
|
version: 4.2.2
|
||||||
vite:
|
vite:
|
||||||
specifier: ^8.0.5
|
specifier: ^8.0.16
|
||||||
version: 8.0.5(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.3)
|
version: 8.0.16(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.3)
|
||||||
|
|
||||||
packages/application:
|
packages/application:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -117,19 +117,23 @@ packages:
|
|||||||
'@adobe/css-tools@4.4.4':
|
'@adobe/css-tools@4.4.4':
|
||||||
resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==}
|
resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==}
|
||||||
|
|
||||||
'@asamuzakjp/css-color@5.0.1':
|
'@asamuzakjp/css-color@5.1.11':
|
||||||
resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==}
|
resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==}
|
||||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||||
|
|
||||||
'@asamuzakjp/dom-selector@7.0.4':
|
'@asamuzakjp/dom-selector@7.1.1':
|
||||||
resolution: {integrity: sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==}
|
resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==}
|
||||||
|
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||||
|
|
||||||
|
'@asamuzakjp/generational-cache@1.0.1':
|
||||||
|
resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==}
|
||||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||||
|
|
||||||
'@asamuzakjp/nwsapi@2.3.9':
|
'@asamuzakjp/nwsapi@2.3.9':
|
||||||
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
|
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
|
||||||
|
|
||||||
'@babel/code-frame@7.29.0':
|
'@babel/code-frame@7.29.7':
|
||||||
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
|
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@babel/helper-string-parser@7.27.1':
|
'@babel/helper-string-parser@7.27.1':
|
||||||
@@ -144,6 +148,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
|
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/helper-validator-identifier@7.29.7':
|
||||||
|
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@babel/helper-validator-identifier@8.0.0-rc.3':
|
'@babel/helper-validator-identifier@8.0.0-rc.3':
|
||||||
resolution: {integrity: sha512-8AWCJ2VJJyDFlGBep5GpaaQ9AAaE/FjAcrqI7jyssYhtL7WGV0DOKpJsQqM037xDbpRLHXsY8TwU7zDma7coOw==}
|
resolution: {integrity: sha512-8AWCJ2VJJyDFlGBep5GpaaQ9AAaE/FjAcrqI7jyssYhtL7WGV0DOKpJsQqM037xDbpRLHXsY8TwU7zDma7coOw==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -162,8 +170,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
|
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@babel/runtime@7.29.2':
|
'@babel/runtime@7.29.7':
|
||||||
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
|
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@babel/types@7.29.0':
|
'@babel/types@7.29.0':
|
||||||
@@ -247,15 +255,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==}
|
resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==}
|
||||||
engines: {node: '>=20.19.0'}
|
engines: {node: '>=20.19.0'}
|
||||||
|
|
||||||
'@csstools/css-calc@3.1.1':
|
'@csstools/css-calc@3.2.1':
|
||||||
resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==}
|
resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==}
|
||||||
engines: {node: '>=20.19.0'}
|
engines: {node: '>=20.19.0'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@csstools/css-parser-algorithms': ^4.0.0
|
'@csstools/css-parser-algorithms': ^4.0.0
|
||||||
'@csstools/css-tokenizer': ^4.0.0
|
'@csstools/css-tokenizer': ^4.0.0
|
||||||
|
|
||||||
'@csstools/css-color-parser@4.0.2':
|
'@csstools/css-color-parser@4.1.8':
|
||||||
resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==}
|
resolution: {integrity: sha512-3chWb7PRLijpJpPIKkDxdu6IBeO5MrFACND57On0j8OPpc0wZibcGc3xAHrSEbOx/KDRyMHoIxGn0w1PhXMYHw==}
|
||||||
engines: {node: '>=20.19.0'}
|
engines: {node: '>=20.19.0'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@csstools/css-parser-algorithms': ^4.0.0
|
'@csstools/css-parser-algorithms': ^4.0.0
|
||||||
@@ -267,8 +275,8 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@csstools/css-tokenizer': ^4.0.0
|
'@csstools/css-tokenizer': ^4.0.0
|
||||||
|
|
||||||
'@csstools/css-syntax-patches-for-csstree@1.1.1':
|
'@csstools/css-syntax-patches-for-csstree@1.1.5':
|
||||||
resolution: {integrity: sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==}
|
resolution: {integrity: sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
css-tree: ^3.2.1
|
css-tree: ^3.2.1
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
@@ -279,15 +287,24 @@ packages:
|
|||||||
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
|
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
|
||||||
engines: {node: '>=20.19.0'}
|
engines: {node: '>=20.19.0'}
|
||||||
|
|
||||||
|
'@emnapi/core@1.10.0':
|
||||||
|
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
|
||||||
|
|
||||||
'@emnapi/core@1.8.1':
|
'@emnapi/core@1.8.1':
|
||||||
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
|
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.10.0':
|
||||||
|
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
|
||||||
|
|
||||||
'@emnapi/runtime@1.8.1':
|
'@emnapi/runtime@1.8.1':
|
||||||
resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==}
|
resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==}
|
||||||
|
|
||||||
'@emnapi/wasi-threads@1.1.0':
|
'@emnapi/wasi-threads@1.1.0':
|
||||||
resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
|
resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
|
||||||
|
|
||||||
|
'@emnapi/wasi-threads@1.2.1':
|
||||||
|
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
|
||||||
|
|
||||||
'@exodus/bytes@1.15.0':
|
'@exodus/bytes@1.15.0':
|
||||||
resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==}
|
resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==}
|
||||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||||
@@ -331,8 +348,8 @@ packages:
|
|||||||
'@napi-rs/wasm-runtime@1.1.1':
|
'@napi-rs/wasm-runtime@1.1.1':
|
||||||
resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
|
resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
|
||||||
|
|
||||||
'@napi-rs/wasm-runtime@1.1.2':
|
'@napi-rs/wasm-runtime@1.1.5':
|
||||||
resolution: {integrity: sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==}
|
resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@emnapi/core': ^1.7.1
|
'@emnapi/core': ^1.7.1
|
||||||
'@emnapi/runtime': ^1.7.1
|
'@emnapi/runtime': ^1.7.1
|
||||||
@@ -349,8 +366,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
'@oxc-project/types@0.122.0':
|
'@oxc-project/types@0.133.0':
|
||||||
resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==}
|
resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==}
|
||||||
|
|
||||||
'@oxc-resolver/binding-android-arm-eabi@11.19.1':
|
'@oxc-resolver/binding-android-arm-eabi@11.19.1':
|
||||||
resolution: {integrity: sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==}
|
resolution: {integrity: sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==}
|
||||||
@@ -612,107 +629,107 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@rolldown/binding-android-arm64@1.0.0-rc.12':
|
'@rolldown/binding-android-arm64@1.0.3':
|
||||||
resolution: {integrity: sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==}
|
resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [android]
|
os: [android]
|
||||||
|
|
||||||
'@rolldown/binding-darwin-arm64@1.0.0-rc.12':
|
'@rolldown/binding-darwin-arm64@1.0.3':
|
||||||
resolution: {integrity: sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==}
|
resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@rolldown/binding-darwin-x64@1.0.0-rc.12':
|
'@rolldown/binding-darwin-x64@1.0.3':
|
||||||
resolution: {integrity: sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==}
|
resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@rolldown/binding-freebsd-x64@1.0.0-rc.12':
|
'@rolldown/binding-freebsd-x64@1.0.3':
|
||||||
resolution: {integrity: sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==}
|
resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [freebsd]
|
os: [freebsd]
|
||||||
|
|
||||||
'@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12':
|
'@rolldown/binding-linux-arm-gnueabihf@1.0.3':
|
||||||
resolution: {integrity: sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==}
|
resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12':
|
'@rolldown/binding-linux-arm64-gnu@1.0.3':
|
||||||
resolution: {integrity: sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==}
|
resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.12':
|
'@rolldown/binding-linux-arm64-musl@1.0.3':
|
||||||
resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==}
|
resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
libc: [musl]
|
||||||
|
|
||||||
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12':
|
'@rolldown/binding-linux-ppc64-gnu@1.0.3':
|
||||||
resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==}
|
resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12':
|
'@rolldown/binding-linux-s390x-gnu@1.0.3':
|
||||||
resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==}
|
resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.12':
|
'@rolldown/binding-linux-x64-gnu@1.0.3':
|
||||||
resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==}
|
resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
libc: [glibc]
|
||||||
|
|
||||||
'@rolldown/binding-linux-x64-musl@1.0.0-rc.12':
|
'@rolldown/binding-linux-x64-musl@1.0.3':
|
||||||
resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==}
|
resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
libc: [musl]
|
||||||
|
|
||||||
'@rolldown/binding-openharmony-arm64@1.0.0-rc.12':
|
'@rolldown/binding-openharmony-arm64@1.0.3':
|
||||||
resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==}
|
resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [openharmony]
|
os: [openharmony]
|
||||||
|
|
||||||
'@rolldown/binding-wasm32-wasi@1.0.0-rc.12':
|
'@rolldown/binding-wasm32-wasi@1.0.3':
|
||||||
resolution: {integrity: sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==}
|
resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [wasm32]
|
cpu: [wasm32]
|
||||||
|
|
||||||
'@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12':
|
'@rolldown/binding-win32-arm64-msvc@1.0.3':
|
||||||
resolution: {integrity: sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==}
|
resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@rolldown/binding-win32-x64-msvc@1.0.0-rc.12':
|
'@rolldown/binding-win32-x64-msvc@1.0.3':
|
||||||
resolution: {integrity: sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==}
|
resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-rc.12':
|
|
||||||
resolution: {integrity: sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==}
|
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-rc.7':
|
'@rolldown/pluginutils@1.0.0-rc.7':
|
||||||
resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
|
resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
|
||||||
|
|
||||||
|
'@rolldown/pluginutils@1.0.1':
|
||||||
|
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
|
||||||
|
|
||||||
'@standard-schema/spec@1.1.0':
|
'@standard-schema/spec@1.1.0':
|
||||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||||
|
|
||||||
@@ -842,6 +859,9 @@ packages:
|
|||||||
'@tybys/wasm-util@0.10.1':
|
'@tybys/wasm-util@0.10.1':
|
||||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||||
|
|
||||||
|
'@tybys/wasm-util@0.10.2':
|
||||||
|
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
|
||||||
|
|
||||||
'@types/aria-query@5.0.4':
|
'@types/aria-query@5.0.4':
|
||||||
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
|
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
|
||||||
|
|
||||||
@@ -1083,9 +1103,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==}
|
resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
|
|
||||||
entities@6.0.1:
|
entities@8.0.0:
|
||||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
|
||||||
engines: {node: '>=0.12'}
|
engines: {node: '>=20.19.0'}
|
||||||
|
|
||||||
es-define-property@1.0.1:
|
es-define-property@1.0.1:
|
||||||
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
|
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
|
||||||
@@ -1300,8 +1320,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-d2VNT/2Hv4dxT2/59He8Lyda4DYOxPRyRG9zBaOpTZAqJCVf2xLrBlZkT8Va6Lo9u3X2qz8Bpq4HrDi4JsrQhA==}
|
resolution: {integrity: sha512-d2VNT/2Hv4dxT2/59He8Lyda4DYOxPRyRG9zBaOpTZAqJCVf2xLrBlZkT8Va6Lo9u3X2qz8Bpq4HrDi4JsrQhA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
jsdom@29.0.1:
|
jsdom@29.1.1:
|
||||||
resolution: {integrity: sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==}
|
resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==}
|
||||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
|
engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
canvas: ^3.0.0
|
canvas: ^3.0.0
|
||||||
@@ -1455,8 +1475,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
|
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
|
|
||||||
lru-cache@11.2.7:
|
lru-cache@11.5.1:
|
||||||
resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==}
|
resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==}
|
||||||
engines: {node: 20 || >=22}
|
engines: {node: 20 || >=22}
|
||||||
|
|
||||||
lucide-react@0.577.0:
|
lucide-react@0.577.0:
|
||||||
@@ -1510,8 +1530,8 @@ packages:
|
|||||||
minimist@1.2.8:
|
minimist@1.2.8:
|
||||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||||
|
|
||||||
nanoid@3.3.11:
|
nanoid@3.3.13:
|
||||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
resolution: {integrity: sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==}
|
||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
@@ -1554,8 +1574,8 @@ packages:
|
|||||||
oxlint-tsgolint:
|
oxlint-tsgolint:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
parse5@8.0.0:
|
parse5@8.0.1:
|
||||||
resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==}
|
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
|
||||||
|
|
||||||
path-key@3.1.1:
|
path-key@3.1.1:
|
||||||
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
||||||
@@ -1574,8 +1594,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
postcss@8.5.8:
|
postcss@8.5.15:
|
||||||
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
|
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
pretty-format@27.5.1:
|
pretty-format@27.5.1:
|
||||||
@@ -1667,8 +1687,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||||
|
|
||||||
rolldown@1.0.0-rc.12:
|
rolldown@1.0.3:
|
||||||
resolution: {integrity: sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==}
|
resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
@@ -1782,6 +1802,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
|
tinyglobby@0.2.17:
|
||||||
|
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
tinyrainbow@3.1.0:
|
tinyrainbow@3.1.0:
|
||||||
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
|
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
@@ -1823,21 +1847,21 @@ packages:
|
|||||||
undici-types@7.18.2:
|
undici-types@7.18.2:
|
||||||
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
||||||
|
|
||||||
undici@7.24.2:
|
undici@7.24.8:
|
||||||
resolution: {integrity: sha512-P9J1HWYV/ajFr8uCqk5QixwiRKmB1wOamgS0e+o2Z4A44Ej2+thFVRLG/eA7qprx88XXhnV5Bl8LHXTURpzB3Q==}
|
resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==}
|
||||||
engines: {node: '>=20.18.1'}
|
engines: {node: '>=20.18.1'}
|
||||||
|
|
||||||
universalify@2.0.1:
|
universalify@2.0.1:
|
||||||
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
|
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
|
|
||||||
vite@8.0.5:
|
vite@8.0.16:
|
||||||
resolution: {integrity: sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ==}
|
resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@types/node': ^20.19.0 || >=22.12.0
|
'@types/node': ^20.19.0 || >=22.12.0
|
||||||
'@vitejs/devtools': ^0.1.0
|
'@vitejs/devtools': ^0.1.18
|
||||||
esbuild: ^0.27.0 || ^0.28.0
|
esbuild: ^0.27.0 || ^0.28.0
|
||||||
jiti: '>=1.21.0'
|
jiti: '>=1.21.0'
|
||||||
less: ^4.0.0
|
less: ^4.0.0
|
||||||
@@ -1969,27 +1993,29 @@ snapshots:
|
|||||||
|
|
||||||
'@adobe/css-tools@4.4.4': {}
|
'@adobe/css-tools@4.4.4': {}
|
||||||
|
|
||||||
'@asamuzakjp/css-color@5.0.1':
|
'@asamuzakjp/css-color@5.1.11':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
|
'@asamuzakjp/generational-cache': 1.0.1
|
||||||
'@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
|
'@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
|
||||||
|
'@csstools/css-color-parser': 4.1.8(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
|
||||||
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
|
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
|
||||||
'@csstools/css-tokenizer': 4.0.0
|
'@csstools/css-tokenizer': 4.0.0
|
||||||
lru-cache: 11.2.7
|
|
||||||
|
|
||||||
'@asamuzakjp/dom-selector@7.0.4':
|
'@asamuzakjp/dom-selector@7.1.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@asamuzakjp/generational-cache': 1.0.1
|
||||||
'@asamuzakjp/nwsapi': 2.3.9
|
'@asamuzakjp/nwsapi': 2.3.9
|
||||||
bidi-js: 1.0.3
|
bidi-js: 1.0.3
|
||||||
css-tree: 3.2.1
|
css-tree: 3.2.1
|
||||||
is-potential-custom-element-name: 1.0.1
|
is-potential-custom-element-name: 1.0.1
|
||||||
lru-cache: 11.2.7
|
|
||||||
|
'@asamuzakjp/generational-cache@1.0.1': {}
|
||||||
|
|
||||||
'@asamuzakjp/nwsapi@2.3.9': {}
|
'@asamuzakjp/nwsapi@2.3.9': {}
|
||||||
|
|
||||||
'@babel/code-frame@7.29.0':
|
'@babel/code-frame@7.29.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/helper-validator-identifier': 7.28.5
|
'@babel/helper-validator-identifier': 7.29.7
|
||||||
js-tokens: 4.0.0
|
js-tokens: 4.0.0
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
|
|
||||||
@@ -1999,6 +2025,8 @@ snapshots:
|
|||||||
|
|
||||||
'@babel/helper-validator-identifier@7.28.5': {}
|
'@babel/helper-validator-identifier@7.28.5': {}
|
||||||
|
|
||||||
|
'@babel/helper-validator-identifier@7.29.7': {}
|
||||||
|
|
||||||
'@babel/helper-validator-identifier@8.0.0-rc.3': {}
|
'@babel/helper-validator-identifier@8.0.0-rc.3': {}
|
||||||
|
|
||||||
'@babel/parser@7.29.0':
|
'@babel/parser@7.29.0':
|
||||||
@@ -2011,7 +2039,7 @@ snapshots:
|
|||||||
|
|
||||||
'@babel/runtime@7.28.6': {}
|
'@babel/runtime@7.28.6': {}
|
||||||
|
|
||||||
'@babel/runtime@7.29.2': {}
|
'@babel/runtime@7.29.7': {}
|
||||||
|
|
||||||
'@babel/types@7.29.0':
|
'@babel/types@7.29.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -2069,15 +2097,15 @@ snapshots:
|
|||||||
|
|
||||||
'@csstools/color-helpers@6.0.2': {}
|
'@csstools/color-helpers@6.0.2': {}
|
||||||
|
|
||||||
'@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
|
'@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
|
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
|
||||||
'@csstools/css-tokenizer': 4.0.0
|
'@csstools/css-tokenizer': 4.0.0
|
||||||
|
|
||||||
'@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
|
'@csstools/css-color-parser@4.1.8(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@csstools/color-helpers': 6.0.2
|
'@csstools/color-helpers': 6.0.2
|
||||||
'@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
|
'@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
|
||||||
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
|
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
|
||||||
'@csstools/css-tokenizer': 4.0.0
|
'@csstools/css-tokenizer': 4.0.0
|
||||||
|
|
||||||
@@ -2085,18 +2113,29 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@csstools/css-tokenizer': 4.0.0
|
'@csstools/css-tokenizer': 4.0.0
|
||||||
|
|
||||||
'@csstools/css-syntax-patches-for-csstree@1.1.1(css-tree@3.2.1)':
|
'@csstools/css-syntax-patches-for-csstree@1.1.5(css-tree@3.2.1)':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
css-tree: 3.2.1
|
css-tree: 3.2.1
|
||||||
|
|
||||||
'@csstools/css-tokenizer@4.0.0': {}
|
'@csstools/css-tokenizer@4.0.0': {}
|
||||||
|
|
||||||
|
'@emnapi/core@1.10.0':
|
||||||
|
dependencies:
|
||||||
|
'@emnapi/wasi-threads': 1.2.1
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@emnapi/core@1.8.1':
|
'@emnapi/core@1.8.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/wasi-threads': 1.1.0
|
'@emnapi/wasi-threads': 1.1.0
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.10.0':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@emnapi/runtime@1.8.1':
|
'@emnapi/runtime@1.8.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
@@ -2107,6 +2146,11 @@ snapshots:
|
|||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@emnapi/wasi-threads@1.2.1':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@exodus/bytes@1.15.0': {}
|
'@exodus/bytes@1.15.0': {}
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
@@ -2170,11 +2214,11 @@ snapshots:
|
|||||||
'@tybys/wasm-util': 0.10.1
|
'@tybys/wasm-util': 0.10.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@napi-rs/wasm-runtime@1.1.2(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)':
|
'@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/core': 1.8.1
|
'@emnapi/core': 1.10.0
|
||||||
'@emnapi/runtime': 1.8.1
|
'@emnapi/runtime': 1.10.0
|
||||||
'@tybys/wasm-util': 0.10.1
|
'@tybys/wasm-util': 0.10.2
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@nodelib/fs.scandir@2.1.5':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
@@ -2189,7 +2233,7 @@ snapshots:
|
|||||||
'@nodelib/fs.scandir': 2.1.5
|
'@nodelib/fs.scandir': 2.1.5
|
||||||
fastq: 1.20.1
|
fastq: 1.20.1
|
||||||
|
|
||||||
'@oxc-project/types@0.122.0': {}
|
'@oxc-project/types@0.133.0': {}
|
||||||
|
|
||||||
'@oxc-resolver/binding-android-arm-eabi@11.19.1':
|
'@oxc-resolver/binding-android-arm-eabi@11.19.1':
|
||||||
optional: true
|
optional: true
|
||||||
@@ -2328,60 +2372,59 @@ snapshots:
|
|||||||
'@oxlint/binding-win32-x64-msvc@1.56.0':
|
'@oxlint/binding-win32-x64-msvc@1.56.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-android-arm64@1.0.0-rc.12':
|
'@rolldown/binding-android-arm64@1.0.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-darwin-arm64@1.0.0-rc.12':
|
'@rolldown/binding-darwin-arm64@1.0.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-darwin-x64@1.0.0-rc.12':
|
'@rolldown/binding-darwin-x64@1.0.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-freebsd-x64@1.0.0-rc.12':
|
'@rolldown/binding-freebsd-x64@1.0.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.12':
|
'@rolldown/binding-linux-arm-gnueabihf@1.0.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-linux-arm64-gnu@1.0.0-rc.12':
|
'@rolldown/binding-linux-arm64-gnu@1.0.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.12':
|
'@rolldown/binding-linux-arm64-musl@1.0.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12':
|
'@rolldown/binding-linux-ppc64-gnu@1.0.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12':
|
'@rolldown/binding-linux-s390x-gnu@1.0.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.12':
|
'@rolldown/binding-linux-x64-gnu@1.0.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-linux-x64-musl@1.0.0-rc.12':
|
'@rolldown/binding-linux-x64-musl@1.0.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-openharmony-arm64@1.0.0-rc.12':
|
'@rolldown/binding-openharmony-arm64@1.0.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-wasm32-wasi@1.0.0-rc.12(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)':
|
'@rolldown/binding-wasm32-wasi@1.0.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)
|
'@emnapi/core': 1.10.0
|
||||||
transitivePeerDependencies:
|
'@emnapi/runtime': 1.10.0
|
||||||
- '@emnapi/core'
|
'@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
|
||||||
- '@emnapi/runtime'
|
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-win32-arm64-msvc@1.0.0-rc.12':
|
'@rolldown/binding-win32-arm64-msvc@1.0.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/binding-win32-x64-msvc@1.0.0-rc.12':
|
'@rolldown/binding-win32-x64-msvc@1.0.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-rc.12': {}
|
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-rc.7': {}
|
'@rolldown/pluginutils@1.0.0-rc.7': {}
|
||||||
|
|
||||||
|
'@rolldown/pluginutils@1.0.1': {}
|
||||||
|
|
||||||
'@standard-schema/spec@1.1.0': {}
|
'@standard-schema/spec@1.1.0': {}
|
||||||
|
|
||||||
'@tailwindcss/node@4.2.2':
|
'@tailwindcss/node@4.2.2':
|
||||||
@@ -2445,17 +2488,17 @@ snapshots:
|
|||||||
'@tailwindcss/oxide-win32-arm64-msvc': 4.2.2
|
'@tailwindcss/oxide-win32-arm64-msvc': 4.2.2
|
||||||
'@tailwindcss/oxide-win32-x64-msvc': 4.2.2
|
'@tailwindcss/oxide-win32-x64-msvc': 4.2.2
|
||||||
|
|
||||||
'@tailwindcss/vite@4.2.2(vite@8.0.5(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.3))':
|
'@tailwindcss/vite@4.2.2(vite@8.0.16(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tailwindcss/node': 4.2.2
|
'@tailwindcss/node': 4.2.2
|
||||||
'@tailwindcss/oxide': 4.2.2
|
'@tailwindcss/oxide': 4.2.2
|
||||||
tailwindcss: 4.2.2
|
tailwindcss: 4.2.2
|
||||||
vite: 8.0.5(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.3)
|
vite: 8.0.16(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.3)
|
||||||
|
|
||||||
'@testing-library/dom@10.4.1':
|
'@testing-library/dom@10.4.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.29.0
|
'@babel/code-frame': 7.29.7
|
||||||
'@babel/runtime': 7.29.2
|
'@babel/runtime': 7.29.7
|
||||||
'@types/aria-query': 5.0.4
|
'@types/aria-query': 5.0.4
|
||||||
aria-query: 5.3.0
|
aria-query: 5.3.0
|
||||||
dom-accessibility-api: 0.5.16
|
dom-accessibility-api: 0.5.16
|
||||||
@@ -2491,6 +2534,11 @@ snapshots:
|
|||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@tybys/wasm-util@0.10.2':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@types/aria-query@5.0.4': {}
|
'@types/aria-query@5.0.4': {}
|
||||||
|
|
||||||
'@types/chai@5.2.3':
|
'@types/chai@5.2.3':
|
||||||
@@ -2516,12 +2564,12 @@ snapshots:
|
|||||||
|
|
||||||
'@types/sarif@2.1.7': {}
|
'@types/sarif@2.1.7': {}
|
||||||
|
|
||||||
'@vitejs/plugin-react@6.0.1(vite@8.0.5(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.3))':
|
'@vitejs/plugin-react@6.0.1(vite@8.0.16(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@rolldown/pluginutils': 1.0.0-rc.7
|
'@rolldown/pluginutils': 1.0.0-rc.7
|
||||||
vite: 8.0.5(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.3)
|
vite: 8.0.16(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.3)
|
||||||
|
|
||||||
'@vitest/coverage-v8@4.1.0(vitest@4.1.0(@types/node@25.3.3)(jsdom@29.0.1)(vite@8.0.5(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.3)))':
|
'@vitest/coverage-v8@4.1.0(vitest@4.1.0(@types/node@25.3.3)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.3)))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@bcoe/v8-coverage': 1.0.2
|
'@bcoe/v8-coverage': 1.0.2
|
||||||
'@vitest/utils': 4.1.0
|
'@vitest/utils': 4.1.0
|
||||||
@@ -2533,7 +2581,7 @@ snapshots:
|
|||||||
obug: 2.1.1
|
obug: 2.1.1
|
||||||
std-env: 4.0.0
|
std-env: 4.0.0
|
||||||
tinyrainbow: 3.1.0
|
tinyrainbow: 3.1.0
|
||||||
vitest: 4.1.0(@types/node@25.3.3)(jsdom@29.0.1)(vite@8.0.5(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.3))
|
vitest: 4.1.0(@types/node@25.3.3)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.3))
|
||||||
|
|
||||||
'@vitest/expect@4.1.0':
|
'@vitest/expect@4.1.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -2544,13 +2592,13 @@ snapshots:
|
|||||||
chai: 6.2.2
|
chai: 6.2.2
|
||||||
tinyrainbow: 3.1.0
|
tinyrainbow: 3.1.0
|
||||||
|
|
||||||
'@vitest/mocker@4.1.0(vite@8.0.5(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.3))':
|
'@vitest/mocker@4.1.0(vite@8.0.16(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/spy': 4.1.0
|
'@vitest/spy': 4.1.0
|
||||||
estree-walker: 3.0.3
|
estree-walker: 3.0.3
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
vite: 8.0.5(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.3)
|
vite: 8.0.16(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.3)
|
||||||
|
|
||||||
'@vitest/pretty-format@4.1.0':
|
'@vitest/pretty-format@4.1.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -2729,7 +2777,7 @@ snapshots:
|
|||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
tapable: 2.3.0
|
tapable: 2.3.0
|
||||||
|
|
||||||
entities@6.0.1: {}
|
entities@8.0.0: {}
|
||||||
|
|
||||||
es-define-property@1.0.1: {}
|
es-define-property@1.0.1: {}
|
||||||
|
|
||||||
@@ -2938,24 +2986,24 @@ snapshots:
|
|||||||
gitignore-to-glob: 0.3.0
|
gitignore-to-glob: 0.3.0
|
||||||
jscpd-sarif-reporter: 4.0.6
|
jscpd-sarif-reporter: 4.0.6
|
||||||
|
|
||||||
jsdom@29.0.1:
|
jsdom@29.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@asamuzakjp/css-color': 5.0.1
|
'@asamuzakjp/css-color': 5.1.11
|
||||||
'@asamuzakjp/dom-selector': 7.0.4
|
'@asamuzakjp/dom-selector': 7.1.1
|
||||||
'@bramus/specificity': 2.4.2
|
'@bramus/specificity': 2.4.2
|
||||||
'@csstools/css-syntax-patches-for-csstree': 1.1.1(css-tree@3.2.1)
|
'@csstools/css-syntax-patches-for-csstree': 1.1.5(css-tree@3.2.1)
|
||||||
'@exodus/bytes': 1.15.0
|
'@exodus/bytes': 1.15.0
|
||||||
css-tree: 3.2.1
|
css-tree: 3.2.1
|
||||||
data-urls: 7.0.0
|
data-urls: 7.0.0
|
||||||
decimal.js: 10.6.0
|
decimal.js: 10.6.0
|
||||||
html-encoding-sniffer: 6.0.0
|
html-encoding-sniffer: 6.0.0
|
||||||
is-potential-custom-element-name: 1.0.1
|
is-potential-custom-element-name: 1.0.1
|
||||||
lru-cache: 11.2.7
|
lru-cache: 11.5.1
|
||||||
parse5: 8.0.0
|
parse5: 8.0.1
|
||||||
saxes: 6.0.0
|
saxes: 6.0.0
|
||||||
symbol-tree: 3.2.4
|
symbol-tree: 3.2.4
|
||||||
tough-cookie: 6.0.1
|
tough-cookie: 6.0.1
|
||||||
undici: 7.24.2
|
undici: 7.24.8
|
||||||
w3c-xmlserializer: 5.0.0
|
w3c-xmlserializer: 5.0.0
|
||||||
webidl-conversions: 8.0.1
|
webidl-conversions: 8.0.1
|
||||||
whatwg-mimetype: 5.0.0
|
whatwg-mimetype: 5.0.0
|
||||||
@@ -3095,7 +3143,7 @@ snapshots:
|
|||||||
lightningcss-win32-arm64-msvc: 1.32.0
|
lightningcss-win32-arm64-msvc: 1.32.0
|
||||||
lightningcss-win32-x64-msvc: 1.32.0
|
lightningcss-win32-x64-msvc: 1.32.0
|
||||||
|
|
||||||
lru-cache@11.2.7: {}
|
lru-cache@11.5.1: {}
|
||||||
|
|
||||||
lucide-react@0.577.0(react@19.2.4):
|
lucide-react@0.577.0(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -3140,7 +3188,7 @@ snapshots:
|
|||||||
|
|
||||||
minimist@1.2.8: {}
|
minimist@1.2.8: {}
|
||||||
|
|
||||||
nanoid@3.3.11: {}
|
nanoid@3.3.13: {}
|
||||||
|
|
||||||
node-sarif-builder@3.4.0:
|
node-sarif-builder@3.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -3218,9 +3266,9 @@ snapshots:
|
|||||||
'@oxlint/binding-win32-x64-msvc': 1.56.0
|
'@oxlint/binding-win32-x64-msvc': 1.56.0
|
||||||
oxlint-tsgolint: 0.17.1
|
oxlint-tsgolint: 0.17.1
|
||||||
|
|
||||||
parse5@8.0.0:
|
parse5@8.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
entities: 6.0.1
|
entities: 8.0.0
|
||||||
|
|
||||||
path-key@3.1.1: {}
|
path-key@3.1.1: {}
|
||||||
|
|
||||||
@@ -3232,9 +3280,9 @@ snapshots:
|
|||||||
|
|
||||||
picomatch@4.0.4: {}
|
picomatch@4.0.4: {}
|
||||||
|
|
||||||
postcss@8.5.8:
|
postcss@8.5.15:
|
||||||
dependencies:
|
dependencies:
|
||||||
nanoid: 3.3.11
|
nanoid: 3.3.13
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
@@ -3352,29 +3400,26 @@ snapshots:
|
|||||||
|
|
||||||
reusify@1.1.0: {}
|
reusify@1.1.0: {}
|
||||||
|
|
||||||
rolldown@1.0.0-rc.12(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1):
|
rolldown@1.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@oxc-project/types': 0.122.0
|
'@oxc-project/types': 0.133.0
|
||||||
'@rolldown/pluginutils': 1.0.0-rc.12
|
'@rolldown/pluginutils': 1.0.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@rolldown/binding-android-arm64': 1.0.0-rc.12
|
'@rolldown/binding-android-arm64': 1.0.3
|
||||||
'@rolldown/binding-darwin-arm64': 1.0.0-rc.12
|
'@rolldown/binding-darwin-arm64': 1.0.3
|
||||||
'@rolldown/binding-darwin-x64': 1.0.0-rc.12
|
'@rolldown/binding-darwin-x64': 1.0.3
|
||||||
'@rolldown/binding-freebsd-x64': 1.0.0-rc.12
|
'@rolldown/binding-freebsd-x64': 1.0.3
|
||||||
'@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.12
|
'@rolldown/binding-linux-arm-gnueabihf': 1.0.3
|
||||||
'@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.12
|
'@rolldown/binding-linux-arm64-gnu': 1.0.3
|
||||||
'@rolldown/binding-linux-arm64-musl': 1.0.0-rc.12
|
'@rolldown/binding-linux-arm64-musl': 1.0.3
|
||||||
'@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.12
|
'@rolldown/binding-linux-ppc64-gnu': 1.0.3
|
||||||
'@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.12
|
'@rolldown/binding-linux-s390x-gnu': 1.0.3
|
||||||
'@rolldown/binding-linux-x64-gnu': 1.0.0-rc.12
|
'@rolldown/binding-linux-x64-gnu': 1.0.3
|
||||||
'@rolldown/binding-linux-x64-musl': 1.0.0-rc.12
|
'@rolldown/binding-linux-x64-musl': 1.0.3
|
||||||
'@rolldown/binding-openharmony-arm64': 1.0.0-rc.12
|
'@rolldown/binding-openharmony-arm64': 1.0.3
|
||||||
'@rolldown/binding-wasm32-wasi': 1.0.0-rc.12(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)
|
'@rolldown/binding-wasm32-wasi': 1.0.3
|
||||||
'@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12
|
'@rolldown/binding-win32-arm64-msvc': 1.0.3
|
||||||
'@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12
|
'@rolldown/binding-win32-x64-msvc': 1.0.3
|
||||||
transitivePeerDependencies:
|
|
||||||
- '@emnapi/core'
|
|
||||||
- '@emnapi/runtime'
|
|
||||||
|
|
||||||
run-parallel@1.2.0:
|
run-parallel@1.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -3457,6 +3502,11 @@ snapshots:
|
|||||||
fdir: 6.5.0(picomatch@4.0.4)
|
fdir: 6.5.0(picomatch@4.0.4)
|
||||||
picomatch: 4.0.4
|
picomatch: 4.0.4
|
||||||
|
|
||||||
|
tinyglobby@0.2.17:
|
||||||
|
dependencies:
|
||||||
|
fdir: 6.5.0(picomatch@4.0.4)
|
||||||
|
picomatch: 4.0.4
|
||||||
|
|
||||||
tinyrainbow@3.1.0: {}
|
tinyrainbow@3.1.0: {}
|
||||||
|
|
||||||
tldts-core@7.0.25: {}
|
tldts-core@7.0.25: {}
|
||||||
@@ -3488,30 +3538,27 @@ snapshots:
|
|||||||
|
|
||||||
undici-types@7.18.2: {}
|
undici-types@7.18.2: {}
|
||||||
|
|
||||||
undici@7.24.2: {}
|
undici@7.24.8: {}
|
||||||
|
|
||||||
universalify@2.0.1: {}
|
universalify@2.0.1: {}
|
||||||
|
|
||||||
vite@8.0.5(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.3):
|
vite@8.0.16(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
lightningcss: 1.32.0
|
lightningcss: 1.32.0
|
||||||
picomatch: 4.0.4
|
picomatch: 4.0.4
|
||||||
postcss: 8.5.8
|
postcss: 8.5.15
|
||||||
rolldown: 1.0.0-rc.12(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)
|
rolldown: 1.0.3
|
||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.17
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 25.3.3
|
'@types/node': 25.3.3
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
jiti: 2.6.1
|
jiti: 2.6.1
|
||||||
yaml: 2.8.3
|
yaml: 2.8.3
|
||||||
transitivePeerDependencies:
|
|
||||||
- '@emnapi/core'
|
|
||||||
- '@emnapi/runtime'
|
|
||||||
|
|
||||||
vitest@4.1.0(@types/node@25.3.3)(jsdom@29.0.1)(vite@8.0.5(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.3)):
|
vitest@4.1.0(@types/node@25.3.3)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/expect': 4.1.0
|
'@vitest/expect': 4.1.0
|
||||||
'@vitest/mocker': 4.1.0(vite@8.0.5(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.3))
|
'@vitest/mocker': 4.1.0(vite@8.0.16(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.3))
|
||||||
'@vitest/pretty-format': 4.1.0
|
'@vitest/pretty-format': 4.1.0
|
||||||
'@vitest/runner': 4.1.0
|
'@vitest/runner': 4.1.0
|
||||||
'@vitest/snapshot': 4.1.0
|
'@vitest/snapshot': 4.1.0
|
||||||
@@ -3528,11 +3575,11 @@ snapshots:
|
|||||||
tinyexec: 1.0.4
|
tinyexec: 1.0.4
|
||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
tinyrainbow: 3.1.0
|
tinyrainbow: 3.1.0
|
||||||
vite: 8.0.5(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.3)
|
vite: 8.0.16(@types/node@25.3.3)(jiti@2.6.1)(yaml@2.8.3)
|
||||||
why-is-node-running: 2.3.0
|
why-is-node-running: 2.3.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 25.3.3
|
'@types/node': 25.3.3
|
||||||
jsdom: 29.0.1
|
jsdom: 29.1.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- msw
|
- msw
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user