Compare commits
27 Commits
0.9.25
..
a045e3a0f9
| Author | SHA1 | Date | |
|---|---|---|---|
| a045e3a0f9 | |||
| 934d98025e | |||
| 3b2fb99b37 | |||
| 111b464da5 | |||
| a97ffe5ed1 | |||
| 1930473753 | |||
| c343fd3cd0 | |||
| d9fb271607 | |||
| 064af16f95 | |||
| 0f640601b6 | |||
| 4b1c1deda2 | |||
| 09a801487d | |||
| a44f82127e | |||
| c3707cf0b6 | |||
| 1eaeecad32 | |||
| e2e8297c95 | |||
| e161645228 | |||
| 9b0cb38897 | |||
| 5cb5721a6f | |||
| 48795071f7 | |||
| f721d7e5da | |||
| e7930a1431 | |||
| 553e09f280 | |||
| 1c107a500b | |||
| 0c235112ee | |||
| 57278e0c82 | |||
| f9cfaa2570 |
@@ -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.
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: commit
|
name: commit
|
||||||
description: Create a git commit with pre-commit hooks (bypasses sandbox restrictions).
|
description: Create a git commit with pre-commit hooks (bypasses sandbox restrictions).
|
||||||
disable-model-invocation: true
|
|
||||||
allowed-tools: Bash(git *), Bash(pnpm *)
|
allowed-tools: Bash(git *), Bash(pnpm *)
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
name: ship
|
||||||
|
description: Commit, tag with the next version, and push to remote.
|
||||||
|
disable-model-invocation: true
|
||||||
|
allowed-tools: Bash(git *), Bash(pnpm *), Skill
|
||||||
|
---
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
Commit current changes, create the next version tag, and push everything to remote.
|
||||||
|
|
||||||
|
### Step 1 — Commit
|
||||||
|
|
||||||
|
Use the `/commit` skill to stage and commit changes. Pass along any user arguments as the commit message.
|
||||||
|
|
||||||
|
```
|
||||||
|
/commit $ARGUMENTS
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2 — Tag
|
||||||
|
|
||||||
|
Get the latest tag and increment the patch number (e.g., `0.9.27` → `0.9.28`). Create the tag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag --sort=-v:refname | head -1
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag <next-version>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3 — Push
|
||||||
|
|
||||||
|
Push the commit and tag to remote:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push && git push --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4 — Verify
|
||||||
|
|
||||||
|
Confirm the tag exists on the pushed commit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git log --oneline -1 --decorate
|
||||||
|
```
|
||||||
|
|
||||||
|
## User arguments
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ARGUMENTS
|
||||||
|
```
|
||||||
|
|
||||||
|
If the user provided arguments, treat them as the commit message or guidance for what to commit.
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ export function createTestAdapters(options?: {
|
|||||||
getDefaultFetchUrl: (sourceCode) =>
|
getDefaultFetchUrl: (sourceCode) =>
|
||||||
`https://example.com/creatures-${sourceCode.toLowerCase()}.json`,
|
`https://example.com/creatures-${sourceCode.toLowerCase()}.json`,
|
||||||
getSourceDisplayName: (sourceCode) => sourceCode,
|
getSourceDisplayName: (sourceCode) => sourceCode,
|
||||||
|
getCreaturePathsForSource: () => [],
|
||||||
|
getCreatureNamesByPaths: () => new Map(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,8 +33,7 @@ async function addCombatant(
|
|||||||
opts?: { maxHp?: string },
|
opts?: { maxHp?: string },
|
||||||
) {
|
) {
|
||||||
const inputs = screen.getAllByPlaceholderText("+ Add combatants");
|
const inputs = screen.getAllByPlaceholderText("+ Add combatants");
|
||||||
// biome-ignore lint/style/noNonNullAssertion: getAllBy always returns at least one
|
const input = inputs.at(-1) ?? inputs[0];
|
||||||
const input = inputs.at(-1)!;
|
|
||||||
await user.type(input, name);
|
await user.type(input, name);
|
||||||
|
|
||||||
if (opts?.maxHp) {
|
if (opts?.maxHp) {
|
||||||
|
|||||||
@@ -198,21 +198,23 @@ describe("ConfirmButton", () => {
|
|||||||
|
|
||||||
it("Enter/Space keydown stops propagation to prevent parent handlers", () => {
|
it("Enter/Space keydown stops propagation to prevent parent handlers", () => {
|
||||||
const parentHandler = vi.fn();
|
const parentHandler = vi.fn();
|
||||||
render(
|
function Wrapper() {
|
||||||
// biome-ignore lint/a11y/noStaticElementInteractions: test wrapper
|
return (
|
||||||
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: test wrapper
|
<button type="button" onKeyDown={parentHandler}>
|
||||||
<div onKeyDown={parentHandler}>
|
|
||||||
<ConfirmButton
|
<ConfirmButton
|
||||||
icon={<XIcon />}
|
icon={<XIcon />}
|
||||||
label="Remove combatant"
|
label="Remove combatant"
|
||||||
onConfirm={vi.fn()}
|
onConfirm={vi.fn()}
|
||||||
/>
|
/>
|
||||||
</div>,
|
</button>
|
||||||
);
|
);
|
||||||
const button = screen.getByRole("button");
|
}
|
||||||
|
render(<Wrapper />);
|
||||||
|
const buttons = screen.getAllByRole("button");
|
||||||
|
const confirmButton = buttons.at(-1) ?? buttons[0];
|
||||||
|
|
||||||
fireEvent.keyDown(button, { key: "Enter" });
|
fireEvent.keyDown(confirmButton, { key: "Enter" });
|
||||||
fireEvent.keyDown(button, { key: " " });
|
fireEvent.keyDown(confirmButton, { key: " " });
|
||||||
|
|
||||||
expect(parentHandler).not.toHaveBeenCalled();
|
expect(parentHandler).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -16,12 +16,18 @@ vi.mock("../contexts/bestiary-context.js", () => ({
|
|||||||
useBestiaryContext: vi.fn(),
|
useBestiaryContext: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../contexts/encounter-context.js", () => ({
|
||||||
|
useEncounterContext: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
import { StatBlockPanel } from "../components/stat-block-panel.js";
|
import { StatBlockPanel } from "../components/stat-block-panel.js";
|
||||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||||
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||||
|
|
||||||
const mockUseSidePanelContext = vi.mocked(useSidePanelContext);
|
const mockUseSidePanelContext = vi.mocked(useSidePanelContext);
|
||||||
const mockUseBestiaryContext = vi.mocked(useBestiaryContext);
|
const mockUseBestiaryContext = vi.mocked(useBestiaryContext);
|
||||||
|
const mockUseEncounterContext = vi.mocked(useEncounterContext);
|
||||||
|
|
||||||
const CLOSE_REGEX = /close/i;
|
const CLOSE_REGEX = /close/i;
|
||||||
const COLLAPSE_REGEX = /collapse/i;
|
const COLLAPSE_REGEX = /collapse/i;
|
||||||
@@ -82,6 +88,7 @@ function setupMocks(overrides: PanelOverrides = {}) {
|
|||||||
|
|
||||||
mockUseSidePanelContext.mockReturnValue({
|
mockUseSidePanelContext.mockReturnValue({
|
||||||
selectedCreatureId: panelRole === "browse" ? creatureId : null,
|
selectedCreatureId: panelRole === "browse" ? creatureId : null,
|
||||||
|
selectedCombatantId: null,
|
||||||
pinnedCreatureId: panelRole === "pinned" ? creatureId : null,
|
pinnedCreatureId: panelRole === "pinned" ? creatureId : null,
|
||||||
isRightPanelCollapsed: panelRole === "browse" ? isCollapsed : false,
|
isRightPanelCollapsed: panelRole === "browse" ? isCollapsed : false,
|
||||||
isWideDesktop: false,
|
isWideDesktop: false,
|
||||||
@@ -110,6 +117,11 @@ function setupMocks(overrides: PanelOverrides = {}) {
|
|||||||
refreshCache: vi.fn(),
|
refreshCache: vi.fn(),
|
||||||
} as ReturnType<typeof useBestiaryContext>);
|
} as ReturnType<typeof useBestiaryContext>);
|
||||||
|
|
||||||
|
mockUseEncounterContext.mockReturnValue({
|
||||||
|
encounter: { combatants: [], activeIndex: 0, roundNumber: 1 },
|
||||||
|
setCreatureAdjustment: vi.fn(),
|
||||||
|
} as unknown as ReturnType<typeof useEncounterContext>);
|
||||||
|
|
||||||
return { onToggleCollapse, onPin, onUnpin, onDismiss };
|
return { onToggleCollapse, onPin, onUnpin, onDismiss };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -181,17 +181,20 @@ describe("normalizeBestiary", () => {
|
|||||||
expect(sc?.name).toBe("Spellcasting");
|
expect(sc?.name).toBe("Spellcasting");
|
||||||
expect(sc?.headerText).toContain("DC 15");
|
expect(sc?.headerText).toContain("DC 15");
|
||||||
expect(sc?.headerText).not.toContain("{@");
|
expect(sc?.headerText).not.toContain("{@");
|
||||||
expect(sc?.atWill).toEqual(["Detect Magic", "Mage Hand"]);
|
expect(sc?.atWill).toEqual([
|
||||||
|
{ name: "Detect Magic" },
|
||||||
|
{ name: "Mage Hand" },
|
||||||
|
]);
|
||||||
expect(sc?.daily).toHaveLength(2);
|
expect(sc?.daily).toHaveLength(2);
|
||||||
expect(sc?.daily).toContainEqual({
|
expect(sc?.daily).toContainEqual({
|
||||||
uses: 2,
|
uses: 2,
|
||||||
each: true,
|
each: true,
|
||||||
spells: ["Fireball"],
|
spells: [{ name: "Fireball" }],
|
||||||
});
|
});
|
||||||
expect(sc?.daily).toContainEqual({
|
expect(sc?.daily).toContainEqual({
|
||||||
uses: 1,
|
uses: 1,
|
||||||
each: false,
|
each: false,
|
||||||
spells: ["Dimension Door"],
|
spells: [{ name: "Dimension Door" }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,11 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const PACK_DIR_PREFIX = /^pathfinder-monster-core\//;
|
||||||
|
const JSON_EXTENSION = /\.json$/;
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getAllPf2eSourceCodes,
|
getAllPf2eSourceCodes,
|
||||||
|
getCreaturePathsForSource,
|
||||||
getDefaultPf2eFetchUrl,
|
getDefaultPf2eFetchUrl,
|
||||||
getPf2eSourceDisplayName,
|
getPf2eSourceDisplayName,
|
||||||
loadPf2eBestiaryIndex,
|
loadPf2eBestiaryIndex,
|
||||||
@@ -30,7 +35,15 @@ describe("loadPf2eBestiaryIndex", () => {
|
|||||||
|
|
||||||
it("contains a substantial number of creatures", () => {
|
it("contains a substantial number of creatures", () => {
|
||||||
const index = loadPf2eBestiaryIndex();
|
const index = loadPf2eBestiaryIndex();
|
||||||
expect(index.creatures.length).toBeGreaterThan(2000);
|
expect(index.creatures.length).toBeGreaterThan(2500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creatures have size and type populated", () => {
|
||||||
|
const index = loadPf2eBestiaryIndex();
|
||||||
|
const withSize = index.creatures.filter((c) => c.size !== "");
|
||||||
|
const withType = index.creatures.filter((c) => c.type !== "");
|
||||||
|
expect(withSize.length).toBeGreaterThan(index.creatures.length * 0.9);
|
||||||
|
expect(withType.length).toBeGreaterThan(index.creatures.length * 0.8);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns the same cached instance on subsequent calls", () => {
|
it("returns the same cached instance on subsequent calls", () => {
|
||||||
@@ -49,20 +62,42 @@ describe("getAllPf2eSourceCodes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("getDefaultPf2eFetchUrl", () => {
|
describe("getDefaultPf2eFetchUrl", () => {
|
||||||
it("returns Pf2eTools GitHub URL with lowercase source code", () => {
|
it("returns Foundry VTT PF2e base URL", () => {
|
||||||
const url = getDefaultPf2eFetchUrl("B1");
|
const url = getDefaultPf2eFetchUrl("pathfinder-monster-core");
|
||||||
expect(url).toBe(
|
expect(url).toBe(
|
||||||
"https://raw.githubusercontent.com/Pf2eToolsOrg/Pf2eTools/dev/data/bestiary/creatures-b1.json",
|
"https://raw.githubusercontent.com/foundryvtt/pf2e/v13-dev/packs/pf2e/",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("normalizes custom base URL with trailing slash", () => {
|
||||||
|
const url = getDefaultPf2eFetchUrl(
|
||||||
|
"pathfinder-monster-core",
|
||||||
|
"https://example.com/pf2e",
|
||||||
|
);
|
||||||
|
expect(url).toBe("https://example.com/pf2e/");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getPf2eSourceDisplayName", () => {
|
describe("getPf2eSourceDisplayName", () => {
|
||||||
it("returns display name for a known source", () => {
|
it("returns display name for a known source", () => {
|
||||||
expect(getPf2eSourceDisplayName("B1")).toBe("Bestiary");
|
const name = getPf2eSourceDisplayName("pathfinder-monster-core");
|
||||||
|
expect(name).toBe("Monster Core");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to source code for unknown source", () => {
|
it("falls back to source code for unknown source", () => {
|
||||||
expect(getPf2eSourceDisplayName("UNKNOWN")).toBe("UNKNOWN");
|
expect(getPf2eSourceDisplayName("UNKNOWN")).toBe("UNKNOWN");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getCreaturePathsForSource", () => {
|
||||||
|
it("returns file paths for a known source", () => {
|
||||||
|
const paths = getCreaturePathsForSource("pathfinder-monster-core");
|
||||||
|
expect(paths.length).toBeGreaterThan(100);
|
||||||
|
expect(paths[0]).toMatch(PACK_DIR_PREFIX);
|
||||||
|
expect(paths[0]).toMatch(JSON_EXTENSION);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array for unknown source", () => {
|
||||||
|
expect(getCreaturePathsForSource("nonexistent")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { stripFoundryTags } from "../strip-foundry-tags.js";
|
||||||
|
|
||||||
|
describe("stripFoundryTags", () => {
|
||||||
|
describe("@Damage tags", () => {
|
||||||
|
it("formats damage with type bracket", () => {
|
||||||
|
expect(stripFoundryTags("@Damage[3d6+10[fire]]")).toBe("3d6+10 fire");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers display text when present", () => {
|
||||||
|
expect(
|
||||||
|
stripFoundryTags("@Damage[3d6+10[fire]]{3d6+10 fire damage}"),
|
||||||
|
).toBe("3d6+10 fire damage");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multiple damage types", () => {
|
||||||
|
expect(
|
||||||
|
stripFoundryTags("@Damage[2d8+5[slashing]] plus @Damage[1d6[fire]]"),
|
||||||
|
).toBe("2d8+5 slashing plus 1d6 fire");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("@Check tags", () => {
|
||||||
|
it("formats basic saving throw", () => {
|
||||||
|
expect(stripFoundryTags("@Check[reflex|dc:33|basic]")).toBe(
|
||||||
|
"DC 33 basic Reflex",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats non-basic check", () => {
|
||||||
|
expect(stripFoundryTags("@Check[athletics|dc:25]")).toBe(
|
||||||
|
"DC 25 Athletics",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats check without DC", () => {
|
||||||
|
expect(stripFoundryTags("@Check[fortitude]")).toBe("Fortitude");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("@UUID tags", () => {
|
||||||
|
it("extracts display text", () => {
|
||||||
|
expect(
|
||||||
|
stripFoundryTags(
|
||||||
|
"@UUID[Compendium.pf2e.conditionitems.Item.Grabbed]{Grabbed}",
|
||||||
|
),
|
||||||
|
).toBe("Grabbed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts last segment when no display text", () => {
|
||||||
|
expect(
|
||||||
|
stripFoundryTags("@UUID[Compendium.pf2e.conditionitems.Item.Grabbed]"),
|
||||||
|
).toBe("Grabbed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("@Template tags", () => {
|
||||||
|
it("formats cone template", () => {
|
||||||
|
expect(stripFoundryTags("@Template[cone|distance:40]")).toBe(
|
||||||
|
"40-foot cone",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats emanation template", () => {
|
||||||
|
expect(stripFoundryTags("@Template[emanation|distance:10]")).toBe(
|
||||||
|
"10-foot emanation",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers display text", () => {
|
||||||
|
expect(
|
||||||
|
stripFoundryTags("@Template[cone|distance:40]{40-foot cone}"),
|
||||||
|
).toBe("40-foot cone");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unknown @Tag patterns", () => {
|
||||||
|
it("uses display text for unknown tags", () => {
|
||||||
|
expect(stripFoundryTags("@Localize[some.key]{Some Text}")).toBe(
|
||||||
|
"Some Text",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips unknown tags without display text", () => {
|
||||||
|
expect(stripFoundryTags("@Localize[some.key]")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HTML stripping", () => {
|
||||||
|
it("strips paragraph tags", () => {
|
||||||
|
expect(stripFoundryTags("<p>text</p>")).toBe("text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts br to newline", () => {
|
||||||
|
expect(stripFoundryTags("line1<br />line2")).toBe("line1\nline2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts hr to newline", () => {
|
||||||
|
expect(stripFoundryTags("before<hr />after")).toBe("before\nafter");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves strong and em tags", () => {
|
||||||
|
expect(stripFoundryTags("<strong>bold</strong> <em>italic</em>")).toBe(
|
||||||
|
"<strong>bold</strong> <em>italic</em>",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves list tags", () => {
|
||||||
|
expect(stripFoundryTags("<ul><li>first</li><li>second</li></ul>")).toBe(
|
||||||
|
"<ul><li>first</li><li>second</li></ul>",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converts p-to-p transitions to newlines", () => {
|
||||||
|
expect(stripFoundryTags("<p>first</p><p>second</p>")).toBe(
|
||||||
|
"first\nsecond",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips action-glyph spans", () => {
|
||||||
|
expect(
|
||||||
|
stripFoundryTags('<span class="action-glyph">1</span> Strike'),
|
||||||
|
).toBe("Strike");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HTML entities", () => {
|
||||||
|
it("decodes &", () => {
|
||||||
|
expect(stripFoundryTags("fire & ice")).toBe("fire & ice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes < and >", () => {
|
||||||
|
expect(stripFoundryTags("<tag>")).toBe("<tag>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes "", () => {
|
||||||
|
expect(stripFoundryTags(""hello"")).toBe('"hello"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("whitespace handling", () => {
|
||||||
|
it("collapses multiple spaces", () => {
|
||||||
|
expect(stripFoundryTags("a b c")).toBe("a b c");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses multiple blank lines", () => {
|
||||||
|
expect(stripFoundryTags("a\n\n\nb")).toBe("a\nb");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims leading and trailing whitespace", () => {
|
||||||
|
expect(stripFoundryTags(" hello ")).toBe("hello");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("combined/edge cases", () => {
|
||||||
|
it("handles enrichment tags inside HTML", () => {
|
||||||
|
expect(
|
||||||
|
stripFoundryTags(
|
||||||
|
"<p>Deal @Damage[2d6[fire]] damage, @Check[reflex|dc:20|basic] save.</p>",
|
||||||
|
),
|
||||||
|
).toBe("Deal 2d6 fire damage, DC 20 basic Reflex save.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty string", () => {
|
||||||
|
expect(stripFoundryTags("")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -138,12 +138,20 @@ describe("stripTags", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles nested tags gracefully", () => {
|
it("handles sibling tags in the same string", () => {
|
||||||
expect(
|
expect(
|
||||||
stripTags("The spell {@spell Fireball|XPHB} deals {@damage 8d6}."),
|
stripTags("The spell {@spell Fireball|XPHB} deals {@damage 8d6}."),
|
||||||
).toBe("The spell Fireball deals 8d6.");
|
).toBe("The spell Fireball deals 8d6.");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("handles nested tags (outer wrapping inner)", () => {
|
||||||
|
expect(
|
||||||
|
stripTags(
|
||||||
|
"{@b Arcane Innate Spells DC 24; 3rd {@spell fireball}, {@spell slow}}",
|
||||||
|
),
|
||||||
|
).toBe("Arcane Innate Spells DC 24; 3rd fireball, slow");
|
||||||
|
});
|
||||||
|
|
||||||
it("handles text with no tags", () => {
|
it("handles text with no tags", () => {
|
||||||
expect(stripTags("Just plain text.")).toBe("Just plain text.");
|
expect(stripTags("Just plain text.")).toBe("Just plain text.");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
DailySpells,
|
DailySpells,
|
||||||
LegendaryBlock,
|
LegendaryBlock,
|
||||||
SpellcastingBlock,
|
SpellcastingBlock,
|
||||||
|
SpellReference,
|
||||||
TraitBlock,
|
TraitBlock,
|
||||||
TraitListItem,
|
TraitListItem,
|
||||||
TraitSegment,
|
TraitSegment,
|
||||||
@@ -385,7 +386,7 @@ function normalizeSpellcasting(
|
|||||||
const block: {
|
const block: {
|
||||||
name: string;
|
name: string;
|
||||||
headerText: string;
|
headerText: string;
|
||||||
atWill?: string[];
|
atWill?: SpellReference[];
|
||||||
daily?: DailySpells[];
|
daily?: DailySpells[];
|
||||||
restLong?: DailySpells[];
|
restLong?: DailySpells[];
|
||||||
} = {
|
} = {
|
||||||
@@ -396,7 +397,7 @@ function normalizeSpellcasting(
|
|||||||
const hidden = new Set(sc.hidden ?? []);
|
const hidden = new Set(sc.hidden ?? []);
|
||||||
|
|
||||||
if (sc.will && !hidden.has("will")) {
|
if (sc.will && !hidden.has("will")) {
|
||||||
block.atWill = sc.will.map((s) => stripTags(s));
|
block.atWill = sc.will.map((s) => ({ name: stripTags(s) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sc.daily) {
|
if (sc.daily) {
|
||||||
@@ -418,7 +419,7 @@ function parseDailyMap(map: Record<string, string[]>): DailySpells[] {
|
|||||||
return {
|
return {
|
||||||
uses,
|
uses,
|
||||||
each,
|
each,
|
||||||
spells: spells.map((s) => stripTags(s)),
|
spells: spells.map((s) => ({ name: stripTags(s) })),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { type IDBPDatabase, openDB } from "idb";
|
|||||||
|
|
||||||
const DB_NAME = "initiative-bestiary";
|
const DB_NAME = "initiative-bestiary";
|
||||||
const STORE_NAME = "sources";
|
const STORE_NAME = "sources";
|
||||||
const DB_VERSION = 4;
|
// v8 (2026-04-10): Attack effects, ability frequency, perception details added to PF2e creatures
|
||||||
|
const DB_VERSION = 8;
|
||||||
|
|
||||||
interface CachedSourceInfo {
|
interface CachedSourceInfo {
|
||||||
readonly sourceCode: string;
|
readonly sourceCode: string;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,8 @@ interface CompactCreature {
|
|||||||
readonly pc: number;
|
readonly pc: number;
|
||||||
readonly sz: string;
|
readonly sz: string;
|
||||||
readonly tp: string;
|
readonly tp: string;
|
||||||
|
readonly f: string;
|
||||||
|
readonly li: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CompactIndex {
|
interface CompactIndex {
|
||||||
@@ -53,15 +55,30 @@ export function getAllPf2eSourceCodes(): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getDefaultPf2eFetchUrl(
|
export function getDefaultPf2eFetchUrl(
|
||||||
sourceCode: string,
|
_sourceCode: string,
|
||||||
baseUrl?: string,
|
baseUrl?: string,
|
||||||
): string {
|
): string {
|
||||||
const filename = `creatures-${sourceCode.toLowerCase()}.json`;
|
|
||||||
if (baseUrl !== undefined) {
|
if (baseUrl !== undefined) {
|
||||||
const normalized = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
||||||
return `${normalized}${filename}`;
|
|
||||||
}
|
}
|
||||||
return `https://raw.githubusercontent.com/Pf2eToolsOrg/Pf2eTools/dev/data/bestiary/${filename}`;
|
return "https://raw.githubusercontent.com/foundryvtt/pf2e/v13-dev/packs/pf2e/";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCreaturePathsForSource(sourceCode: string): string[] {
|
||||||
|
const compact = rawIndex as unknown as CompactIndex;
|
||||||
|
return compact.creatures.filter((c) => c.s === sourceCode).map((c) => c.f);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCreatureNamesByPaths(paths: string[]): Map<string, string> {
|
||||||
|
const compact = rawIndex as unknown as CompactIndex;
|
||||||
|
const pathSet = new Set(paths);
|
||||||
|
const result = new Map<string, string>();
|
||||||
|
for (const c of compact.creatures) {
|
||||||
|
if (pathSet.has(c.f)) {
|
||||||
|
result.set(c.f, c.n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPf2eSourceDisplayName(sourceCode: string): string {
|
export function getPf2eSourceDisplayName(sourceCode: string): string {
|
||||||
|
|||||||
@@ -56,4 +56,6 @@ export interface Pf2eBestiaryIndexPort {
|
|||||||
getAllSourceCodes(): string[];
|
getAllSourceCodes(): string[];
|
||||||
getDefaultFetchUrl(sourceCode: string, baseUrl?: string): string;
|
getDefaultFetchUrl(sourceCode: string, baseUrl?: string): string;
|
||||||
getSourceDisplayName(sourceCode: string): string;
|
getSourceDisplayName(sourceCode: string): string;
|
||||||
|
getCreaturePathsForSource(sourceCode: string): string[];
|
||||||
|
getCreatureNamesByPaths(paths: string[]): Map<string, string>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,5 +47,7 @@ export const productionAdapters: Adapters = {
|
|||||||
getAllSourceCodes: pf2eBestiaryIndex.getAllPf2eSourceCodes,
|
getAllSourceCodes: pf2eBestiaryIndex.getAllPf2eSourceCodes,
|
||||||
getDefaultFetchUrl: pf2eBestiaryIndex.getDefaultPf2eFetchUrl,
|
getDefaultFetchUrl: pf2eBestiaryIndex.getDefaultPf2eFetchUrl,
|
||||||
getSourceDisplayName: pf2eBestiaryIndex.getPf2eSourceDisplayName,
|
getSourceDisplayName: pf2eBestiaryIndex.getPf2eSourceDisplayName,
|
||||||
|
getCreaturePathsForSource: pf2eBestiaryIndex.getCreaturePathsForSource,
|
||||||
|
getCreatureNamesByPaths: pf2eBestiaryIndex.getCreatureNamesByPaths,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* Strips Foundry VTT HTML descriptions with enrichment syntax to plain
|
||||||
|
* readable text. Handles @Damage, @Check, @UUID, @Template and generic
|
||||||
|
* @Tag patterns as well as common HTML elements.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// -- Enrichment-param helpers --
|
||||||
|
|
||||||
|
function formatDamage(params: string): string {
|
||||||
|
// "3d6+10[fire]" → "3d6+10 fire"
|
||||||
|
// "d4[persistent,fire]" → "d4 persistent fire"
|
||||||
|
return params
|
||||||
|
.replaceAll(
|
||||||
|
/\[([^\]]*)\]/g,
|
||||||
|
(_, type: string) => ` ${type.replaceAll(",", " ")}`,
|
||||||
|
)
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCheck(params: string): string {
|
||||||
|
// "reflex|dc:33|basic" → "DC 33 basic Reflex"
|
||||||
|
const parts = params.split("|");
|
||||||
|
const type = parts[0] ?? "";
|
||||||
|
let dc = "";
|
||||||
|
let basic = false;
|
||||||
|
for (const part of parts.slice(1)) {
|
||||||
|
if (part.startsWith("dc:")) {
|
||||||
|
dc = part.slice(3);
|
||||||
|
} else if (part === "basic") {
|
||||||
|
basic = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const label = type.charAt(0).toUpperCase() + type.slice(1);
|
||||||
|
const dcStr = dc ? `DC ${dc} ` : "";
|
||||||
|
const basicStr = basic ? "basic " : "";
|
||||||
|
return `${dcStr}${basicStr}${label}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTemplate(params: string): string {
|
||||||
|
// "cone|distance:40" → "40-foot cone"
|
||||||
|
const parts = params.split("|");
|
||||||
|
const shape = parts[0] ?? "";
|
||||||
|
let distance = "";
|
||||||
|
for (const part of parts.slice(1)) {
|
||||||
|
if (part.startsWith("distance:")) {
|
||||||
|
distance = part.slice(9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return distance ? `${distance}-foot ${shape}` : shape;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripFoundryTags(html: string): string {
|
||||||
|
if (typeof html !== "string") return String(html);
|
||||||
|
let result = html;
|
||||||
|
|
||||||
|
// Strip Foundry enrichment tags (with optional display text)
|
||||||
|
// @Tag[params]{display} → display (prefer display text)
|
||||||
|
// @Tag[params] → extracted content
|
||||||
|
|
||||||
|
// @Damage has nested brackets: @Damage[3d6+10[fire]]
|
||||||
|
result = result.replaceAll(
|
||||||
|
/@Damage\[((?:[^[\]]|\[[^\]]*\])*)\](?:\{([^}]+)\})?/g,
|
||||||
|
(_, params: string, display: string | undefined) =>
|
||||||
|
display ?? formatDamage(params),
|
||||||
|
);
|
||||||
|
result = result.replaceAll(
|
||||||
|
/@Check\[([^\]]+)\](?:\{([^}]*)\})?/g,
|
||||||
|
(_, params: string) => formatCheck(params),
|
||||||
|
);
|
||||||
|
result = result.replaceAll(
|
||||||
|
/@UUID\[[^\]]+?([^./\]]+)\](?:\{([^}]+)\})?/g,
|
||||||
|
(_, lastSegment: string, display: string | undefined) =>
|
||||||
|
display ?? lastSegment,
|
||||||
|
);
|
||||||
|
result = result.replaceAll(
|
||||||
|
/@Template\[([^\]]+)\](?:\{([^}]+)\})?/g,
|
||||||
|
(_, params: string, display: string | undefined) =>
|
||||||
|
display ?? formatTemplate(params),
|
||||||
|
);
|
||||||
|
// Catch-all for unknown @Tag patterns
|
||||||
|
result = result.replaceAll(
|
||||||
|
/@\w+\[[^\]]*\](?:\{([^}]+)\})?/g,
|
||||||
|
(_, display: string | undefined) => display ?? "",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Strip action-glyph spans (content is a number the renderer handles)
|
||||||
|
result = result.replaceAll(/<span class="action-glyph">[^<]*<\/span>/gi, "");
|
||||||
|
|
||||||
|
// Strip HTML tags (preserve <strong> for UI rendering)
|
||||||
|
result = result.replaceAll(/<br\s*\/?>/gi, "\n");
|
||||||
|
result = result.replaceAll(/<hr\s*\/?>/gi, "\n");
|
||||||
|
result = result.replaceAll(/<\/p>\s*<p[^>]*>/gi, "\n");
|
||||||
|
result = result.replaceAll(/<(?!\/?(?:strong|em|ul|ol|li)\b)[^>]+>/g, "");
|
||||||
|
|
||||||
|
// Decode common HTML entities
|
||||||
|
result = result.replaceAll("&", "&");
|
||||||
|
result = result.replaceAll("<", "<");
|
||||||
|
result = result.replaceAll(">", ">");
|
||||||
|
result = result.replaceAll(""", '"');
|
||||||
|
|
||||||
|
// Collapse whitespace around list tags so they don't create extra
|
||||||
|
// line breaks when rendered with whitespace-pre-line
|
||||||
|
result = result.replaceAll(/\s*(<\/?(?:ul|ol)>)\s*/g, "$1");
|
||||||
|
result = result.replaceAll(/\s*(<\/?li>)\s*/g, "$1");
|
||||||
|
|
||||||
|
// Collapse whitespace
|
||||||
|
result = result.replaceAll(/[ \t]+/g, " ");
|
||||||
|
result = result.replaceAll(/\n\s*\n/g, "\n");
|
||||||
|
return result.trim();
|
||||||
|
}
|
||||||
@@ -98,20 +98,26 @@ export function stripTags(text: string): string {
|
|||||||
// Generic tags: {@tag Display|Source|...} → Display (first segment before |)
|
// Generic tags: {@tag Display|Source|...} → Display (first segment before |)
|
||||||
// Covers: spell, condition, damage, dice, variantrule, action, skill,
|
// Covers: spell, condition, damage, dice, variantrule, action, skill,
|
||||||
// creature, hazard, status, plus any unknown tags
|
// creature, hazard, status, plus any unknown tags
|
||||||
|
// Run in a loop to resolve nested tags (e.g. {@b ... {@spell fireball} ...})
|
||||||
|
// from innermost to outermost.
|
||||||
|
const tagPattern = /\{@(\w+)\s+([^}]+)\}/g;
|
||||||
|
while (tagPattern.test(result)) {
|
||||||
result = result.replaceAll(
|
result = result.replaceAll(
|
||||||
/\{@(\w+)\s+([^}]+)\}/g,
|
tagPattern,
|
||||||
(_, tag: string, content: string) => {
|
(_, tag: string, content: string) => {
|
||||||
// For tags with Display|Source format, extract first segment
|
|
||||||
const segments = content.split("|");
|
const segments = content.split("|");
|
||||||
|
|
||||||
// Some tags have a third segment as display text: {@variantrule Name|Source|Display}
|
if (
|
||||||
if ((tag === "variantrule" || tag === "action") && segments.length >= 3) {
|
(tag === "variantrule" || tag === "action") &&
|
||||||
|
segments.length >= 3
|
||||||
|
) {
|
||||||
return segments[2];
|
return segments[2];
|
||||||
}
|
}
|
||||||
|
|
||||||
return segments[0];
|
return segments[0];
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,43 +5,73 @@ import {
|
|||||||
type ConditionEntry,
|
type ConditionEntry,
|
||||||
type ConditionId,
|
type ConditionId,
|
||||||
getConditionsForEdition,
|
getConditionsForEdition,
|
||||||
|
type PersistentDamageEntry,
|
||||||
|
type PersistentDamageType,
|
||||||
|
type RulesEdition,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { createRef, type RefObject } from "react";
|
import { createRef, type ReactNode, type RefObject, useEffect } from "react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { RulesEditionProvider } from "../../contexts/index.js";
|
import { RulesEditionProvider } from "../../contexts/index.js";
|
||||||
|
import { useRulesEditionContext } from "../../contexts/rules-edition-context.js";
|
||||||
import { ConditionPicker } from "../condition-picker";
|
import { ConditionPicker } from "../condition-picker";
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function EditionSetter({
|
||||||
|
edition,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
edition: RulesEdition;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const { setEdition } = useRulesEditionContext();
|
||||||
|
useEffect(() => {
|
||||||
|
setEdition(edition);
|
||||||
|
}, [edition, setEdition]);
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
function renderPicker(
|
function renderPicker(
|
||||||
overrides: Partial<{
|
overrides: Partial<{
|
||||||
activeConditions: readonly ConditionEntry[];
|
activeConditions: readonly ConditionEntry[];
|
||||||
|
activePersistentDamage: readonly PersistentDamageEntry[];
|
||||||
onToggle: (conditionId: ConditionId) => void;
|
onToggle: (conditionId: ConditionId) => void;
|
||||||
onSetValue: (conditionId: ConditionId, value: number) => void;
|
onSetValue: (conditionId: ConditionId, value: number) => void;
|
||||||
|
onAddPersistentDamage: (
|
||||||
|
damageType: PersistentDamageType,
|
||||||
|
formula: string,
|
||||||
|
) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
edition: RulesEdition;
|
||||||
}> = {},
|
}> = {},
|
||||||
) {
|
) {
|
||||||
const onToggle = overrides.onToggle ?? vi.fn();
|
const onToggle = overrides.onToggle ?? vi.fn();
|
||||||
const onSetValue = overrides.onSetValue ?? vi.fn();
|
const onSetValue = overrides.onSetValue ?? vi.fn();
|
||||||
|
const onAddPersistentDamage = overrides.onAddPersistentDamage ?? vi.fn();
|
||||||
const onClose = overrides.onClose ?? vi.fn();
|
const onClose = overrides.onClose ?? vi.fn();
|
||||||
|
const edition = overrides.edition ?? "5.5e";
|
||||||
const anchorRef = createRef<HTMLElement>() as RefObject<HTMLElement>;
|
const anchorRef = createRef<HTMLElement>() as RefObject<HTMLElement>;
|
||||||
const anchor = document.createElement("div");
|
const anchor = document.createElement("div");
|
||||||
document.body.appendChild(anchor);
|
document.body.appendChild(anchor);
|
||||||
(anchorRef as { current: HTMLElement }).current = anchor;
|
(anchorRef as { current: HTMLElement }).current = anchor;
|
||||||
const result = render(
|
const result = render(
|
||||||
<RulesEditionProvider>
|
<RulesEditionProvider>
|
||||||
|
<EditionSetter edition={edition}>
|
||||||
<ConditionPicker
|
<ConditionPicker
|
||||||
anchorRef={anchorRef}
|
anchorRef={anchorRef}
|
||||||
activeConditions={overrides.activeConditions ?? []}
|
activeConditions={overrides.activeConditions ?? []}
|
||||||
|
activePersistentDamage={overrides.activePersistentDamage}
|
||||||
onToggle={onToggle}
|
onToggle={onToggle}
|
||||||
onSetValue={onSetValue}
|
onSetValue={onSetValue}
|
||||||
|
onAddPersistentDamage={onAddPersistentDamage}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
/>
|
/>
|
||||||
|
</EditionSetter>
|
||||||
</RulesEditionProvider>,
|
</RulesEditionProvider>,
|
||||||
);
|
);
|
||||||
return { ...result, onToggle, onSetValue, onClose };
|
return { ...result, onToggle, onSetValue, onAddPersistentDamage, onClose };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("ConditionPicker", () => {
|
describe("ConditionPicker", () => {
|
||||||
@@ -77,4 +107,111 @@ describe("ConditionPicker", () => {
|
|||||||
const label = screen.getByText("Charmed");
|
const label = screen.getByText("Charmed");
|
||||||
expect(label.className).toContain("text-foreground");
|
expect(label.className).toContain("text-foreground");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Valued conditions (PF2e)", () => {
|
||||||
|
it("clicking a valued condition opens the counter editor", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPicker({ edition: "pf2e" });
|
||||||
|
await user.click(screen.getByText("Frightened"));
|
||||||
|
// Counter editor shows value badge and [-]/[+] buttons
|
||||||
|
expect(screen.getByText("1")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen
|
||||||
|
.getAllByRole("button")
|
||||||
|
.some((b) => b.querySelector(".lucide-minus")),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increment and decrement adjust the counter value", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPicker({ edition: "pf2e" });
|
||||||
|
await user.click(screen.getByText("Frightened"));
|
||||||
|
// Value starts at 1; click [+] to go to 2
|
||||||
|
const plusButtons = screen.getAllByRole("button");
|
||||||
|
const plusButton = plusButtons.find((b) =>
|
||||||
|
b.querySelector(".lucide-plus"),
|
||||||
|
);
|
||||||
|
if (!plusButton) throw new Error("Plus button not found");
|
||||||
|
await user.click(plusButton);
|
||||||
|
expect(screen.getByText("2")).toBeInTheDocument();
|
||||||
|
// Click [-] to go back to 1
|
||||||
|
const minusButton = plusButtons.find((b) =>
|
||||||
|
b.querySelector(".lucide-minus"),
|
||||||
|
);
|
||||||
|
if (!minusButton) throw new Error("Minus button not found");
|
||||||
|
await user.click(minusButton);
|
||||||
|
expect(screen.getByText("1")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("confirm button calls onSetValue with condition and value", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onSetValue } = renderPicker({ edition: "pf2e" });
|
||||||
|
await user.click(screen.getByText("Frightened"));
|
||||||
|
// Increment to 2, then confirm
|
||||||
|
const plusButton = screen
|
||||||
|
.getAllByRole("button")
|
||||||
|
.find((b) => b.querySelector(".lucide-plus"));
|
||||||
|
if (!plusButton) throw new Error("Plus button not found");
|
||||||
|
await user.click(plusButton);
|
||||||
|
const checkButton = screen
|
||||||
|
.getAllByRole("button")
|
||||||
|
.find((b) => b.querySelector(".lucide-check"));
|
||||||
|
if (!checkButton) throw new Error("Check button not found");
|
||||||
|
await user.click(checkButton);
|
||||||
|
expect(onSetValue).toHaveBeenCalledWith("frightened", 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows active value badge for existing valued condition", () => {
|
||||||
|
renderPicker({
|
||||||
|
edition: "pf2e",
|
||||||
|
activeConditions: [{ id: "frightened", value: 3 }],
|
||||||
|
});
|
||||||
|
expect(screen.getByText("3")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pre-fills counter with existing value when editing", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPicker({
|
||||||
|
edition: "pf2e",
|
||||||
|
activeConditions: [{ id: "frightened", value: 3 }],
|
||||||
|
});
|
||||||
|
await user.click(screen.getByText("Frightened"));
|
||||||
|
expect(screen.getByText("3")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables increment at maxValue", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPicker({
|
||||||
|
edition: "pf2e",
|
||||||
|
activeConditions: [{ id: "doomed", value: 3 }],
|
||||||
|
});
|
||||||
|
// Doomed has maxValue: 3, click to edit
|
||||||
|
await user.click(screen.getByText("Doomed"));
|
||||||
|
const plusButton = screen
|
||||||
|
.getAllByRole("button")
|
||||||
|
.find((b) => b.querySelector(".lucide-plus"));
|
||||||
|
expect(plusButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Persistent Damage (PF2e)", () => {
|
||||||
|
it("shows 'Persistent Damage' entry when edition is pf2e", () => {
|
||||||
|
renderPicker({ edition: "pf2e" });
|
||||||
|
expect(screen.getByText("Persistent Damage")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking 'Persistent Damage' opens sub-picker", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPicker({ edition: "pf2e" });
|
||||||
|
await user.click(screen.getByText("Persistent Damage"));
|
||||||
|
expect(screen.getByPlaceholderText("2d6")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Persistent Damage (D&D)", () => {
|
||||||
|
it("hides 'Persistent Damage' entry when edition is D&D", () => {
|
||||||
|
renderPicker({ edition: "5.5e" });
|
||||||
|
expect(screen.queryByText("Persistent Damage")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import type { EquipmentItem } from "@initiative/domain";
|
||||||
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { EquipmentDetailPopover } from "../equipment-detail-popover.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const POISON: EquipmentItem = {
|
||||||
|
name: "Giant Wasp Venom",
|
||||||
|
level: 7,
|
||||||
|
category: "poison",
|
||||||
|
traits: ["consumable", "poison", "injury"],
|
||||||
|
description: "A deadly poison extracted from giant wasps.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SCROLL: EquipmentItem = {
|
||||||
|
name: "Scroll of Teleport",
|
||||||
|
level: 11,
|
||||||
|
category: "scroll",
|
||||||
|
traits: ["consumable", "magical", "scroll"],
|
||||||
|
description: "A scroll containing Teleport.",
|
||||||
|
spellName: "Teleport",
|
||||||
|
spellRank: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ANCHOR: DOMRect = new DOMRect(100, 100, 50, 20);
|
||||||
|
const SCROLL_SPELL_REGEX = /Teleport \(Rank 6\)/;
|
||||||
|
const DIALOG_LABEL_REGEX = /Equipment details: Giant Wasp Venom/;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn().mockImplementation(() => ({
|
||||||
|
matches: true,
|
||||||
|
media: "(min-width: 1024px)",
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("EquipmentDetailPopover", () => {
|
||||||
|
it("renders item name, level, traits, and description", () => {
|
||||||
|
render(
|
||||||
|
<EquipmentDetailPopover
|
||||||
|
item={POISON}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Giant Wasp Venom")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("7")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("consumable")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("poison")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("injury")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("A deadly poison extracted from giant wasps."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders scroll/wand spell info", () => {
|
||||||
|
render(
|
||||||
|
<EquipmentDetailPopover
|
||||||
|
item={SCROLL}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(SCROLL_SPELL_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onClose when Escape is pressed", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<EquipmentDetailPopover
|
||||||
|
item={POISON}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the dialog role with the item name as label", () => {
|
||||||
|
render(
|
||||||
|
<EquipmentDetailPopover
|
||||||
|
item={POISON}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("dialog", {
|
||||||
|
name: DIALOG_LABEL_REGEX,
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { PersistentDamagePicker } from "../persistent-damage-picker.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function renderPicker(
|
||||||
|
overrides: Partial<{
|
||||||
|
activeEntries: { type: string; formula: string }[];
|
||||||
|
onAdd: (damageType: string, formula: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}> = {},
|
||||||
|
) {
|
||||||
|
const onAdd = overrides.onAdd ?? vi.fn();
|
||||||
|
const onClose = overrides.onClose ?? vi.fn();
|
||||||
|
const result = render(
|
||||||
|
<PersistentDamagePicker
|
||||||
|
activeEntries={
|
||||||
|
(overrides.activeEntries as Parameters<
|
||||||
|
typeof PersistentDamagePicker
|
||||||
|
>[0]["activeEntries"]) ?? undefined
|
||||||
|
}
|
||||||
|
onAdd={onAdd as Parameters<typeof PersistentDamagePicker>[0]["onAdd"]}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
return { ...result, onAdd, onClose };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PersistentDamagePicker", () => {
|
||||||
|
it("renders damage type dropdown and formula input", () => {
|
||||||
|
renderPicker();
|
||||||
|
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText("2d6")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("confirm button is disabled when formula is empty", () => {
|
||||||
|
renderPicker();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Add persistent damage" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submitting calls onAdd with selected type and formula", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onAdd } = renderPicker();
|
||||||
|
await user.type(screen.getByPlaceholderText("2d6"), "3d6");
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "Add persistent damage" }),
|
||||||
|
);
|
||||||
|
expect(onAdd).toHaveBeenCalledWith("fire", "3d6");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Enter in formula input confirms", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onAdd } = renderPicker();
|
||||||
|
await user.type(screen.getByPlaceholderText("2d6"), "2d6{Enter}");
|
||||||
|
expect(onAdd).toHaveBeenCalledWith("fire", "2d6");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pre-fills formula for existing active entry", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPicker({
|
||||||
|
activeEntries: [{ type: "fire", formula: "2d6" }],
|
||||||
|
});
|
||||||
|
expect(screen.getByPlaceholderText("2d6")).toHaveValue("2d6");
|
||||||
|
|
||||||
|
// Change type to one without active entry
|
||||||
|
await user.selectOptions(screen.getByRole("combobox"), "bleed");
|
||||||
|
expect(screen.getByPlaceholderText("2d6")).toHaveValue("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
PersistentDamageEntry,
|
||||||
|
PersistentDamageType,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { PersistentDamageTags } from "../persistent-damage-tags.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function renderTags(
|
||||||
|
entries: readonly PersistentDamageEntry[] | undefined,
|
||||||
|
onRemove = vi.fn(),
|
||||||
|
) {
|
||||||
|
const result = render(
|
||||||
|
<PersistentDamageTags entries={entries} onRemove={onRemove} />,
|
||||||
|
);
|
||||||
|
return { ...result, onRemove };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PersistentDamageTags", () => {
|
||||||
|
it("renders nothing when entries undefined", () => {
|
||||||
|
const { container } = renderTags(undefined);
|
||||||
|
expect(container.innerHTML).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing when entries is empty array", () => {
|
||||||
|
const { container } = renderTags([]);
|
||||||
|
expect(container.innerHTML).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders tag per entry with icon and formula text", () => {
|
||||||
|
renderTags([
|
||||||
|
{ type: "fire", formula: "2d6" },
|
||||||
|
{ type: "bleed", formula: "1d4" },
|
||||||
|
]);
|
||||||
|
expect(screen.getByText("2d6")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("1d4")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("click calls onRemove with correct damage type", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { onRemove } = renderTags([{ type: "fire", formula: "2d6" }]);
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: "Remove persistent Fire damage",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(onRemove).toHaveBeenCalledWith(
|
||||||
|
"fire" satisfies PersistentDamageType,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tooltip shows full description", () => {
|
||||||
|
renderTags([{ type: "fire", formula: "2d6" }]);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: "Remove persistent Fire damage",
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,497 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import type { Pf2eCreature } from "@initiative/domain";
|
||||||
|
import { creatureId } from "@initiative/domain";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { Pf2eStatBlock } from "../pf2e-stat-block.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const USES_PER_DAY_REGEX = /×3/;
|
||||||
|
const HEAL_DESCRIPTION_REGEX = /channel positive energy/;
|
||||||
|
const PERCEPTION_SENSES_REGEX = /\+2.*Darkvision/;
|
||||||
|
const SKILLS_REGEX = /Acrobatics \+5.*Stealth \+5/;
|
||||||
|
const SAVE_CONDITIONAL_REGEX = /\+12.*\+1 status to all saves vs\. magic/;
|
||||||
|
const SAVE_CONDITIONAL_ABSENT_REGEX = /status to all saves/;
|
||||||
|
const HP_DETAILS_REGEX = /115.*regeneration 20/;
|
||||||
|
const REGEN_REGEX = /regeneration/;
|
||||||
|
const ATTACK_NAME_REGEX = /Dogslicer/;
|
||||||
|
const ATTACK_DAMAGE_REGEX = /1d6 slashing/;
|
||||||
|
const SPELLCASTING_ENTRY_REGEX = /Divine Innate Spells\./;
|
||||||
|
const ABILITY_MID_NAME_REGEX = /Goblin Scuttle/;
|
||||||
|
const ABILITY_MID_DESC_REGEX = /The goblin Steps\./;
|
||||||
|
const CANTRIPS_REGEX = /Cantrips:/;
|
||||||
|
const AC_REGEX = /16/;
|
||||||
|
const RK_DC_13_REGEX = /DC 13/;
|
||||||
|
const RK_DC_15_REGEX = /DC 15/;
|
||||||
|
const RK_DC_25_REGEX = /DC 25/;
|
||||||
|
const RK_HUMANOID_SOCIETY_REGEX = /Humanoid \(Society\)/;
|
||||||
|
const RK_UNDEAD_RELIGION_REGEX = /Undead \(Religion\)/;
|
||||||
|
const RK_BEAST_SKILLS_REGEX = /Beast \(Arcana\/Nature\)/;
|
||||||
|
const SCROLL_NAME_REGEX = /Scroll of Teleport/;
|
||||||
|
|
||||||
|
const GOBLIN_WARRIOR: Pf2eCreature = {
|
||||||
|
system: "pf2e",
|
||||||
|
id: creatureId("pathfinder-monster-core:goblin-warrior"),
|
||||||
|
name: "Goblin Warrior",
|
||||||
|
source: "pathfinder-monster-core",
|
||||||
|
sourceDisplayName: "Monster Core",
|
||||||
|
level: -1,
|
||||||
|
traits: ["small", "goblin", "humanoid"],
|
||||||
|
perception: 2,
|
||||||
|
senses: "Darkvision",
|
||||||
|
languages: "Common, Goblin",
|
||||||
|
skills: "Acrobatics +5, Athletics +2, Nature +1, Stealth +5",
|
||||||
|
abilityMods: { str: 0, dex: 3, con: 1, int: 0, wis: -1, cha: 1 },
|
||||||
|
ac: 16,
|
||||||
|
saveFort: 5,
|
||||||
|
saveRef: 7,
|
||||||
|
saveWill: 3,
|
||||||
|
hp: 6,
|
||||||
|
speed: "25 feet",
|
||||||
|
attacks: [
|
||||||
|
{
|
||||||
|
name: "Dogslicer",
|
||||||
|
activity: { number: 1, unit: "action" },
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
value: "+7 (agile, backstabber, finesse), 1d6 slashing",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
abilitiesMid: [
|
||||||
|
{
|
||||||
|
name: "Goblin Scuttle",
|
||||||
|
activity: { number: 1, unit: "reaction" },
|
||||||
|
segments: [{ type: "text", value: "The goblin Steps." }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const NAUNET: Pf2eCreature = {
|
||||||
|
system: "pf2e",
|
||||||
|
id: creatureId("pathfinder-monster-core-2:naunet"),
|
||||||
|
name: "Naunet",
|
||||||
|
source: "pathfinder-monster-core-2",
|
||||||
|
sourceDisplayName: "Monster Core 2",
|
||||||
|
level: 7,
|
||||||
|
traits: ["large", "monitor", "protean"],
|
||||||
|
perception: 14,
|
||||||
|
senses: "Darkvision",
|
||||||
|
languages: "Chthonian, Empyrean, Protean",
|
||||||
|
skills:
|
||||||
|
"Acrobatics +14, Athletics +16, Intimidation +16, Stealth +14, Survival +12",
|
||||||
|
abilityMods: { str: 5, dex: 3, con: 5, int: 0, wis: 3, cha: 3 },
|
||||||
|
ac: 24,
|
||||||
|
saveFort: 18,
|
||||||
|
saveRef: 14,
|
||||||
|
saveWill: 12,
|
||||||
|
saveConditional: "+1 status to all saves vs. magic",
|
||||||
|
hp: 120,
|
||||||
|
resistances: "Precision 5, Protean anatomy 10",
|
||||||
|
speed: "25 feet, Fly 30 feet, Swim 25 feet (unfettered movement)",
|
||||||
|
spellcasting: [
|
||||||
|
{
|
||||||
|
name: "Divine Innate Spells",
|
||||||
|
headerText: "DC 25, attack +17",
|
||||||
|
daily: [
|
||||||
|
{
|
||||||
|
uses: 4,
|
||||||
|
each: true,
|
||||||
|
spells: [{ name: "Unfettered Movement (Constant)" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
atWill: [{ name: "Detect Magic" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const TROLL: Pf2eCreature = {
|
||||||
|
system: "pf2e",
|
||||||
|
id: creatureId("pathfinder-monster-core:forest-troll"),
|
||||||
|
name: "Forest Troll",
|
||||||
|
source: "pathfinder-monster-core",
|
||||||
|
sourceDisplayName: "Monster Core",
|
||||||
|
level: 5,
|
||||||
|
traits: ["large", "giant", "troll"],
|
||||||
|
perception: 11,
|
||||||
|
senses: "Darkvision",
|
||||||
|
languages: "Jotun",
|
||||||
|
skills: "Athletics +12, Intimidation +12",
|
||||||
|
abilityMods: { str: 5, dex: 2, con: 6, int: -2, wis: 0, cha: -2 },
|
||||||
|
ac: 20,
|
||||||
|
saveFort: 17,
|
||||||
|
saveRef: 11,
|
||||||
|
saveWill: 7,
|
||||||
|
hp: 115,
|
||||||
|
hpDetails: "regeneration 20 (deactivated by acid or fire)",
|
||||||
|
weaknesses: "Fire 10",
|
||||||
|
speed: "30 feet",
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderStatBlock(creature: Pf2eCreature) {
|
||||||
|
return render(<Pf2eStatBlock creature={creature} />);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Pf2eStatBlock", () => {
|
||||||
|
describe("header", () => {
|
||||||
|
it("renders creature name and level", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Goblin Warrior" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Level -1")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders traits as tags", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("Small")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Humanoid")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders source display name", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("Monster Core")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("recall knowledge", () => {
|
||||||
|
it("renders Recall Knowledge line for a creature with a recognized type trait", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("Recall Knowledge")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(RK_DC_13_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(RK_HUMANOID_SOCIETY_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adjusts DC for uncommon rarity", () => {
|
||||||
|
const uncommonCreature: Pf2eCreature = {
|
||||||
|
...GOBLIN_WARRIOR,
|
||||||
|
traits: ["uncommon", "small", "humanoid"],
|
||||||
|
};
|
||||||
|
renderStatBlock(uncommonCreature);
|
||||||
|
expect(screen.getByText(RK_DC_15_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adjusts DC for rare rarity", () => {
|
||||||
|
const rareCreature: Pf2eCreature = {
|
||||||
|
...GOBLIN_WARRIOR,
|
||||||
|
level: 5,
|
||||||
|
traits: ["rare", "medium", "undead"],
|
||||||
|
};
|
||||||
|
renderStatBlock(rareCreature);
|
||||||
|
expect(screen.getByText(RK_DC_25_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(RK_UNDEAD_RELIGION_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows multiple skills for types with dual skill mapping", () => {
|
||||||
|
const beastCreature: Pf2eCreature = {
|
||||||
|
...GOBLIN_WARRIOR,
|
||||||
|
traits: ["small", "beast"],
|
||||||
|
};
|
||||||
|
renderStatBlock(beastCreature);
|
||||||
|
expect(screen.getByText(RK_BEAST_SKILLS_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits Recall Knowledge when no type trait is recognized", () => {
|
||||||
|
const noTypeCreature: Pf2eCreature = {
|
||||||
|
...GOBLIN_WARRIOR,
|
||||||
|
traits: ["small", "goblin"],
|
||||||
|
};
|
||||||
|
renderStatBlock(noTypeCreature);
|
||||||
|
expect(screen.queryByText("Recall Knowledge")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("perception and senses", () => {
|
||||||
|
it("renders perception modifier and senses", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("Perception")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(PERCEPTION_SENSES_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders languages", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("Languages")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Common, Goblin")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders skills", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("Skills")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(SKILLS_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ability modifiers", () => {
|
||||||
|
it("renders all six ability labels", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
for (const label of ["Str", "Dex", "Con", "Int", "Wis", "Cha"]) {
|
||||||
|
expect(screen.getByText(label)).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders positive and negative modifiers", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("+3")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("-1")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("defenses", () => {
|
||||||
|
it("renders AC and saves", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("AC")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(AC_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Fort")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Ref")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Will")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders HP", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("HP")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("6")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders saveConditional inline with saves", () => {
|
||||||
|
renderStatBlock(NAUNET);
|
||||||
|
expect(screen.getByText(SAVE_CONDITIONAL_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits saveConditional when absent", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(
|
||||||
|
screen.queryByText(SAVE_CONDITIONAL_ABSENT_REGEX),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders hpDetails in parentheses after HP", () => {
|
||||||
|
renderStatBlock(TROLL);
|
||||||
|
expect(screen.getByText(HP_DETAILS_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits hpDetails when absent", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.queryByText(REGEN_REGEX)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders resistances and weaknesses", () => {
|
||||||
|
renderStatBlock(NAUNET);
|
||||||
|
expect(screen.getByText("Resistances")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Precision 5, Protean anatomy 10"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("abilities", () => {
|
||||||
|
it("renders mid (defensive) abilities", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText(ABILITY_MID_NAME_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(ABILITY_MID_DESC_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("speed and attacks", () => {
|
||||||
|
it("renders speed", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText("Speed")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("25 feet")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders attacks", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.getByText(ATTACK_NAME_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(ATTACK_DAMAGE_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("spellcasting", () => {
|
||||||
|
it("renders spellcasting entry with header", () => {
|
||||||
|
renderStatBlock(NAUNET);
|
||||||
|
expect(screen.getByText(SPELLCASTING_ENTRY_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("DC 25, attack +17")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders ranked spells", () => {
|
||||||
|
renderStatBlock(NAUNET);
|
||||||
|
expect(screen.getByText("Rank 4:")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Unfettered Movement (Constant)"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders cantrips", () => {
|
||||||
|
renderStatBlock(NAUNET);
|
||||||
|
expect(screen.getByText("Cantrips:")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Detect Magic")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits spellcasting when absent", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(screen.queryByText(CANTRIPS_REGEX)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("equipment section", () => {
|
||||||
|
const CREATURE_WITH_EQUIPMENT: Pf2eCreature = {
|
||||||
|
...GOBLIN_WARRIOR,
|
||||||
|
id: creatureId("test:equipped"),
|
||||||
|
name: "Equipped NPC",
|
||||||
|
items: "longsword, leather armor",
|
||||||
|
equipment: [
|
||||||
|
{
|
||||||
|
name: "Giant Wasp Venom",
|
||||||
|
level: 7,
|
||||||
|
category: "poison",
|
||||||
|
traits: ["consumable", "poison"],
|
||||||
|
description: "A deadly poison extracted from giant wasps.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Scroll of Teleport",
|
||||||
|
level: 11,
|
||||||
|
category: "scroll",
|
||||||
|
traits: ["consumable", "magical", "scroll"],
|
||||||
|
description: "A scroll containing Teleport.",
|
||||||
|
spellName: "Teleport",
|
||||||
|
spellRank: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Plain Talisman",
|
||||||
|
level: 1,
|
||||||
|
traits: ["magical"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("renders Equipment section with item names", () => {
|
||||||
|
renderStatBlock(CREATURE_WITH_EQUIPMENT);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: "Equipment" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Giant Wasp Venom")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders scroll name as-is from Foundry data", () => {
|
||||||
|
renderStatBlock(CREATURE_WITH_EQUIPMENT);
|
||||||
|
expect(screen.getByText(SCROLL_NAME_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render Equipment section when creature has no equipment", () => {
|
||||||
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("heading", { name: "Equipment" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders equipment items with descriptions as clickable buttons", () => {
|
||||||
|
renderStatBlock(CREATURE_WITH_EQUIPMENT);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Giant Wasp Venom" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders equipment items without descriptions as plain text", () => {
|
||||||
|
renderStatBlock(CREATURE_WITH_EQUIPMENT);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Plain Talisman" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Plain Talisman")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders Items line with mundane item names", () => {
|
||||||
|
renderStatBlock(CREATURE_WITH_EQUIPMENT);
|
||||||
|
expect(screen.getByText("Items")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("longsword, leather armor")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clickable spells", () => {
|
||||||
|
const SPELLCASTER: Pf2eCreature = {
|
||||||
|
...NAUNET,
|
||||||
|
id: creatureId("test:spellcaster"),
|
||||||
|
name: "Spellcaster",
|
||||||
|
spellcasting: [
|
||||||
|
{
|
||||||
|
name: "Divine Innate Spells",
|
||||||
|
headerText: "DC 30, attack +20",
|
||||||
|
atWill: [{ name: "Detect Magic", rank: 1 }],
|
||||||
|
daily: [
|
||||||
|
{
|
||||||
|
uses: 4,
|
||||||
|
each: true,
|
||||||
|
spells: [
|
||||||
|
{
|
||||||
|
name: "Heal",
|
||||||
|
description: "You channel positive energy to heal.",
|
||||||
|
rank: 4,
|
||||||
|
usesPerDay: 3,
|
||||||
|
},
|
||||||
|
{ name: "Restoration", rank: 4 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn().mockImplementation(() => ({
|
||||||
|
matches: true,
|
||||||
|
media: "(min-width: 1024px)",
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a spell with a description as a clickable button", () => {
|
||||||
|
renderStatBlock(SPELLCASTER);
|
||||||
|
expect(screen.getByRole("button", { name: "Heal" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a spell without description as plain text (not a button)", () => {
|
||||||
|
renderStatBlock(SPELLCASTER);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Restoration" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Restoration")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders usesPerDay as plain text alongside the spell button", () => {
|
||||||
|
renderStatBlock(SPELLCASTER);
|
||||||
|
expect(screen.getByText(USES_PER_DAY_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the spell popover when a spell button is clicked", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderStatBlock(SPELLCASTER);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Heal" }));
|
||||||
|
expect(screen.getByText(HEAL_DESCRIPTION_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the popover when Escape is pressed", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderStatBlock(SPELLCASTER);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Heal" }));
|
||||||
|
expect(screen.getByText(HEAL_DESCRIPTION_REGEX)).toBeInTheDocument();
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
expect(
|
||||||
|
screen.queryByText(HEAL_DESCRIPTION_REGEX),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -65,7 +65,7 @@ describe("SourceFetchPrompt", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("Load calls fetchAndCacheSource and onSourceLoaded on success", async () => {
|
it("Load calls fetchAndCacheSource and onSourceLoaded on success", async () => {
|
||||||
mockFetchAndCacheSource.mockResolvedValueOnce(undefined);
|
mockFetchAndCacheSource.mockResolvedValueOnce({ skippedNames: [] });
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { onSourceLoaded } = renderPrompt();
|
const { onSourceLoaded } = renderPrompt();
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ beforeAll(() => {
|
|||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
function renderWithSources(sources: CachedSourceInfo[] = []) {
|
function renderWithSources(sources: CachedSourceInfo[] = []): void {
|
||||||
const adapters = createTestAdapters();
|
const adapters = createTestAdapters();
|
||||||
// Wire getCachedSources to return the provided sources initially,
|
// Wire getCachedSources to return the provided sources initially,
|
||||||
// then empty after clear operations
|
// then empty after clear operations
|
||||||
@@ -57,14 +57,14 @@ function renderWithSources(sources: CachedSourceInfo[] = []) {
|
|||||||
|
|
||||||
describe("SourceManager", () => {
|
describe("SourceManager", () => {
|
||||||
it("shows 'No cached sources' empty state when no sources", async () => {
|
it("shows 'No cached sources' empty state when no sources", async () => {
|
||||||
void renderWithSources([]);
|
renderWithSources([]);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("No cached sources")).toBeInTheDocument();
|
expect(screen.getByText("No cached sources")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists cached sources with display name and creature count", async () => {
|
it("lists cached sources with display name and creature count", async () => {
|
||||||
void renderWithSources([
|
renderWithSources([
|
||||||
{
|
{
|
||||||
sourceCode: "mm",
|
sourceCode: "mm",
|
||||||
displayName: "Monster Manual",
|
displayName: "Monster Manual",
|
||||||
@@ -88,7 +88,7 @@ describe("SourceManager", () => {
|
|||||||
|
|
||||||
it("Clear All button removes all sources", async () => {
|
it("Clear All button removes all sources", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
void renderWithSources([
|
renderWithSources([
|
||||||
{
|
{
|
||||||
sourceCode: "mm",
|
sourceCode: "mm",
|
||||||
displayName: "Monster Manual",
|
displayName: "Monster Manual",
|
||||||
@@ -110,7 +110,7 @@ describe("SourceManager", () => {
|
|||||||
|
|
||||||
it("individual source delete button removes that source", async () => {
|
it("individual source delete button removes that source", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
void renderWithSources([
|
renderWithSources([
|
||||||
{
|
{
|
||||||
sourceCode: "mm",
|
sourceCode: "mm",
|
||||||
displayName: "Monster Manual",
|
displayName: "Monster Manual",
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import type { SpellReference } from "@initiative/domain";
|
||||||
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { SpellDetailPopover } from "../spell-detail-popover.js";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const FIREBALL: SpellReference = {
|
||||||
|
name: "Fireball",
|
||||||
|
slug: "fireball",
|
||||||
|
rank: 3,
|
||||||
|
description: "A spark leaps from your fingertip to the target.",
|
||||||
|
traits: ["fire", "manipulate"],
|
||||||
|
traditions: ["arcane", "primal"],
|
||||||
|
range: "500 feet",
|
||||||
|
area: "20-foot burst",
|
||||||
|
defense: "basic Reflex",
|
||||||
|
actionCost: "2",
|
||||||
|
heightening: "Heightened (+1) The damage increases by 2d6.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ANCHOR: DOMRect = new DOMRect(100, 100, 50, 20);
|
||||||
|
|
||||||
|
const SPARK_LEAPS_REGEX = /spark leaps/;
|
||||||
|
const HEIGHTENED_REGEX = /Heightened.*2d6/;
|
||||||
|
const RANGE_REGEX = /500 feet/;
|
||||||
|
const AREA_REGEX = /20-foot burst/;
|
||||||
|
const DEFENSE_REGEX = /basic Reflex/;
|
||||||
|
const NO_DESCRIPTION_REGEX = /No description available/;
|
||||||
|
const DIALOG_LABEL_REGEX = /Spell details: Fireball/;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Force desktop variant in jsdom
|
||||||
|
Object.defineProperty(globalThis, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn().mockImplementation(() => ({
|
||||||
|
matches: true,
|
||||||
|
media: "(min-width: 1024px)",
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SpellDetailPopover", () => {
|
||||||
|
it("renders spell name, rank, traits, and description", () => {
|
||||||
|
render(
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={FIREBALL}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Fireball")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("3rd")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("fire")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("manipulate")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(SPARK_LEAPS_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders heightening rules when present", () => {
|
||||||
|
render(
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={FIREBALL}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(HEIGHTENED_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders range, area, and defense", () => {
|
||||||
|
render(
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={FIREBALL}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(RANGE_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(AREA_REGEX)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(DEFENSE_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onClose when Escape is pressed", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={FIREBALL}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={onClose}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows placeholder when description is missing", () => {
|
||||||
|
const spell: SpellReference = { name: "Mystery", rank: 1 };
|
||||||
|
render(
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={spell}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(NO_DESCRIPTION_REGEX)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the action cost as an icon when it is a numeric action count", () => {
|
||||||
|
render(
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={FIREBALL}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// Action cost "2" renders as an SVG ActivityIcon (portaled to body)
|
||||||
|
const dialog = screen.getByRole("dialog");
|
||||||
|
expect(dialog.querySelector("svg")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders non-numeric action cost as text", () => {
|
||||||
|
const spell: SpellReference = {
|
||||||
|
...FIREBALL,
|
||||||
|
actionCost: "1 minute",
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={spell}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("1 minute")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the dialog role with the spell name as label", () => {
|
||||||
|
render(
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={FIREBALL}
|
||||||
|
anchorRect={ANCHOR}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("dialog", { name: DIALOG_LABEL_REGEX }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -111,9 +111,15 @@ const DRAGON: Creature = {
|
|||||||
{
|
{
|
||||||
name: "Innate Spellcasting",
|
name: "Innate Spellcasting",
|
||||||
headerText: "The dragon's spellcasting ability is Charisma.",
|
headerText: "The dragon's spellcasting ability is Charisma.",
|
||||||
atWill: ["detect magic", "suggestion"],
|
atWill: [{ name: "detect magic" }, { name: "suggestion" }],
|
||||||
daily: [{ uses: 3, each: true, spells: ["fireball", "wall of fire"] }],
|
daily: [
|
||||||
restLong: [{ uses: 1, each: false, spells: ["wish"] }],
|
{
|
||||||
|
uses: 3,
|
||||||
|
each: true,
|
||||||
|
spells: [{ name: "fireball" }, { name: "wall of fire" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
restLong: [{ uses: 1, each: false, spells: [{ name: "wish" }] }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const DND_BASE_URL =
|
|||||||
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/";
|
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/";
|
||||||
|
|
||||||
const PF2E_BASE_URL =
|
const PF2E_BASE_URL =
|
||||||
"https://raw.githubusercontent.com/Pf2eToolsOrg/Pf2eTools/dev/data/bestiary/";
|
"https://raw.githubusercontent.com/foundryvtt/pf2e/v13-dev/packs/pf2e/";
|
||||||
|
|
||||||
export function BulkImportPrompt() {
|
export function BulkImportPrompt() {
|
||||||
const { bestiaryIndex, pf2eBestiaryIndex } = useAdapters();
|
const { bestiaryIndex, pf2eBestiaryIndex } = useAdapters();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
type ConditionEntry,
|
type ConditionEntry,
|
||||||
type CreatureId,
|
type CreatureId,
|
||||||
deriveHpStatus,
|
deriveHpStatus,
|
||||||
|
type PersistentDamageEntry,
|
||||||
type PlayerIcon,
|
type PlayerIcon,
|
||||||
type RollMode,
|
type RollMode,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
@@ -10,6 +11,7 @@ import { Brain, Pencil, X } from "lucide-react";
|
|||||||
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
|
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
|
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
|
||||||
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||||
import { useLongPress } from "../hooks/use-long-press.js";
|
import { useLongPress } from "../hooks/use-long-press.js";
|
||||||
import { cn } from "../lib/utils.js";
|
import { cn } from "../lib/utils.js";
|
||||||
@@ -18,6 +20,7 @@ import { ConditionPicker } from "./condition-picker.js";
|
|||||||
import { ConditionTags } from "./condition-tags.js";
|
import { ConditionTags } from "./condition-tags.js";
|
||||||
import { D20Icon } from "./d20-icon.js";
|
import { D20Icon } from "./d20-icon.js";
|
||||||
import { HpAdjustPopover } from "./hp-adjust-popover.js";
|
import { HpAdjustPopover } from "./hp-adjust-popover.js";
|
||||||
|
import { PersistentDamageTags } from "./persistent-damage-tags.js";
|
||||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
|
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
|
||||||
import { RollModeMenu } from "./roll-mode-menu.js";
|
import { RollModeMenu } from "./roll-mode-menu.js";
|
||||||
import { ConfirmButton } from "./ui/confirm-button.js";
|
import { ConfirmButton } from "./ui/confirm-button.js";
|
||||||
@@ -32,6 +35,7 @@ interface Combatant {
|
|||||||
readonly tempHp?: number;
|
readonly tempHp?: number;
|
||||||
readonly ac?: number;
|
readonly ac?: number;
|
||||||
readonly conditions?: readonly ConditionEntry[];
|
readonly conditions?: readonly ConditionEntry[];
|
||||||
|
readonly persistentDamage?: readonly PersistentDamageEntry[];
|
||||||
readonly isConcentrating?: boolean;
|
readonly isConcentrating?: boolean;
|
||||||
readonly color?: string;
|
readonly color?: string;
|
||||||
readonly icon?: string;
|
readonly icon?: string;
|
||||||
@@ -415,12 +419,14 @@ function InitiativeDisplay({
|
|||||||
function rowBorderClass(
|
function rowBorderClass(
|
||||||
isActive: boolean,
|
isActive: boolean,
|
||||||
isConcentrating: boolean | undefined,
|
isConcentrating: boolean | undefined,
|
||||||
|
isPf2e: boolean,
|
||||||
): string {
|
): string {
|
||||||
if (isActive && isConcentrating)
|
const showConcentration = isConcentrating && !isPf2e;
|
||||||
|
if (isActive && showConcentration)
|
||||||
return "border border-l-2 border-active-row-border border-l-purple-400 bg-active-row-bg card-glow";
|
return "border border-l-2 border-active-row-border border-l-purple-400 bg-active-row-bg card-glow";
|
||||||
if (isActive)
|
if (isActive)
|
||||||
return "border border-l-2 border-active-row-border bg-active-row-bg card-glow";
|
return "border border-l-2 border-active-row-border bg-active-row-bg card-glow";
|
||||||
if (isConcentrating)
|
if (showConcentration)
|
||||||
return "border border-l-2 border-transparent border-l-purple-400";
|
return "border border-l-2 border-transparent border-l-purple-400";
|
||||||
return "border border-l-2 border-transparent";
|
return "border border-l-2 border-transparent";
|
||||||
}
|
}
|
||||||
@@ -451,13 +457,23 @@ export function CombatantRow({
|
|||||||
setConditionValue,
|
setConditionValue,
|
||||||
decrementCondition,
|
decrementCondition,
|
||||||
toggleConcentration,
|
toggleConcentration,
|
||||||
|
addPersistentDamage,
|
||||||
|
removePersistentDamage,
|
||||||
} = useEncounterContext();
|
} = useEncounterContext();
|
||||||
const { selectedCreatureId, showCreature, toggleCollapse } =
|
const {
|
||||||
useSidePanelContext();
|
selectedCreatureId,
|
||||||
|
selectedCombatantId,
|
||||||
|
showCreature,
|
||||||
|
toggleCollapse,
|
||||||
|
} = useSidePanelContext();
|
||||||
const { handleRollInitiative } = useInitiativeRollsContext();
|
const { handleRollInitiative } = useInitiativeRollsContext();
|
||||||
|
const { edition } = useRulesEditionContext();
|
||||||
|
const isPf2e = edition === "pf2e";
|
||||||
|
|
||||||
// Derive what was previously conditional props
|
// Derive what was previously conditional props
|
||||||
const isStatBlockOpen = combatant.creatureId === selectedCreatureId;
|
const isStatBlockOpen =
|
||||||
|
combatant.creatureId === selectedCreatureId &&
|
||||||
|
combatant.id === selectedCombatantId;
|
||||||
const { creatureId } = combatant;
|
const { creatureId } = combatant;
|
||||||
const hasStatBlock = !!creatureId;
|
const hasStatBlock = !!creatureId;
|
||||||
const onToggleStatBlock = hasStatBlock
|
const onToggleStatBlock = hasStatBlock
|
||||||
@@ -465,7 +481,7 @@ export function CombatantRow({
|
|||||||
if (isStatBlockOpen) {
|
if (isStatBlockOpen) {
|
||||||
toggleCollapse();
|
toggleCollapse();
|
||||||
} else {
|
} else {
|
||||||
showCreature(creatureId);
|
showCreature(creatureId, combatant.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -495,12 +511,16 @@ export function CombatantRow({
|
|||||||
const tempHpDropped =
|
const tempHpDropped =
|
||||||
prevTempHp !== undefined && (combatant.tempHp ?? 0) < prevTempHp;
|
prevTempHp !== undefined && (combatant.tempHp ?? 0) < prevTempHp;
|
||||||
|
|
||||||
if ((realHpDropped || tempHpDropped) && combatant.isConcentrating) {
|
if (
|
||||||
|
(realHpDropped || tempHpDropped) &&
|
||||||
|
combatant.isConcentrating &&
|
||||||
|
!isPf2e
|
||||||
|
) {
|
||||||
setIsPulsing(true);
|
setIsPulsing(true);
|
||||||
clearTimeout(pulseTimerRef.current);
|
clearTimeout(pulseTimerRef.current);
|
||||||
pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200);
|
pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200);
|
||||||
}
|
}
|
||||||
}, [currentHp, combatant.tempHp, combatant.isConcentrating]);
|
}, [currentHp, combatant.tempHp, combatant.isConcentrating, isPf2e]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!combatant.isConcentrating) {
|
if (!combatant.isConcentrating) {
|
||||||
@@ -518,12 +538,20 @@ export function CombatantRow({
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group rounded-lg pr-3 transition-colors",
|
"group rounded-lg pr-3 transition-colors",
|
||||||
rowBorderClass(isActive, combatant.isConcentrating),
|
rowBorderClass(isActive, combatant.isConcentrating, isPf2e),
|
||||||
isPulsing && "animate-concentration-pulse",
|
isPulsing && "animate-concentration-pulse",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-[2rem_3rem_auto_1fr_auto_2rem] items-center gap-1.5 py-3 sm:grid-cols-[2rem_3.5rem_auto_1fr_auto_2rem] sm:gap-3 sm:py-2">
|
<div
|
||||||
{/* Concentration */}
|
className={cn(
|
||||||
|
"grid items-center gap-1.5 py-3 sm:gap-3 sm:py-2",
|
||||||
|
isPf2e
|
||||||
|
? "grid-cols-[3rem_auto_1fr_auto_2rem] pl-3 sm:grid-cols-[3.5rem_auto_1fr_auto_2rem]"
|
||||||
|
: "grid-cols-[2rem_3rem_auto_1fr_auto_2rem] sm:grid-cols-[2rem_3.5rem_auto_1fr_auto_2rem]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Concentration — hidden in PF2e mode */}
|
||||||
|
{!isPf2e && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleConcentration(id)}
|
onClick={() => toggleConcentration(id)}
|
||||||
@@ -536,6 +564,7 @@ export function CombatantRow({
|
|||||||
>
|
>
|
||||||
<Brain size={16} />
|
<Brain size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Initiative */}
|
{/* Initiative */}
|
||||||
<div className="rounded-md bg-muted/30 px-1">
|
<div className="rounded-md bg-muted/30 px-1">
|
||||||
@@ -589,16 +618,29 @@ 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)}
|
||||||
|
>
|
||||||
|
{isPf2e && (
|
||||||
|
<PersistentDamageTags
|
||||||
|
entries={combatant.persistentDamage}
|
||||||
|
onRemove={(damageType) =>
|
||||||
|
removePersistentDamage(id, damageType)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</ConditionTags>
|
||||||
</div>
|
</div>
|
||||||
{!!pickerOpen && (
|
{!!pickerOpen && (
|
||||||
<ConditionPicker
|
<ConditionPicker
|
||||||
anchorRef={conditionAnchorRef}
|
anchorRef={conditionAnchorRef}
|
||||||
activeConditions={combatant.conditions}
|
activeConditions={combatant.conditions}
|
||||||
|
activePersistentDamage={combatant.persistentDamage}
|
||||||
onToggle={(conditionId) => toggleCondition(id, conditionId)}
|
onToggle={(conditionId) => toggleCondition(id, conditionId)}
|
||||||
onSetValue={(conditionId, value) =>
|
onSetValue={(conditionId, value) =>
|
||||||
setConditionValue(id, conditionId, value)
|
setConditionValue(id, conditionId, value)
|
||||||
}
|
}
|
||||||
|
onAddPersistentDamage={(damageType, formula) =>
|
||||||
|
addPersistentDamage(id, damageType, formula)
|
||||||
|
}
|
||||||
onClose={() => setPickerOpen(false)}
|
onClose={() => setPickerOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import {
|
|||||||
type ConditionId,
|
type ConditionId,
|
||||||
getConditionDescription,
|
getConditionDescription,
|
||||||
getConditionsForEdition,
|
getConditionsForEdition,
|
||||||
|
type PersistentDamageEntry,
|
||||||
|
type PersistentDamageType,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { Check, Minus, Plus } from "lucide-react";
|
import { Check, Flame, Minus, Plus } from "lucide-react";
|
||||||
import { useLayoutEffect, useRef, useState } from "react";
|
import React, { useLayoutEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
import { useClickOutside } from "../hooks/use-click-outside.js";
|
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||||
@@ -14,21 +16,29 @@ import {
|
|||||||
CONDITION_COLOR_CLASSES,
|
CONDITION_COLOR_CLASSES,
|
||||||
CONDITION_ICON_MAP,
|
CONDITION_ICON_MAP,
|
||||||
} from "./condition-styles.js";
|
} from "./condition-styles.js";
|
||||||
|
import { PersistentDamagePicker } from "./persistent-damage-picker.js";
|
||||||
import { Tooltip } from "./ui/tooltip.js";
|
import { Tooltip } from "./ui/tooltip.js";
|
||||||
|
|
||||||
interface ConditionPickerProps {
|
interface ConditionPickerProps {
|
||||||
anchorRef: React.RefObject<HTMLElement | null>;
|
anchorRef: React.RefObject<HTMLElement | null>;
|
||||||
activeConditions: readonly ConditionEntry[] | undefined;
|
activeConditions: readonly ConditionEntry[] | undefined;
|
||||||
|
activePersistentDamage?: readonly PersistentDamageEntry[];
|
||||||
onToggle: (conditionId: ConditionId) => void;
|
onToggle: (conditionId: ConditionId) => void;
|
||||||
onSetValue: (conditionId: ConditionId, value: number) => void;
|
onSetValue: (conditionId: ConditionId, value: number) => void;
|
||||||
|
onAddPersistentDamage?: (
|
||||||
|
damageType: PersistentDamageType,
|
||||||
|
formula: string,
|
||||||
|
) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConditionPicker({
|
export function ConditionPicker({
|
||||||
anchorRef,
|
anchorRef,
|
||||||
activeConditions,
|
activeConditions,
|
||||||
|
activePersistentDamage,
|
||||||
onToggle,
|
onToggle,
|
||||||
onSetValue,
|
onSetValue,
|
||||||
|
onAddPersistentDamage,
|
||||||
onClose,
|
onClose,
|
||||||
}: Readonly<ConditionPickerProps>) {
|
}: Readonly<ConditionPickerProps>) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@@ -42,6 +52,7 @@ export function ConditionPicker({
|
|||||||
id: ConditionId;
|
id: ConditionId;
|
||||||
value: number;
|
value: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [showPersistentDamage, setShowPersistentDamage] = useState(false);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const anchor = anchorRef.current;
|
const anchor = anchorRef.current;
|
||||||
@@ -71,6 +82,51 @@ export function ConditionPicker({
|
|||||||
const activeMap = new Map(
|
const activeMap = new Map(
|
||||||
(activeConditions ?? []).map((e) => [e.id, e.value]),
|
(activeConditions ?? []).map((e) => [e.id, e.value]),
|
||||||
);
|
);
|
||||||
|
const showPersistentDamageEntry =
|
||||||
|
edition === "pf2e" && !!onAddPersistentDamage;
|
||||||
|
const persistentDamageInsertIndex = showPersistentDamageEntry
|
||||||
|
? conditions.findIndex(
|
||||||
|
(d) => d.label.localeCompare("Persistent Damage") > 0,
|
||||||
|
)
|
||||||
|
: -1;
|
||||||
|
|
||||||
|
const persistentDamageEntry = showPersistentDamageEntry ? (
|
||||||
|
<React.Fragment key="persistent-damage">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors",
|
||||||
|
showPersistentDamage && "bg-card/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex flex-1 items-center gap-2"
|
||||||
|
onClick={() => setShowPersistentDamage((prev) => !prev)}
|
||||||
|
>
|
||||||
|
<Flame
|
||||||
|
size={14}
|
||||||
|
className={
|
||||||
|
showPersistentDamage ? "text-orange-400" : "text-muted-foreground"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
showPersistentDamage ? "text-foreground" : "text-muted-foreground"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Persistent Damage
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!!showPersistentDamage && (
|
||||||
|
<PersistentDamagePicker
|
||||||
|
activeEntries={activePersistentDamage}
|
||||||
|
onAdd={onAddPersistentDamage}
|
||||||
|
onClose={() => setShowPersistentDamage(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
) : null;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
@@ -82,7 +138,7 @@ export function ConditionPicker({
|
|||||||
: { visibility: "hidden" as const }
|
: { visibility: "hidden" as const }
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{conditions.map((def) => {
|
{conditions.map((def, index) => {
|
||||||
const Icon = CONDITION_ICON_MAP[def.iconName];
|
const Icon = CONDITION_ICON_MAP[def.iconName];
|
||||||
if (!Icon) return null;
|
if (!Icon) return null;
|
||||||
const isActive = activeMap.has(def.id);
|
const isActive = activeMap.has(def.id);
|
||||||
@@ -104,8 +160,9 @@ export function ConditionPicker({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<React.Fragment key={def.id}>
|
||||||
|
{index === persistentDamageInsertIndex && persistentDamageEntry}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
key={def.id}
|
|
||||||
content={getConditionDescription(def, edition)}
|
content={getConditionDescription(def, edition)}
|
||||||
className="block"
|
className="block"
|
||||||
>
|
>
|
||||||
@@ -123,7 +180,9 @@ export function ConditionPicker({
|
|||||||
<Icon
|
<Icon
|
||||||
size={14}
|
size={14}
|
||||||
className={
|
className={
|
||||||
isActive || isEditing ? colorClass : "text-muted-foreground"
|
isActive || isEditing
|
||||||
|
? colorClass
|
||||||
|
: "text-muted-foreground"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
@@ -162,20 +221,35 @@ export function ConditionPicker({
|
|||||||
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-primary-foreground text-xs">
|
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-primary-foreground text-xs">
|
||||||
{editing.value}
|
{editing.value}
|
||||||
</span>
|
</span>
|
||||||
|
{(() => {
|
||||||
|
const atMax =
|
||||||
|
def.maxValue !== undefined &&
|
||||||
|
editing.value >= def.maxValue;
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded p-0.5 text-foreground hover:bg-accent/40"
|
className={cn(
|
||||||
|
"rounded p-0.5",
|
||||||
|
atMax
|
||||||
|
? "cursor-not-allowed text-muted-foreground opacity-50"
|
||||||
|
: "text-foreground hover:bg-accent/40",
|
||||||
|
)}
|
||||||
|
disabled={atMax}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
if (!atMax) {
|
||||||
setEditing({
|
setEditing({
|
||||||
...editing,
|
...editing,
|
||||||
value: editing.value + 1,
|
value: editing.value + 1,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus className="h-3 w-3" />
|
<Plus className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="ml-0.5 rounded p-0.5 text-foreground hover:bg-accent/40"
|
className="ml-0.5 rounded p-0.5 text-foreground hover:bg-accent/40"
|
||||||
@@ -192,8 +266,10 @@ export function ConditionPicker({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{persistentDamageInsertIndex === -1 && persistentDamageEntry}
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,8 +11,12 @@ import {
|
|||||||
Droplet,
|
Droplet,
|
||||||
Droplets,
|
Droplets,
|
||||||
EarOff,
|
EarOff,
|
||||||
|
Eclipse,
|
||||||
Eye,
|
Eye,
|
||||||
|
EyeClosed,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
Flame,
|
||||||
|
FlaskConical,
|
||||||
Footprints,
|
Footprints,
|
||||||
Gem,
|
Gem,
|
||||||
Ghost,
|
Ghost,
|
||||||
@@ -22,15 +26,20 @@ import {
|
|||||||
HeartPulse,
|
HeartPulse,
|
||||||
Link,
|
Link,
|
||||||
Moon,
|
Moon,
|
||||||
|
Orbit,
|
||||||
PersonStanding,
|
PersonStanding,
|
||||||
ShieldMinus,
|
ShieldMinus,
|
||||||
ShieldOff,
|
ShieldOff,
|
||||||
Siren,
|
Siren,
|
||||||
Skull,
|
Skull,
|
||||||
Snail,
|
Snail,
|
||||||
|
Snowflake,
|
||||||
|
Sparkle,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Sun,
|
Sun,
|
||||||
|
Sword,
|
||||||
TrendingDown,
|
TrendingDown,
|
||||||
|
Wind,
|
||||||
Zap,
|
Zap,
|
||||||
ZapOff,
|
ZapOff,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -47,8 +56,12 @@ export const CONDITION_ICON_MAP: Record<string, LucideIcon> = {
|
|||||||
Droplet,
|
Droplet,
|
||||||
Droplets,
|
Droplets,
|
||||||
EarOff,
|
EarOff,
|
||||||
|
Eclipse,
|
||||||
Eye,
|
Eye,
|
||||||
|
EyeClosed,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
Flame,
|
||||||
|
FlaskConical,
|
||||||
Footprints,
|
Footprints,
|
||||||
Gem,
|
Gem,
|
||||||
Ghost,
|
Ghost,
|
||||||
@@ -58,15 +71,20 @@ export const CONDITION_ICON_MAP: Record<string, LucideIcon> = {
|
|||||||
HeartPulse,
|
HeartPulse,
|
||||||
Link,
|
Link,
|
||||||
Moon,
|
Moon,
|
||||||
|
Orbit,
|
||||||
PersonStanding,
|
PersonStanding,
|
||||||
ShieldMinus,
|
ShieldMinus,
|
||||||
ShieldOff,
|
ShieldOff,
|
||||||
Siren,
|
Siren,
|
||||||
Skull,
|
Skull,
|
||||||
Snail,
|
Snail,
|
||||||
|
Snowflake,
|
||||||
|
Sparkle,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Sun,
|
Sun,
|
||||||
|
Sword,
|
||||||
TrendingDown,
|
TrendingDown,
|
||||||
|
Wind,
|
||||||
Zap,
|
Zap,
|
||||||
ZapOff,
|
ZapOff,
|
||||||
};
|
};
|
||||||
@@ -76,11 +94,13 @@ 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",
|
||||||
slate: "text-slate-400",
|
slate: "text-slate-400",
|
||||||
green: "text-green-400",
|
green: "text-green-400",
|
||||||
|
lime: "text-lime-400",
|
||||||
indigo: "text-indigo-400",
|
indigo: "text-indigo-400",
|
||||||
sky: "text-sky-400",
|
sky: "text-sky-400",
|
||||||
red: "text-red-400",
|
red: "text-red-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"
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||||
|
import { useSwipeToDismissDown } from "../hooks/use-swipe-to-dismiss.js";
|
||||||
|
import { cn } from "../lib/utils.js";
|
||||||
|
|
||||||
|
interface DetailPopoverProps {
|
||||||
|
readonly anchorRect: DOMRect;
|
||||||
|
readonly onClose: () => void;
|
||||||
|
readonly ariaLabel: string;
|
||||||
|
readonly children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DesktopPanel({
|
||||||
|
anchorRect,
|
||||||
|
onClose,
|
||||||
|
ariaLabel,
|
||||||
|
children,
|
||||||
|
}: Readonly<DetailPopoverProps>) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const popover = el.getBoundingClientRect();
|
||||||
|
const vw = document.documentElement.clientWidth;
|
||||||
|
const vh = document.documentElement.clientHeight;
|
||||||
|
// Prefer placement to the LEFT of the anchor (panel is on the right edge)
|
||||||
|
let left = anchorRect.left - popover.width - 8;
|
||||||
|
if (left < 8) {
|
||||||
|
left = anchorRect.right + 8;
|
||||||
|
}
|
||||||
|
if (left + popover.width > vw - 8) {
|
||||||
|
left = vw - popover.width - 8;
|
||||||
|
}
|
||||||
|
let top = anchorRect.top;
|
||||||
|
if (top + popover.height > vh - 8) {
|
||||||
|
top = vh - popover.height - 8;
|
||||||
|
}
|
||||||
|
if (top < 8) top = 8;
|
||||||
|
setPos({ top, left });
|
||||||
|
}, [anchorRect]);
|
||||||
|
|
||||||
|
useClickOutside(ref, onClose);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="card-glow fixed z-50 max-h-[calc(100vh-16px)] w-80 max-w-[calc(100vw-16px)] overflow-y-auto rounded-lg border border-border bg-card p-4 shadow-lg"
|
||||||
|
style={pos ? { top: pos.top, left: pos.left } : { visibility: "hidden" }}
|
||||||
|
role="dialog"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileSheet({
|
||||||
|
onClose,
|
||||||
|
ariaLabel,
|
||||||
|
children,
|
||||||
|
}: Readonly<Omit<DetailPopoverProps, "anchorRect">>) {
|
||||||
|
const { offsetY, isSwiping, handlers } = useSwipeToDismissDown(onClose);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handler);
|
||||||
|
return () => document.removeEventListener("keydown", handler);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="fade-in absolute inset-0 animate-in bg-black/50"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close details"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"panel-glow absolute right-0 bottom-0 left-0 max-h-[80vh] rounded-t-2xl border-border border-t bg-card",
|
||||||
|
!isSwiping && "animate-slide-in-bottom",
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
isSwiping ? { transform: `translateY(${offsetY}px)` } : undefined
|
||||||
|
}
|
||||||
|
{...handlers}
|
||||||
|
role="dialog"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
<div className="flex justify-center pt-2 pb-1">
|
||||||
|
<div className="h-1 w-10 rounded-full bg-muted-foreground/40" />
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[calc(80vh-24px)] overflow-y-auto p-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DetailPopover({
|
||||||
|
anchorRect,
|
||||||
|
onClose,
|
||||||
|
ariaLabel,
|
||||||
|
children,
|
||||||
|
}: Readonly<DetailPopoverProps>) {
|
||||||
|
const [isDesktop, setIsDesktop] = useState(
|
||||||
|
() => globalThis.matchMedia("(min-width: 1024px)").matches,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = globalThis.matchMedia("(min-width: 1024px)");
|
||||||
|
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
|
||||||
|
mq.addEventListener("change", handler);
|
||||||
|
return () => mq.removeEventListener("change", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Portal to document.body to escape any CSS transforms on ancestors
|
||||||
|
// (the side panel uses translate-x for collapse animation, which would
|
||||||
|
// otherwise become the containing block for fixed-positioned children).
|
||||||
|
const content = isDesktop ? (
|
||||||
|
<DesktopPanel
|
||||||
|
anchorRect={anchorRect}
|
||||||
|
onClose={onClose}
|
||||||
|
ariaLabel={ariaLabel}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DesktopPanel>
|
||||||
|
) : (
|
||||||
|
<MobileSheet onClose={onClose} ariaLabel={ariaLabel}>
|
||||||
|
{children}
|
||||||
|
</MobileSheet>
|
||||||
|
);
|
||||||
|
return createPortal(content, document.body);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ export function DndStatBlock({ creature }: Readonly<DndStatBlockProps>) {
|
|||||||
{sc.atWill && sc.atWill.length > 0 && (
|
{sc.atWill && sc.atWill.length > 0 && (
|
||||||
<div className="pl-2">
|
<div className="pl-2">
|
||||||
<span className="font-semibold">At Will:</span>{" "}
|
<span className="font-semibold">At Will:</span>{" "}
|
||||||
{sc.atWill.join(", ")}
|
{sc.atWill.map((s) => s.name).join(", ")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{sc.daily?.map((d) => (
|
{sc.daily?.map((d) => (
|
||||||
@@ -143,7 +143,7 @@ export function DndStatBlock({ creature }: Readonly<DndStatBlockProps>) {
|
|||||||
{d.uses}/day
|
{d.uses}/day
|
||||||
{d.each ? " each" : ""}:
|
{d.each ? " each" : ""}:
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
{d.spells.join(", ")}
|
{d.spells.map((s) => s.name).join(", ")}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{sc.restLong?.map((d) => (
|
{sc.restLong?.map((d) => (
|
||||||
@@ -155,7 +155,7 @@ export function DndStatBlock({ creature }: Readonly<DndStatBlockProps>) {
|
|||||||
{d.uses}/long rest
|
{d.uses}/long rest
|
||||||
{d.each ? " each" : ""}:
|
{d.each ? " each" : ""}:
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
{d.spells.join(", ")}
|
{d.spells.map((s) => s.name).join(", ")}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { EquipmentItem } from "@initiative/domain";
|
||||||
|
import { DetailPopover } from "./detail-popover.js";
|
||||||
|
import { RichDescription } from "./rich-description.js";
|
||||||
|
|
||||||
|
interface EquipmentDetailPopoverProps {
|
||||||
|
readonly item: EquipmentItem;
|
||||||
|
readonly anchorRect: DOMRect;
|
||||||
|
readonly onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EquipmentDetailContent({ item }: Readonly<{ item: EquipmentItem }>) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<h3 className="font-bold text-lg text-stat-heading">{item.name}</h3>
|
||||||
|
{item.traits && item.traits.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{item.traits.map((t) => (
|
||||||
|
<span
|
||||||
|
key={t}
|
||||||
|
className="rounded border border-border bg-card px-1.5 py-0.5 text-foreground text-xs"
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-0.5 text-xs">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">Level</span> {item.level}
|
||||||
|
</div>
|
||||||
|
{item.category ? (
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">Category</span>{" "}
|
||||||
|
{item.category.charAt(0).toUpperCase() + item.category.slice(1)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{item.spellName ? (
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">Spell</span> {item.spellName}
|
||||||
|
{item.spellRank === undefined ? "" : ` (Rank ${item.spellRank})`}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{item.description ? (
|
||||||
|
<RichDescription
|
||||||
|
text={item.description}
|
||||||
|
className="whitespace-pre-line text-foreground"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground italic">
|
||||||
|
No description available.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EquipmentDetailPopover({
|
||||||
|
item,
|
||||||
|
anchorRect,
|
||||||
|
onClose,
|
||||||
|
}: Readonly<EquipmentDetailPopoverProps>) {
|
||||||
|
return (
|
||||||
|
<DetailPopover
|
||||||
|
anchorRect={anchorRect}
|
||||||
|
onClose={onClose}
|
||||||
|
ariaLabel={`Equipment details: ${item.name}`}
|
||||||
|
>
|
||||||
|
<EquipmentDetailContent item={item} />
|
||||||
|
</DetailPopover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import {
|
||||||
|
PERSISTENT_DAMAGE_DEFINITIONS,
|
||||||
|
type PersistentDamageEntry,
|
||||||
|
type PersistentDamageType,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
interface PersistentDamagePickerProps {
|
||||||
|
activeEntries: readonly PersistentDamageEntry[] | undefined;
|
||||||
|
onAdd: (damageType: PersistentDamageType, formula: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PersistentDamagePicker({
|
||||||
|
activeEntries,
|
||||||
|
onAdd,
|
||||||
|
onClose,
|
||||||
|
}: Readonly<PersistentDamagePickerProps>) {
|
||||||
|
const [selectedType, setSelectedType] = useState<PersistentDamageType>(
|
||||||
|
PERSISTENT_DAMAGE_DEFINITIONS[0].type,
|
||||||
|
);
|
||||||
|
const activeFormula =
|
||||||
|
activeEntries?.find((e) => e.type === selectedType)?.formula ?? "";
|
||||||
|
const [formula, setFormula] = useState(activeFormula);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const existing = activeEntries?.find(
|
||||||
|
(e) => e.type === selectedType,
|
||||||
|
)?.formula;
|
||||||
|
setFormula(existing ?? "");
|
||||||
|
}, [selectedType, activeEntries]);
|
||||||
|
|
||||||
|
const canSubmit = formula.trim().length > 0;
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
if (canSubmit) {
|
||||||
|
onAdd(selectedType, formula);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEscape(e: React.KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 py-1 pr-2 pl-6">
|
||||||
|
<select
|
||||||
|
value={selectedType}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSelectedType(e.target.value as PersistentDamageType)
|
||||||
|
}
|
||||||
|
onKeyDown={handleEscape}
|
||||||
|
className="h-7 rounded border border-border bg-background px-1 text-foreground text-xs"
|
||||||
|
>
|
||||||
|
{PERSISTENT_DAMAGE_DEFINITIONS.map((def) => (
|
||||||
|
<option key={def.type} value={def.type}>
|
||||||
|
{def.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={formula}
|
||||||
|
placeholder="2d6"
|
||||||
|
className="h-7 w-16 rounded border border-border bg-background px-1.5 text-foreground text-xs"
|
||||||
|
onChange={(e) => setFormula(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
handleEscape(e);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!canSubmit}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="rounded p-0.5 text-foreground hover:bg-accent/40 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
aria-label="Add persistent damage"
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import {
|
||||||
|
PERSISTENT_DAMAGE_DEFINITIONS,
|
||||||
|
type PersistentDamageEntry,
|
||||||
|
type PersistentDamageType,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { cn } from "../lib/utils.js";
|
||||||
|
import {
|
||||||
|
CONDITION_COLOR_CLASSES,
|
||||||
|
CONDITION_ICON_MAP,
|
||||||
|
} from "./condition-styles.js";
|
||||||
|
import { Tooltip } from "./ui/tooltip.js";
|
||||||
|
|
||||||
|
interface PersistentDamageTagsProps {
|
||||||
|
entries: readonly PersistentDamageEntry[] | undefined;
|
||||||
|
onRemove: (damageType: PersistentDamageType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PersistentDamageTags({
|
||||||
|
entries,
|
||||||
|
onRemove,
|
||||||
|
}: Readonly<PersistentDamageTagsProps>) {
|
||||||
|
if (!entries || entries.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{entries.map((entry) => {
|
||||||
|
const def = PERSISTENT_DAMAGE_DEFINITIONS.find(
|
||||||
|
(d) => d.type === entry.type,
|
||||||
|
);
|
||||||
|
if (!def) return null;
|
||||||
|
const Icon = CONDITION_ICON_MAP[def.iconName];
|
||||||
|
if (!Icon) return null;
|
||||||
|
const colorClass =
|
||||||
|
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
key={entry.type}
|
||||||
|
content={`Persistent ${def.label} ${entry.formula}\nTake damage at end of turn. DC 15 flat check to end.`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Remove persistent ${def.label} damage`}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-0.5 rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
|
||||||
|
colorClass,
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemove(entry.type);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={14} />
|
||||||
|
<span className="font-medium text-xs leading-none">
|
||||||
|
{entry.formula}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
import type { Pf2eCreature } from "@initiative/domain";
|
import type {
|
||||||
import { formatInitiativeModifier } from "@initiative/domain";
|
CombatantId,
|
||||||
|
EquipmentItem,
|
||||||
|
Pf2eCreature,
|
||||||
|
SpellReference,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { formatInitiativeModifier, recallKnowledge } from "@initiative/domain";
|
||||||
|
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { cn } from "../lib/utils.js";
|
||||||
|
import { EquipmentDetailPopover } from "./equipment-detail-popover.js";
|
||||||
|
import { SpellDetailPopover } from "./spell-detail-popover.js";
|
||||||
import {
|
import {
|
||||||
PropertyLine,
|
PropertyLine,
|
||||||
SectionDivider,
|
SectionDivider,
|
||||||
@@ -8,6 +18,14 @@ import {
|
|||||||
|
|
||||||
interface Pf2eStatBlockProps {
|
interface Pf2eStatBlockProps {
|
||||||
creature: Pf2eCreature;
|
creature: Pf2eCreature;
|
||||||
|
adjustment?: "weak" | "elite";
|
||||||
|
combatantId?: CombatantId;
|
||||||
|
baseCreature?: Pf2eCreature;
|
||||||
|
onSetAdjustment?: (
|
||||||
|
id: CombatantId,
|
||||||
|
adj: "weak" | "elite" | undefined,
|
||||||
|
base: Pf2eCreature,
|
||||||
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALIGNMENTS = new Set([
|
const ALIGNMENTS = new Set([
|
||||||
@@ -34,7 +52,137 @@ function formatMod(mod: number): string {
|
|||||||
return mod >= 0 ? `+${mod}` : `${mod}`;
|
return mod >= 0 ? `+${mod}` : `${mod}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
/** Returns the text color class for stats affected by weak/elite adjustment. */
|
||||||
|
function adjustmentColor(adjustment: "weak" | "elite" | undefined): string {
|
||||||
|
if (adjustment === "elite") return "text-blue-400";
|
||||||
|
if (adjustment === "weak") return "text-red-400";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpellLinkProps {
|
||||||
|
readonly spell: SpellReference;
|
||||||
|
readonly onOpen: (spell: SpellReference, rect: DOMRect) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsesPerDay({ count }: Readonly<{ count: number | undefined }>) {
|
||||||
|
if (count === undefined || count <= 1) return null;
|
||||||
|
return <span> (×{count})</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpellLink({ spell, onOpen }: Readonly<SpellLinkProps>) {
|
||||||
|
const ref = useRef<HTMLButtonElement>(null);
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
if (!spell.description) return;
|
||||||
|
const rect = ref.current?.getBoundingClientRect();
|
||||||
|
if (rect) onOpen(spell, rect);
|
||||||
|
}, [spell, onOpen]);
|
||||||
|
|
||||||
|
if (!spell.description) {
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{spell.name}
|
||||||
|
<UsesPerDay count={spell.usesPerDay} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
type="button"
|
||||||
|
onClick={handleClick}
|
||||||
|
className="cursor-pointer text-foreground underline decoration-dotted underline-offset-2 hover:text-hover-neutral"
|
||||||
|
>
|
||||||
|
{spell.name}
|
||||||
|
</button>
|
||||||
|
<UsesPerDay count={spell.usesPerDay} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpellListLineProps {
|
||||||
|
readonly label: string;
|
||||||
|
readonly spells: readonly SpellReference[];
|
||||||
|
readonly onOpen: (spell: SpellReference, rect: DOMRect) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpellListLine({
|
||||||
|
label,
|
||||||
|
spells,
|
||||||
|
onOpen,
|
||||||
|
}: Readonly<SpellListLineProps>) {
|
||||||
|
return (
|
||||||
|
<div className="pl-2">
|
||||||
|
<span className="font-semibold">{label}:</span>{" "}
|
||||||
|
{spells.map((spell, i) => (
|
||||||
|
<span key={spell.slug ?? spell.name}>
|
||||||
|
{i > 0 ? ", " : ""}
|
||||||
|
<SpellLink spell={spell} onOpen={onOpen} />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EquipmentLinkProps {
|
||||||
|
readonly item: EquipmentItem;
|
||||||
|
readonly onOpen: (item: EquipmentItem, rect: DOMRect) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EquipmentLink({ item, onOpen }: Readonly<EquipmentLinkProps>) {
|
||||||
|
const ref = useRef<HTMLButtonElement>(null);
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
if (!item.description) return;
|
||||||
|
const rect = ref.current?.getBoundingClientRect();
|
||||||
|
if (rect) onOpen(item, rect);
|
||||||
|
}, [item, onOpen]);
|
||||||
|
|
||||||
|
if (!item.description) {
|
||||||
|
return <span>{item.name}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
type="button"
|
||||||
|
onClick={handleClick}
|
||||||
|
className="cursor-pointer text-foreground underline decoration-dotted underline-offset-2 hover:text-hover-neutral"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Pf2eStatBlock({
|
||||||
|
creature,
|
||||||
|
adjustment,
|
||||||
|
combatantId,
|
||||||
|
baseCreature,
|
||||||
|
onSetAdjustment,
|
||||||
|
}: Readonly<Pf2eStatBlockProps>) {
|
||||||
|
const [openSpell, setOpenSpell] = useState<{
|
||||||
|
spell: SpellReference;
|
||||||
|
rect: DOMRect;
|
||||||
|
} | null>(null);
|
||||||
|
const handleOpenSpell = useCallback(
|
||||||
|
(spell: SpellReference, rect: DOMRect) => setOpenSpell({ spell, rect }),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const handleCloseSpell = useCallback(() => setOpenSpell(null), []);
|
||||||
|
const [openEquipment, setOpenEquipment] = useState<{
|
||||||
|
item: EquipmentItem;
|
||||||
|
rect: DOMRect;
|
||||||
|
} | null>(null);
|
||||||
|
const handleOpenEquipment = useCallback(
|
||||||
|
(item: EquipmentItem, rect: DOMRect) => setOpenEquipment({ item, rect }),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const handleCloseEquipment = useCallback(() => setOpenEquipment(null), []);
|
||||||
|
|
||||||
|
const rk = recallKnowledge(creature.level, creature.traits);
|
||||||
|
const adjColor = adjustmentColor(adjustment);
|
||||||
|
|
||||||
const abilityEntries = [
|
const abilityEntries = [
|
||||||
{ label: "Str", mod: creature.abilityMods.str },
|
{ label: "Str", mod: creature.abilityMods.str },
|
||||||
{ label: "Dex", mod: creature.abilityMods.dex },
|
{ label: "Dex", mod: creature.abilityMods.dex },
|
||||||
@@ -49,13 +197,46 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
<h2 className="font-bold text-stat-heading text-xl">
|
<h2 className="flex items-center gap-1.5 font-bold text-stat-heading text-xl">
|
||||||
|
{adjustment === "elite" && (
|
||||||
|
<ChevronUp className="h-5 w-5 shrink-0 text-blue-400" />
|
||||||
|
)}
|
||||||
|
{adjustment === "weak" && (
|
||||||
|
<ChevronDown className="h-5 w-5 shrink-0 text-red-400" />
|
||||||
|
)}
|
||||||
{creature.name}
|
{creature.name}
|
||||||
</h2>
|
</h2>
|
||||||
<span className="shrink-0 font-semibold text-sm">
|
<span className={cn("shrink-0 font-semibold text-sm", adjColor)}>
|
||||||
Level {creature.level}
|
Level {creature.level}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{combatantId != null &&
|
||||||
|
onSetAdjustment != null &&
|
||||||
|
baseCreature != null && (
|
||||||
|
<div className="mt-1 flex gap-1">
|
||||||
|
{(["weak", "normal", "elite"] as const).map((opt) => {
|
||||||
|
const value = opt === "normal" ? undefined : opt;
|
||||||
|
const isActive = adjustment === value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"rounded px-2 py-0.5 font-medium text-xs capitalize",
|
||||||
|
isActive
|
||||||
|
? "bg-accent text-primary-foreground"
|
||||||
|
: "bg-card text-muted-foreground hover:bg-accent/30",
|
||||||
|
)}
|
||||||
|
onClick={() =>
|
||||||
|
onSetAdjustment(combatantId, value, baseCreature)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{opt}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
{displayTraits(creature.traits).map((trait) => (
|
{displayTraits(creature.traits).map((trait) => (
|
||||||
<span
|
<span
|
||||||
@@ -69,16 +250,24 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
|||||||
<p className="mt-1 text-muted-foreground text-xs">
|
<p className="mt-1 text-muted-foreground text-xs">
|
||||||
{creature.sourceDisplayName}
|
{creature.sourceDisplayName}
|
||||||
</p>
|
</p>
|
||||||
|
{rk && (
|
||||||
|
<p className="mt-1 text-sm">
|
||||||
|
<span className="font-semibold">Recall Knowledge</span> DC {rk.dc}{" "}
|
||||||
|
• {capitalize(rk.type)} ({rk.skills.join("/")})
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SectionDivider />
|
<SectionDivider />
|
||||||
|
|
||||||
{/* Perception, Languages, Skills */}
|
{/* Perception, Languages, Skills */}
|
||||||
<div className="space-y-0.5 text-sm">
|
<div className="space-y-0.5 text-sm">
|
||||||
<div>
|
<div className={adjColor}>
|
||||||
<span className="font-semibold">Perception</span>{" "}
|
<span className="font-semibold">Perception</span>{" "}
|
||||||
{formatInitiativeModifier(creature.perception)}
|
{formatInitiativeModifier(creature.perception)}
|
||||||
{creature.senses ? `; ${creature.senses}` : ""}
|
{creature.senses || creature.perceptionDetails
|
||||||
|
? `; ${[creature.senses, creature.perceptionDetails].filter(Boolean).join(", ")}`
|
||||||
|
: ""}
|
||||||
</div>
|
</div>
|
||||||
<PropertyLine label="Languages" value={creature.languages} />
|
<PropertyLine label="Languages" value={creature.languages} />
|
||||||
<PropertyLine label="Skills" value={creature.skills} />
|
<PropertyLine label="Skills" value={creature.skills} />
|
||||||
@@ -105,7 +294,7 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
|||||||
|
|
||||||
{/* Defenses */}
|
{/* Defenses */}
|
||||||
<div className="space-y-0.5 text-sm">
|
<div className="space-y-0.5 text-sm">
|
||||||
<div>
|
<div className={adjColor}>
|
||||||
<span className="font-semibold">AC</span> {creature.ac}
|
<span className="font-semibold">AC</span> {creature.ac}
|
||||||
{creature.acConditional ? ` (${creature.acConditional})` : ""};{" "}
|
{creature.acConditional ? ` (${creature.acConditional})` : ""};{" "}
|
||||||
<span className="font-semibold">Fort</span>{" "}
|
<span className="font-semibold">Fort</span>{" "}
|
||||||
@@ -114,9 +303,11 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
|||||||
{formatMod(creature.saveRef)},{" "}
|
{formatMod(creature.saveRef)},{" "}
|
||||||
<span className="font-semibold">Will</span>{" "}
|
<span className="font-semibold">Will</span>{" "}
|
||||||
{formatMod(creature.saveWill)}
|
{formatMod(creature.saveWill)}
|
||||||
|
{creature.saveConditional ? `; ${creature.saveConditional}` : ""}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className={adjColor}>
|
||||||
<span className="font-semibold">HP</span> {creature.hp}
|
<span className="font-semibold">HP</span> {creature.hp}
|
||||||
|
{creature.hpDetails ? ` (${creature.hpDetails})` : ""}
|
||||||
</div>
|
</div>
|
||||||
<PropertyLine label="Immunities" value={creature.immunities} />
|
<PropertyLine label="Immunities" value={creature.immunities} />
|
||||||
<PropertyLine label="Resistances" value={creature.resistances} />
|
<PropertyLine label="Resistances" value={creature.resistances} />
|
||||||
@@ -138,6 +329,63 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
|||||||
|
|
||||||
{/* Bottom abilities (active abilities) */}
|
{/* Bottom abilities (active abilities) */}
|
||||||
<TraitSection entries={creature.abilitiesBot} />
|
<TraitSection entries={creature.abilitiesBot} />
|
||||||
|
|
||||||
|
{/* Spellcasting */}
|
||||||
|
{creature.spellcasting && creature.spellcasting.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SectionDivider />
|
||||||
|
{creature.spellcasting.map((sc) => (
|
||||||
|
<div key={sc.name} className="space-y-1 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold italic">{sc.name}.</span>{" "}
|
||||||
|
{sc.headerText}
|
||||||
|
</div>
|
||||||
|
{sc.daily?.map((d) => (
|
||||||
|
<SpellListLine
|
||||||
|
key={d.uses}
|
||||||
|
label={d.uses === 0 ? "Cantrips" : `Rank ${d.uses}`}
|
||||||
|
spells={d.spells}
|
||||||
|
onOpen={handleOpenSpell}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{sc.atWill && sc.atWill.length > 0 && (
|
||||||
|
<SpellListLine
|
||||||
|
label="Cantrips"
|
||||||
|
spells={sc.atWill}
|
||||||
|
onOpen={handleOpenSpell}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{creature.equipment && creature.equipment.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SectionDivider />
|
||||||
|
<h3 className="font-bold text-base text-stat-heading">Equipment</h3>
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
{creature.equipment.map((item) => (
|
||||||
|
<div key={item.name}>
|
||||||
|
<EquipmentLink item={item} onOpen={handleOpenEquipment} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{openSpell ? (
|
||||||
|
<SpellDetailPopover
|
||||||
|
spell={openSpell.spell}
|
||||||
|
anchorRect={openSpell.rect}
|
||||||
|
onClose={handleCloseSpell}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{openEquipment ? (
|
||||||
|
<EquipmentDetailPopover
|
||||||
|
item={openEquipment.item}
|
||||||
|
anchorRect={openEquipment.rect}
|
||||||
|
onClose={handleCloseEquipment}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { cn } from "../lib/utils.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders text containing safe HTML formatting tags (strong, em, ul, ol, li)
|
||||||
|
* preserved by the stripFoundryTags pipeline. All other HTML is already
|
||||||
|
* stripped before reaching this component.
|
||||||
|
*/
|
||||||
|
export function RichDescription({
|
||||||
|
text,
|
||||||
|
className,
|
||||||
|
}: Readonly<{ text: string; className?: string }>) {
|
||||||
|
const props = {
|
||||||
|
className: cn(
|
||||||
|
"[&_ol]:list-decimal [&_ol]:pl-4 [&_ul]:list-disc [&_ul]:pl-4",
|
||||||
|
className,
|
||||||
|
),
|
||||||
|
dangerouslySetInnerHTML: { __html: text },
|
||||||
|
};
|
||||||
|
return <div {...props} />;
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import { Input } from "./ui/input.js";
|
|||||||
|
|
||||||
interface SourceFetchPromptProps {
|
interface SourceFetchPromptProps {
|
||||||
sourceCode: string;
|
sourceCode: string;
|
||||||
onSourceLoaded: () => void;
|
onSourceLoaded: (skippedNames: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SourceFetchPrompt({
|
export function SourceFetchPrompt({
|
||||||
@@ -32,8 +32,9 @@ export function SourceFetchPrompt({
|
|||||||
setStatus("fetching");
|
setStatus("fetching");
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
await fetchAndCacheSource(sourceCode, url);
|
const { skippedNames } = await fetchAndCacheSource(sourceCode, url);
|
||||||
onSourceLoaded();
|
setStatus("idle");
|
||||||
|
onSourceLoaded(skippedNames);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
setError(e instanceof Error ? e.message : "Failed to fetch source data");
|
setError(e instanceof Error ? e.message : "Failed to fetch source data");
|
||||||
@@ -51,7 +52,7 @@ export function SourceFetchPrompt({
|
|||||||
const text = await file.text();
|
const text = await file.text();
|
||||||
const json = JSON.parse(text);
|
const json = JSON.parse(text);
|
||||||
await uploadAndCacheSource(sourceCode, json);
|
await uploadAndCacheSource(sourceCode, json);
|
||||||
onSourceLoaded();
|
onSourceLoaded([]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
setError(
|
setError(
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import type { ActivityCost, SpellReference } from "@initiative/domain";
|
||||||
|
import { DetailPopover } from "./detail-popover.js";
|
||||||
|
import { RichDescription } from "./rich-description.js";
|
||||||
|
import { ActivityIcon } from "./stat-block-parts.js";
|
||||||
|
|
||||||
|
interface SpellDetailPopoverProps {
|
||||||
|
readonly spell: SpellReference;
|
||||||
|
readonly anchorRect: DOMRect;
|
||||||
|
readonly onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RANK_LABELS = [
|
||||||
|
"Cantrip",
|
||||||
|
"1st",
|
||||||
|
"2nd",
|
||||||
|
"3rd",
|
||||||
|
"4th",
|
||||||
|
"5th",
|
||||||
|
"6th",
|
||||||
|
"7th",
|
||||||
|
"8th",
|
||||||
|
"9th",
|
||||||
|
"10th",
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatRank(rank: number | undefined): string {
|
||||||
|
if (rank === undefined) return "";
|
||||||
|
return RANK_LABELS[rank] ?? `Rank ${rank}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseActionCost(cost: string): ActivityCost | null {
|
||||||
|
if (cost === "free") return { number: 1, unit: "free" };
|
||||||
|
if (cost === "reaction") return { number: 1, unit: "reaction" };
|
||||||
|
const n = Number(cost);
|
||||||
|
if (n >= 1 && n <= 3) return { number: n, unit: "action" };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpellActionCost({ cost }: Readonly<{ cost: string | undefined }>) {
|
||||||
|
if (!cost) return null;
|
||||||
|
const activity = parseActionCost(cost);
|
||||||
|
if (activity) {
|
||||||
|
return (
|
||||||
|
<span className="shrink-0 text-lg">
|
||||||
|
<ActivityIcon activity={activity} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span className="shrink-0 text-muted-foreground text-xs">{cost}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpellHeader({ spell }: Readonly<{ spell: SpellReference }>) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h3 className="font-bold text-lg text-stat-heading">{spell.name}</h3>
|
||||||
|
<SpellActionCost cost={spell.actionCost} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpellTraits({ traits }: Readonly<{ traits: readonly string[] }>) {
|
||||||
|
if (traits.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{traits.map((t) => (
|
||||||
|
<span
|
||||||
|
key={t}
|
||||||
|
className="rounded border border-border bg-card px-1.5 py-0.5 text-foreground text-xs"
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LabeledValue({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: Readonly<{ label: string; value: string }>) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="font-semibold">{label}</span> {value}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpellRangeLine({ spell }: Readonly<{ spell: SpellReference }>) {
|
||||||
|
const items: { label: string; value: string }[] = [];
|
||||||
|
if (spell.range) items.push({ label: "Range", value: spell.range });
|
||||||
|
if (spell.target) items.push({ label: "Target", value: spell.target });
|
||||||
|
if (spell.area) items.push({ label: "Area", value: spell.area });
|
||||||
|
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<span key={item.label}>
|
||||||
|
{i > 0 ? "; " : ""}
|
||||||
|
<LabeledValue label={item.label} value={item.value} />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpellMeta({ spell }: Readonly<{ spell: SpellReference }>) {
|
||||||
|
const hasTraditions =
|
||||||
|
spell.traditions !== undefined && spell.traditions.length > 0;
|
||||||
|
return (
|
||||||
|
<div className="space-y-0.5 text-xs">
|
||||||
|
{spell.rank === undefined ? null : (
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">{formatRank(spell.rank)}</span>
|
||||||
|
{hasTraditions ? (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{" "}
|
||||||
|
({spell.traditions?.join(", ")})
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<SpellRangeLine spell={spell} />
|
||||||
|
{spell.duration ? (
|
||||||
|
<div>
|
||||||
|
<LabeledValue label="Duration" value={spell.duration} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{spell.defense ? (
|
||||||
|
<div>
|
||||||
|
<LabeledValue label="Defense" value={spell.defense} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpellDetailContent({ spell }: Readonly<{ spell: SpellReference }>) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<SpellHeader spell={spell} />
|
||||||
|
<SpellTraits traits={spell.traits ?? []} />
|
||||||
|
<SpellMeta spell={spell} />
|
||||||
|
{spell.description ? (
|
||||||
|
<RichDescription
|
||||||
|
text={spell.description}
|
||||||
|
className="whitespace-pre-line text-foreground"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground italic">
|
||||||
|
No description available.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{spell.heightening ? (
|
||||||
|
<RichDescription
|
||||||
|
text={spell.heightening}
|
||||||
|
className="whitespace-pre-line text-foreground text-xs"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SpellDetailPopover({
|
||||||
|
spell,
|
||||||
|
anchorRect,
|
||||||
|
onClose,
|
||||||
|
}: Readonly<SpellDetailPopoverProps>) {
|
||||||
|
return (
|
||||||
|
<DetailPopover
|
||||||
|
anchorRect={anchorRect}
|
||||||
|
onClose={onClose}
|
||||||
|
ariaLabel={`Spell details: ${spell.name}`}
|
||||||
|
>
|
||||||
|
<SpellDetailContent spell={spell} />
|
||||||
|
</DetailPopover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,17 @@
|
|||||||
import type { Creature, CreatureId } from "@initiative/domain";
|
import type {
|
||||||
|
AnyCreature,
|
||||||
|
Combatant,
|
||||||
|
CombatantId,
|
||||||
|
Creature,
|
||||||
|
CreatureId,
|
||||||
|
Pf2eCreature,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { applyPf2eAdjustment } from "@initiative/domain";
|
||||||
import { PanelRightClose, Pin, PinOff } from "lucide-react";
|
import { PanelRightClose, Pin, PinOff } from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||||
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||||
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
|
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
|
||||||
import { cn } from "../lib/utils.js";
|
import { cn } from "../lib/utils.js";
|
||||||
@@ -11,6 +20,7 @@ import { DndStatBlock } from "./dnd-stat-block.js";
|
|||||||
import { Pf2eStatBlock } from "./pf2e-stat-block.js";
|
import { Pf2eStatBlock } from "./pf2e-stat-block.js";
|
||||||
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
|
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
|
||||||
import { SourceManager } from "./source-manager.js";
|
import { SourceManager } from "./source-manager.js";
|
||||||
|
import { Toast } from "./toast.js";
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
|
|
||||||
interface StatBlockPanelProps {
|
interface StatBlockPanelProps {
|
||||||
@@ -21,7 +31,10 @@ interface StatBlockPanelProps {
|
|||||||
function extractSourceCode(cId: CreatureId): string {
|
function extractSourceCode(cId: CreatureId): string {
|
||||||
const colonIndex = cId.indexOf(":");
|
const colonIndex = cId.indexOf(":");
|
||||||
if (colonIndex === -1) return "";
|
if (colonIndex === -1) return "";
|
||||||
return cId.slice(0, colonIndex).toUpperCase();
|
const prefix = cId.slice(0, colonIndex);
|
||||||
|
// D&D source codes are short uppercase (e.g. "mm" from "MM").
|
||||||
|
// PF2e source codes use hyphens (e.g. "pathfinder-monster-core").
|
||||||
|
return prefix.includes("-") ? prefix : prefix.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function CollapsedTab({
|
function CollapsedTab({
|
||||||
@@ -212,6 +225,7 @@ function MobileDrawer({
|
|||||||
function usePanelRole(panelRole: "browse" | "pinned") {
|
function usePanelRole(panelRole: "browse" | "pinned") {
|
||||||
const sidePanel = useSidePanelContext();
|
const sidePanel = useSidePanelContext();
|
||||||
const { getCreature } = useBestiaryContext();
|
const { getCreature } = useBestiaryContext();
|
||||||
|
const { encounter, setCreatureAdjustment } = useEncounterContext();
|
||||||
|
|
||||||
const creatureId =
|
const creatureId =
|
||||||
panelRole === "browse"
|
panelRole === "browse"
|
||||||
@@ -219,10 +233,18 @@ function usePanelRole(panelRole: "browse" | "pinned") {
|
|||||||
: sidePanel.pinnedCreatureId;
|
: sidePanel.pinnedCreatureId;
|
||||||
const creature = creatureId ? (getCreature(creatureId) ?? null) : null;
|
const creature = creatureId ? (getCreature(creatureId) ?? null) : null;
|
||||||
|
|
||||||
|
const combatantId =
|
||||||
|
panelRole === "browse" ? sidePanel.selectedCombatantId : null;
|
||||||
|
const combatant = combatantId
|
||||||
|
? (encounter.combatants.find((c) => c.id === combatantId) ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
const isBrowse = panelRole === "browse";
|
const isBrowse = panelRole === "browse";
|
||||||
return {
|
return {
|
||||||
creatureId,
|
creatureId,
|
||||||
creature,
|
creature,
|
||||||
|
combatant,
|
||||||
|
setCreatureAdjustment,
|
||||||
isCollapsed: isBrowse ? sidePanel.isRightPanelCollapsed : false,
|
isCollapsed: isBrowse ? sidePanel.isRightPanelCollapsed : false,
|
||||||
onToggleCollapse: isBrowse ? sidePanel.toggleCollapse : () => {},
|
onToggleCollapse: isBrowse ? sidePanel.toggleCollapse : () => {},
|
||||||
onDismiss: isBrowse ? sidePanel.dismissPanel : () => {},
|
onDismiss: isBrowse ? sidePanel.dismissPanel : () => {},
|
||||||
@@ -234,14 +256,42 @@ function usePanelRole(panelRole: "browse" | "pinned") {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderStatBlock(
|
||||||
|
creature: AnyCreature,
|
||||||
|
combatant: Combatant | null,
|
||||||
|
setCreatureAdjustment: (
|
||||||
|
id: CombatantId,
|
||||||
|
adj: "weak" | "elite" | undefined,
|
||||||
|
base: Pf2eCreature,
|
||||||
|
) => void,
|
||||||
|
) {
|
||||||
|
if ("system" in creature && creature.system === "pf2e") {
|
||||||
|
const baseCreature = creature;
|
||||||
|
const adjusted = combatant?.creatureAdjustment
|
||||||
|
? applyPf2eAdjustment(baseCreature, combatant.creatureAdjustment)
|
||||||
|
: baseCreature;
|
||||||
|
return (
|
||||||
|
<Pf2eStatBlock
|
||||||
|
creature={adjusted}
|
||||||
|
adjustment={combatant?.creatureAdjustment}
|
||||||
|
combatantId={combatant?.id}
|
||||||
|
baseCreature={baseCreature}
|
||||||
|
onSetAdjustment={setCreatureAdjustment}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <DndStatBlock creature={creature as Creature} />;
|
||||||
|
}
|
||||||
|
|
||||||
export function StatBlockPanel({
|
export function StatBlockPanel({
|
||||||
panelRole,
|
panelRole,
|
||||||
side,
|
side,
|
||||||
}: Readonly<StatBlockPanelProps>) {
|
}: Readonly<StatBlockPanelProps>) {
|
||||||
const { isSourceCached } = useBestiaryContext();
|
|
||||||
const {
|
const {
|
||||||
creatureId,
|
creatureId,
|
||||||
creature,
|
creature,
|
||||||
|
combatant,
|
||||||
|
setCreatureAdjustment,
|
||||||
isCollapsed,
|
isCollapsed,
|
||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
@@ -257,6 +307,7 @@ export function StatBlockPanel({
|
|||||||
);
|
);
|
||||||
const [needsFetch, setNeedsFetch] = useState(false);
|
const [needsFetch, setNeedsFetch] = useState(false);
|
||||||
const [checkingCache, setCheckingCache] = useState(false);
|
const [checkingCache, setCheckingCache] = useState(false);
|
||||||
|
const [skippedToast, setSkippedToast] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mq = globalThis.matchMedia("(min-width: 1024px)");
|
const mq = globalThis.matchMedia("(min-width: 1024px)");
|
||||||
@@ -277,19 +328,23 @@ export function StatBlockPanel({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setCheckingCache(true);
|
// Show fetch prompt both when source is uncached AND when the source is
|
||||||
void isSourceCached(sourceCode).then((cached) => {
|
// cached but this specific creature is missing (e.g. skipped by ad blocker).
|
||||||
setNeedsFetch(!cached);
|
setNeedsFetch(true);
|
||||||
setCheckingCache(false);
|
setCheckingCache(false);
|
||||||
});
|
}, [creatureId, creature]);
|
||||||
}, [creatureId, creature, isSourceCached]);
|
|
||||||
|
|
||||||
if (!creatureId && !bulkImportMode && !sourceManagerMode) return null;
|
if (!creatureId && !bulkImportMode && !sourceManagerMode) return null;
|
||||||
|
|
||||||
const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
|
const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
|
||||||
|
|
||||||
const handleSourceLoaded = () => {
|
const handleSourceLoaded = (skippedNames: string[]) => {
|
||||||
setNeedsFetch(false);
|
if (skippedNames.length > 0) {
|
||||||
|
const names = skippedNames.join(", ");
|
||||||
|
setSkippedToast(
|
||||||
|
`${skippedNames.length} creature(s) skipped (ad blocker?): ${names}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
@@ -308,10 +363,7 @@ export function StatBlockPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (creature) {
|
if (creature) {
|
||||||
if ("system" in creature && creature.system === "pf2e") {
|
return renderStatBlock(creature, combatant, setCreatureAdjustment);
|
||||||
return <Pf2eStatBlock creature={creature} />;
|
|
||||||
}
|
|
||||||
return <DndStatBlock creature={creature as Creature} />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (needsFetch && sourceCode) {
|
if (needsFetch && sourceCode) {
|
||||||
@@ -335,8 +387,13 @@ export function StatBlockPanel({
|
|||||||
else if (bulkImportMode) fallbackName = "Import All Sources";
|
else if (bulkImportMode) fallbackName = "Import All Sources";
|
||||||
const creatureName = creature?.name ?? fallbackName;
|
const creatureName = creature?.name ?? fallbackName;
|
||||||
|
|
||||||
|
const toast = skippedToast ? (
|
||||||
|
<Toast message={skippedToast} onDismiss={() => setSkippedToast(null)} />
|
||||||
|
) : null;
|
||||||
|
|
||||||
if (isDesktop) {
|
if (isDesktop) {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<DesktopPanel
|
<DesktopPanel
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
side={side}
|
side={side}
|
||||||
@@ -349,10 +406,17 @@ export function StatBlockPanel({
|
|||||||
>
|
>
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
</DesktopPanel>
|
</DesktopPanel>
|
||||||
|
{toast}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (panelRole === "pinned" || isCollapsed) return null;
|
if (panelRole === "pinned" || isCollapsed) return null;
|
||||||
|
|
||||||
return <MobileDrawer onDismiss={onDismiss}>{renderContent()}</MobileDrawer>;
|
return (
|
||||||
|
<>
|
||||||
|
<MobileDrawer onDismiss={onDismiss}>{renderContent()}</MobileDrawer>
|
||||||
|
{toast}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import type { TraitBlock, TraitSegment } from "@initiative/domain";
|
import type {
|
||||||
|
ActivityCost,
|
||||||
|
TraitBlock,
|
||||||
|
TraitSegment,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { RichDescription } from "./rich-description.js";
|
||||||
|
|
||||||
export function PropertyLine({
|
export function PropertyLine({
|
||||||
label,
|
label,
|
||||||
@@ -35,20 +40,22 @@ function TraitSegments({
|
|||||||
{segments.map((seg, i) => {
|
{segments.map((seg, i) => {
|
||||||
if (seg.type === "text") {
|
if (seg.type === "text") {
|
||||||
return (
|
return (
|
||||||
<span key={segmentKey(seg)}>
|
<RichDescription
|
||||||
{i === 0 ? ` ${seg.value}` : seg.value}
|
key={segmentKey(seg)}
|
||||||
</span>
|
text={i === 0 ? ` ${seg.value}` : seg.value}
|
||||||
|
className="inline whitespace-pre-line"
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div key={segmentKey(seg)} className="mt-1 space-y-0.5">
|
<div key={segmentKey(seg)} className="mt-1 space-y-0.5">
|
||||||
{seg.items.map((item) => (
|
{seg.items.map((item) => (
|
||||||
<p key={item.label ?? item.text}>
|
<div key={item.label ?? item.text}>
|
||||||
{item.label != null && (
|
{item.label != null && (
|
||||||
<span className="font-semibold">{item.label}. </span>
|
<span className="font-semibold">{item.label}. </span>
|
||||||
)}
|
)}
|
||||||
{item.text}
|
<RichDescription text={item.text} className="inline" />
|
||||||
</p>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -57,10 +64,96 @@ function TraitSegments({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ACTION_DIAMOND = "M50 2 L96 50 L50 98 L4 50 Z M48 27 L71 50 L48 73 Z";
|
||||||
|
const ACTION_DIAMOND_SOLID = "M50 2 L96 50 L50 98 L4 50 Z";
|
||||||
|
const ACTION_DIAMOND_OUTLINE =
|
||||||
|
"M90 2 L136 50 L90 98 L44 50 Z M90 29 L111 50 L90 71 L69 50 Z";
|
||||||
|
const FREE_ACTION_DIAMOND =
|
||||||
|
"M50 2 L96 50 L50 98 L4 50 Z M50 12 L12 50 L50 88 L88 50 Z";
|
||||||
|
const FREE_ACTION_CHEVRON = "M48 27 L71 50 L48 73 Z";
|
||||||
|
const REACTION_ARROW =
|
||||||
|
"M75 15 A42 42 0 1 0 85 55 L72 55 A30 30 0 1 1 65 25 L65 40 L92 20 L65 0 L65 15 Z";
|
||||||
|
|
||||||
|
export function ActivityIcon({
|
||||||
|
activity,
|
||||||
|
}: Readonly<{ activity: ActivityCost }>) {
|
||||||
|
const cls = "inline-block h-[1em] align-[-0.1em]";
|
||||||
|
if (activity.unit === "free") {
|
||||||
|
return (
|
||||||
|
<svg aria-hidden="true" className={cls} viewBox="0 0 100 100">
|
||||||
|
<path d={FREE_ACTION_DIAMOND} fill="currentColor" fillRule="evenodd" />
|
||||||
|
<path d={FREE_ACTION_CHEVRON} fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (activity.unit === "reaction") {
|
||||||
|
return (
|
||||||
|
<svg aria-hidden="true" className={cls} viewBox="0 0 100 100">
|
||||||
|
<g transform="translate(100,100) rotate(180)">
|
||||||
|
<path d={REACTION_ARROW} fill="currentColor" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const count = activity.number;
|
||||||
|
if (count === 1) {
|
||||||
|
return (
|
||||||
|
<svg aria-hidden="true" className={cls} viewBox="0 0 100 100">
|
||||||
|
<path d={ACTION_DIAMOND} fill="currentColor" fillRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (count === 2) {
|
||||||
|
return (
|
||||||
|
<svg aria-hidden="true" className={cls} viewBox="0 0 140 100">
|
||||||
|
<path d={ACTION_DIAMOND_SOLID} fill="currentColor" />
|
||||||
|
<path
|
||||||
|
d={ACTION_DIAMOND_OUTLINE}
|
||||||
|
fill="currentColor"
|
||||||
|
fillRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg aria-hidden="true" className={cls} viewBox="0 0 180 100">
|
||||||
|
<path d={ACTION_DIAMOND_SOLID} fill="currentColor" />
|
||||||
|
<path d="M90 2 L136 50 L90 98 L44 50 Z" fill="currentColor" />
|
||||||
|
<path
|
||||||
|
d="M130 2 L176 50 L130 98 L84 50 Z M130 29 L151 50 L130 71 L109 50 Z"
|
||||||
|
fill="currentColor"
|
||||||
|
fillRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function TraitEntry({ trait }: Readonly<{ trait: TraitBlock }>) {
|
export function TraitEntry({ trait }: Readonly<{ trait: TraitBlock }>) {
|
||||||
return (
|
return (
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<span className="font-semibold italic">{trait.name}.</span>
|
<span className="font-semibold italic">
|
||||||
|
{trait.name}
|
||||||
|
{trait.activity ? null : "."}
|
||||||
|
{trait.activity ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
<ActivityIcon activity={trait.activity} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
{trait.frequency ? ` (${trait.frequency})` : null}
|
||||||
|
{trait.trigger ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
<span className="font-semibold">Trigger</span> {trait.trigger}
|
||||||
|
{trait.segments.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
<span className="font-semibold">Effect</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<TraitSegments segments={trait.segments} />
|
<TraitSegments segments={trait.segments} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import type { Pf2eCreature } from "@initiative/domain";
|
||||||
|
import {
|
||||||
|
combatantId,
|
||||||
|
creatureId,
|
||||||
|
EMPTY_UNDO_REDO_STATE,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { type EncounterState, encounterReducer } from "../use-encounter.js";
|
||||||
|
|
||||||
|
const BASE_CREATURE: Pf2eCreature = {
|
||||||
|
system: "pf2e",
|
||||||
|
id: creatureId("b1:goblin-warrior"),
|
||||||
|
name: "Goblin Warrior",
|
||||||
|
source: "B1",
|
||||||
|
sourceDisplayName: "Bestiary",
|
||||||
|
level: 5,
|
||||||
|
traits: ["humanoid"],
|
||||||
|
perception: 12,
|
||||||
|
abilityMods: { str: 4, dex: 2, con: 3, int: 0, wis: 1, cha: -1 },
|
||||||
|
ac: 22,
|
||||||
|
saveFort: 14,
|
||||||
|
saveRef: 11,
|
||||||
|
saveWill: 9,
|
||||||
|
hp: 75,
|
||||||
|
speed: "25 feet",
|
||||||
|
};
|
||||||
|
|
||||||
|
function stateWithCreature(
|
||||||
|
name: string,
|
||||||
|
hp: number,
|
||||||
|
ac: number,
|
||||||
|
adj?: "weak" | "elite",
|
||||||
|
): EncounterState {
|
||||||
|
return {
|
||||||
|
encounter: {
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name,
|
||||||
|
maxHp: hp,
|
||||||
|
currentHp: hp,
|
||||||
|
ac,
|
||||||
|
creatureId: creatureId("b1:goblin-warrior"),
|
||||||
|
...(adj !== undefined && { creatureAdjustment: adj }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
},
|
||||||
|
undoRedoState: EMPTY_UNDO_REDO_STATE,
|
||||||
|
events: [],
|
||||||
|
nextId: 1,
|
||||||
|
lastCreatureId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("set-creature-adjustment", () => {
|
||||||
|
it("Normal → Elite: HP increases, AC +2, name prefixed, adjustment stored", () => {
|
||||||
|
const state = stateWithCreature("Goblin Warrior", 75, 22);
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "set-creature-adjustment",
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
adjustment: "elite",
|
||||||
|
baseCreature: BASE_CREATURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const c = next.encounter.combatants[0];
|
||||||
|
expect(c.maxHp).toBe(95); // 75 + 20 (level 5 bracket)
|
||||||
|
expect(c.currentHp).toBe(95);
|
||||||
|
expect(c.ac).toBe(24);
|
||||||
|
expect(c.name).toBe("Elite Goblin Warrior");
|
||||||
|
expect(c.creatureAdjustment).toBe("elite");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Normal → Weak: HP decreases, AC −2, name prefixed", () => {
|
||||||
|
const state = stateWithCreature("Goblin Warrior", 75, 22);
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "set-creature-adjustment",
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
adjustment: "weak",
|
||||||
|
baseCreature: BASE_CREATURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const c = next.encounter.combatants[0];
|
||||||
|
expect(c.maxHp).toBe(55); // 75 - 20
|
||||||
|
expect(c.currentHp).toBe(55);
|
||||||
|
expect(c.ac).toBe(20);
|
||||||
|
expect(c.name).toBe("Weak Goblin Warrior");
|
||||||
|
expect(c.creatureAdjustment).toBe("weak");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Elite → Normal: HP/AC/name revert", () => {
|
||||||
|
const state = stateWithCreature("Elite Goblin Warrior", 95, 24, "elite");
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "set-creature-adjustment",
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
adjustment: undefined,
|
||||||
|
baseCreature: BASE_CREATURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const c = next.encounter.combatants[0];
|
||||||
|
expect(c.maxHp).toBe(75);
|
||||||
|
expect(c.currentHp).toBe(75);
|
||||||
|
expect(c.ac).toBe(22);
|
||||||
|
expect(c.name).toBe("Goblin Warrior");
|
||||||
|
expect(c.creatureAdjustment).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Elite → Weak: full swing applied in one step", () => {
|
||||||
|
const state = stateWithCreature("Elite Goblin Warrior", 95, 24, "elite");
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "set-creature-adjustment",
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
adjustment: "weak",
|
||||||
|
baseCreature: BASE_CREATURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const c = next.encounter.combatants[0];
|
||||||
|
expect(c.maxHp).toBe(55); // 95 - 40 (revert +20, apply -20)
|
||||||
|
expect(c.currentHp).toBe(55);
|
||||||
|
expect(c.ac).toBe(20); // 24 - 4
|
||||||
|
expect(c.name).toBe("Weak Goblin Warrior");
|
||||||
|
expect(c.creatureAdjustment).toBe("weak");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggle with damage taken: currentHp shifted by delta, clamped to 0", () => {
|
||||||
|
const state: EncounterState = {
|
||||||
|
...stateWithCreature("Goblin Warrior", 75, 22),
|
||||||
|
};
|
||||||
|
// Simulate damage: currentHp = 10
|
||||||
|
const damaged: EncounterState = {
|
||||||
|
...state,
|
||||||
|
encounter: {
|
||||||
|
...state.encounter,
|
||||||
|
combatants: [{ ...state.encounter.combatants[0], currentHp: 10 }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = encounterReducer(damaged, {
|
||||||
|
type: "set-creature-adjustment",
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
adjustment: "weak",
|
||||||
|
baseCreature: BASE_CREATURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const c = next.encounter.combatants[0];
|
||||||
|
expect(c.maxHp).toBe(55);
|
||||||
|
// currentHp = 10 - 20 = -10, clamped to 0
|
||||||
|
expect(c.currentHp).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggle with temp HP: temp HP unchanged", () => {
|
||||||
|
const state = stateWithCreature("Goblin Warrior", 75, 22);
|
||||||
|
const withTemp: EncounterState = {
|
||||||
|
...state,
|
||||||
|
encounter: {
|
||||||
|
...state.encounter,
|
||||||
|
combatants: [{ ...state.encounter.combatants[0], tempHp: 10 }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = encounterReducer(withTemp, {
|
||||||
|
type: "set-creature-adjustment",
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
adjustment: "elite",
|
||||||
|
baseCreature: BASE_CREATURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.encounter.combatants[0].tempHp).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("name with auto-number suffix: 'Goblin 2' → 'Elite Goblin 2'", () => {
|
||||||
|
const state = stateWithCreature("Goblin 2", 75, 22);
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "set-creature-adjustment",
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
adjustment: "elite",
|
||||||
|
baseCreature: BASE_CREATURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next.encounter.combatants[0].name).toBe("Elite Goblin 2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("manually renamed combatant: prefix not found, name unchanged", () => {
|
||||||
|
// Combatant was elite but manually renamed to "Big Boss"
|
||||||
|
const state = stateWithCreature("Big Boss", 95, 24, "elite");
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "set-creature-adjustment",
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
adjustment: undefined,
|
||||||
|
baseCreature: BASE_CREATURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
// No "Elite " prefix found, so name stays as is
|
||||||
|
expect(next.encounter.combatants[0].name).toBe("Big Boss");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits CreatureAdjustmentSet event", () => {
|
||||||
|
const state = stateWithCreature("Goblin Warrior", 75, 22);
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "set-creature-adjustment",
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
adjustment: "elite",
|
||||||
|
baseCreature: BASE_CREATURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const event = next.events.find((e) => e.type === "CreatureAdjustmentSet");
|
||||||
|
expect(event).toEqual({
|
||||||
|
type: "CreatureAdjustmentSet",
|
||||||
|
combatantId: "c-1",
|
||||||
|
adjustment: "elite",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns unchanged state when adjustment is the same", () => {
|
||||||
|
const state = stateWithCreature("Elite Goblin Warrior", 95, 24, "elite");
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "set-creature-adjustment",
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
adjustment: "elite",
|
||||||
|
baseCreature: BASE_CREATURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next).toBe(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns unchanged state for unknown combatant", () => {
|
||||||
|
const state = stateWithCreature("Goblin Warrior", 75, 22);
|
||||||
|
const next = encounterReducer(state, {
|
||||||
|
type: "set-creature-adjustment",
|
||||||
|
id: combatantId("c-99"),
|
||||||
|
adjustment: "elite",
|
||||||
|
baseCreature: BASE_CREATURE,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(next).toBe(state);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ export function useAutoStatBlock(): void {
|
|||||||
const { encounter } = useEncounterContext();
|
const { encounter } = useEncounterContext();
|
||||||
const { panelView, updateCreature } = useSidePanelContext();
|
const { panelView, updateCreature } = useSidePanelContext();
|
||||||
|
|
||||||
const activeCreatureId =
|
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
||||||
encounter.combatants[encounter.activeIndex]?.creatureId;
|
const activeCreatureId = activeCombatant?.creatureId;
|
||||||
const prevActiveIndexRef = useRef(encounter.activeIndex);
|
const prevActiveIndexRef = useRef(encounter.activeIndex);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -21,7 +21,13 @@ export function useAutoStatBlock(): void {
|
|||||||
activeCreatureId &&
|
activeCreatureId &&
|
||||||
panelView.mode === "creature"
|
panelView.mode === "creature"
|
||||||
) {
|
) {
|
||||||
updateCreature(activeCreatureId);
|
updateCreature(activeCreatureId, activeCombatant.id);
|
||||||
}
|
}
|
||||||
}, [encounter.activeIndex, activeCreatureId, panelView.mode, updateCreature]);
|
}, [
|
||||||
|
encounter.activeIndex,
|
||||||
|
activeCreatureId,
|
||||||
|
activeCombatant?.id,
|
||||||
|
panelView.mode,
|
||||||
|
updateCreature,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,8 @@ import {
|
|||||||
normalizeBestiary,
|
normalizeBestiary,
|
||||||
setSourceDisplayNames,
|
setSourceDisplayNames,
|
||||||
} from "../adapters/bestiary-adapter.js";
|
} from "../adapters/bestiary-adapter.js";
|
||||||
import {
|
import { loadBundledDndCreatures } from "../adapters/dnd-bundled-adapter.js";
|
||||||
normalizePf2eBestiary,
|
import { normalizeFoundryCreatures } from "../adapters/pf2e-bestiary-adapter.js";
|
||||||
setPf2eSourceDisplayNames,
|
|
||||||
} 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";
|
||||||
|
|
||||||
@@ -31,7 +29,10 @@ interface BestiaryHook {
|
|||||||
getCreature: (id: CreatureId) => AnyCreature | undefined;
|
getCreature: (id: CreatureId) => AnyCreature | undefined;
|
||||||
isLoaded: boolean;
|
isLoaded: boolean;
|
||||||
isSourceCached: (sourceCode: string) => Promise<boolean>;
|
isSourceCached: (sourceCode: string) => Promise<boolean>;
|
||||||
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
|
fetchAndCacheSource: (
|
||||||
|
sourceCode: string,
|
||||||
|
url: string,
|
||||||
|
) => Promise<{ skippedNames: string[] }>;
|
||||||
uploadAndCacheSource: (
|
uploadAndCacheSource: (
|
||||||
sourceCode: string,
|
sourceCode: string,
|
||||||
jsonData: unknown,
|
jsonData: unknown,
|
||||||
@@ -39,6 +40,108 @@ interface BestiaryHook {
|
|||||||
refreshCache: () => Promise<void>;
|
refreshCache: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BatchResult {
|
||||||
|
readonly responses: unknown[];
|
||||||
|
readonly failed: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(url: string, path: string): Promise<unknown> {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch ${path}: ${response.status} ${response.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithRetry(
|
||||||
|
url: string,
|
||||||
|
path: string,
|
||||||
|
retries = 2,
|
||||||
|
): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
return await fetchJson(url, path);
|
||||||
|
} catch (error) {
|
||||||
|
if (retries <= 0) throw error;
|
||||||
|
await new Promise<void>((r) => setTimeout(r, 500));
|
||||||
|
return fetchWithRetry(url, path, retries - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchBatch(
|
||||||
|
baseUrl: string,
|
||||||
|
paths: string[],
|
||||||
|
): Promise<BatchResult> {
|
||||||
|
const settled = await Promise.allSettled(
|
||||||
|
paths.map((path) => fetchWithRetry(`${baseUrl}${path}`, path)),
|
||||||
|
);
|
||||||
|
const responses: unknown[] = [];
|
||||||
|
const failed: string[] = [];
|
||||||
|
for (let i = 0; i < settled.length; i++) {
|
||||||
|
const result = settled[i];
|
||||||
|
if (result.status === "fulfilled") {
|
||||||
|
responses.push(result.value);
|
||||||
|
} else {
|
||||||
|
failed.push(paths[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { responses, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchInBatches(
|
||||||
|
paths: string[],
|
||||||
|
baseUrl: string,
|
||||||
|
concurrency: number,
|
||||||
|
): Promise<BatchResult> {
|
||||||
|
const batches: string[][] = [];
|
||||||
|
for (let i = 0; i < paths.length; i += concurrency) {
|
||||||
|
batches.push(paths.slice(i, i + concurrency));
|
||||||
|
}
|
||||||
|
const accumulated = await batches.reduce<Promise<BatchResult>>(
|
||||||
|
async (prev, batch) => {
|
||||||
|
const acc = await prev;
|
||||||
|
const result = await fetchBatch(baseUrl, batch);
|
||||||
|
return {
|
||||||
|
responses: [...acc.responses, ...result.responses],
|
||||||
|
failed: [...acc.failed, ...result.failed],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
Promise.resolve({ responses: [], failed: [] }),
|
||||||
|
);
|
||||||
|
return accumulated;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Pf2eFetchResult {
|
||||||
|
creatures: AnyCreature[];
|
||||||
|
skippedNames: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPf2eSource(
|
||||||
|
paths: string[],
|
||||||
|
url: string,
|
||||||
|
sourceCode: string,
|
||||||
|
displayName: string,
|
||||||
|
resolveNames: (failedPaths: string[]) => Map<string, string>,
|
||||||
|
): Promise<Pf2eFetchResult> {
|
||||||
|
const baseUrl = url.endsWith("/") ? url : `${url}/`;
|
||||||
|
const { responses, failed } = await fetchInBatches(paths, baseUrl, 6);
|
||||||
|
if (responses.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch any creatures (${failed.length} failed). This may be caused by an ad blocker — try disabling it for this site or use file upload instead.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const nameMap = failed.length > 0 ? resolveNames(failed) : new Map();
|
||||||
|
const skippedNames = failed.map((p) => nameMap.get(p) ?? p);
|
||||||
|
if (skippedNames.length > 0) {
|
||||||
|
console.warn("Skipped creatures (ad blocker?):", skippedNames);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
creatures: normalizeFoundryCreatures(responses, sourceCode, displayName),
|
||||||
|
skippedNames,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function useBestiary(): BestiaryHook {
|
export function useBestiary(): BestiaryHook {
|
||||||
const { bestiaryCache, bestiaryIndex, pf2eBestiaryIndex } = useAdapters();
|
const { bestiaryCache, bestiaryIndex, pf2eBestiaryIndex } = useAdapters();
|
||||||
const { edition } = useRulesEditionContext();
|
const { edition } = useRulesEditionContext();
|
||||||
@@ -52,14 +155,17 @@ export function useBestiary(): BestiaryHook {
|
|||||||
setSourceDisplayNames(index.sources as Record<string, string>);
|
setSourceDisplayNames(index.sources as Record<string, string>);
|
||||||
|
|
||||||
const pf2eIndex = pf2eBestiaryIndex.loadIndex();
|
const pf2eIndex = pf2eBestiaryIndex.loadIndex();
|
||||||
setPf2eSourceDisplayNames(pf2eIndex.sources as Record<string, string>);
|
|
||||||
|
|
||||||
if (index.creatures.length > 0 || pf2eIndex.creatures.length > 0) {
|
if (index.creatures.length > 0 || pf2eIndex.creatures.length > 0) {
|
||||||
setIsLoaded(true);
|
setIsLoaded(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
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]);
|
||||||
|
|
||||||
@@ -112,7 +218,26 @@ export function useBestiary(): BestiaryHook {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const fetchAndCacheSource = useCallback(
|
const fetchAndCacheSource = useCallback(
|
||||||
async (sourceCode: string, url: string): Promise<void> => {
|
async (
|
||||||
|
sourceCode: string,
|
||||||
|
url: string,
|
||||||
|
): Promise<{ skippedNames: string[] }> => {
|
||||||
|
let creatures: AnyCreature[];
|
||||||
|
let skippedNames: string[] = [];
|
||||||
|
|
||||||
|
if (edition === "pf2e") {
|
||||||
|
const paths = pf2eBestiaryIndex.getCreaturePathsForSource(sourceCode);
|
||||||
|
const displayName = pf2eBestiaryIndex.getSourceDisplayName(sourceCode);
|
||||||
|
const result = await fetchPf2eSource(
|
||||||
|
paths,
|
||||||
|
url,
|
||||||
|
sourceCode,
|
||||||
|
displayName,
|
||||||
|
pf2eBestiaryIndex.getCreatureNamesByPaths,
|
||||||
|
);
|
||||||
|
creatures = result.creatures;
|
||||||
|
skippedNames = result.skippedNames;
|
||||||
|
} else {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -120,10 +245,9 @@ export function useBestiary(): BestiaryHook {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
const creatures =
|
creatures = normalizeBestiary(json);
|
||||||
edition === "pf2e"
|
}
|
||||||
? normalizePf2eBestiary(json)
|
|
||||||
: normalizeBestiary(json);
|
|
||||||
const displayName =
|
const displayName =
|
||||||
edition === "pf2e"
|
edition === "pf2e"
|
||||||
? pf2eBestiaryIndex.getSourceDisplayName(sourceCode)
|
? pf2eBestiaryIndex.getSourceDisplayName(sourceCode)
|
||||||
@@ -141,6 +265,7 @@ export function useBestiary(): BestiaryHook {
|
|||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
return { skippedNames };
|
||||||
},
|
},
|
||||||
[bestiaryCache, bestiaryIndex, pf2eBestiaryIndex, edition, system],
|
[bestiaryCache, bestiaryIndex, pf2eBestiaryIndex, edition, system],
|
||||||
);
|
);
|
||||||
@@ -149,7 +274,11 @@ export function useBestiary(): BestiaryHook {
|
|||||||
async (sourceCode: string, jsonData: unknown): Promise<void> => {
|
async (sourceCode: string, jsonData: unknown): Promise<void> => {
|
||||||
const creatures =
|
const creatures =
|
||||||
edition === "pf2e"
|
edition === "pf2e"
|
||||||
? normalizePf2eBestiary(jsonData as { creature: unknown[] })
|
? normalizeFoundryCreatures(
|
||||||
|
Array.isArray(jsonData) ? jsonData : [jsonData],
|
||||||
|
sourceCode,
|
||||||
|
pf2eBestiaryIndex.getSourceDisplayName(sourceCode),
|
||||||
|
)
|
||||||
: normalizeBestiary(
|
: normalizeBestiary(
|
||||||
jsonData as Parameters<typeof normalizeBestiary>[0],
|
jsonData as Parameters<typeof normalizeBestiary>[0],
|
||||||
);
|
);
|
||||||
@@ -176,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]);
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ interface BulkImportHook {
|
|||||||
state: BulkImportState;
|
state: BulkImportState;
|
||||||
startImport: (
|
startImport: (
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>,
|
fetchAndCacheSource: (
|
||||||
|
sourceCode: string,
|
||||||
|
url: string,
|
||||||
|
) => Promise<{ skippedNames: string[] }>,
|
||||||
isSourceCached: (sourceCode: string) => Promise<boolean>,
|
isSourceCached: (sourceCode: string) => Promise<boolean>,
|
||||||
refreshCache: () => Promise<void>,
|
refreshCache: () => Promise<void>,
|
||||||
) => void;
|
) => void;
|
||||||
@@ -39,7 +42,10 @@ export function useBulkImport(): BulkImportHook {
|
|||||||
const startImport = useCallback(
|
const startImport = useCallback(
|
||||||
(
|
(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>,
|
fetchAndCacheSource: (
|
||||||
|
sourceCode: string,
|
||||||
|
url: string,
|
||||||
|
) => Promise<{ skippedNames: string[] }>,
|
||||||
isSourceCached: (sourceCode: string) => Promise<boolean>,
|
isSourceCached: (sourceCode: string) => Promise<boolean>,
|
||||||
refreshCache: () => Promise<void>,
|
refreshCache: () => Promise<void>,
|
||||||
) => {
|
) => {
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { EncounterStore, UndoRedoStore } from "@initiative/application";
|
import type { EncounterStore, UndoRedoStore } from "@initiative/application";
|
||||||
import {
|
import {
|
||||||
addCombatantUseCase,
|
addCombatantUseCase,
|
||||||
|
addPersistentDamageUseCase,
|
||||||
adjustHpUseCase,
|
adjustHpUseCase,
|
||||||
advanceTurnUseCase,
|
advanceTurnUseCase,
|
||||||
clearEncounterUseCase,
|
clearEncounterUseCase,
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
editCombatantUseCase,
|
editCombatantUseCase,
|
||||||
redoUseCase,
|
redoUseCase,
|
||||||
removeCombatantUseCase,
|
removeCombatantUseCase,
|
||||||
|
removePersistentDamageUseCase,
|
||||||
retreatTurnUseCase,
|
retreatTurnUseCase,
|
||||||
setAcUseCase,
|
setAcUseCase,
|
||||||
setConditionValueUseCase,
|
setConditionValueUseCase,
|
||||||
@@ -28,12 +30,16 @@ import type {
|
|||||||
DomainError,
|
DomainError,
|
||||||
DomainEvent,
|
DomainEvent,
|
||||||
Encounter,
|
Encounter,
|
||||||
|
PersistentDamageType,
|
||||||
|
Pf2eCreature,
|
||||||
PlayerCharacter,
|
PlayerCharacter,
|
||||||
UndoRedoState,
|
UndoRedoState,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import {
|
import {
|
||||||
|
acDelta,
|
||||||
clearHistory,
|
clearHistory,
|
||||||
combatantId,
|
combatantId,
|
||||||
|
hpDelta,
|
||||||
isDomainError,
|
isDomainError,
|
||||||
creatureId as makeCreatureId,
|
creatureId as makeCreatureId,
|
||||||
pushUndo,
|
pushUndo,
|
||||||
@@ -75,6 +81,17 @@ type EncounterAction =
|
|||||||
conditionId: ConditionId;
|
conditionId: ConditionId;
|
||||||
}
|
}
|
||||||
| { type: "toggle-concentration"; id: CombatantId }
|
| { type: "toggle-concentration"; id: CombatantId }
|
||||||
|
| {
|
||||||
|
type: "add-persistent-damage";
|
||||||
|
id: CombatantId;
|
||||||
|
damageType: PersistentDamageType;
|
||||||
|
formula: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "remove-persistent-damage";
|
||||||
|
id: CombatantId;
|
||||||
|
damageType: PersistentDamageType;
|
||||||
|
}
|
||||||
| { type: "clear-encounter" }
|
| { type: "clear-encounter" }
|
||||||
| { type: "undo" }
|
| { type: "undo" }
|
||||||
| { type: "redo" }
|
| { type: "redo" }
|
||||||
@@ -84,6 +101,12 @@ type EncounterAction =
|
|||||||
entry: SearchResult;
|
entry: SearchResult;
|
||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: "set-creature-adjustment";
|
||||||
|
id: CombatantId;
|
||||||
|
adjustment: "weak" | "elite" | undefined;
|
||||||
|
baseCreature: Pf2eCreature;
|
||||||
|
}
|
||||||
| { type: "add-from-player-character"; pc: PlayerCharacter }
|
| { type: "add-from-player-character"; pc: PlayerCharacter }
|
||||||
| {
|
| {
|
||||||
type: "import";
|
type: "import";
|
||||||
@@ -279,6 +302,76 @@ function handleAddFromPlayerCharacter(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyNamePrefix(
|
||||||
|
name: string,
|
||||||
|
oldAdj: "weak" | "elite" | undefined,
|
||||||
|
newAdj: "weak" | "elite" | undefined,
|
||||||
|
): string {
|
||||||
|
let base = name;
|
||||||
|
if (oldAdj === "weak" && name.startsWith("Weak ")) base = name.slice(5);
|
||||||
|
else if (oldAdj === "elite" && name.startsWith("Elite "))
|
||||||
|
base = name.slice(6);
|
||||||
|
if (newAdj === "weak") return `Weak ${base}`;
|
||||||
|
if (newAdj === "elite") return `Elite ${base}`;
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSetCreatureAdjustment(
|
||||||
|
state: EncounterState,
|
||||||
|
id: CombatantId,
|
||||||
|
adjustment: "weak" | "elite" | undefined,
|
||||||
|
baseCreature: Pf2eCreature,
|
||||||
|
): EncounterState {
|
||||||
|
const combatant = state.encounter.combatants.find((c) => c.id === id);
|
||||||
|
if (!combatant) return state;
|
||||||
|
|
||||||
|
const oldAdj = combatant.creatureAdjustment;
|
||||||
|
if (oldAdj === adjustment) return state;
|
||||||
|
|
||||||
|
const baseLevel = baseCreature.level;
|
||||||
|
const oldHpDelta = oldAdj ? hpDelta(baseLevel, oldAdj) : 0;
|
||||||
|
const newHpDelta = adjustment ? hpDelta(baseLevel, adjustment) : 0;
|
||||||
|
const netHpDelta = newHpDelta - oldHpDelta;
|
||||||
|
|
||||||
|
const oldAcDelta = oldAdj ? acDelta(oldAdj) : 0;
|
||||||
|
const newAcDelta = adjustment ? acDelta(adjustment) : 0;
|
||||||
|
const netAcDelta = newAcDelta - oldAcDelta;
|
||||||
|
|
||||||
|
const newMaxHp =
|
||||||
|
combatant.maxHp === undefined ? undefined : combatant.maxHp + netHpDelta;
|
||||||
|
const newCurrentHp =
|
||||||
|
combatant.currentHp === undefined || newMaxHp === undefined
|
||||||
|
? undefined
|
||||||
|
: Math.max(0, Math.min(combatant.currentHp + netHpDelta, newMaxHp));
|
||||||
|
const newAc =
|
||||||
|
combatant.ac === undefined ? undefined : combatant.ac + netAcDelta;
|
||||||
|
const newName = applyNamePrefix(combatant.name, oldAdj, adjustment);
|
||||||
|
|
||||||
|
const updatedCombatant: typeof combatant = {
|
||||||
|
...combatant,
|
||||||
|
name: newName,
|
||||||
|
...(newMaxHp !== undefined && { maxHp: newMaxHp }),
|
||||||
|
...(newCurrentHp !== undefined && { currentHp: newCurrentHp }),
|
||||||
|
...(newAc !== undefined && { ac: newAc }),
|
||||||
|
...(adjustment === undefined
|
||||||
|
? { creatureAdjustment: undefined }
|
||||||
|
: { creatureAdjustment: adjustment }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const combatants = state.encounter.combatants.map((c) =>
|
||||||
|
c.id === id ? updatedCombatant : c,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
encounter: { ...state.encounter, combatants },
|
||||||
|
events: [
|
||||||
|
...state.events,
|
||||||
|
{ type: "CreatureAdjustmentSet", combatantId: id, adjustment },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// -- Reducer --
|
// -- Reducer --
|
||||||
|
|
||||||
export function encounterReducer(
|
export function encounterReducer(
|
||||||
@@ -310,6 +403,13 @@ export function encounterReducer(
|
|||||||
lastCreatureId: null,
|
lastCreatureId: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case "set-creature-adjustment":
|
||||||
|
return handleSetCreatureAdjustment(
|
||||||
|
state,
|
||||||
|
action.id,
|
||||||
|
action.adjustment,
|
||||||
|
action.baseCreature,
|
||||||
|
);
|
||||||
case "add-from-bestiary":
|
case "add-from-bestiary":
|
||||||
return handleAddFromBestiary(state, action.entry, 1);
|
return handleAddFromBestiary(state, action.entry, 1);
|
||||||
case "add-multiple-from-bestiary":
|
case "add-multiple-from-bestiary":
|
||||||
@@ -341,6 +441,8 @@ function dispatchEncounterAction(
|
|||||||
| { type: "set-condition-value" }
|
| { type: "set-condition-value" }
|
||||||
| { type: "decrement-condition" }
|
| { type: "decrement-condition" }
|
||||||
| { type: "toggle-concentration" }
|
| { type: "toggle-concentration" }
|
||||||
|
| { type: "add-persistent-damage" }
|
||||||
|
| { type: "remove-persistent-damage" }
|
||||||
>,
|
>,
|
||||||
): EncounterState {
|
): EncounterState {
|
||||||
const { store, getEncounter } = makeStoreFromState(state);
|
const { store, getEncounter } = makeStoreFromState(state);
|
||||||
@@ -402,6 +504,21 @@ function dispatchEncounterAction(
|
|||||||
case "toggle-concentration":
|
case "toggle-concentration":
|
||||||
result = toggleConcentrationUseCase(store, action.id);
|
result = toggleConcentrationUseCase(store, action.id);
|
||||||
break;
|
break;
|
||||||
|
case "add-persistent-damage":
|
||||||
|
result = addPersistentDamageUseCase(
|
||||||
|
store,
|
||||||
|
action.id,
|
||||||
|
action.damageType,
|
||||||
|
action.formula,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "remove-persistent-damage":
|
||||||
|
result = removePersistentDamageUseCase(
|
||||||
|
store,
|
||||||
|
action.id,
|
||||||
|
action.damageType,
|
||||||
|
);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDomainError(result)) return state;
|
if (isDomainError(result)) return state;
|
||||||
@@ -421,7 +538,10 @@ function dispatchEncounterAction(
|
|||||||
export function useEncounter() {
|
export function useEncounter() {
|
||||||
const { encounterPersistence, undoRedoPersistence } = useAdapters();
|
const { encounterPersistence, undoRedoPersistence } = useAdapters();
|
||||||
const [state, dispatch] = useReducer(encounterReducer, null, () =>
|
const [state, dispatch] = useReducer(encounterReducer, null, () =>
|
||||||
initializeState(encounterPersistence.load, undoRedoPersistence.load),
|
initializeState(
|
||||||
|
() => encounterPersistence.load(),
|
||||||
|
() => undoRedoPersistence.load(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const { encounter, undoRedoState, events } = state;
|
const { encounter, undoRedoState, events } = state;
|
||||||
|
|
||||||
@@ -562,6 +682,30 @@ export function useEncounter() {
|
|||||||
(id: CombatantId) => dispatch({ type: "toggle-concentration", id }),
|
(id: CombatantId) => dispatch({ type: "toggle-concentration", id }),
|
||||||
[],
|
[],
|
||||||
),
|
),
|
||||||
|
addPersistentDamage: useCallback(
|
||||||
|
(id: CombatantId, damageType: PersistentDamageType, formula: string) =>
|
||||||
|
dispatch({ type: "add-persistent-damage", id, damageType, formula }),
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
removePersistentDamage: useCallback(
|
||||||
|
(id: CombatantId, damageType: PersistentDamageType) =>
|
||||||
|
dispatch({ type: "remove-persistent-damage", id, damageType }),
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
setCreatureAdjustment: useCallback(
|
||||||
|
(
|
||||||
|
id: CombatantId,
|
||||||
|
adjustment: "weak" | "elite" | undefined,
|
||||||
|
baseCreature: Pf2eCreature,
|
||||||
|
) =>
|
||||||
|
dispatch({
|
||||||
|
type: "set-creature-adjustment",
|
||||||
|
id,
|
||||||
|
adjustment,
|
||||||
|
baseCreature,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
),
|
||||||
clearEncounter: useCallback(
|
clearEncounter: useCallback(
|
||||||
() => dispatch({ type: "clear-encounter" }),
|
() => dispatch({ type: "clear-encounter" }),
|
||||||
[],
|
[],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import type { CreatureId } from "@initiative/domain";
|
import type { CombatantId, CreatureId } from "@initiative/domain";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
type PanelView =
|
type PanelView =
|
||||||
| { mode: "closed" }
|
| { mode: "closed" }
|
||||||
| { mode: "creature"; creatureId: CreatureId }
|
| { mode: "creature"; creatureId: CreatureId; combatantId?: CombatantId }
|
||||||
| { mode: "bulk-import" }
|
| { mode: "bulk-import" }
|
||||||
| { mode: "source-manager" };
|
| { mode: "source-manager" };
|
||||||
|
|
||||||
interface SidePanelState {
|
interface SidePanelState {
|
||||||
panelView: PanelView;
|
panelView: PanelView;
|
||||||
selectedCreatureId: CreatureId | null;
|
selectedCreatureId: CreatureId | null;
|
||||||
|
selectedCombatantId: CombatantId | null;
|
||||||
bulkImportMode: boolean;
|
bulkImportMode: boolean;
|
||||||
sourceManagerMode: boolean;
|
sourceManagerMode: boolean;
|
||||||
isRightPanelCollapsed: boolean;
|
isRightPanelCollapsed: boolean;
|
||||||
@@ -18,8 +19,8 @@ interface SidePanelState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SidePanelActions {
|
interface SidePanelActions {
|
||||||
showCreature: (creatureId: CreatureId) => void;
|
showCreature: (creatureId: CreatureId, combatantId?: CombatantId) => void;
|
||||||
updateCreature: (creatureId: CreatureId) => void;
|
updateCreature: (creatureId: CreatureId, combatantId?: CombatantId) => void;
|
||||||
showBulkImport: () => void;
|
showBulkImport: () => void;
|
||||||
showSourceManager: () => void;
|
showSourceManager: () => void;
|
||||||
dismissPanel: () => void;
|
dismissPanel: () => void;
|
||||||
@@ -48,14 +49,23 @@ export function useSidePanelState(): SidePanelState & SidePanelActions {
|
|||||||
const selectedCreatureId =
|
const selectedCreatureId =
|
||||||
panelView.mode === "creature" ? panelView.creatureId : null;
|
panelView.mode === "creature" ? panelView.creatureId : null;
|
||||||
|
|
||||||
const showCreature = useCallback((creatureId: CreatureId) => {
|
const selectedCombatantId =
|
||||||
setPanelView({ mode: "creature", creatureId });
|
panelView.mode === "creature" ? (panelView.combatantId ?? null) : null;
|
||||||
setIsRightPanelCollapsed(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const updateCreature = useCallback((creatureId: CreatureId) => {
|
const showCreature = useCallback(
|
||||||
setPanelView({ mode: "creature", creatureId });
|
(creatureId: CreatureId, combatantId?: CombatantId) => {
|
||||||
}, []);
|
setPanelView({ mode: "creature", creatureId, combatantId });
|
||||||
|
setIsRightPanelCollapsed(false);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateCreature = useCallback(
|
||||||
|
(creatureId: CreatureId, combatantId?: CombatantId) => {
|
||||||
|
setPanelView({ mode: "creature", creatureId, combatantId });
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const showBulkImport = useCallback(() => {
|
const showBulkImport = useCallback(() => {
|
||||||
setPanelView({ mode: "bulk-import" });
|
setPanelView({ mode: "bulk-import" });
|
||||||
@@ -90,6 +100,7 @@ export function useSidePanelState(): SidePanelState & SidePanelActions {
|
|||||||
return {
|
return {
|
||||||
panelView,
|
panelView,
|
||||||
selectedCreatureId,
|
selectedCreatureId,
|
||||||
|
selectedCombatantId,
|
||||||
bulkImportMode: panelView.mode === "bulk-import",
|
bulkImportMode: panelView.mode === "bulk-import",
|
||||||
sourceManagerMode: panelView.mode === "source-manager",
|
sourceManagerMode: panelView.mode === "source-manager",
|
||||||
isRightPanelCollapsed,
|
isRightPanelCollapsed,
|
||||||
|
|||||||
@@ -70,3 +70,72 @@ export function useSwipeToDismiss(onDismiss: () => void) {
|
|||||||
handlers: { onTouchStart, onTouchMove, onTouchEnd },
|
handlers: { onTouchStart, onTouchMove, onTouchEnd },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vertical (down-only) variant for dismissing bottom sheets via swipe-down.
|
||||||
|
* Mirrors `useSwipeToDismiss` but locks to vertical direction and tracks
|
||||||
|
* the sheet height instead of width.
|
||||||
|
*/
|
||||||
|
export function useSwipeToDismissDown(onDismiss: () => void) {
|
||||||
|
const [swipe, setSwipe] = useState<SwipeState>({
|
||||||
|
offsetX: 0,
|
||||||
|
isSwiping: false,
|
||||||
|
});
|
||||||
|
const startX = useRef(0);
|
||||||
|
const startY = useRef(0);
|
||||||
|
const startTime = useRef(0);
|
||||||
|
const sheetHeight = useRef(0);
|
||||||
|
const directionLocked = useRef<"horizontal" | "vertical" | null>(null);
|
||||||
|
|
||||||
|
const onTouchStart = useCallback((e: React.TouchEvent) => {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
startX.current = touch.clientX;
|
||||||
|
startY.current = touch.clientY;
|
||||||
|
startTime.current = Date.now();
|
||||||
|
directionLocked.current = null;
|
||||||
|
const el = e.currentTarget as HTMLElement;
|
||||||
|
sheetHeight.current = el.getBoundingClientRect().height;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onTouchMove = useCallback((e: React.TouchEvent) => {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const dx = touch.clientX - startX.current;
|
||||||
|
const dy = touch.clientY - startY.current;
|
||||||
|
|
||||||
|
if (!directionLocked.current) {
|
||||||
|
if (Math.abs(dx) < 10 && Math.abs(dy) < 10) return;
|
||||||
|
directionLocked.current =
|
||||||
|
Math.abs(dy) > Math.abs(dx) ? "vertical" : "horizontal";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (directionLocked.current === "horizontal") return;
|
||||||
|
|
||||||
|
const clampedY = Math.max(0, dy);
|
||||||
|
// `offsetX` is reused as the vertical offset to keep SwipeState shared.
|
||||||
|
setSwipe({ offsetX: clampedY, isSwiping: true });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onTouchEnd = useCallback(() => {
|
||||||
|
if (directionLocked.current !== "vertical") {
|
||||||
|
setSwipe({ offsetX: 0, isSwiping: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = (Date.now() - startTime.current) / 1000;
|
||||||
|
const velocity = swipe.offsetX / elapsed / sheetHeight.current;
|
||||||
|
const ratio =
|
||||||
|
sheetHeight.current > 0 ? swipe.offsetX / sheetHeight.current : 0;
|
||||||
|
|
||||||
|
if (ratio > DISMISS_THRESHOLD || velocity > VELOCITY_THRESHOLD) {
|
||||||
|
onDismiss();
|
||||||
|
}
|
||||||
|
|
||||||
|
setSwipe({ offsetX: 0, isSwiping: false });
|
||||||
|
}, [swipe.offsetX, onDismiss]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
offsetY: swipe.offsetX,
|
||||||
|
isSwiping: swipe.isSwiping,
|
||||||
|
handlers: { onTouchStart, onTouchMove, onTouchEnd },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -103,6 +103,19 @@
|
|||||||
animation: slide-in-right 200ms ease-out;
|
animation: slide-in-right 200ms ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes slide-in-bottom {
|
||||||
|
from {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility animate-slide-in-bottom {
|
||||||
|
animation: slide-in-bottom 200ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes confirm-pulse {
|
@keyframes confirm-pulse {
|
||||||
0% {
|
0% {
|
||||||
scale: 1;
|
scale: 1;
|
||||||
|
|||||||
+2
-1
@@ -10,7 +10,8 @@
|
|||||||
"!coverage",
|
"!coverage",
|
||||||
"!.pnpm-store",
|
"!.pnpm-store",
|
||||||
"!.rodney",
|
"!.rodney",
|
||||||
"!.agent-tests"
|
"!.agent-tests",
|
||||||
|
"!data"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"assist": {
|
"assist": {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+1
-25103
File diff suppressed because one or more lines are too long
+15
-2
@@ -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": {
|
||||||
@@ -31,7 +44,7 @@
|
|||||||
"knip": "knip",
|
"knip": "knip",
|
||||||
"jscpd": "jscpd",
|
"jscpd": "jscpd",
|
||||||
"jsinspect": "jsinspect -c .jsinspectrc apps/web/src packages/domain/src packages/application/src",
|
"jsinspect": "jsinspect -c .jsinspectrc apps/web/src packages/domain/src packages/application/src",
|
||||||
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware --deny warnings",
|
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware --deny-warnings",
|
||||||
"check:ignores": "node scripts/check-lint-ignores.mjs",
|
"check:ignores": "node scripts/check-lint-ignores.mjs",
|
||||||
"check:classnames": "node scripts/check-cn-classnames.mjs",
|
"check:classnames": "node scripts/check-cn-classnames.mjs",
|
||||||
"check:props": "node scripts/check-component-props.mjs",
|
"check:props": "node scripts/check-component-props.mjs",
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import {
|
||||||
|
addPersistentDamage,
|
||||||
|
type CombatantId,
|
||||||
|
type DomainError,
|
||||||
|
type DomainEvent,
|
||||||
|
type PersistentDamageType,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
import { runEncounterAction } from "./run-encounter-action.js";
|
||||||
|
|
||||||
|
export function addPersistentDamageUseCase(
|
||||||
|
store: EncounterStore,
|
||||||
|
combatantId: CombatantId,
|
||||||
|
damageType: PersistentDamageType,
|
||||||
|
formula: string,
|
||||||
|
): DomainEvent[] | DomainError {
|
||||||
|
return runEncounterAction(store, (encounter) =>
|
||||||
|
addPersistentDamage(encounter, combatantId, damageType, formula),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export { addCombatantUseCase } from "./add-combatant-use-case.js";
|
export { addCombatantUseCase } from "./add-combatant-use-case.js";
|
||||||
|
export { addPersistentDamageUseCase } from "./add-persistent-damage-use-case.js";
|
||||||
export { adjustHpUseCase } from "./adjust-hp-use-case.js";
|
export { adjustHpUseCase } from "./adjust-hp-use-case.js";
|
||||||
export { advanceTurnUseCase } from "./advance-turn-use-case.js";
|
export { advanceTurnUseCase } from "./advance-turn-use-case.js";
|
||||||
export { clearEncounterUseCase } from "./clear-encounter-use-case.js";
|
export { clearEncounterUseCase } from "./clear-encounter-use-case.js";
|
||||||
@@ -15,6 +16,7 @@ export type {
|
|||||||
} from "./ports.js";
|
} from "./ports.js";
|
||||||
export { redoUseCase } from "./redo-use-case.js";
|
export { redoUseCase } from "./redo-use-case.js";
|
||||||
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
|
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
|
||||||
|
export { removePersistentDamageUseCase } from "./remove-persistent-damage-use-case.js";
|
||||||
export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
|
export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
|
||||||
export {
|
export {
|
||||||
type RollAllResult,
|
type RollAllResult,
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type DomainError,
|
||||||
|
type DomainEvent,
|
||||||
|
type PersistentDamageType,
|
||||||
|
removePersistentDamage,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
import { runEncounterAction } from "./run-encounter-action.js";
|
||||||
|
|
||||||
|
export function removePersistentDamageUseCase(
|
||||||
|
store: EncounterStore,
|
||||||
|
combatantId: CombatantId,
|
||||||
|
damageType: PersistentDamageType,
|
||||||
|
): DomainEvent[] | DomainError {
|
||||||
|
return runEncounterAction(store, (encounter) =>
|
||||||
|
removePersistentDamage(encounter, combatantId, damageType),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,8 +47,8 @@ describe("getConditionDescription", () => {
|
|||||||
(d.systems.includes("5e") && d.systems.includes("5.5e")),
|
(d.systems.includes("5e") && d.systems.includes("5.5e")),
|
||||||
);
|
);
|
||||||
for (const def of sharedDndConditions) {
|
for (const def of sharedDndConditions) {
|
||||||
expect(def.description, `${def.id} missing description`).toBeTruthy();
|
expect(def.description).toBeTruthy();
|
||||||
expect(def.description5e, `${def.id} missing description5e`).toBeTruthy();
|
expect(def.description5e).toBeTruthy();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
addPersistentDamage,
|
||||||
|
type PersistentDamageType,
|
||||||
|
removePersistentDamage,
|
||||||
|
} from "../persistent-damage.js";
|
||||||
|
import type { Encounter } from "../types.js";
|
||||||
|
import { combatantId } from "../types.js";
|
||||||
|
|
||||||
|
const goblinId = combatantId("goblin-1");
|
||||||
|
|
||||||
|
function buildEncounter(overrides: Partial<Encounter> = {}): Encounter {
|
||||||
|
return {
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: goblinId,
|
||||||
|
name: "Goblin",
|
||||||
|
...overrides.combatants?.[0],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeIndex: overrides.activeIndex ?? 0,
|
||||||
|
roundNumber: overrides.roundNumber ?? 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("addPersistentDamage", () => {
|
||||||
|
it("adds persistent fire damage to combatant", () => {
|
||||||
|
const encounter = buildEncounter();
|
||||||
|
const result = addPersistentDamage(encounter, goblinId, "fire", "2d6");
|
||||||
|
|
||||||
|
expect(result).not.toHaveProperty("kind");
|
||||||
|
if ("kind" in result) return;
|
||||||
|
|
||||||
|
const target = result.encounter.combatants[0];
|
||||||
|
expect(target.persistentDamage).toEqual([{ type: "fire", formula: "2d6" }]);
|
||||||
|
expect(result.events).toEqual([
|
||||||
|
{
|
||||||
|
type: "PersistentDamageAdded",
|
||||||
|
combatantId: goblinId,
|
||||||
|
damageType: "fire",
|
||||||
|
formula: "2d6",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces existing entry of same type with new formula", () => {
|
||||||
|
const encounter = buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: goblinId,
|
||||||
|
name: "Goblin",
|
||||||
|
persistentDamage: [{ type: "fire", formula: "2d6" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = addPersistentDamage(encounter, goblinId, "fire", "3d6");
|
||||||
|
|
||||||
|
expect(result).not.toHaveProperty("kind");
|
||||||
|
if ("kind" in result) return;
|
||||||
|
|
||||||
|
expect(result.encounter.combatants[0].persistentDamage).toEqual([
|
||||||
|
{ type: "fire", formula: "3d6" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows multiple different damage types", () => {
|
||||||
|
const encounter = buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: goblinId,
|
||||||
|
name: "Goblin",
|
||||||
|
persistentDamage: [{ type: "fire", formula: "2d6" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = addPersistentDamage(encounter, goblinId, "bleed", "1d4");
|
||||||
|
|
||||||
|
expect(result).not.toHaveProperty("kind");
|
||||||
|
if ("kind" in result) return;
|
||||||
|
|
||||||
|
expect(result.encounter.combatants[0].persistentDamage).toEqual([
|
||||||
|
{ type: "fire", formula: "2d6" },
|
||||||
|
{ type: "bleed", formula: "1d4" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts entries by definition order", () => {
|
||||||
|
const encounter = buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: goblinId,
|
||||||
|
name: "Goblin",
|
||||||
|
persistentDamage: [{ type: "cold", formula: "1d6" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = addPersistentDamage(encounter, goblinId, "fire", "2d6");
|
||||||
|
|
||||||
|
expect(result).not.toHaveProperty("kind");
|
||||||
|
if ("kind" in result) return;
|
||||||
|
|
||||||
|
const types = result.encounter.combatants[0].persistentDamage?.map(
|
||||||
|
(e) => e.type,
|
||||||
|
);
|
||||||
|
expect(types).toEqual(["fire", "cold"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error for empty formula", () => {
|
||||||
|
const encounter = buildEncounter();
|
||||||
|
const result = addPersistentDamage(encounter, goblinId, "fire", " ");
|
||||||
|
|
||||||
|
expect(result).toHaveProperty("kind", "domain-error");
|
||||||
|
if (!("kind" in result)) return;
|
||||||
|
expect(result.code).toBe("empty-formula");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error for unknown damage type", () => {
|
||||||
|
const encounter = buildEncounter();
|
||||||
|
const result = addPersistentDamage(
|
||||||
|
encounter,
|
||||||
|
goblinId,
|
||||||
|
"radiant" as PersistentDamageType,
|
||||||
|
"2d6",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toHaveProperty("kind", "domain-error");
|
||||||
|
if (!("kind" in result)) return;
|
||||||
|
expect(result.code).toBe("unknown-damage-type");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error for unknown combatant", () => {
|
||||||
|
const encounter = buildEncounter();
|
||||||
|
const result = addPersistentDamage(
|
||||||
|
encounter,
|
||||||
|
combatantId("nonexistent"),
|
||||||
|
"fire",
|
||||||
|
"2d6",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toHaveProperty("kind", "domain-error");
|
||||||
|
if (!("kind" in result)) return;
|
||||||
|
expect(result.code).toBe("combatant-not-found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims formula whitespace", () => {
|
||||||
|
const encounter = buildEncounter();
|
||||||
|
const result = addPersistentDamage(encounter, goblinId, "fire", " 2d6 ");
|
||||||
|
|
||||||
|
expect(result).not.toHaveProperty("kind");
|
||||||
|
if ("kind" in result) return;
|
||||||
|
|
||||||
|
expect(result.encounter.combatants[0].persistentDamage?.[0].formula).toBe(
|
||||||
|
"2d6",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mutate input encounter", () => {
|
||||||
|
const encounter = buildEncounter();
|
||||||
|
const originalCombatants = encounter.combatants;
|
||||||
|
addPersistentDamage(encounter, goblinId, "fire", "2d6");
|
||||||
|
|
||||||
|
expect(encounter.combatants).toBe(originalCombatants);
|
||||||
|
expect(encounter.combatants[0].persistentDamage).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("removePersistentDamage", () => {
|
||||||
|
it("removes existing persistent damage entry", () => {
|
||||||
|
const encounter = buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: goblinId,
|
||||||
|
name: "Goblin",
|
||||||
|
persistentDamage: [
|
||||||
|
{ type: "fire", formula: "2d6" },
|
||||||
|
{ type: "bleed", formula: "1d4" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = removePersistentDamage(encounter, goblinId, "fire");
|
||||||
|
|
||||||
|
expect(result).not.toHaveProperty("kind");
|
||||||
|
if ("kind" in result) return;
|
||||||
|
|
||||||
|
expect(result.encounter.combatants[0].persistentDamage).toEqual([
|
||||||
|
{ type: "bleed", formula: "1d4" },
|
||||||
|
]);
|
||||||
|
expect(result.events).toEqual([
|
||||||
|
{
|
||||||
|
type: "PersistentDamageRemoved",
|
||||||
|
combatantId: goblinId,
|
||||||
|
damageType: "fire",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets persistentDamage to undefined when last entry removed", () => {
|
||||||
|
const encounter = buildEncounter({
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: goblinId,
|
||||||
|
name: "Goblin",
|
||||||
|
persistentDamage: [{ type: "fire", formula: "2d6" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = removePersistentDamage(encounter, goblinId, "fire");
|
||||||
|
|
||||||
|
expect(result).not.toHaveProperty("kind");
|
||||||
|
if ("kind" in result) return;
|
||||||
|
|
||||||
|
expect(result.encounter.combatants[0].persistentDamage).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error when damage type not active", () => {
|
||||||
|
const encounter = buildEncounter();
|
||||||
|
const result = removePersistentDamage(encounter, goblinId, "fire");
|
||||||
|
|
||||||
|
expect(result).toHaveProperty("kind", "domain-error");
|
||||||
|
if (!("kind" in result)) return;
|
||||||
|
expect(result.code).toBe("persistent-damage-not-active");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error for unknown combatant", () => {
|
||||||
|
const encounter = buildEncounter();
|
||||||
|
const result = removePersistentDamage(
|
||||||
|
encounter,
|
||||||
|
combatantId("nonexistent"),
|
||||||
|
"fire",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toHaveProperty("kind", "domain-error");
|
||||||
|
if (!("kind" in result)) return;
|
||||||
|
expect(result.code).toBe("combatant-not-found");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { Pf2eCreature } from "../creature-types.js";
|
||||||
|
import { creatureId } from "../creature-types.js";
|
||||||
|
import {
|
||||||
|
acDelta,
|
||||||
|
adjustedLevel,
|
||||||
|
applyPf2eAdjustment,
|
||||||
|
hpDelta,
|
||||||
|
modDelta,
|
||||||
|
} from "../pf2e-adjustments.js";
|
||||||
|
|
||||||
|
describe("adjustedLevel", () => {
|
||||||
|
it("elite on level 5 → 6", () => {
|
||||||
|
expect(adjustedLevel(5, "elite")).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("elite on level 0 → 2 (double bump)", () => {
|
||||||
|
expect(adjustedLevel(0, "elite")).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("elite on level −1 → 1 (double bump)", () => {
|
||||||
|
expect(adjustedLevel(-1, "elite")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weak on level 5 → 4", () => {
|
||||||
|
expect(adjustedLevel(5, "weak")).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weak on level 1 → −1 (double drop)", () => {
|
||||||
|
expect(adjustedLevel(1, "weak")).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weak on level 0 → −1", () => {
|
||||||
|
expect(adjustedLevel(0, "weak")).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hpDelta", () => {
|
||||||
|
it("level 1 elite → +10", () => {
|
||||||
|
expect(hpDelta(1, "elite")).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("level 1 weak → −10", () => {
|
||||||
|
expect(hpDelta(1, "weak")).toBe(-10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("level 3 elite → +15", () => {
|
||||||
|
expect(hpDelta(3, "elite")).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("level 3 weak → −15", () => {
|
||||||
|
expect(hpDelta(3, "weak")).toBe(-15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("level 10 elite → +20", () => {
|
||||||
|
expect(hpDelta(10, "elite")).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("level 10 weak → −20", () => {
|
||||||
|
expect(hpDelta(10, "weak")).toBe(-20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("level 25 elite → +30", () => {
|
||||||
|
expect(hpDelta(25, "elite")).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("level 25 weak → −30", () => {
|
||||||
|
expect(hpDelta(25, "weak")).toBe(-30);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("acDelta", () => {
|
||||||
|
it("elite → +2", () => {
|
||||||
|
expect(acDelta("elite")).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weak → −2", () => {
|
||||||
|
expect(acDelta("weak")).toBe(-2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("modDelta", () => {
|
||||||
|
it("elite → +2", () => {
|
||||||
|
expect(modDelta("elite")).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weak → −2", () => {
|
||||||
|
expect(modDelta("weak")).toBe(-2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function baseCreature(overrides?: Partial<Pf2eCreature>): Pf2eCreature {
|
||||||
|
return {
|
||||||
|
system: "pf2e",
|
||||||
|
id: creatureId("test-creature"),
|
||||||
|
name: "Test Creature",
|
||||||
|
source: "test-source",
|
||||||
|
sourceDisplayName: "Test Source",
|
||||||
|
level: 5,
|
||||||
|
traits: ["humanoid"],
|
||||||
|
perception: 12,
|
||||||
|
skills: "Athletics +14",
|
||||||
|
abilityMods: {
|
||||||
|
str: 4,
|
||||||
|
dex: 2,
|
||||||
|
con: 3,
|
||||||
|
int: 0,
|
||||||
|
wis: 1,
|
||||||
|
cha: -1,
|
||||||
|
},
|
||||||
|
ac: 22,
|
||||||
|
saveFort: 14,
|
||||||
|
saveRef: 11,
|
||||||
|
saveWill: 9,
|
||||||
|
hp: 75,
|
||||||
|
speed: "25 feet",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("applyPf2eAdjustment", () => {
|
||||||
|
it("adjusts all numeric stats for elite", () => {
|
||||||
|
const creature = baseCreature();
|
||||||
|
const result = applyPf2eAdjustment(creature, "elite");
|
||||||
|
|
||||||
|
expect(result.level).toBe(6);
|
||||||
|
expect(result.ac).toBe(24);
|
||||||
|
expect(result.hp).toBe(95); // 75 + 20 (level 5 bracket)
|
||||||
|
expect(result.perception).toBe(14);
|
||||||
|
expect(result.saveFort).toBe(16);
|
||||||
|
expect(result.saveRef).toBe(13);
|
||||||
|
expect(result.saveWill).toBe(11);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adjusts all numeric stats for weak", () => {
|
||||||
|
const creature = baseCreature();
|
||||||
|
const result = applyPf2eAdjustment(creature, "weak");
|
||||||
|
|
||||||
|
expect(result.level).toBe(4);
|
||||||
|
expect(result.ac).toBe(20);
|
||||||
|
expect(result.hp).toBe(55); // 75 - 20 (level 5 bracket)
|
||||||
|
expect(result.perception).toBe(10);
|
||||||
|
expect(result.saveFort).toBe(12);
|
||||||
|
expect(result.saveRef).toBe(9);
|
||||||
|
expect(result.saveWill).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adjusts attack bonuses and damage", () => {
|
||||||
|
const creature = baseCreature({
|
||||||
|
attacks: [
|
||||||
|
{
|
||||||
|
name: "Melee",
|
||||||
|
activity: { number: 1, unit: "action" },
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
value: "+15 [+10/+5] (agile), 2d12+7 piercing plus Grab",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = applyPf2eAdjustment(creature, "elite");
|
||||||
|
const text = result.attacks?.[0].segments[0];
|
||||||
|
expect(text).toEqual({
|
||||||
|
type: "text",
|
||||||
|
value: "+17 [+12/+7] (agile), 2d12+9 piercing plus Grab",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adjusts attack damage for weak", () => {
|
||||||
|
const creature = baseCreature({
|
||||||
|
attacks: [
|
||||||
|
{
|
||||||
|
name: "Melee",
|
||||||
|
activity: { number: 1, unit: "action" },
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
value: "+15 (agile), 2d12+7 piercing plus Grab",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = applyPf2eAdjustment(creature, "weak");
|
||||||
|
const text = result.attacks?.[0].segments[0];
|
||||||
|
expect(text).toEqual({
|
||||||
|
type: "text",
|
||||||
|
value: "+13 (agile), 2d12+5 piercing plus Grab",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles damage bonus becoming zero", () => {
|
||||||
|
const creature = baseCreature({
|
||||||
|
attacks: [
|
||||||
|
{
|
||||||
|
name: "Melee",
|
||||||
|
activity: { number: 1, unit: "action" },
|
||||||
|
segments: [{ type: "text", value: "+10, 1d4+2 slashing" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = applyPf2eAdjustment(creature, "weak");
|
||||||
|
const text = result.attacks?.[0].segments[0];
|
||||||
|
expect(text).toEqual({
|
||||||
|
type: "text",
|
||||||
|
value: "+8, 1d4 slashing",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles damage bonus becoming negative", () => {
|
||||||
|
const creature = baseCreature({
|
||||||
|
attacks: [
|
||||||
|
{
|
||||||
|
name: "Melee",
|
||||||
|
activity: { number: 1, unit: "action" },
|
||||||
|
segments: [{ type: "text", value: "+10, 1d4+1 slashing" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = applyPf2eAdjustment(creature, "weak");
|
||||||
|
const text = result.attacks?.[0].segments[0];
|
||||||
|
expect(text).toEqual({
|
||||||
|
type: "text",
|
||||||
|
value: "+8, 1d4-1 slashing",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not modify non-attack abilities", () => {
|
||||||
|
const creature = baseCreature({
|
||||||
|
abilitiesTop: [
|
||||||
|
{
|
||||||
|
name: "Darkvision",
|
||||||
|
segments: [{ type: "text", value: "Can see in darkness." }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = applyPf2eAdjustment(creature, "elite");
|
||||||
|
expect(result.abilitiesTop).toEqual(creature.abilitiesTop);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves non-text segments in attacks", () => {
|
||||||
|
const creature = baseCreature({
|
||||||
|
attacks: [
|
||||||
|
{
|
||||||
|
name: "Melee",
|
||||||
|
activity: { number: 1, unit: "action" },
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
type: "list",
|
||||||
|
items: [{ text: "some list item" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = applyPf2eAdjustment(creature, "elite");
|
||||||
|
expect(result.attacks?.[0].segments[0]).toEqual({
|
||||||
|
type: "list",
|
||||||
|
items: [{ text: "some list item" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { recallKnowledge } from "../recall-knowledge.js";
|
||||||
|
|
||||||
|
describe("recallKnowledge", () => {
|
||||||
|
it("returns null when no type trait is recognized", () => {
|
||||||
|
expect(recallKnowledge(5, ["small", "goblin"])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates DC for a common creature from the DC-by-level table", () => {
|
||||||
|
const result = recallKnowledge(5, ["humanoid"]);
|
||||||
|
expect(result).toEqual({ dc: 20, type: "humanoid", skills: ["Society"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates DC for level -1", () => {
|
||||||
|
const result = recallKnowledge(-1, ["humanoid"]);
|
||||||
|
expect(result).toEqual({ dc: 13, type: "humanoid", skills: ["Society"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates DC for level 0", () => {
|
||||||
|
const result = recallKnowledge(0, ["animal"]);
|
||||||
|
expect(result).toEqual({ dc: 14, type: "animal", skills: ["Nature"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates DC for level 25 (max table entry)", () => {
|
||||||
|
const result = recallKnowledge(25, ["dragon"]);
|
||||||
|
expect(result).toEqual({ dc: 50, type: "dragon", skills: ["Arcana"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps DC for levels beyond the table", () => {
|
||||||
|
const result = recallKnowledge(30, ["dragon"]);
|
||||||
|
expect(result).toEqual({ dc: 50, type: "dragon", skills: ["Arcana"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adjusts DC for uncommon rarity (+2)", () => {
|
||||||
|
const result = recallKnowledge(5, ["uncommon", "medium", "undead"]);
|
||||||
|
expect(result).toEqual({
|
||||||
|
dc: 22,
|
||||||
|
type: "undead",
|
||||||
|
skills: ["Religion"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adjusts DC for rare rarity (+5)", () => {
|
||||||
|
const result = recallKnowledge(5, ["rare", "large", "dragon"]);
|
||||||
|
expect(result).toEqual({ dc: 25, type: "dragon", skills: ["Arcana"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adjusts DC for unique rarity (+10)", () => {
|
||||||
|
const result = recallKnowledge(5, ["unique", "medium", "humanoid"]);
|
||||||
|
expect(result).toEqual({
|
||||||
|
dc: 30,
|
||||||
|
type: "humanoid",
|
||||||
|
skills: ["Society"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns multiple skills for beast type", () => {
|
||||||
|
const result = recallKnowledge(3, ["beast"]);
|
||||||
|
expect(result).toEqual({
|
||||||
|
dc: 18,
|
||||||
|
type: "beast",
|
||||||
|
skills: ["Arcana", "Nature"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns multiple skills for construct type", () => {
|
||||||
|
const result = recallKnowledge(1, ["construct"]);
|
||||||
|
expect(result).toEqual({
|
||||||
|
dc: 15,
|
||||||
|
type: "construct",
|
||||||
|
skills: ["Arcana", "Crafting"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches type traits case-insensitively", () => {
|
||||||
|
const result = recallKnowledge(5, ["Humanoid"]);
|
||||||
|
expect(result).toEqual({ dc: 20, type: "Humanoid", skills: ["Society"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the first matching type trait when multiple are present", () => {
|
||||||
|
const result = recallKnowledge(7, ["large", "monitor", "protean"]);
|
||||||
|
expect(result).toEqual({
|
||||||
|
dc: 23,
|
||||||
|
type: "monitor",
|
||||||
|
skills: ["Religion"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves original trait casing in the returned type", () => {
|
||||||
|
const result = recallKnowledge(1, ["Fey"]);
|
||||||
|
expect(result?.type).toBe("Fey");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores common rarity (no adjustment)", () => {
|
||||||
|
// "common" is not included in traits by the normalization pipeline
|
||||||
|
const result = recallKnowledge(5, ["medium", "humanoid"]);
|
||||||
|
expect(result?.dc).toBe(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -301,6 +301,52 @@ describe("rehydrateCombatant", () => {
|
|||||||
expect(result?.side).toBeUndefined();
|
expect(result?.side).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves valid persistent damage entries", () => {
|
||||||
|
const result = rehydrateCombatant({
|
||||||
|
...minimalCombatant(),
|
||||||
|
persistentDamage: [
|
||||||
|
{ type: "fire", formula: "2d6" },
|
||||||
|
{ type: "bleed", formula: "1d4" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(result?.persistentDamage).toEqual([
|
||||||
|
{ type: "fire", formula: "2d6" },
|
||||||
|
{ type: "bleed", formula: "1d4" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters out invalid persistent damage entries", () => {
|
||||||
|
const result = rehydrateCombatant({
|
||||||
|
...minimalCombatant(),
|
||||||
|
persistentDamage: [
|
||||||
|
{ type: "fire", formula: "2d6" },
|
||||||
|
{ type: "radiant", formula: "1d4" },
|
||||||
|
{ type: "bleed", formula: "" },
|
||||||
|
{ type: "acid" },
|
||||||
|
{ formula: "1d6" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(result?.persistentDamage).toEqual([
|
||||||
|
{ type: "fire", formula: "2d6" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined persistentDamage for non-array value", () => {
|
||||||
|
const result = rehydrateCombatant({
|
||||||
|
...minimalCombatant(),
|
||||||
|
persistentDamage: "fire",
|
||||||
|
});
|
||||||
|
expect(result?.persistentDamage).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined persistentDamage for empty array", () => {
|
||||||
|
const result = rehydrateCombatant({
|
||||||
|
...minimalCombatant(),
|
||||||
|
persistentDamage: [],
|
||||||
|
});
|
||||||
|
expect(result?.persistentDamage).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("drops invalid tempHp — keeps combatant", () => {
|
it("drops invalid tempHp — keeps combatant", () => {
|
||||||
for (const tempHp of [-1, 1.5, "3"]) {
|
for (const tempHp of [-1, 1.5, "3"]) {
|
||||||
const result = rehydrateCombatant({
|
const result = rehydrateCombatant({
|
||||||
|
|||||||
@@ -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 })));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -169,6 +170,60 @@ describe("setConditionValue", () => {
|
|||||||
);
|
);
|
||||||
expectDomainError(result, "unknown-condition");
|
expectDomainError(result, "unknown-condition");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("clamps value to maxValue for capped conditions", () => {
|
||||||
|
const e = enc([makeCombatant("A")]);
|
||||||
|
const result = setConditionValue(e, combatantId("A"), "dying", 6);
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
|
||||||
|
expect(result.encounter.combatants[0].conditions).toEqual([
|
||||||
|
{ id: "dying", value: 4 },
|
||||||
|
]);
|
||||||
|
expect(result.events[0]).toMatchObject({
|
||||||
|
type: "ConditionAdded",
|
||||||
|
value: 4,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows value at exactly the max", () => {
|
||||||
|
const e = enc([makeCombatant("A")]);
|
||||||
|
const result = setConditionValue(e, combatantId("A"), "doomed", 3);
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
|
||||||
|
expect(result.encounter.combatants[0].conditions).toEqual([
|
||||||
|
{ id: "doomed", value: 3 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows value below the max", () => {
|
||||||
|
const e = enc([makeCombatant("A")]);
|
||||||
|
const result = setConditionValue(e, combatantId("A"), "wounded", 2);
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
|
||||||
|
expect(result.encounter.combatants[0].conditions).toEqual([
|
||||||
|
{ id: "wounded", value: 2 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not cap conditions without a maxValue", () => {
|
||||||
|
const e = enc([makeCombatant("A")]);
|
||||||
|
const result = setConditionValue(e, combatantId("A"), "frightened", 10);
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
|
||||||
|
expect(result.encounter.combatants[0].conditions).toEqual([
|
||||||
|
{ id: "frightened", value: 10 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps when updating an existing capped condition", () => {
|
||||||
|
const e = enc([makeCombatant("A", [{ id: "slowed-pf2e", value: 2 }])]);
|
||||||
|
const result = setConditionValue(e, combatantId("A"), "slowed-pf2e", 5);
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
|
||||||
|
expect(result.encounter.combatants[0].conditions).toEqual([
|
||||||
|
{ id: "slowed-pf2e", value: 3 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("decrementCondition", () => {
|
describe("decrementCondition", () => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface CombatantInit {
|
|||||||
readonly ac?: number;
|
readonly ac?: number;
|
||||||
readonly initiative?: number;
|
readonly initiative?: number;
|
||||||
readonly creatureId?: CreatureId;
|
readonly creatureId?: CreatureId;
|
||||||
|
readonly creatureAdjustment?: "weak" | "elite";
|
||||||
readonly color?: string;
|
readonly color?: string;
|
||||||
readonly icon?: string;
|
readonly icon?: string;
|
||||||
readonly playerCharacterId?: PlayerCharacterId;
|
readonly playerCharacterId?: PlayerCharacterId;
|
||||||
@@ -67,6 +68,9 @@ function buildCombatant(
|
|||||||
...(init?.ac !== undefined && { ac: init.ac }),
|
...(init?.ac !== undefined && { ac: init.ac }),
|
||||||
...(init?.initiative !== undefined && { initiative: init.initiative }),
|
...(init?.initiative !== undefined && { initiative: init.initiative }),
|
||||||
...(init?.creatureId !== undefined && { creatureId: init.creatureId }),
|
...(init?.creatureId !== undefined && { creatureId: init.creatureId }),
|
||||||
|
...(init?.creatureAdjustment !== undefined && {
|
||||||
|
creatureAdjustment: init.creatureAdjustment,
|
||||||
|
}),
|
||||||
...(init?.color !== undefined && { color: init.color }),
|
...(init?.color !== undefined && { color: init.color }),
|
||||||
...(init?.icon !== undefined && { icon: init.icon }),
|
...(init?.icon !== undefined && { icon: init.icon }),
|
||||||
...(init?.playerCharacterId !== undefined && {
|
...(init?.playerCharacterId !== undefined && {
|
||||||
|
|||||||
@@ -1,3 +1,28 @@
|
|||||||
|
const DIGITS_ONLY = /^\d+$/;
|
||||||
|
|
||||||
|
function scanExisting(
|
||||||
|
baseName: string,
|
||||||
|
existingNames: readonly string[],
|
||||||
|
): { exactMatches: number[]; maxNumber: number } {
|
||||||
|
const exactMatches: number[] = [];
|
||||||
|
let maxNumber = 0;
|
||||||
|
const prefix = `${baseName} `;
|
||||||
|
|
||||||
|
for (let i = 0; i < existingNames.length; i++) {
|
||||||
|
const name = existingNames[i];
|
||||||
|
if (name === baseName) {
|
||||||
|
exactMatches.push(i);
|
||||||
|
} else if (name.startsWith(prefix)) {
|
||||||
|
const suffix = name.slice(prefix.length);
|
||||||
|
if (DIGITS_ONLY.test(suffix)) {
|
||||||
|
const num = Number.parseInt(suffix, 10);
|
||||||
|
if (num > maxNumber) maxNumber = num;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { exactMatches, maxNumber };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a creature name against existing combatant names,
|
* Resolves a creature name against existing combatant names,
|
||||||
* handling auto-numbering for duplicates.
|
* handling auto-numbering for duplicates.
|
||||||
@@ -14,25 +39,7 @@ export function resolveCreatureName(
|
|||||||
newName: string;
|
newName: string;
|
||||||
renames: ReadonlyArray<{ from: string; to: string }>;
|
renames: ReadonlyArray<{ from: string; to: string }>;
|
||||||
} {
|
} {
|
||||||
// Find exact matches and numbered matches (e.g., "Goblin 1", "Goblin 2")
|
const { exactMatches, maxNumber } = scanExisting(baseName, existingNames);
|
||||||
const exactMatches: number[] = [];
|
|
||||||
let maxNumber = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < existingNames.length; i++) {
|
|
||||||
const name = existingNames[i];
|
|
||||||
if (name === baseName) {
|
|
||||||
exactMatches.push(i);
|
|
||||||
} else {
|
|
||||||
const match = new RegExp(
|
|
||||||
String.raw`^${escapeRegExp(baseName)} (\d+)$`,
|
|
||||||
).exec(name);
|
|
||||||
// biome-ignore lint/nursery/noUnnecessaryConditions: RegExp.exec() returns null on no match — false positive
|
|
||||||
if (match) {
|
|
||||||
const num = Number.parseInt(match[1], 10);
|
|
||||||
if (num > maxNumber) maxNumber = num;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No conflict at all
|
// No conflict at all
|
||||||
if (exactMatches.length === 0 && maxNumber === 0) {
|
if (exactMatches.length === 0 && maxNumber === 0) {
|
||||||
@@ -51,7 +58,3 @@ export function resolveCreatureName(
|
|||||||
const nextNumber = Math.max(maxNumber, exactMatches.length) + 1;
|
const nextNumber = Math.max(maxNumber, exactMatches.length) + 1;
|
||||||
return { newName: `${baseName} ${nextNumber}`, renames: [] };
|
return { newName: `${baseName} ${nextNumber}`, renames: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeRegExp(s: string): string {
|
|
||||||
return s.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ export interface ConditionDefinition {
|
|||||||
/** When set, the condition only appears in these systems' pickers. */
|
/** When set, the condition only appears in these systems' pickers. */
|
||||||
readonly systems?: readonly RulesEdition[];
|
readonly systems?: readonly RulesEdition[];
|
||||||
readonly valued?: boolean;
|
readonly valued?: boolean;
|
||||||
|
/** Rule-defined maximum value for PF2e valued conditions. */
|
||||||
|
readonly maxValue?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getConditionDescription(
|
export function getConditionDescription(
|
||||||
@@ -329,6 +331,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
color: "red",
|
color: "red",
|
||||||
systems: ["pf2e"],
|
systems: ["pf2e"],
|
||||||
valued: true,
|
valued: true,
|
||||||
|
maxValue: 3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "drained",
|
id: "drained",
|
||||||
@@ -353,6 +356,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
color: "red",
|
color: "red",
|
||||||
systems: ["pf2e"],
|
systems: ["pf2e"],
|
||||||
valued: true,
|
valued: true,
|
||||||
|
maxValue: 4,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "enfeebled",
|
id: "enfeebled",
|
||||||
@@ -475,6 +479,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
color: "sky",
|
color: "sky",
|
||||||
systems: ["pf2e"],
|
systems: ["pf2e"],
|
||||||
valued: true,
|
valued: true,
|
||||||
|
maxValue: 3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "stupefied",
|
id: "stupefied",
|
||||||
@@ -495,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"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -510,6 +515,7 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
|||||||
color: "red",
|
color: "red",
|
||||||
systems: ["pf2e"],
|
systems: ["pf2e"],
|
||||||
valued: true,
|
valued: true,
|
||||||
|
maxValue: 3,
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,16 @@ export interface TraitListItem {
|
|||||||
readonly text: string;
|
readonly text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ActivityCost {
|
||||||
|
readonly number: number;
|
||||||
|
readonly unit: "action" | "free" | "reaction";
|
||||||
|
}
|
||||||
|
|
||||||
export interface TraitBlock {
|
export interface TraitBlock {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
|
readonly activity?: ActivityCost;
|
||||||
|
readonly trigger?: string;
|
||||||
|
readonly frequency?: string;
|
||||||
readonly segments: readonly TraitSegment[];
|
readonly segments: readonly TraitSegment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,16 +32,84 @@ export interface LegendaryBlock {
|
|||||||
readonly entries: readonly TraitBlock[];
|
readonly entries: readonly TraitBlock[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single spell entry within a creature's spellcasting block.
|
||||||
|
*
|
||||||
|
* `name` is always populated. All other fields are optional and are only
|
||||||
|
* populated for PF2e creatures (sourced from embedded Foundry VTT spell items).
|
||||||
|
* D&D 5e creatures populate only `name`.
|
||||||
|
*/
|
||||||
|
export interface SpellReference {
|
||||||
|
readonly name: string;
|
||||||
|
|
||||||
|
/** Stable slug from Foundry VTT (e.g. "magic-missile"). PF2e only. */
|
||||||
|
readonly slug?: string;
|
||||||
|
|
||||||
|
/** Plain-text description with Foundry enrichment tags stripped. */
|
||||||
|
readonly description?: string;
|
||||||
|
|
||||||
|
/** Spell rank/level (0 = cantrip). */
|
||||||
|
readonly rank?: number;
|
||||||
|
|
||||||
|
/** Trait slugs (e.g. ["concentrate", "manipulate", "force"]). */
|
||||||
|
readonly traits?: readonly string[];
|
||||||
|
|
||||||
|
/** Tradition labels (e.g. ["arcane", "occult"]). */
|
||||||
|
readonly traditions?: readonly string[];
|
||||||
|
|
||||||
|
/** Range (e.g. "30 feet", "touch"). */
|
||||||
|
readonly range?: string;
|
||||||
|
|
||||||
|
/** Target (e.g. "1 creature"). */
|
||||||
|
readonly target?: string;
|
||||||
|
|
||||||
|
/** Area (e.g. "20-foot burst"). */
|
||||||
|
readonly area?: string;
|
||||||
|
|
||||||
|
/** Duration (e.g. "1 minute", "sustained up to 1 minute"). */
|
||||||
|
readonly duration?: string;
|
||||||
|
|
||||||
|
/** Defense / save (e.g. "basic Reflex", "Will"). */
|
||||||
|
readonly defense?: string;
|
||||||
|
|
||||||
|
/** Action cost. PF2e: number = action count, "reaction", "free", or
|
||||||
|
* "1 minute" / "10 minutes" for cast time. */
|
||||||
|
readonly actionCost?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heightening rules text. May come from `system.heightening` (fixed
|
||||||
|
* intervals) or `system.overlays` (variant casts). Plain text after
|
||||||
|
* tag stripping.
|
||||||
|
*/
|
||||||
|
readonly heightening?: string;
|
||||||
|
|
||||||
|
/** Uses per day for "(×N)" rendering, when > 1. PF2e only. */
|
||||||
|
readonly usesPerDay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A carried equipment item on a PF2e creature (weapon, consumable, magic item, etc.). */
|
||||||
|
export interface EquipmentItem {
|
||||||
|
readonly name: string;
|
||||||
|
readonly level: number;
|
||||||
|
readonly category?: string;
|
||||||
|
readonly traits?: readonly string[];
|
||||||
|
readonly description?: string;
|
||||||
|
/** For scrolls/wands: the embedded spell name. */
|
||||||
|
readonly spellName?: string;
|
||||||
|
/** For scrolls/wands: the embedded spell rank. */
|
||||||
|
readonly spellRank?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DailySpells {
|
export interface DailySpells {
|
||||||
readonly uses: number;
|
readonly uses: number;
|
||||||
readonly each: boolean;
|
readonly each: boolean;
|
||||||
readonly spells: readonly string[];
|
readonly spells: readonly SpellReference[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpellcastingBlock {
|
export interface SpellcastingBlock {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly headerText: string;
|
readonly headerText: string;
|
||||||
readonly atWill?: readonly string[];
|
readonly atWill?: readonly SpellReference[];
|
||||||
readonly daily?: readonly DailySpells[];
|
readonly daily?: readonly DailySpells[];
|
||||||
readonly restLong?: readonly DailySpells[];
|
readonly restLong?: readonly DailySpells[];
|
||||||
}
|
}
|
||||||
@@ -110,6 +186,7 @@ export interface Pf2eCreature {
|
|||||||
readonly level: number;
|
readonly level: number;
|
||||||
readonly traits: readonly string[];
|
readonly traits: readonly string[];
|
||||||
readonly perception: number;
|
readonly perception: number;
|
||||||
|
readonly perceptionDetails?: string;
|
||||||
readonly senses?: string;
|
readonly senses?: string;
|
||||||
readonly languages?: string;
|
readonly languages?: string;
|
||||||
readonly skills?: string;
|
readonly skills?: string;
|
||||||
@@ -127,7 +204,9 @@ export interface Pf2eCreature {
|
|||||||
readonly saveFort: number;
|
readonly saveFort: number;
|
||||||
readonly saveRef: number;
|
readonly saveRef: number;
|
||||||
readonly saveWill: number;
|
readonly saveWill: number;
|
||||||
|
readonly saveConditional?: string;
|
||||||
readonly hp: number;
|
readonly hp: number;
|
||||||
|
readonly hpDetails?: string;
|
||||||
readonly immunities?: string;
|
readonly immunities?: string;
|
||||||
readonly resistances?: string;
|
readonly resistances?: string;
|
||||||
readonly weaknesses?: string;
|
readonly weaknesses?: string;
|
||||||
@@ -137,6 +216,7 @@ export interface Pf2eCreature {
|
|||||||
readonly abilitiesMid?: readonly TraitBlock[];
|
readonly abilitiesMid?: readonly TraitBlock[];
|
||||||
readonly abilitiesBot?: readonly TraitBlock[];
|
readonly abilitiesBot?: readonly TraitBlock[];
|
||||||
readonly spellcasting?: readonly SpellcastingBlock[];
|
readonly spellcasting?: readonly SpellcastingBlock[];
|
||||||
|
readonly equipment?: readonly EquipmentItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AnyCreature = Creature | Pf2eCreature;
|
export type AnyCreature = Creature | Pf2eCreature;
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ConditionId } from "./conditions.js";
|
import type { ConditionId } from "./conditions.js";
|
||||||
import type { CreatureId } from "./creature-types.js";
|
import type { CreatureId } from "./creature-types.js";
|
||||||
|
import type { PersistentDamageType } from "./persistent-damage.js";
|
||||||
import type { PlayerCharacterId } from "./player-character-types.js";
|
import type { PlayerCharacterId } from "./player-character-types.js";
|
||||||
import type { CombatantId } from "./types.js";
|
import type { CombatantId } from "./types.js";
|
||||||
|
|
||||||
@@ -132,6 +133,25 @@ export interface ConcentrationEnded {
|
|||||||
readonly combatantId: CombatantId;
|
readonly combatantId: CombatantId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PersistentDamageAdded {
|
||||||
|
readonly type: "PersistentDamageAdded";
|
||||||
|
readonly combatantId: CombatantId;
|
||||||
|
readonly damageType: PersistentDamageType;
|
||||||
|
readonly formula: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersistentDamageRemoved {
|
||||||
|
readonly type: "PersistentDamageRemoved";
|
||||||
|
readonly combatantId: CombatantId;
|
||||||
|
readonly damageType: PersistentDamageType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatureAdjustmentSet {
|
||||||
|
readonly type: "CreatureAdjustmentSet";
|
||||||
|
readonly combatantId: CombatantId;
|
||||||
|
readonly adjustment: "weak" | "elite" | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EncounterCleared {
|
export interface EncounterCleared {
|
||||||
readonly type: "EncounterCleared";
|
readonly type: "EncounterCleared";
|
||||||
readonly combatantCount: number;
|
readonly combatantCount: number;
|
||||||
@@ -175,6 +195,9 @@ export type DomainEvent =
|
|||||||
| ConditionRemoved
|
| ConditionRemoved
|
||||||
| ConcentrationStarted
|
| ConcentrationStarted
|
||||||
| ConcentrationEnded
|
| ConcentrationEnded
|
||||||
|
| PersistentDamageAdded
|
||||||
|
| PersistentDamageRemoved
|
||||||
|
| CreatureAdjustmentSet
|
||||||
| EncounterCleared
|
| EncounterCleared
|
||||||
| PlayerCharacterCreated
|
| PlayerCharacterCreated
|
||||||
| PlayerCharacterUpdated
|
| PlayerCharacterUpdated
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export {
|
|||||||
createPlayerCharacter,
|
createPlayerCharacter,
|
||||||
} from "./create-player-character.js";
|
} from "./create-player-character.js";
|
||||||
export {
|
export {
|
||||||
|
type ActivityCost,
|
||||||
type AnyCreature,
|
type AnyCreature,
|
||||||
type BestiaryIndex,
|
type BestiaryIndex,
|
||||||
type BestiaryIndexEntry,
|
type BestiaryIndexEntry,
|
||||||
@@ -32,12 +33,14 @@ export {
|
|||||||
type CreatureId,
|
type CreatureId,
|
||||||
creatureId,
|
creatureId,
|
||||||
type DailySpells,
|
type DailySpells,
|
||||||
|
type EquipmentItem,
|
||||||
type LegendaryBlock,
|
type LegendaryBlock,
|
||||||
type Pf2eBestiaryIndex,
|
type Pf2eBestiaryIndex,
|
||||||
type Pf2eBestiaryIndexEntry,
|
type Pf2eBestiaryIndexEntry,
|
||||||
type Pf2eCreature,
|
type Pf2eCreature,
|
||||||
proficiencyBonus,
|
proficiencyBonus,
|
||||||
type SpellcastingBlock,
|
type SpellcastingBlock,
|
||||||
|
type SpellReference,
|
||||||
type TraitBlock,
|
type TraitBlock,
|
||||||
type TraitListItem,
|
type TraitListItem,
|
||||||
type TraitSegment,
|
type TraitSegment,
|
||||||
@@ -61,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 {
|
||||||
@@ -72,12 +77,15 @@ export type {
|
|||||||
ConcentrationStarted,
|
ConcentrationStarted,
|
||||||
ConditionAdded,
|
ConditionAdded,
|
||||||
ConditionRemoved,
|
ConditionRemoved,
|
||||||
|
CreatureAdjustmentSet,
|
||||||
CrSet,
|
CrSet,
|
||||||
CurrentHpAdjusted,
|
CurrentHpAdjusted,
|
||||||
DomainEvent,
|
DomainEvent,
|
||||||
EncounterCleared,
|
EncounterCleared,
|
||||||
InitiativeSet,
|
InitiativeSet,
|
||||||
MaxHpSet,
|
MaxHpSet,
|
||||||
|
PersistentDamageAdded,
|
||||||
|
PersistentDamageRemoved,
|
||||||
PlayerCharacterCreated,
|
PlayerCharacterCreated,
|
||||||
PlayerCharacterDeleted,
|
PlayerCharacterDeleted,
|
||||||
PlayerCharacterUpdated,
|
PlayerCharacterUpdated,
|
||||||
@@ -96,6 +104,25 @@ export {
|
|||||||
formatInitiativeModifier,
|
formatInitiativeModifier,
|
||||||
type InitiativeResult,
|
type InitiativeResult,
|
||||||
} from "./initiative.js";
|
} from "./initiative.js";
|
||||||
|
export {
|
||||||
|
addPersistentDamage,
|
||||||
|
PERSISTENT_DAMAGE_DEFINITIONS,
|
||||||
|
PERSISTENT_DAMAGE_TYPES,
|
||||||
|
type PersistentDamageDefinition,
|
||||||
|
type PersistentDamageEntry,
|
||||||
|
type PersistentDamageSuccess,
|
||||||
|
type PersistentDamageType,
|
||||||
|
removePersistentDamage,
|
||||||
|
VALID_PERSISTENT_DAMAGE_TYPES,
|
||||||
|
} from "./persistent-damage.js";
|
||||||
|
export {
|
||||||
|
acDelta,
|
||||||
|
adjustedLevel,
|
||||||
|
applyPf2eAdjustment,
|
||||||
|
type CreatureAdjustment,
|
||||||
|
hpDelta,
|
||||||
|
modDelta,
|
||||||
|
} from "./pf2e-adjustments.js";
|
||||||
export {
|
export {
|
||||||
type PlayerCharacter,
|
type PlayerCharacter,
|
||||||
type PlayerCharacterId,
|
type PlayerCharacterId,
|
||||||
@@ -106,6 +133,10 @@ export {
|
|||||||
VALID_PLAYER_COLORS,
|
VALID_PLAYER_COLORS,
|
||||||
VALID_PLAYER_ICONS,
|
VALID_PLAYER_ICONS,
|
||||||
} from "./player-character-types.js";
|
} from "./player-character-types.js";
|
||||||
|
export {
|
||||||
|
type RecallKnowledge,
|
||||||
|
recallKnowledge,
|
||||||
|
} from "./recall-knowledge.js";
|
||||||
export { rehydrateCombatant } from "./rehydrate-combatant.js";
|
export { rehydrateCombatant } from "./rehydrate-combatant.js";
|
||||||
export { rehydratePlayerCharacter } from "./rehydrate-player-character.js";
|
export { rehydratePlayerCharacter } from "./rehydrate-player-character.js";
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import type { DomainEvent } from "./events.js";
|
||||||
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type DomainError,
|
||||||
|
type Encounter,
|
||||||
|
findCombatant,
|
||||||
|
isDomainError,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export const PERSISTENT_DAMAGE_TYPES = [
|
||||||
|
"fire",
|
||||||
|
"bleed",
|
||||||
|
"acid",
|
||||||
|
"cold",
|
||||||
|
"electricity",
|
||||||
|
"poison",
|
||||||
|
"mental",
|
||||||
|
"force",
|
||||||
|
"void",
|
||||||
|
"spirit",
|
||||||
|
"vitality",
|
||||||
|
"piercing",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type PersistentDamageType = (typeof PERSISTENT_DAMAGE_TYPES)[number];
|
||||||
|
|
||||||
|
export const VALID_PERSISTENT_DAMAGE_TYPES: ReadonlySet<string> = new Set(
|
||||||
|
PERSISTENT_DAMAGE_TYPES,
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface PersistentDamageEntry {
|
||||||
|
readonly type: PersistentDamageType;
|
||||||
|
readonly formula: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersistentDamageDefinition {
|
||||||
|
readonly type: PersistentDamageType;
|
||||||
|
readonly label: string;
|
||||||
|
readonly iconName: string;
|
||||||
|
readonly color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PERSISTENT_DAMAGE_DEFINITIONS: readonly PersistentDamageDefinition[] =
|
||||||
|
[
|
||||||
|
{ type: "fire", label: "Fire", iconName: "Flame", color: "orange" },
|
||||||
|
{ type: "bleed", label: "Bleed", iconName: "Droplets", color: "red" },
|
||||||
|
{
|
||||||
|
type: "acid",
|
||||||
|
label: "Acid",
|
||||||
|
iconName: "FlaskConical",
|
||||||
|
color: "lime",
|
||||||
|
},
|
||||||
|
{ type: "cold", label: "Cold", iconName: "Snowflake", color: "sky" },
|
||||||
|
{
|
||||||
|
type: "electricity",
|
||||||
|
label: "Electricity",
|
||||||
|
iconName: "Zap",
|
||||||
|
color: "yellow",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "poison",
|
||||||
|
label: "Poison",
|
||||||
|
iconName: "Droplet",
|
||||||
|
color: "green",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "mental",
|
||||||
|
label: "Mental",
|
||||||
|
iconName: "BrainCog",
|
||||||
|
color: "pink",
|
||||||
|
},
|
||||||
|
{ type: "force", label: "Force", iconName: "Orbit", color: "indigo" },
|
||||||
|
{ type: "void", label: "Void", iconName: "Eclipse", color: "purple" },
|
||||||
|
{ type: "spirit", label: "Spirit", iconName: "Wind", color: "neutral" },
|
||||||
|
{
|
||||||
|
type: "vitality",
|
||||||
|
label: "Vitality",
|
||||||
|
iconName: "Sparkle",
|
||||||
|
color: "amber",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "piercing",
|
||||||
|
label: "Piercing",
|
||||||
|
iconName: "Sword",
|
||||||
|
color: "neutral",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface PersistentDamageSuccess {
|
||||||
|
readonly encounter: Encounter;
|
||||||
|
readonly events: DomainEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPersistentDamage(
|
||||||
|
encounter: Encounter,
|
||||||
|
combatantId: CombatantId,
|
||||||
|
newEntries: readonly PersistentDamageEntry[] | undefined,
|
||||||
|
): Encounter {
|
||||||
|
return {
|
||||||
|
combatants: encounter.combatants.map((c) =>
|
||||||
|
c.id === combatantId ? { ...c, persistentDamage: newEntries } : c,
|
||||||
|
),
|
||||||
|
activeIndex: encounter.activeIndex,
|
||||||
|
roundNumber: encounter.roundNumber,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addPersistentDamage(
|
||||||
|
encounter: Encounter,
|
||||||
|
combatantId: CombatantId,
|
||||||
|
damageType: PersistentDamageType,
|
||||||
|
formula: string,
|
||||||
|
): PersistentDamageSuccess | DomainError {
|
||||||
|
if (!VALID_PERSISTENT_DAMAGE_TYPES.has(damageType)) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "unknown-damage-type",
|
||||||
|
message: `Unknown persistent damage type "${damageType}"`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (formula.trim().length === 0) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "empty-formula",
|
||||||
|
message: "Persistent damage formula must not be empty",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const found = findCombatant(encounter, combatantId);
|
||||||
|
if (isDomainError(found)) return found;
|
||||||
|
const { combatant: target } = found;
|
||||||
|
const current = target.persistentDamage ?? [];
|
||||||
|
|
||||||
|
// Replace existing entry of same type, or append
|
||||||
|
const filtered = current.filter((e) => e.type !== damageType);
|
||||||
|
const newEntries = [
|
||||||
|
...filtered,
|
||||||
|
{ type: damageType, formula: formula.trim() },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Sort by definition order
|
||||||
|
const order = PERSISTENT_DAMAGE_DEFINITIONS.map((d) => d.type);
|
||||||
|
newEntries.sort((a, b) => order.indexOf(a.type) - order.indexOf(b.type));
|
||||||
|
|
||||||
|
return {
|
||||||
|
encounter: applyPersistentDamage(encounter, combatantId, newEntries),
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
type: "PersistentDamageAdded",
|
||||||
|
combatantId,
|
||||||
|
damageType,
|
||||||
|
formula: formula.trim(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removePersistentDamage(
|
||||||
|
encounter: Encounter,
|
||||||
|
combatantId: CombatantId,
|
||||||
|
damageType: PersistentDamageType,
|
||||||
|
): PersistentDamageSuccess | DomainError {
|
||||||
|
const found = findCombatant(encounter, combatantId);
|
||||||
|
if (isDomainError(found)) return found;
|
||||||
|
const { combatant: target } = found;
|
||||||
|
const current = target.persistentDamage ?? [];
|
||||||
|
|
||||||
|
if (!current.some((e) => e.type === damageType)) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "persistent-damage-not-active",
|
||||||
|
message: `Persistent ${damageType} damage is not active`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = current.filter((e) => e.type !== damageType);
|
||||||
|
return {
|
||||||
|
encounter: applyPersistentDamage(
|
||||||
|
encounter,
|
||||||
|
combatantId,
|
||||||
|
filtered.length > 0 ? filtered : undefined,
|
||||||
|
),
|
||||||
|
events: [{ type: "PersistentDamageRemoved", combatantId, damageType }],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import type {
|
||||||
|
Pf2eCreature,
|
||||||
|
TraitBlock,
|
||||||
|
TraitSegment,
|
||||||
|
} from "./creature-types.js";
|
||||||
|
|
||||||
|
export type CreatureAdjustment = "weak" | "elite";
|
||||||
|
|
||||||
|
/** HP bracket delta by creature level (standard PF2e table). */
|
||||||
|
function hpBracketDelta(level: number): number {
|
||||||
|
if (level <= 1) return 10;
|
||||||
|
if (level <= 4) return 15;
|
||||||
|
if (level <= 19) return 20;
|
||||||
|
return 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Level shift: elite +1 (or +2 if level ≤ 0), weak −1 (or −2 if level is 1). */
|
||||||
|
export function adjustedLevel(
|
||||||
|
baseLevel: number,
|
||||||
|
adjustment: CreatureAdjustment,
|
||||||
|
): number {
|
||||||
|
if (adjustment === "elite") {
|
||||||
|
return baseLevel <= 0 ? baseLevel + 2 : baseLevel + 1;
|
||||||
|
}
|
||||||
|
return baseLevel === 1 ? baseLevel - 2 : baseLevel - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signed HP delta for a given base level and adjustment. */
|
||||||
|
export function hpDelta(
|
||||||
|
baseLevel: number,
|
||||||
|
adjustment: CreatureAdjustment,
|
||||||
|
): number {
|
||||||
|
const delta = hpBracketDelta(baseLevel);
|
||||||
|
return adjustment === "elite" ? delta : -delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AC delta: +2 for elite, −2 for weak. */
|
||||||
|
export function acDelta(adjustment: CreatureAdjustment): number {
|
||||||
|
return adjustment === "elite" ? 2 : -2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generic ±2 modifier delta. Used for saves, Perception, attacks, damage. */
|
||||||
|
export function modDelta(adjustment: CreatureAdjustment): number {
|
||||||
|
return adjustment === "elite" ? 2 : -2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ATTACK_BONUS_RE = /^([+-])(\d+)/;
|
||||||
|
const MAP_RE = /\[([+-]\d+)\/([+-]\d+)\]/g;
|
||||||
|
const DAMAGE_BONUS_RE = /(\d+d\d+)([+-])(\d+)/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adjust attack bonus in a formatted attack string.
|
||||||
|
* "+15 (agile), 2d12+7 piercing plus Grab" → "+17 (agile), 2d12+9 piercing plus Grab"
|
||||||
|
*/
|
||||||
|
function adjustAttackText(text: string, delta: number): string {
|
||||||
|
// Adjust leading attack bonus: "+15" → "+17"
|
||||||
|
let result = text.replace(ATTACK_BONUS_RE, (_, sign, num) => {
|
||||||
|
const adjusted = (sign === "+" ? 1 : -1) * Number(num) + delta;
|
||||||
|
return adjusted >= 0 ? `+${adjusted}` : `${adjusted}`;
|
||||||
|
});
|
||||||
|
// Adjust MAP values in brackets: "[+10/+5]" → "[+12/+7]"
|
||||||
|
result = result.replace(MAP_RE, (_, m1, m2) => {
|
||||||
|
const a1 = Number(m1) + delta;
|
||||||
|
const a2 = Number(m2) + delta;
|
||||||
|
const f = (n: number) => (n >= 0 ? `+${n}` : `${n}`);
|
||||||
|
return `[${f(a1)}/${f(a2)}]`;
|
||||||
|
});
|
||||||
|
// Adjust damage bonus in "NdN+N type" patterns
|
||||||
|
result = result.replace(DAMAGE_BONUS_RE, (_, dice, sign, num) => {
|
||||||
|
const current = (sign === "+" ? 1 : -1) * Number(num);
|
||||||
|
const adjusted = current + delta;
|
||||||
|
if (adjusted === 0) return dice as string;
|
||||||
|
return adjusted > 0 ? `${dice}+${adjusted}` : `${dice}${adjusted}`;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustTraitBlock(block: TraitBlock, delta: number): TraitBlock {
|
||||||
|
return {
|
||||||
|
...block,
|
||||||
|
segments: block.segments.map(
|
||||||
|
(seg): TraitSegment =>
|
||||||
|
seg.type === "text"
|
||||||
|
? { type: "text", value: adjustAttackText(seg.value, delta) }
|
||||||
|
: seg,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a weak or elite adjustment to a full PF2e creature.
|
||||||
|
* Returns a new Pf2eCreature with all numeric stats adjusted.
|
||||||
|
*/
|
||||||
|
export function applyPf2eAdjustment(
|
||||||
|
creature: Pf2eCreature,
|
||||||
|
adjustment: CreatureAdjustment,
|
||||||
|
): Pf2eCreature {
|
||||||
|
const d = modDelta(adjustment);
|
||||||
|
return {
|
||||||
|
...creature,
|
||||||
|
level: adjustedLevel(creature.level, adjustment),
|
||||||
|
ac: creature.ac + d,
|
||||||
|
hp: creature.hp + hpDelta(creature.level, adjustment),
|
||||||
|
perception: creature.perception + d,
|
||||||
|
saveFort: creature.saveFort + d,
|
||||||
|
saveRef: creature.saveRef + d,
|
||||||
|
saveWill: creature.saveWill + d,
|
||||||
|
attacks: creature.attacks?.map((a) => adjustTraitBlock(a, d)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* PF2e Recall Knowledge DC calculation and type-to-skill mapping.
|
||||||
|
*
|
||||||
|
* DC is derived from creature level using the standard DC-by-level table
|
||||||
|
* (Player Core / GM Core), adjusted for rarity.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Standard DC-by-level table from PF2e GM Core. Index = level + 1 (level -1 → index 0). */
|
||||||
|
const DC_BY_LEVEL: readonly number[] = [
|
||||||
|
13, // level -1
|
||||||
|
14, // level 0
|
||||||
|
15, // level 1
|
||||||
|
16, // level 2
|
||||||
|
18, // level 3
|
||||||
|
19, // level 4
|
||||||
|
20, // level 5
|
||||||
|
22, // level 6
|
||||||
|
23, // level 7
|
||||||
|
24, // level 8
|
||||||
|
26, // level 9
|
||||||
|
27, // level 10
|
||||||
|
28, // level 11
|
||||||
|
30, // level 12
|
||||||
|
31, // level 13
|
||||||
|
32, // level 14
|
||||||
|
34, // level 15
|
||||||
|
35, // level 16
|
||||||
|
36, // level 17
|
||||||
|
38, // level 18
|
||||||
|
39, // level 19
|
||||||
|
40, // level 20
|
||||||
|
42, // level 21
|
||||||
|
44, // level 22
|
||||||
|
46, // level 23
|
||||||
|
48, // level 24
|
||||||
|
50, // level 25
|
||||||
|
];
|
||||||
|
|
||||||
|
const RARITY_ADJUSTMENT: Readonly<Record<string, number>> = {
|
||||||
|
uncommon: 2,
|
||||||
|
rare: 5,
|
||||||
|
unique: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping from PF2e creature type traits to the skill(s) used for
|
||||||
|
* Recall Knowledge. Types that map to multiple skills list all of them.
|
||||||
|
*/
|
||||||
|
const TYPE_TO_SKILLS: Readonly<Record<string, readonly string[]>> = {
|
||||||
|
aberration: ["Occultism"],
|
||||||
|
animal: ["Nature"],
|
||||||
|
astral: ["Occultism"],
|
||||||
|
beast: ["Arcana", "Nature"],
|
||||||
|
celestial: ["Religion"],
|
||||||
|
construct: ["Arcana", "Crafting"],
|
||||||
|
dragon: ["Arcana"],
|
||||||
|
dream: ["Occultism"],
|
||||||
|
elemental: ["Arcana", "Nature"],
|
||||||
|
ethereal: ["Occultism"],
|
||||||
|
fey: ["Nature"],
|
||||||
|
fiend: ["Religion"],
|
||||||
|
fungus: ["Nature"],
|
||||||
|
giant: ["Society"],
|
||||||
|
humanoid: ["Society"],
|
||||||
|
monitor: ["Religion"],
|
||||||
|
ooze: ["Occultism"],
|
||||||
|
plant: ["Nature"],
|
||||||
|
undead: ["Religion"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface RecallKnowledge {
|
||||||
|
readonly dc: number;
|
||||||
|
readonly type: string;
|
||||||
|
readonly skills: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate Recall Knowledge DC, type, and skill(s) for a PF2e creature.
|
||||||
|
*
|
||||||
|
* Returns `null` when no recognized type trait is found in the creature's
|
||||||
|
* traits array, indicating the Recall Knowledge line should be omitted.
|
||||||
|
*/
|
||||||
|
export function recallKnowledge(
|
||||||
|
level: number,
|
||||||
|
traits: readonly string[],
|
||||||
|
): RecallKnowledge | null {
|
||||||
|
// Find the first type trait that maps to a skill
|
||||||
|
let matchedType: string | undefined;
|
||||||
|
let skills: readonly string[] | undefined;
|
||||||
|
|
||||||
|
for (const trait of traits) {
|
||||||
|
const lower = trait.toLowerCase();
|
||||||
|
const mapped = TYPE_TO_SKILLS[lower];
|
||||||
|
if (mapped) {
|
||||||
|
matchedType = trait;
|
||||||
|
skills = mapped;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matchedType || !skills) return null;
|
||||||
|
|
||||||
|
// Calculate DC from level
|
||||||
|
const clampedIndex = Math.max(0, Math.min(level + 1, DC_BY_LEVEL.length - 1));
|
||||||
|
let dc = DC_BY_LEVEL[clampedIndex];
|
||||||
|
|
||||||
|
// Apply rarity adjustment (rarity traits are included in the traits array
|
||||||
|
// for non-common creatures by the normalization pipeline)
|
||||||
|
for (const trait of traits) {
|
||||||
|
const adjustment = RARITY_ADJUSTMENT[trait.toLowerCase()];
|
||||||
|
if (adjustment) {
|
||||||
|
dc += adjustment;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { dc, type: matchedType, skills };
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import type { ConditionEntry, ConditionId } from "./conditions.js";
|
|||||||
import { VALID_CONDITION_IDS } from "./conditions.js";
|
import { VALID_CONDITION_IDS } from "./conditions.js";
|
||||||
import { creatureId } from "./creature-types.js";
|
import { creatureId } from "./creature-types.js";
|
||||||
import { VALID_CR_VALUES } from "./encounter-difficulty.js";
|
import { VALID_CR_VALUES } from "./encounter-difficulty.js";
|
||||||
|
import type { PersistentDamageEntry } from "./persistent-damage.js";
|
||||||
|
import { VALID_PERSISTENT_DAMAGE_TYPES } from "./persistent-damage.js";
|
||||||
import {
|
import {
|
||||||
playerCharacterId,
|
playerCharacterId,
|
||||||
VALID_PLAYER_COLORS,
|
VALID_PLAYER_COLORS,
|
||||||
@@ -42,6 +44,32 @@ function validateConditions(value: unknown): ConditionEntry[] | undefined {
|
|||||||
return entries.length > 0 ? entries : undefined;
|
return entries.length > 0 ? entries : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validatePersistentDamage(
|
||||||
|
value: unknown,
|
||||||
|
): PersistentDamageEntry[] | undefined {
|
||||||
|
if (!Array.isArray(value)) return undefined;
|
||||||
|
const entries: PersistentDamageEntry[] = [];
|
||||||
|
for (const item of value) {
|
||||||
|
if (
|
||||||
|
typeof item === "object" &&
|
||||||
|
item !== null &&
|
||||||
|
typeof (item as Record<string, unknown>).type === "string" &&
|
||||||
|
VALID_PERSISTENT_DAMAGE_TYPES.has(
|
||||||
|
(item as Record<string, unknown>).type as string,
|
||||||
|
) &&
|
||||||
|
typeof (item as Record<string, unknown>).formula === "string" &&
|
||||||
|
((item as Record<string, unknown>).formula as string).length > 0
|
||||||
|
) {
|
||||||
|
entries.push({
|
||||||
|
type: (item as Record<string, unknown>)
|
||||||
|
.type as PersistentDamageEntry["type"],
|
||||||
|
formula: (item as Record<string, unknown>).formula as string,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries.length > 0 ? entries : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function validateHp(
|
function validateHp(
|
||||||
rawMaxHp: unknown,
|
rawMaxHp: unknown,
|
||||||
rawCurrentHp: unknown,
|
rawCurrentHp: unknown,
|
||||||
@@ -93,6 +121,7 @@ function validateCr(value: unknown): string | undefined {
|
|||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VALID_ADJUSTMENTS = new Set(["weak", "elite"]);
|
||||||
const VALID_SIDES = new Set(["party", "enemy"]);
|
const VALID_SIDES = new Set(["party", "enemy"]);
|
||||||
|
|
||||||
function validateSide(value: unknown): "party" | "enemy" | undefined {
|
function validateSide(value: unknown): "party" | "enemy" | undefined {
|
||||||
@@ -106,10 +135,15 @@ function parseOptionalFields(entry: Record<string, unknown>) {
|
|||||||
initiative: validateInteger(entry.initiative),
|
initiative: validateInteger(entry.initiative),
|
||||||
ac: validateAc(entry.ac),
|
ac: validateAc(entry.ac),
|
||||||
conditions: validateConditions(entry.conditions),
|
conditions: validateConditions(entry.conditions),
|
||||||
|
persistentDamage: validatePersistentDamage(entry.persistentDamage),
|
||||||
isConcentrating: entry.isConcentrating === true ? true : undefined,
|
isConcentrating: entry.isConcentrating === true ? true : undefined,
|
||||||
creatureId: validateNonEmptyString(entry.creatureId)
|
creatureId: validateNonEmptyString(entry.creatureId)
|
||||||
? creatureId(entry.creatureId as string)
|
? creatureId(entry.creatureId as string)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
creatureAdjustment: validateSetMember(
|
||||||
|
entry.creatureAdjustment,
|
||||||
|
VALID_ADJUSTMENTS,
|
||||||
|
) as "weak" | "elite" | undefined,
|
||||||
cr: validateCr(entry.cr),
|
cr: validateCr(entry.cr),
|
||||||
side: validateSide(entry.side),
|
side: validateSide(entry.side),
|
||||||
color: validateSetMember(entry.color, VALID_PLAYER_COLORS),
|
color: validateSetMember(entry.color, VALID_PLAYER_COLORS),
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +85,11 @@ export function setConditionValue(
|
|||||||
const { combatant: target } = found;
|
const { combatant: target } = found;
|
||||||
const current = target.conditions ?? [];
|
const current = target.conditions ?? [];
|
||||||
|
|
||||||
if (value <= 0) {
|
const def = CONDITION_DEFINITIONS.find((d) => d.id === conditionId);
|
||||||
|
const clampedValue =
|
||||||
|
def?.maxValue === undefined ? value : Math.min(value, def.maxValue);
|
||||||
|
|
||||||
|
if (clampedValue <= 0) {
|
||||||
const filtered = current.filter((c) => c.id !== conditionId);
|
const filtered = current.filter((c) => c.id !== conditionId);
|
||||||
const newConditions = filtered.length > 0 ? filtered : undefined;
|
const newConditions = filtered.length > 0 ? filtered : undefined;
|
||||||
return {
|
return {
|
||||||
@@ -106,7 +103,7 @@ export function setConditionValue(
|
|||||||
const existing = current.find((c) => c.id === conditionId);
|
const existing = current.find((c) => c.id === conditionId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const updated = current.map((c) =>
|
const updated = current.map((c) =>
|
||||||
c.id === conditionId ? { ...c, value } : c,
|
c.id === conditionId ? { ...c, value: clampedValue } : c,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
encounter: applyConditions(encounter, combatantId, updated),
|
encounter: applyConditions(encounter, combatantId, updated),
|
||||||
@@ -115,17 +112,22 @@ export function setConditionValue(
|
|||||||
type: "ConditionAdded",
|
type: "ConditionAdded",
|
||||||
combatantId,
|
combatantId,
|
||||||
condition: conditionId,
|
condition: conditionId,
|
||||||
value,
|
value: clampedValue,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const added = sortByDefinitionOrder([...current, { id: conditionId, value }]);
|
const added = [...current, { id: conditionId, value: clampedValue }];
|
||||||
return {
|
return {
|
||||||
encounter: applyConditions(encounter, combatantId, added),
|
encounter: applyConditions(encounter, combatantId, added),
|
||||||
events: [
|
events: [
|
||||||
{ type: "ConditionAdded", combatantId, condition: conditionId, value },
|
{
|
||||||
|
type: "ConditionAdded",
|
||||||
|
combatantId,
|
||||||
|
condition: conditionId,
|
||||||
|
value: clampedValue,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export function combatantId(id: string): CombatantId {
|
|||||||
|
|
||||||
import type { ConditionEntry } from "./conditions.js";
|
import type { ConditionEntry } from "./conditions.js";
|
||||||
import type { CreatureId } from "./creature-types.js";
|
import type { CreatureId } from "./creature-types.js";
|
||||||
|
import type { PersistentDamageEntry } from "./persistent-damage.js";
|
||||||
import type { PlayerCharacterId } from "./player-character-types.js";
|
import type { PlayerCharacterId } from "./player-character-types.js";
|
||||||
|
|
||||||
export interface Combatant {
|
export interface Combatant {
|
||||||
@@ -18,8 +19,10 @@ export interface Combatant {
|
|||||||
readonly tempHp?: number;
|
readonly tempHp?: number;
|
||||||
readonly ac?: number;
|
readonly ac?: number;
|
||||||
readonly conditions?: readonly ConditionEntry[];
|
readonly conditions?: readonly ConditionEntry[];
|
||||||
|
readonly persistentDamage?: readonly PersistentDamageEntry[];
|
||||||
readonly isConcentrating?: boolean;
|
readonly isConcentrating?: boolean;
|
||||||
readonly creatureId?: CreatureId;
|
readonly creatureId?: CreatureId;
|
||||||
|
readonly creatureAdjustment?: "weak" | "elite";
|
||||||
readonly cr?: string;
|
readonly cr?: string;
|
||||||
readonly side?: "party" | "enemy";
|
readonly side?: "party" | "enemy";
|
||||||
readonly color?: string;
|
readonly color?: string;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user