Compare commits
35
Commits
1c107a500b
..
0.9.46
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c029c0ca8d | ||
|
|
2e4865d5fc | ||
|
|
be3778a0c9 | ||
|
|
7b599e9ad9 | ||
|
|
5d99764e14 | ||
|
|
67c398d3a2 | ||
|
|
8204122bd0 | ||
|
|
685526d53b | ||
|
|
91f46ff3c8 | ||
|
|
90eb39b227 | ||
|
|
78079bf1b2 | ||
|
|
aa5555ef86 | ||
|
|
a045e3a0f9 | ||
|
|
934d98025e | ||
|
|
3b2fb99b37 | ||
|
|
111b464da5 | ||
|
|
a97ffe5ed1 | ||
|
|
1930473753 | ||
|
|
c343fd3cd0 | ||
|
|
d9fb271607 | ||
|
|
064af16f95 | ||
|
|
0f640601b6 | ||
|
|
4b1c1deda2 | ||
|
|
09a801487d | ||
|
|
a44f82127e | ||
|
|
c3707cf0b6 | ||
|
|
1eaeecad32 | ||
|
|
e2e8297c95 | ||
|
|
e161645228 | ||
|
|
9b0cb38897 | ||
|
|
5cb5721a6f | ||
|
|
48795071f7 | ||
|
|
f721d7e5da | ||
|
|
e7930a1431 | ||
|
|
553e09f280 |
@@ -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.
|
||||||
+9
-3
@@ -1,8 +1,14 @@
|
|||||||
{
|
{
|
||||||
"threshold": 5,
|
"threshold": 3,
|
||||||
"minLines": 5,
|
"minLines": 5,
|
||||||
"minTokens": 50,
|
"minTokens": 50,
|
||||||
"pattern": ["**/*.ts", "**/*.tsx"],
|
"pattern": "**/*.{ts,tsx}",
|
||||||
"ignore": ["node_modules", "dist", "build", "coverage", ".specify", "specs"],
|
"ignore": [
|
||||||
|
"**/node_modules/**",
|
||||||
|
"**/dist/**",
|
||||||
|
"**/build/**",
|
||||||
|
"**/coverage/**",
|
||||||
|
"**/__tests__/**"
|
||||||
|
],
|
||||||
"reporters": ["console"]
|
"reporters": ["console"]
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/tc39-proposal-type-annotations/refs/heads/main/packages/oxlint/configuration_file_schema.json",
|
"$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/tc39-proposal-type-annotations/refs/heads/main/packages/oxlint/configuration_file_schema.json",
|
||||||
"plugins": ["typescript", "unicorn", "jest"],
|
"plugins": ["typescript", "unicorn", "jest", "import"],
|
||||||
"categories": {},
|
"categories": {},
|
||||||
"rules": {
|
"rules": {
|
||||||
"typescript/no-unnecessary-type-assertion": "error",
|
"typescript/no-unnecessary-type-assertion": "error",
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"typescript/prefer-regexp-exec": "error",
|
"typescript/prefer-regexp-exec": "error",
|
||||||
"unicorn/prefer-string-replace-all": "error",
|
"unicorn/prefer-string-replace-all": "error",
|
||||||
"unicorn/prefer-string-raw": "error",
|
"unicorn/prefer-string-raw": "error",
|
||||||
|
"import/no-cycle": "error",
|
||||||
"jest/expect-expect": [
|
"jest/expect-expect": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
@@ -21,7 +22,6 @@
|
|||||||
".claude",
|
".claude",
|
||||||
".specify",
|
".specify",
|
||||||
"specs",
|
"specs",
|
||||||
".pnpm-store",
|
".pnpm-store"
|
||||||
"scripts"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "^30.0.1",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
"vite": "^8.0.5"
|
"vite": "^8.0.16"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ export function createTestAdapters(options?: {
|
|||||||
`https://example.com/creatures-${sourceCode.toLowerCase()}.json`,
|
`https://example.com/creatures-${sourceCode.toLowerCase()}.json`,
|
||||||
getSourceDisplayName: (sourceCode) => sourceCode,
|
getSourceDisplayName: (sourceCode) => sourceCode,
|
||||||
getCreaturePathsForSource: () => [],
|
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()}
|
/>
|
||||||
/>
|
</button>
|
||||||
</div>,
|
);
|
||||||
);
|
}
|
||||||
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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -266,6 +266,45 @@ describe("round-trip: export then import", () => {
|
|||||||
expect(imported.encounter.combatants[1].side).toBe("enemy");
|
expect(imported.encounter.combatants[1].side).toBe("enemy");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("round-trips a combatant with hpVariant field", () => {
|
||||||
|
const encounterWithVariant: Encounter = {
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Ogre",
|
||||||
|
maxHp: 24,
|
||||||
|
currentHp: 24,
|
||||||
|
hpVariant: "max",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: combatantId("c-2"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 2,
|
||||||
|
currentHp: 2,
|
||||||
|
hpVariant: "min",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
};
|
||||||
|
const emptyUndoRedo: UndoRedoState = {
|
||||||
|
undoStack: [],
|
||||||
|
redoStack: [],
|
||||||
|
};
|
||||||
|
const bundle = assembleExportBundle(
|
||||||
|
encounterWithVariant,
|
||||||
|
emptyUndoRedo,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const serialized = JSON.parse(JSON.stringify(bundle));
|
||||||
|
const result = validateImportBundle(serialized);
|
||||||
|
|
||||||
|
expect(typeof result).toBe("object");
|
||||||
|
const imported = result as ExportBundle;
|
||||||
|
expect(imported.encounter.combatants[0].hpVariant).toBe("max");
|
||||||
|
expect(imported.encounter.combatants[1].hpVariant).toBe("min");
|
||||||
|
});
|
||||||
|
|
||||||
it("round-trips a combatant without side field as undefined", () => {
|
it("round-trips a combatant without side field as undefined", () => {
|
||||||
const encounterNoSide: Encounter = {
|
const encounterNoSide: Encounter = {
|
||||||
combatants: [{ id: combatantId("c-1"), name: "Custom" }],
|
combatants: [{ id: combatantId("c-1"), name: "Custom" }],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { PlayerCharacter } from "@initiative/domain";
|
||||||
|
import { playerCharacterId } from "@initiative/domain";
|
||||||
|
|
||||||
|
let counter = 0;
|
||||||
|
|
||||||
|
export function buildPlayerCharacter(
|
||||||
|
overrides?: Partial<PlayerCharacter>,
|
||||||
|
): PlayerCharacter {
|
||||||
|
return {
|
||||||
|
id: playerCharacterId(`pc-${++counter}`),
|
||||||
|
name: "Player Character",
|
||||||
|
ac: 15,
|
||||||
|
maxHp: 25,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
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";
|
||||||
|
export { buildPlayerCharacter } from "./build-player-character.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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -131,6 +131,39 @@ describe("normalizeFoundryCreature", () => {
|
|||||||
);
|
);
|
||||||
expect(creature.senses).toBe("Scent 60 feet");
|
expect(creature.senses).toBe("Scent 60 feet");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("extracts perception details", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
system: {
|
||||||
|
...minimalCreature().system,
|
||||||
|
perception: {
|
||||||
|
mod: 35,
|
||||||
|
details: "smoke vision",
|
||||||
|
senses: [{ type: "darkvision" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(creature.perceptionDetails).toBe("smoke vision");
|
||||||
|
expect(creature.senses).toBe("Darkvision");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits perception details when empty", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
system: {
|
||||||
|
...minimalCreature().system,
|
||||||
|
perception: {
|
||||||
|
mod: 8,
|
||||||
|
details: "",
|
||||||
|
senses: [{ type: "darkvision" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(creature.perceptionDetails).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("languages formatting", () => {
|
describe("languages formatting", () => {
|
||||||
@@ -386,6 +419,101 @@ describe("normalizeFoundryCreature", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes attack effects in damage text", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "atk1",
|
||||||
|
name: "talon",
|
||||||
|
type: "melee",
|
||||||
|
system: {
|
||||||
|
bonus: { value: 14 },
|
||||||
|
damageRolls: {
|
||||||
|
abc: {
|
||||||
|
damage: "1d10+6",
|
||||||
|
damageType: "piercing",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
traits: { value: [] },
|
||||||
|
attackEffects: { value: ["grab"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const attack = creature.attacks?.[0];
|
||||||
|
expect(attack?.segments[0]).toEqual({
|
||||||
|
type: "text",
|
||||||
|
value: "+14, 1d10+6 piercing plus Grab",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joins multiple attack effects with 'and'", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "atk1",
|
||||||
|
name: "claw",
|
||||||
|
type: "melee",
|
||||||
|
system: {
|
||||||
|
bonus: { value: 18 },
|
||||||
|
damageRolls: {
|
||||||
|
abc: {
|
||||||
|
damage: "2d8+6",
|
||||||
|
damageType: "slashing",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
traits: { value: [] },
|
||||||
|
attackEffects: {
|
||||||
|
value: ["grab", "knockdown"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const attack = creature.attacks?.[0];
|
||||||
|
expect(attack?.segments[0]).toEqual({
|
||||||
|
type: "text",
|
||||||
|
value: "+18, 2d8+6 slashing plus Grab and Knockdown",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips creature-name prefix from attack effect slugs", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
name: "Lich",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "atk1",
|
||||||
|
name: "hand",
|
||||||
|
type: "melee",
|
||||||
|
system: {
|
||||||
|
bonus: { value: 24 },
|
||||||
|
damageRolls: {
|
||||||
|
abc: {
|
||||||
|
damage: "2d12+7",
|
||||||
|
damageType: "negative",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
traits: { value: [] },
|
||||||
|
attackEffects: {
|
||||||
|
value: ["lich-siphon-life"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const attack = creature.attacks?.[0];
|
||||||
|
expect(attack?.segments[0]).toEqual({
|
||||||
|
type: "text",
|
||||||
|
value: "+24, 2d12+7 negative plus Siphon Life",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ability normalization", () => {
|
describe("ability normalization", () => {
|
||||||
@@ -539,6 +667,396 @@ describe("normalizeFoundryCreature", () => {
|
|||||||
: undefined,
|
: undefined,
|
||||||
).toBe("(Concentrate, Polymorph) Takes a new form.");
|
).toBe("(Concentrate, Polymorph) Takes a new form.");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("extracts frequency from ability", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "a1",
|
||||||
|
name: "Drain Soul Cage",
|
||||||
|
type: "action",
|
||||||
|
system: {
|
||||||
|
category: "offensive",
|
||||||
|
actionType: { value: "free" },
|
||||||
|
actions: { value: null },
|
||||||
|
traits: { value: [] },
|
||||||
|
description: { value: "<p>Drains the soul.</p>" },
|
||||||
|
frequency: { max: 1, per: "day" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(creature.abilitiesBot?.[0]?.frequency).toBe("1/day");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips redundant frequency line from description", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "a1",
|
||||||
|
name: "Consult the Text",
|
||||||
|
type: "action",
|
||||||
|
system: {
|
||||||
|
category: "offensive",
|
||||||
|
actionType: { value: "action" },
|
||||||
|
actions: { value: 1 },
|
||||||
|
traits: { value: [] },
|
||||||
|
description: {
|
||||||
|
value:
|
||||||
|
"<p><strong>Frequency</strong> once per day</p>\n<hr />\n<p><strong>Effect</strong> The lich opens their spell tome.</p>",
|
||||||
|
},
|
||||||
|
frequency: { max: 1, per: "day" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const text =
|
||||||
|
creature.abilitiesBot?.[0]?.segments[0]?.type === "text"
|
||||||
|
? creature.abilitiesBot[0].segments[0].value
|
||||||
|
: "";
|
||||||
|
expect(text).not.toContain("Frequency");
|
||||||
|
expect(text).toContain("The lich opens their spell tome.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips frequency line even when preceded by other text", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "a1",
|
||||||
|
name: "Drain Soul Cage",
|
||||||
|
type: "action",
|
||||||
|
system: {
|
||||||
|
category: "offensive",
|
||||||
|
actionType: { value: "free" },
|
||||||
|
actions: { value: null },
|
||||||
|
traits: { value: [] },
|
||||||
|
description: {
|
||||||
|
value:
|
||||||
|
"<p>6th rank</p>\n<hr />\n<p><strong>Frequency</strong> once per day</p>\n<hr />\n<p><strong>Effect</strong> The lich taps into their soul cage.</p>",
|
||||||
|
},
|
||||||
|
frequency: { max: 1, per: "day" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const text =
|
||||||
|
creature.abilitiesBot?.[0]?.segments[0]?.type === "text"
|
||||||
|
? creature.abilitiesBot[0].segments[0].value
|
||||||
|
: "";
|
||||||
|
expect(text).not.toContain("Frequency");
|
||||||
|
expect(text).toContain("6th rank");
|
||||||
|
expect(text).toContain("The lich taps into their soul cage.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits frequency when not present", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "a1",
|
||||||
|
name: "Strike",
|
||||||
|
type: "action",
|
||||||
|
system: {
|
||||||
|
category: "offensive",
|
||||||
|
actionType: { value: "action" },
|
||||||
|
actions: { value: 1 },
|
||||||
|
traits: { value: [] },
|
||||||
|
description: { value: "<p>Strikes.</p>" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(creature.abilitiesBot?.[0]?.frequency).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("equipment normalization", () => {
|
||||||
|
it("normalizes a weapon with traits and description", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "w1",
|
||||||
|
name: "Flaming Longsword",
|
||||||
|
type: "weapon",
|
||||||
|
system: {
|
||||||
|
level: { value: 5 },
|
||||||
|
traits: { value: ["magical", "fire"] },
|
||||||
|
description: {
|
||||||
|
value: "<p>This sword blazes with fire.</p>",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(creature.equipment).toHaveLength(1);
|
||||||
|
const item = creature.equipment?.[0];
|
||||||
|
expect(item?.name).toBe("Flaming Longsword");
|
||||||
|
expect(item?.level).toBe(5);
|
||||||
|
expect(item?.traits).toEqual(["magical", "fire"]);
|
||||||
|
expect(item?.description).toBe("This sword blazes with fire.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes a consumable potion with description", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "c1",
|
||||||
|
name: "Healing Potion (Moderate)",
|
||||||
|
type: "consumable",
|
||||||
|
system: {
|
||||||
|
level: { value: 6 },
|
||||||
|
traits: { value: ["consumable", "healing", "magical"] },
|
||||||
|
description: {
|
||||||
|
value: "<p>Restores 3d8+10 Hit Points.</p>",
|
||||||
|
},
|
||||||
|
category: "potion",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(creature.equipment).toHaveLength(1);
|
||||||
|
const item = creature.equipment?.[0];
|
||||||
|
expect(item?.name).toBe("Healing Potion (Moderate)");
|
||||||
|
expect(item?.category).toBe("potion");
|
||||||
|
expect(item?.description).toBe("Restores 3d8+10 Hit Points.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts scroll embedded spell name and rank", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "s1",
|
||||||
|
name: "Scroll of Teleport (Rank 6)",
|
||||||
|
type: "consumable",
|
||||||
|
system: {
|
||||||
|
level: { value: 11 },
|
||||||
|
traits: { value: ["consumable", "magical", "scroll"] },
|
||||||
|
description: { value: "<p>A scroll.</p>" },
|
||||||
|
category: "scroll",
|
||||||
|
spell: {
|
||||||
|
name: "Teleport",
|
||||||
|
system: { level: { value: 6 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const item = creature.equipment?.[0];
|
||||||
|
expect(item?.spellName).toBe("Teleport");
|
||||||
|
expect(item?.spellRank).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts wand embedded spell name and rank", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "w1",
|
||||||
|
name: "Wand of Dispel Magic (Rank 2)",
|
||||||
|
type: "consumable",
|
||||||
|
system: {
|
||||||
|
level: { value: 5 },
|
||||||
|
traits: { value: ["consumable", "magical", "wand"] },
|
||||||
|
description: { value: "<p>A wand.</p>" },
|
||||||
|
category: "wand",
|
||||||
|
spell: {
|
||||||
|
name: "Dispel Magic",
|
||||||
|
system: { level: { value: 2 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const item = creature.equipment?.[0];
|
||||||
|
expect(item?.spellName).toBe("Dispel Magic");
|
||||||
|
expect(item?.spellRank).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters magical equipment into equipment field", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "e1",
|
||||||
|
name: "Ring of Energy Resistance (Fire)",
|
||||||
|
type: "equipment",
|
||||||
|
system: {
|
||||||
|
level: { value: 6 },
|
||||||
|
traits: { value: ["magical", "invested"] },
|
||||||
|
description: {
|
||||||
|
value: "<p>Grants fire resistance 5.</p>",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(creature.equipment).toHaveLength(1);
|
||||||
|
expect(creature.equipment?.[0]?.name).toBe(
|
||||||
|
"Ring of Energy Resistance (Fire)",
|
||||||
|
);
|
||||||
|
expect(creature.items).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters mundane items into items string", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "w1",
|
||||||
|
name: "Longsword",
|
||||||
|
type: "weapon",
|
||||||
|
system: {
|
||||||
|
level: { value: 0 },
|
||||||
|
traits: { value: [] },
|
||||||
|
description: { value: "" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: "a1",
|
||||||
|
name: "Leather Armor",
|
||||||
|
type: "armor",
|
||||||
|
system: {
|
||||||
|
level: { value: 0 },
|
||||||
|
traits: { value: [] },
|
||||||
|
description: { value: "" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(creature.items).toBe("Longsword, Leather Armor");
|
||||||
|
expect(creature.equipment).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits equipment when no detailed items exist", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "w1",
|
||||||
|
name: "Dagger",
|
||||||
|
type: "weapon",
|
||||||
|
system: {
|
||||||
|
level: { value: 0 },
|
||||||
|
traits: { value: [] },
|
||||||
|
description: { value: "" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(creature.equipment).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits items when no mundane items exist", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "c1",
|
||||||
|
name: "Giant Wasp Venom",
|
||||||
|
type: "consumable",
|
||||||
|
system: {
|
||||||
|
level: { value: 7 },
|
||||||
|
traits: { value: ["consumable", "poison"] },
|
||||||
|
description: {
|
||||||
|
value: "<p>A deadly poison.</p>",
|
||||||
|
},
|
||||||
|
category: "poison",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(creature.items).toBeUndefined();
|
||||||
|
expect(creature.equipment).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes armor with special material in equipment", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "a1",
|
||||||
|
name: "Adamantine Full Plate",
|
||||||
|
type: "armor",
|
||||||
|
system: {
|
||||||
|
level: { value: 0 },
|
||||||
|
traits: { value: [] },
|
||||||
|
description: {
|
||||||
|
value: "<p>Full plate made of adamantine.</p>",
|
||||||
|
},
|
||||||
|
material: { type: "adamantine", grade: "standard" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(creature.equipment).toHaveLength(1);
|
||||||
|
expect(creature.equipment?.[0]?.name).toBe("Adamantine Full Plate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes mundane armor from equipment (goes to items)", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "a1",
|
||||||
|
name: "Chain Mail",
|
||||||
|
type: "armor",
|
||||||
|
system: {
|
||||||
|
level: { value: 0 },
|
||||||
|
traits: { value: [] },
|
||||||
|
description: { value: "" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(creature.equipment).toBeUndefined();
|
||||||
|
expect(creature.items).toBe("Chain Mail");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips Foundry HTML tags from equipment descriptions", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "c1",
|
||||||
|
name: "Potion of Speed",
|
||||||
|
type: "consumable",
|
||||||
|
system: {
|
||||||
|
level: { value: 10 },
|
||||||
|
traits: { value: ["consumable", "magical"] },
|
||||||
|
description: {
|
||||||
|
value:
|
||||||
|
"<p>Gain @UUID[Compendium.pf2e.conditionitems.Item.Quickened]{quickened} for 1 minute.</p>",
|
||||||
|
},
|
||||||
|
category: "potion",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const desc = creature.equipment?.[0]?.description;
|
||||||
|
expect(desc).toBe("Gain quickened for 1 minute.");
|
||||||
|
expect(desc).not.toContain("@UUID");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("spellcasting normalization", () => {
|
describe("spellcasting normalization", () => {
|
||||||
@@ -593,11 +1111,12 @@ describe("normalizeFoundryCreature", () => {
|
|||||||
const sc = creature.spellcasting?.[0];
|
const sc = creature.spellcasting?.[0];
|
||||||
expect(sc?.name).toBe("Primal Prepared Spells");
|
expect(sc?.name).toBe("Primal Prepared Spells");
|
||||||
expect(sc?.headerText).toBe("DC 30, attack +22");
|
expect(sc?.headerText).toBe("DC 30, attack +22");
|
||||||
expect(sc?.daily).toEqual([
|
expect(sc?.daily?.map((d) => d.uses)).toEqual([6, 3]);
|
||||||
{ uses: 6, each: true, spells: ["Earthquake"] },
|
expect(sc?.daily?.[0]?.spells.map((s) => s.name)).toEqual(["Earthquake"]);
|
||||||
{ uses: 3, each: true, spells: ["Heal"] },
|
expect(sc?.daily?.[1]?.spells.map((s) => s.name)).toEqual(["Heal"]);
|
||||||
]);
|
expect(sc?.atWill?.map((s) => s.name)).toEqual(["Detect Magic"]);
|
||||||
expect(sc?.atWill).toEqual(["Detect Magic"]);
|
// Cantrip rank auto-heightens to ceil(creatureLevel / 2) = ceil(3/2) = 2
|
||||||
|
expect(sc?.atWill?.[0]?.rank).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes innate spells with uses", () => {
|
it("normalizes innate spells with uses", () => {
|
||||||
@@ -633,13 +1152,334 @@ describe("normalizeFoundryCreature", () => {
|
|||||||
);
|
);
|
||||||
const sc = creature.spellcasting?.[0];
|
const sc = creature.spellcasting?.[0];
|
||||||
expect(sc?.headerText).toBe("DC 32");
|
expect(sc?.headerText).toBe("DC 32");
|
||||||
expect(sc?.daily).toEqual([
|
expect(sc?.daily).toHaveLength(1);
|
||||||
{
|
const spell = sc?.daily?.[0]?.spells[0];
|
||||||
uses: 1,
|
expect(spell?.name).toBe("Sure Strike");
|
||||||
each: true,
|
expect(spell?.usesPerDay).toBe(3);
|
||||||
spells: ["Sure Strike (\u00d73)"],
|
expect(spell?.rank).toBe(1);
|
||||||
},
|
});
|
||||||
]);
|
|
||||||
|
it("preserves full spell data including description and heightening", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "entry1",
|
||||||
|
name: "Divine Innate Spells",
|
||||||
|
type: "spellcastingEntry",
|
||||||
|
system: {
|
||||||
|
tradition: { value: "divine" },
|
||||||
|
prepared: { value: "innate" },
|
||||||
|
spelldc: { dc: 35, value: 27 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: "s1",
|
||||||
|
name: "Heal",
|
||||||
|
type: "spell",
|
||||||
|
system: {
|
||||||
|
slug: "heal",
|
||||||
|
location: { value: "entry1" },
|
||||||
|
level: { value: 6 },
|
||||||
|
traits: {
|
||||||
|
rarity: "common",
|
||||||
|
value: ["healing", "vitality"],
|
||||||
|
traditions: ["divine", "primal"],
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
value:
|
||||||
|
"<p>You channel @UUID[Compendium.pf2e.spells.Item.Heal]{positive} energy to heal the living. The target regains @Damage[2d8[vitality]] Hit Points.</p>",
|
||||||
|
},
|
||||||
|
range: { value: "30 feet" },
|
||||||
|
target: { value: "1 willing creature" },
|
||||||
|
duration: { value: "" },
|
||||||
|
defense: undefined,
|
||||||
|
time: { value: "1" },
|
||||||
|
heightening: {
|
||||||
|
type: "interval",
|
||||||
|
interval: 1,
|
||||||
|
damage: { value: "2d8" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: "s2",
|
||||||
|
name: "Force Barrage",
|
||||||
|
type: "spell",
|
||||||
|
system: {
|
||||||
|
location: { value: "entry1" },
|
||||||
|
level: { value: 1 },
|
||||||
|
traits: { value: ["concentrate", "manipulate"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const sc = creature.spellcasting?.[0];
|
||||||
|
expect(sc).toBeDefined();
|
||||||
|
const heal = sc?.daily
|
||||||
|
?.flatMap((d) => d.spells)
|
||||||
|
.find((s) => s.name === "Heal");
|
||||||
|
expect(heal).toBeDefined();
|
||||||
|
expect(heal?.slug).toBe("heal");
|
||||||
|
expect(heal?.rank).toBe(6);
|
||||||
|
expect(heal?.range).toBe("30 feet");
|
||||||
|
expect(heal?.target).toBe("1 willing creature");
|
||||||
|
expect(heal?.traits).toEqual(["healing", "vitality"]);
|
||||||
|
expect(heal?.traditions).toEqual(["divine", "primal"]);
|
||||||
|
expect(heal?.actionCost).toBe("1");
|
||||||
|
// Foundry tags stripped from description
|
||||||
|
expect(heal?.description).toContain("positive");
|
||||||
|
expect(heal?.description).not.toContain("@UUID");
|
||||||
|
expect(heal?.description).not.toContain("@Damage");
|
||||||
|
// Interval heightening formatted and not duplicated in description
|
||||||
|
expect(heal?.heightening).toBe("Heightened (+1) damage increases by 2d8");
|
||||||
|
|
||||||
|
// Spell without optional data still has name + rank
|
||||||
|
const fb = sc?.daily
|
||||||
|
?.flatMap((d) => d.spells)
|
||||||
|
.find((s) => s.name === "Force Barrage");
|
||||||
|
expect(fb).toBeDefined();
|
||||||
|
expect(fb?.rank).toBe(1);
|
||||||
|
expect(fb?.description).toBeUndefined();
|
||||||
|
expect(fb?.usesPerDay).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats fixed-type heightening levels", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "entry1",
|
||||||
|
name: "Divine Prepared Spells",
|
||||||
|
type: "spellcastingEntry",
|
||||||
|
system: {
|
||||||
|
tradition: { value: "divine" },
|
||||||
|
prepared: { value: "prepared" },
|
||||||
|
spelldc: { dc: 30 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: "s1",
|
||||||
|
name: "Magic Missile",
|
||||||
|
type: "spell",
|
||||||
|
system: {
|
||||||
|
location: { value: "entry1" },
|
||||||
|
level: { value: 1 },
|
||||||
|
traits: { value: [] },
|
||||||
|
heightening: {
|
||||||
|
type: "fixed",
|
||||||
|
levels: {
|
||||||
|
"3": { text: "<p>You shoot two more missiles.</p>" },
|
||||||
|
"5": { text: "<p>You shoot four more missiles.</p>" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const spell = creature.spellcasting?.[0]?.daily
|
||||||
|
?.flatMap((d) => d.spells)
|
||||||
|
.find((s) => s.name === "Magic Missile");
|
||||||
|
expect(spell?.heightening).toContain(
|
||||||
|
"Heightened (3) You shoot two more missiles.",
|
||||||
|
);
|
||||||
|
expect(spell?.heightening).toContain(
|
||||||
|
"Heightened (5) You shoot four more missiles.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats save defense", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "entry1",
|
||||||
|
name: "Arcane Innate Spells",
|
||||||
|
type: "spellcastingEntry",
|
||||||
|
system: {
|
||||||
|
tradition: { value: "arcane" },
|
||||||
|
prepared: { value: "innate" },
|
||||||
|
spelldc: { dc: 25 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: "s1",
|
||||||
|
name: "Fireball",
|
||||||
|
type: "spell",
|
||||||
|
system: {
|
||||||
|
location: { value: "entry1" },
|
||||||
|
level: { value: 3 },
|
||||||
|
traits: { value: ["fire"] },
|
||||||
|
area: { type: "burst", value: 20 },
|
||||||
|
defense: {
|
||||||
|
save: { statistic: "reflex", basic: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const fireball = creature.spellcasting?.[0]?.daily
|
||||||
|
?.flatMap((d) => d.spells)
|
||||||
|
.find((s) => s.name === "Fireball");
|
||||||
|
expect(fireball?.defense).toBe("basic Reflex");
|
||||||
|
expect(fireball?.area).toBe("20-foot burst");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips inline heightening text from description when structured heightening exists", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "entry1",
|
||||||
|
name: "Arcane Prepared Spells",
|
||||||
|
type: "spellcastingEntry",
|
||||||
|
system: {
|
||||||
|
tradition: { value: "arcane" },
|
||||||
|
prepared: { value: "prepared" },
|
||||||
|
spelldc: { dc: 30 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: "s1",
|
||||||
|
name: "Chain Lightning",
|
||||||
|
type: "spell",
|
||||||
|
system: {
|
||||||
|
location: { value: "entry1" },
|
||||||
|
level: { value: 6 },
|
||||||
|
traits: { value: ["electricity"] },
|
||||||
|
description: {
|
||||||
|
value:
|
||||||
|
"<p>You discharge a bolt of lightning. The damage is 8d12.</p><p>Heightened (+1) The damage increases by 1d12.</p>",
|
||||||
|
},
|
||||||
|
heightening: {
|
||||||
|
type: "interval",
|
||||||
|
interval: 1,
|
||||||
|
damage: { value: "1d12" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const spell = creature.spellcasting?.[0]?.daily
|
||||||
|
?.flatMap((d) => d.spells)
|
||||||
|
.find((s) => s.name === "Chain Lightning");
|
||||||
|
expect(spell?.description).toBe(
|
||||||
|
"You discharge a bolt of lightning. The damage is 8d12.",
|
||||||
|
);
|
||||||
|
expect(spell?.description).not.toContain("Heightened");
|
||||||
|
expect(spell?.heightening).toBe(
|
||||||
|
"Heightened (+1) damage increases by 1d12",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats overlays when heightening is absent", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "entry1",
|
||||||
|
name: "Arcane Innate Spells",
|
||||||
|
type: "spellcastingEntry",
|
||||||
|
system: {
|
||||||
|
tradition: { value: "arcane" },
|
||||||
|
prepared: { value: "innate" },
|
||||||
|
spelldc: { dc: 28 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: "s1",
|
||||||
|
name: "Force Barrage",
|
||||||
|
type: "spell",
|
||||||
|
system: {
|
||||||
|
location: { value: "entry1" },
|
||||||
|
level: { value: 1 },
|
||||||
|
traits: { value: ["force", "manipulate"] },
|
||||||
|
description: {
|
||||||
|
value: "<p>You fire darts of force.</p>",
|
||||||
|
},
|
||||||
|
overlays: {
|
||||||
|
variant1: {
|
||||||
|
name: "2 actions",
|
||||||
|
system: {
|
||||||
|
description: {
|
||||||
|
value: "<p>You fire two darts.</p>",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
variant2: {
|
||||||
|
name: "3 actions",
|
||||||
|
system: {
|
||||||
|
description: {
|
||||||
|
value: "<p>You fire three darts.</p>",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const spell = creature.spellcasting?.[0]?.daily
|
||||||
|
?.flatMap((d) => d.spells)
|
||||||
|
.find((s) => s.name === "Force Barrage");
|
||||||
|
expect(spell?.heightening).toContain("2 actions: You fire two darts.");
|
||||||
|
expect(spell?.heightening).toContain("3 actions: You fire three darts.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers heightening over overlays when both present", () => {
|
||||||
|
const creature = normalizeFoundryCreature(
|
||||||
|
minimalCreature({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
_id: "entry1",
|
||||||
|
name: "Arcane Prepared Spells",
|
||||||
|
type: "spellcastingEntry",
|
||||||
|
system: {
|
||||||
|
tradition: { value: "arcane" },
|
||||||
|
prepared: { value: "prepared" },
|
||||||
|
spelldc: { dc: 30 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: "s1",
|
||||||
|
name: "Test Spell",
|
||||||
|
type: "spell",
|
||||||
|
system: {
|
||||||
|
location: { value: "entry1" },
|
||||||
|
level: { value: 1 },
|
||||||
|
traits: { value: [] },
|
||||||
|
heightening: {
|
||||||
|
type: "interval",
|
||||||
|
interval: 2,
|
||||||
|
damage: { value: "1d6" },
|
||||||
|
},
|
||||||
|
overlays: {
|
||||||
|
variant1: {
|
||||||
|
name: "Variant",
|
||||||
|
system: {
|
||||||
|
description: {
|
||||||
|
value: "<p>Should be ignored.</p>",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const spell = creature.spellcasting?.[0]?.daily
|
||||||
|
?.flatMap((d) => d.spells)
|
||||||
|
.find((s) => s.name === "Test Spell");
|
||||||
|
expect(spell?.heightening).toBe(
|
||||||
|
"Heightened (+2) damage increases by 1d6",
|
||||||
|
);
|
||||||
|
expect(spell?.heightening).not.toContain("Should be ignored");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -99,9 +99,15 @@ describe("stripFoundryTags", () => {
|
|||||||
expect(stripFoundryTags("before<hr />after")).toBe("before\nafter");
|
expect(stripFoundryTags("before<hr />after")).toBe("before\nafter");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strips strong and em tags", () => {
|
it("preserves strong and em tags", () => {
|
||||||
expect(stripFoundryTags("<strong>bold</strong> <em>italic</em>")).toBe(
|
expect(stripFoundryTags("<strong>bold</strong> <em>italic</em>")).toBe(
|
||||||
"bold italic",
|
"<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>",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 = 5;
|
// 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: [
|
||||||
.filter((c) => !EXCLUDED_SOURCES.has(c.s))
|
...compact.creatures
|
||||||
.map(mapCreature),
|
.filter((c) => !EXCLUDED_SOURCES.has(c.s))
|
||||||
|
.map(mapCreature),
|
||||||
|
...loadBundledDndIndexEntries(),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
return cachedIndex;
|
return cachedIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAllSourceCodes(): string[] {
|
export function getAllSourceCodes(): string[] {
|
||||||
const index = loadBestiaryIndex();
|
const index = loadBestiaryIndex();
|
||||||
return Object.keys(index.sources).filter((c) => !EXCLUDED_SOURCES.has(c));
|
const bundled = getBundledDndSources();
|
||||||
|
return Object.keys(index.sources).filter(
|
||||||
|
(c) => !EXCLUDED_SOURCES.has(c) && !bundled.has(c),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sourceCodeToFilename(sourceCode: string): string {
|
function sourceCodeToFilename(sourceCode: string): string {
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { BestiaryIndexEntry, Creature } from "@initiative/domain";
|
||||||
|
import { creatureId } from "@initiative/domain";
|
||||||
|
|
||||||
|
import rawBundled from "../../../../data/bestiary/dnd-bundled.json";
|
||||||
|
|
||||||
|
type RawBundledCreature = Omit<Creature, "id"> & { id: string };
|
||||||
|
|
||||||
|
const SIZE_TO_CODE: Record<string, string> = {
|
||||||
|
Tiny: "T",
|
||||||
|
Small: "S",
|
||||||
|
Medium: "M",
|
||||||
|
Large: "L",
|
||||||
|
Huge: "H",
|
||||||
|
Gargantuan: "G",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Full normalized stat blocks for bundled D&D creatures. */
|
||||||
|
export function loadBundledDndCreatures(): Creature[] {
|
||||||
|
return (rawBundled as RawBundledCreature[]).map((c) => ({
|
||||||
|
...c,
|
||||||
|
id: creatureId(c.id),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index entries derived from the bundled creatures, in the compact shape
|
||||||
|
* used by the search index. */
|
||||||
|
export function loadBundledDndIndexEntries(): BestiaryIndexEntry[] {
|
||||||
|
return (rawBundled as RawBundledCreature[]).map((c) => ({
|
||||||
|
name: c.name,
|
||||||
|
source: c.source,
|
||||||
|
ac: c.ac,
|
||||||
|
hp: c.hp.average,
|
||||||
|
dex: c.abilities.dex,
|
||||||
|
cr: c.cr,
|
||||||
|
initiativeProficiency: c.initiativeProficiency,
|
||||||
|
size: SIZE_TO_CODE[c.size.split(" ")[0]] ?? "M",
|
||||||
|
type: c.type.split(" ")[0].toLowerCase(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Source codes → display names, derived from the bundled creatures' own
|
||||||
|
* `source` and `sourceDisplayName` fields. Adding a new book just means
|
||||||
|
* appending creatures with the right `source` field to dnd-bundled.json;
|
||||||
|
* no code change is required here. */
|
||||||
|
export function getBundledDndSources(): ReadonlyMap<string, string> {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const c of rawBundled as RawBundledCreature[]) {
|
||||||
|
if (!map.has(c.source)) {
|
||||||
|
map.set(c.source, c.sourceDisplayName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import type {
|
import type {
|
||||||
CreatureId,
|
CreatureId,
|
||||||
|
EquipmentItem,
|
||||||
Pf2eCreature,
|
Pf2eCreature,
|
||||||
SpellcastingBlock,
|
SpellcastingBlock,
|
||||||
|
SpellReference,
|
||||||
TraitBlock,
|
TraitBlock,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { creatureId } from "@initiative/domain";
|
import { creatureId } from "@initiative/domain";
|
||||||
@@ -61,6 +63,7 @@ interface MeleeSystem {
|
|||||||
bonus?: { value: number };
|
bonus?: { value: number };
|
||||||
damageRolls?: Record<string, { damage: string; damageType: string }>;
|
damageRolls?: Record<string, { damage: string; damageType: string }>;
|
||||||
traits?: { value: string[] };
|
traits?: { value: string[] };
|
||||||
|
attackEffects?: { value: string[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ActionSystem {
|
interface ActionSystem {
|
||||||
@@ -69,6 +72,7 @@ interface ActionSystem {
|
|||||||
actions?: { value: number | null };
|
actions?: { value: number | null };
|
||||||
traits?: { value: string[] };
|
traits?: { value: string[] };
|
||||||
description?: { value: string };
|
description?: { value: string };
|
||||||
|
frequency?: { max: number; per: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SpellcastingEntrySystem {
|
interface SpellcastingEntrySystem {
|
||||||
@@ -78,13 +82,106 @@ interface SpellcastingEntrySystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SpellSystem {
|
interface SpellSystem {
|
||||||
|
slug?: string;
|
||||||
location?: {
|
location?: {
|
||||||
value: string;
|
value: string;
|
||||||
heightenedLevel?: number;
|
heightenedLevel?: number;
|
||||||
uses?: { max: number; value: number };
|
uses?: { max: number; value: number };
|
||||||
};
|
};
|
||||||
|
level?: { value: number };
|
||||||
|
traits?: { rarity?: string; value: string[]; traditions?: string[] };
|
||||||
|
description?: { value: string };
|
||||||
|
range?: { value: string };
|
||||||
|
target?: { value: string };
|
||||||
|
area?: { type?: string; value?: number; details?: string };
|
||||||
|
duration?: { value: string; sustained?: boolean };
|
||||||
|
time?: { value: string };
|
||||||
|
defense?: {
|
||||||
|
save?: { statistic: string; basic?: boolean };
|
||||||
|
passive?: { statistic: string };
|
||||||
|
};
|
||||||
|
heightening?:
|
||||||
|
| {
|
||||||
|
type: "fixed";
|
||||||
|
levels: Record<string, { text?: string }>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "interval";
|
||||||
|
interval: number;
|
||||||
|
damage?: { value: string };
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
overlays?: Record<
|
||||||
|
string,
|
||||||
|
{ name?: string; system?: { description?: { value: string } } }
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConsumableSystem {
|
||||||
level?: { value: number };
|
level?: { value: number };
|
||||||
traits?: { value: string[] };
|
traits?: { value: string[] };
|
||||||
|
description?: { value: string };
|
||||||
|
category?: string;
|
||||||
|
spell?: {
|
||||||
|
name: string;
|
||||||
|
system?: { level?: { value: number } };
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EQUIPMENT_TYPES = new Set(["weapon", "consumable", "equipment", "armor"]);
|
||||||
|
|
||||||
|
/** Items shown in the Equipment section with popovers. */
|
||||||
|
function isDetailedEquipment(item: RawFoundryItem): boolean {
|
||||||
|
if (!EQUIPMENT_TYPES.has(item.type)) return false;
|
||||||
|
const sys = item.system;
|
||||||
|
const level = (sys.level as { value: number } | undefined)?.value ?? 0;
|
||||||
|
const traits = (sys.traits as { value: string[] } | undefined)?.value ?? [];
|
||||||
|
// All consumables are tactically relevant (potions, scrolls, poisons, etc.)
|
||||||
|
if (item.type === "consumable") return true;
|
||||||
|
// Magical/invested items
|
||||||
|
if (traits.includes("magical") || traits.includes("invested")) return true;
|
||||||
|
// Special material armor/equipment
|
||||||
|
const material = sys.material as { type: string | null } | undefined;
|
||||||
|
if (material?.type) return true;
|
||||||
|
// Higher-level items
|
||||||
|
if (level > 0) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Items shown on the "Items" line as plain names. */
|
||||||
|
function isMundaneItem(item: RawFoundryItem): boolean {
|
||||||
|
return EQUIPMENT_TYPES.has(item.type) && !isDetailedEquipment(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEquipmentItem(item: RawFoundryItem): EquipmentItem {
|
||||||
|
const sys = item.system;
|
||||||
|
const level = (sys.level as { value: number } | undefined)?.value ?? 0;
|
||||||
|
const traits = (sys.traits as { value: string[] } | undefined)?.value;
|
||||||
|
const rawDesc = (sys.description as { value: string } | undefined)?.value;
|
||||||
|
const description = rawDesc
|
||||||
|
? stripFoundryTags(rawDesc) || undefined
|
||||||
|
: undefined;
|
||||||
|
const category = sys.category as string | undefined;
|
||||||
|
|
||||||
|
let spellName: string | undefined;
|
||||||
|
let spellRank: number | undefined;
|
||||||
|
if (item.type === "consumable") {
|
||||||
|
const spell = (sys as unknown as ConsumableSystem).spell;
|
||||||
|
if (spell) {
|
||||||
|
spellName = spell.name;
|
||||||
|
spellRank = spell.system?.level?.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: item.name,
|
||||||
|
level,
|
||||||
|
category: category || undefined,
|
||||||
|
traits: traits && traits.length > 0 ? traits : undefined,
|
||||||
|
description,
|
||||||
|
spellName,
|
||||||
|
spellRank,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const SIZE_MAP: Record<string, string> = {
|
const SIZE_MAP: Record<string, string> = {
|
||||||
@@ -247,7 +344,17 @@ function formatSpeed(speed: {
|
|||||||
|
|
||||||
// -- Attack normalization --
|
// -- Attack normalization --
|
||||||
|
|
||||||
function normalizeAttack(item: RawFoundryItem): TraitBlock {
|
/** Format an attack effect slug to display text: "grab" → "Grab", "lich-siphon-life" → "Siphon Life". */
|
||||||
|
function formatAttackEffect(slug: string, creatureName: string): string {
|
||||||
|
const prefix = `${creatureName.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-")}-`;
|
||||||
|
const stripped = slug.startsWith(prefix) ? slug.slice(prefix.length) : slug;
|
||||||
|
return stripped.split("-").map(capitalize).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAttack(
|
||||||
|
item: RawFoundryItem,
|
||||||
|
creatureName: string,
|
||||||
|
): TraitBlock {
|
||||||
const sys = item.system as unknown as MeleeSystem;
|
const sys = item.system as unknown as MeleeSystem;
|
||||||
const bonus = sys.bonus?.value ?? 0;
|
const bonus = sys.bonus?.value ?? 0;
|
||||||
const traits = sys.traits?.value ?? [];
|
const traits = sys.traits?.value ?? [];
|
||||||
@@ -257,13 +364,18 @@ function normalizeAttack(item: RawFoundryItem): TraitBlock {
|
|||||||
.join(" plus ");
|
.join(" plus ");
|
||||||
const traitStr =
|
const traitStr =
|
||||||
traits.length > 0 ? ` (${traits.map(formatTrait).join(", ")})` : "";
|
traits.length > 0 ? ` (${traits.map(formatTrait).join(", ")})` : "";
|
||||||
|
const effects = sys.attackEffects?.value ?? [];
|
||||||
|
const effectStr =
|
||||||
|
effects.length > 0
|
||||||
|
? ` plus ${effects.map((e) => formatAttackEffect(e, creatureName)).join(" and ")}`
|
||||||
|
: "";
|
||||||
return {
|
return {
|
||||||
name: capitalize(item.name),
|
name: capitalize(item.name),
|
||||||
activity: { number: 1, unit: "action" },
|
activity: { number: 1, unit: "action" },
|
||||||
segments: [
|
segments: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
value: `+${bonus}${traitStr}, ${damage}`,
|
value: `+${bonus}${traitStr}, ${damage}${effectStr}`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -287,15 +399,31 @@ function parseActivity(
|
|||||||
|
|
||||||
// -- Ability normalization --
|
// -- Ability normalization --
|
||||||
|
|
||||||
|
const FREQUENCY_LINE = /(<strong>)?Frequency(<\/strong>)?\s+[^\n]+\n*/i;
|
||||||
|
|
||||||
|
/** Strip the "Frequency once per day" line from ability descriptions when structured frequency data exists. */
|
||||||
|
function stripFrequencyLine(text: string): string {
|
||||||
|
return text.replace(FREQUENCY_LINE, "").trimStart();
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeAbility(item: RawFoundryItem): TraitBlock {
|
function normalizeAbility(item: RawFoundryItem): TraitBlock {
|
||||||
const sys = item.system as unknown as ActionSystem;
|
const sys = item.system as unknown as ActionSystem;
|
||||||
const actionType = sys.actionType?.value;
|
const actionType = sys.actionType?.value;
|
||||||
const actionCount = sys.actions?.value;
|
const actionCount = sys.actions?.value;
|
||||||
const description = stripFoundryTags(sys.description?.value ?? "");
|
let description = stripFoundryTags(sys.description?.value ?? "");
|
||||||
const traits = sys.traits?.value ?? [];
|
const traits = sys.traits?.value ?? [];
|
||||||
|
|
||||||
const activity = parseActivity(actionType, actionCount);
|
const activity = parseActivity(actionType, actionCount);
|
||||||
|
|
||||||
|
const frequency =
|
||||||
|
sys.frequency?.max != null && sys.frequency.per
|
||||||
|
? `${sys.frequency.max}/${sys.frequency.per}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (frequency) {
|
||||||
|
description = stripFrequencyLine(description);
|
||||||
|
}
|
||||||
|
|
||||||
const traitStr =
|
const traitStr =
|
||||||
traits.length > 0
|
traits.length > 0
|
||||||
? `(${traits.map((t) => capitalize(formatTrait(t))).join(", ")}) `
|
? `(${traits.map((t) => capitalize(formatTrait(t))).join(", ")}) `
|
||||||
@@ -306,28 +434,122 @@ function normalizeAbility(item: RawFoundryItem): TraitBlock {
|
|||||||
? [{ type: "text", value: text }]
|
? [{ type: "text", value: text }]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return { name: item.name, activity, segments };
|
return { name: item.name, activity, frequency, segments };
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Spellcasting normalization --
|
// -- Spellcasting normalization --
|
||||||
|
|
||||||
function classifySpell(spell: RawFoundryItem): {
|
function formatRange(range: { value: string } | undefined): string | undefined {
|
||||||
isCantrip: boolean;
|
if (!range?.value) return undefined;
|
||||||
rank: number;
|
return range.value;
|
||||||
label: string;
|
}
|
||||||
} {
|
|
||||||
const sys = spell.system as unknown as SpellSystem;
|
function formatArea(
|
||||||
const isCantrip = (sys.traits?.value ?? []).includes("cantrip");
|
area: { type?: string; value?: number; details?: string } | undefined,
|
||||||
const rank = sys.location?.heightenedLevel ?? sys.level?.value ?? 0;
|
): string | undefined {
|
||||||
const uses = sys.location?.uses;
|
if (!area) return undefined;
|
||||||
const label =
|
if (area.value && area.type) return `${area.value}-foot ${area.type}`;
|
||||||
uses && uses.max > 1 ? `${spell.name} (\u00d7${uses.max})` : spell.name;
|
return area.details ?? undefined;
|
||||||
return { isCantrip, rank, label };
|
}
|
||||||
|
|
||||||
|
function formatDefense(defense: SpellSystem["defense"]): string | undefined {
|
||||||
|
if (!defense) return undefined;
|
||||||
|
if (defense.save) {
|
||||||
|
const stat = capitalize(defense.save.statistic);
|
||||||
|
return defense.save.basic ? `basic ${stat}` : stat;
|
||||||
|
}
|
||||||
|
if (defense.passive) return capitalize(defense.passive.statistic);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHeightening(
|
||||||
|
heightening: SpellSystem["heightening"],
|
||||||
|
): string | undefined {
|
||||||
|
if (!heightening) return undefined;
|
||||||
|
if (heightening.type === "fixed") {
|
||||||
|
const parts = Object.entries(heightening.levels)
|
||||||
|
.filter(([, lvl]) => lvl.text)
|
||||||
|
.map(
|
||||||
|
([rank, lvl]) =>
|
||||||
|
`Heightened (${rank}) ${stripFoundryTags(lvl.text as string)}`,
|
||||||
|
);
|
||||||
|
return parts.length > 0 ? parts.join("\n") : undefined;
|
||||||
|
}
|
||||||
|
if (heightening.type === "interval") {
|
||||||
|
const dmg = heightening.damage?.value
|
||||||
|
? ` damage increases by ${heightening.damage.value}`
|
||||||
|
: "";
|
||||||
|
return `Heightened (+${heightening.interval})${dmg}`;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOverlays(overlays: SpellSystem["overlays"]): string | undefined {
|
||||||
|
if (!overlays) return undefined;
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const overlay of Object.values(overlays)) {
|
||||||
|
const desc = overlay.system?.description?.value;
|
||||||
|
if (!desc) continue;
|
||||||
|
const label = overlay.name ? `${overlay.name}: ` : "";
|
||||||
|
parts.push(`${label}${stripFoundryTags(desc)}`);
|
||||||
|
}
|
||||||
|
return parts.length > 0 ? parts.join("\n") : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foundry descriptions often include heightening rules inline at the end.
|
||||||
|
* When we extract heightening into a structured field, strip that trailing
|
||||||
|
* text to avoid duplication.
|
||||||
|
*/
|
||||||
|
const HEIGHTENED_SUFFIX = /\s*Heightened\s*\([^)]*\)[\s\S]*$/;
|
||||||
|
|
||||||
|
function normalizeSpell(
|
||||||
|
item: RawFoundryItem,
|
||||||
|
creatureLevel: number,
|
||||||
|
): SpellReference {
|
||||||
|
const sys = item.system as unknown as SpellSystem;
|
||||||
|
const usesMax = sys.location?.uses?.max;
|
||||||
|
const isCantrip = sys.traits?.value?.includes("cantrip") ?? false;
|
||||||
|
const rank =
|
||||||
|
sys.location?.heightenedLevel ??
|
||||||
|
(isCantrip ? Math.ceil(creatureLevel / 2) : (sys.level?.value ?? 0));
|
||||||
|
const heightening =
|
||||||
|
formatHeightening(sys.heightening) ?? formatOverlays(sys.overlays);
|
||||||
|
|
||||||
|
let description: string | undefined;
|
||||||
|
if (sys.description?.value) {
|
||||||
|
let text = stripFoundryTags(sys.description.value);
|
||||||
|
// Resolve Foundry Roll formula references to the spell's actual rank.
|
||||||
|
// The parenthesized form (e.g., "(@item.level)d4") is most common.
|
||||||
|
text = text.replaceAll(/\(?@item\.(?:rank|level)\)?/g, String(rank));
|
||||||
|
if (heightening) {
|
||||||
|
text = text.replace(HEIGHTENED_SUFFIX, "").trim();
|
||||||
|
}
|
||||||
|
description = text || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: item.name,
|
||||||
|
slug: sys.slug,
|
||||||
|
rank,
|
||||||
|
description,
|
||||||
|
traits: sys.traits?.value,
|
||||||
|
traditions: sys.traits?.traditions,
|
||||||
|
range: formatRange(sys.range),
|
||||||
|
target: sys.target?.value || undefined,
|
||||||
|
area: formatArea(sys.area),
|
||||||
|
duration: sys.duration?.value || undefined,
|
||||||
|
defense: formatDefense(sys.defense),
|
||||||
|
actionCost: sys.time?.value || undefined,
|
||||||
|
heightening,
|
||||||
|
usesPerDay: usesMax && usesMax > 1 ? usesMax : undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSpellcastingEntry(
|
function normalizeSpellcastingEntry(
|
||||||
entry: RawFoundryItem,
|
entry: RawFoundryItem,
|
||||||
allSpells: readonly RawFoundryItem[],
|
allSpells: readonly RawFoundryItem[],
|
||||||
|
creatureLevel: number,
|
||||||
): SpellcastingBlock {
|
): SpellcastingBlock {
|
||||||
const sys = entry.system as unknown as SpellcastingEntrySystem;
|
const sys = entry.system as unknown as SpellcastingEntrySystem;
|
||||||
const tradition = capitalize(sys.tradition?.value ?? "");
|
const tradition = capitalize(sys.tradition?.value ?? "");
|
||||||
@@ -342,26 +564,31 @@ function normalizeSpellcastingEntry(
|
|||||||
(s) => (s.system as unknown as SpellSystem).location?.value === entry._id,
|
(s) => (s.system as unknown as SpellSystem).location?.value === entry._id,
|
||||||
);
|
);
|
||||||
|
|
||||||
const byRank = new Map<number, string[]>();
|
const byRank = new Map<number, SpellReference[]>();
|
||||||
const cantrips: string[] = [];
|
const cantrips: SpellReference[] = [];
|
||||||
|
|
||||||
for (const spell of linkedSpells) {
|
for (const spell of linkedSpells) {
|
||||||
const { isCantrip, rank, label } = classifySpell(spell);
|
const ref = normalizeSpell(spell, creatureLevel);
|
||||||
|
const isCantrip =
|
||||||
|
(spell.system as unknown as SpellSystem).traits?.value?.includes(
|
||||||
|
"cantrip",
|
||||||
|
) ?? false;
|
||||||
if (isCantrip) {
|
if (isCantrip) {
|
||||||
cantrips.push(spell.name);
|
cantrips.push(ref);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const rank = ref.rank ?? 0;
|
||||||
const existing = byRank.get(rank) ?? [];
|
const existing = byRank.get(rank) ?? [];
|
||||||
existing.push(label);
|
existing.push(ref);
|
||||||
byRank.set(rank, existing);
|
byRank.set(rank, existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
const daily = [...byRank.entries()]
|
const daily = [...byRank.entries()]
|
||||||
.sort(([a], [b]) => b - a)
|
.sort(([a], [b]) => b - a)
|
||||||
.map(([rank, spellNames]) => ({
|
.map(([rank, spells]) => ({
|
||||||
uses: rank,
|
uses: rank,
|
||||||
each: true,
|
each: true,
|
||||||
spells: spellNames,
|
spells,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -374,10 +601,13 @@ function normalizeSpellcastingEntry(
|
|||||||
|
|
||||||
function normalizeSpellcasting(
|
function normalizeSpellcasting(
|
||||||
items: readonly RawFoundryItem[],
|
items: readonly RawFoundryItem[],
|
||||||
|
creatureLevel: number,
|
||||||
): SpellcastingBlock[] {
|
): SpellcastingBlock[] {
|
||||||
const entries = items.filter((i) => i.type === "spellcastingEntry");
|
const entries = items.filter((i) => i.type === "spellcastingEntry");
|
||||||
const spells = items.filter((i) => i.type === "spell");
|
const spells = items.filter((i) => i.type === "spell");
|
||||||
return entries.map((entry) => normalizeSpellcastingEntry(entry, spells));
|
return entries.map((entry) =>
|
||||||
|
normalizeSpellcastingEntry(entry, spells, creatureLevel),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Main normalization --
|
// -- Main normalization --
|
||||||
@@ -487,6 +717,7 @@ export function normalizeFoundryCreature(
|
|||||||
level: sys.details?.level?.value ?? 0,
|
level: sys.details?.level?.value ?? 0,
|
||||||
traits: buildTraits(sys.traits),
|
traits: buildTraits(sys.traits),
|
||||||
perception: sys.perception?.mod ?? 0,
|
perception: sys.perception?.mod ?? 0,
|
||||||
|
perceptionDetails: sys.perception?.details || undefined,
|
||||||
senses: formatSenses(sys.perception?.senses),
|
senses: formatSenses(sys.perception?.senses),
|
||||||
languages: formatLanguages(sys.details?.languages),
|
languages: formatLanguages(sys.details?.languages),
|
||||||
skills: formatSkills(sys.skills),
|
skills: formatSkills(sys.skills),
|
||||||
@@ -504,7 +735,9 @@ export function normalizeFoundryCreature(
|
|||||||
weaknesses: formatWeaknesses(sys.attributes.weaknesses),
|
weaknesses: formatWeaknesses(sys.attributes.weaknesses),
|
||||||
speed: formatSpeed(sys.attributes.speed),
|
speed: formatSpeed(sys.attributes.speed),
|
||||||
attacks: orUndefined(
|
attacks: orUndefined(
|
||||||
items.filter((i) => i.type === "melee").map(normalizeAttack),
|
items
|
||||||
|
.filter((i) => i.type === "melee")
|
||||||
|
.map((i) => normalizeAttack(i, r.name)),
|
||||||
),
|
),
|
||||||
abilitiesTop: orUndefined(actionsByCategory(items, "interaction")),
|
abilitiesTop: orUndefined(actionsByCategory(items, "interaction")),
|
||||||
abilitiesMid: orUndefined(
|
abilitiesMid: orUndefined(
|
||||||
@@ -516,7 +749,17 @@ export function normalizeFoundryCreature(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
abilitiesBot: orUndefined(actionsByCategory(items, "offensive")),
|
abilitiesBot: orUndefined(actionsByCategory(items, "offensive")),
|
||||||
spellcasting: orUndefined(normalizeSpellcasting(items)),
|
spellcasting: orUndefined(
|
||||||
|
normalizeSpellcasting(items, sys.details?.level?.value ?? 0),
|
||||||
|
),
|
||||||
|
items:
|
||||||
|
items
|
||||||
|
.filter(isMundaneItem)
|
||||||
|
.map((i) => i.name)
|
||||||
|
.join(", ") || undefined,
|
||||||
|
equipment: orUndefined(
|
||||||
|
items.filter(isDetailedEquipment).map(normalizeEquipmentItem),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,18 @@ export function getCreaturePathsForSource(sourceCode: string): string[] {
|
|||||||
return compact.creatures.filter((c) => c.s === sourceCode).map((c) => c.f);
|
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 {
|
||||||
const index = loadPf2eBestiaryIndex();
|
const index = loadPf2eBestiaryIndex();
|
||||||
return index.sources[sourceCode] ?? sourceCode;
|
return index.sources[sourceCode] ?? sourceCode;
|
||||||
|
|||||||
@@ -9,18 +9,18 @@ import type {
|
|||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
|
|
||||||
export interface EncounterPersistence {
|
export interface EncounterPersistence {
|
||||||
load(): Encounter | null;
|
readonly load: () => Encounter | null;
|
||||||
save(encounter: Encounter): void;
|
readonly save: (encounter: Encounter) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UndoRedoPersistence {
|
export interface UndoRedoPersistence {
|
||||||
load(): UndoRedoState;
|
readonly load: () => UndoRedoState;
|
||||||
save(state: UndoRedoState): void;
|
readonly save: (state: UndoRedoState) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlayerCharacterPersistence {
|
export interface PlayerCharacterPersistence {
|
||||||
load(): PlayerCharacter[];
|
readonly load: () => PlayerCharacter[];
|
||||||
save(characters: PlayerCharacter[]): void;
|
readonly save: (characters: PlayerCharacter[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CachedSourceInfo {
|
export interface CachedSourceInfo {
|
||||||
@@ -31,30 +31,34 @@ export interface CachedSourceInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BestiaryCachePort {
|
export interface BestiaryCachePort {
|
||||||
cacheSource(
|
readonly cacheSource: (
|
||||||
system: string,
|
system: string,
|
||||||
sourceCode: string,
|
sourceCode: string,
|
||||||
displayName: string,
|
displayName: string,
|
||||||
creatures: AnyCreature[],
|
creatures: AnyCreature[],
|
||||||
): Promise<void>;
|
) => Promise<void>;
|
||||||
isSourceCached(system: string, sourceCode: string): Promise<boolean>;
|
readonly isSourceCached: (
|
||||||
getCachedSources(system?: string): Promise<CachedSourceInfo[]>;
|
system: string,
|
||||||
clearSource(system: string, sourceCode: string): Promise<void>;
|
sourceCode: string,
|
||||||
clearAll(): Promise<void>;
|
) => Promise<boolean>;
|
||||||
loadAllCachedCreatures(): Promise<Map<CreatureId, AnyCreature>>;
|
readonly getCachedSources: (system?: string) => Promise<CachedSourceInfo[]>;
|
||||||
|
readonly clearSource: (system: string, sourceCode: string) => Promise<void>;
|
||||||
|
readonly clearAll: () => Promise<void>;
|
||||||
|
readonly loadAllCachedCreatures: () => Promise<Map<CreatureId, AnyCreature>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BestiaryIndexPort {
|
export interface BestiaryIndexPort {
|
||||||
loadIndex(): BestiaryIndex;
|
readonly loadIndex: () => BestiaryIndex;
|
||||||
getAllSourceCodes(): string[];
|
readonly getAllSourceCodes: () => string[];
|
||||||
getDefaultFetchUrl(sourceCode: string, baseUrl?: string): string;
|
readonly getDefaultFetchUrl: (sourceCode: string, baseUrl?: string) => string;
|
||||||
getSourceDisplayName(sourceCode: string): string;
|
readonly getSourceDisplayName: (sourceCode: string) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Pf2eBestiaryIndexPort {
|
export interface Pf2eBestiaryIndexPort {
|
||||||
loadIndex(): Pf2eBestiaryIndex;
|
readonly loadIndex: () => Pf2eBestiaryIndex;
|
||||||
getAllSourceCodes(): string[];
|
readonly getAllSourceCodes: () => string[];
|
||||||
getDefaultFetchUrl(sourceCode: string, baseUrl?: string): string;
|
readonly getDefaultFetchUrl: (sourceCode: string, baseUrl?: string) => string;
|
||||||
getSourceDisplayName(sourceCode: string): string;
|
readonly getSourceDisplayName: (sourceCode: string) => string;
|
||||||
getCreaturePathsForSource(sourceCode: string): string[];
|
readonly getCreaturePathsForSource: (sourceCode: string) => string[];
|
||||||
|
readonly getCreatureNamesByPaths: (paths: string[]) => Map<string, string>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,5 +48,6 @@ export const productionAdapters: Adapters = {
|
|||||||
getDefaultFetchUrl: pf2eBestiaryIndex.getDefaultPf2eFetchUrl,
|
getDefaultFetchUrl: pf2eBestiaryIndex.getDefaultPf2eFetchUrl,
|
||||||
getSourceDisplayName: pf2eBestiaryIndex.getPf2eSourceDisplayName,
|
getSourceDisplayName: pf2eBestiaryIndex.getPf2eSourceDisplayName,
|
||||||
getCreaturePathsForSource: pf2eBestiaryIndex.getCreaturePathsForSource,
|
getCreaturePathsForSource: pf2eBestiaryIndex.getCreaturePathsForSource,
|
||||||
|
getCreatureNamesByPaths: pf2eBestiaryIndex.getCreatureNamesByPaths,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,13 @@
|
|||||||
|
|
||||||
function formatDamage(params: string): string {
|
function formatDamage(params: string): string {
|
||||||
// "3d6+10[fire]" → "3d6+10 fire"
|
// "3d6+10[fire]" → "3d6+10 fire"
|
||||||
return params.replaceAll(/\[([^\]]*)\]/g, " $1").trim();
|
// "d4[persistent,fire]" → "d4 persistent fire"
|
||||||
|
return params
|
||||||
|
.replaceAll(
|
||||||
|
/\[([^\]]*)\]/g,
|
||||||
|
(_, type: string) => ` ${type.replaceAll(",", " ")}`,
|
||||||
|
)
|
||||||
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCheck(params: string): string {
|
function formatCheck(params: string): string {
|
||||||
@@ -80,11 +86,11 @@ export function stripFoundryTags(html: string): string {
|
|||||||
// Strip action-glyph spans (content is a number the renderer handles)
|
// Strip action-glyph spans (content is a number the renderer handles)
|
||||||
result = result.replaceAll(/<span class="action-glyph">[^<]*<\/span>/gi, "");
|
result = result.replaceAll(/<span class="action-glyph">[^<]*<\/span>/gi, "");
|
||||||
|
|
||||||
// Strip HTML tags
|
// Strip HTML tags (preserve <strong> for UI rendering)
|
||||||
result = result.replaceAll(/<br\s*\/?>/gi, "\n");
|
result = result.replaceAll(/<br\s*\/?>/gi, "\n");
|
||||||
result = result.replaceAll(/<hr\s*\/?>/gi, "\n");
|
result = result.replaceAll(/<hr\s*\/?>/gi, "\n");
|
||||||
result = result.replaceAll(/<\/p>\s*<p[^>]*>/gi, "\n");
|
result = result.replaceAll(/<\/p>\s*<p[^>]*>/gi, "\n");
|
||||||
result = result.replaceAll(/<[^>]+>/g, "");
|
result = result.replaceAll(/<(?!\/?(?:strong|em|ul|ol|li)\b)[^>]+>/g, "");
|
||||||
|
|
||||||
// Decode common HTML entities
|
// Decode common HTML entities
|
||||||
result = result.replaceAll("&", "&");
|
result = result.replaceAll("&", "&");
|
||||||
@@ -92,6 +98,11 @@ export function stripFoundryTags(html: string): string {
|
|||||||
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
|
// Collapse whitespace
|
||||||
result = result.replaceAll(/[ \t]+/g, " ");
|
result = result.replaceAll(/[ \t]+/g, " ");
|
||||||
result = result.replaceAll(/\n\s*\n/g, "\n");
|
result = result.replaceAll(/\n\s*\n/g, "\n");
|
||||||
|
|||||||
@@ -373,6 +373,26 @@ describe("CombatantRow", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("popover is not dimmed when the combatant is downed", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderRow({
|
||||||
|
combatant: {
|
||||||
|
id: combatantId("1"),
|
||||||
|
name: "Goblin",
|
||||||
|
maxHp: 10,
|
||||||
|
currentHp: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(screen.getByLabelText(CURRENT_HP_REGEX));
|
||||||
|
// The row dims downed combatants with opacity-50, which would cascade
|
||||||
|
// to the popover if it rendered inside the dimmed subtree.
|
||||||
|
const popover = screen
|
||||||
|
.getByRole("button", { name: "Apply damage" })
|
||||||
|
.closest(".opacity-50");
|
||||||
|
expect(popover).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("HP section is absent when maxHp is undefined", () => {
|
it("HP section is absent when maxHp is undefined", () => {
|
||||||
renderRow({
|
renderRow({
|
||||||
combatant: {
|
combatant: {
|
||||||
|
|||||||
@@ -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>
|
||||||
<ConditionPicker
|
<EditionSetter edition={edition}>
|
||||||
anchorRef={anchorRef}
|
<ConditionPicker
|
||||||
activeConditions={overrides.activeConditions ?? []}
|
anchorRef={anchorRef}
|
||||||
onToggle={onToggle}
|
activeConditions={overrides.activeConditions ?? []}
|
||||||
onSetValue={onSetValue}
|
activePersistentDamage={overrides.activePersistentDamage}
|
||||||
onClose={onClose}
|
onToggle={onToggle}
|
||||||
/>
|
onSetValue={onSetValue}
|
||||||
|
onAddPersistentDamage={onAddPersistentDamage}
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,11 +3,41 @@ import "@testing-library/jest-dom/vitest";
|
|||||||
|
|
||||||
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 { useEffect, useRef, useState } from "react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { HpAdjustPopover } from "../hp-adjust-popover";
|
import { HpAdjustPopover } from "../hp-adjust-popover";
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function AnchoredPopover({
|
||||||
|
onAdjust,
|
||||||
|
onSetTempHp,
|
||||||
|
onClose,
|
||||||
|
}: Readonly<{
|
||||||
|
onAdjust: (delta: number) => void;
|
||||||
|
onSetTempHp: (value: number) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}>) {
|
||||||
|
const anchorRef = useRef<HTMLDivElement>(null);
|
||||||
|
// The popover opens on click in the app, so its anchor is always mounted
|
||||||
|
// first. Mirror that here — otherwise the anchor ref is still null when the
|
||||||
|
// popover measures its position and it renders hidden.
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
useEffect(() => setOpen(true), []);
|
||||||
|
return (
|
||||||
|
<div ref={anchorRef}>
|
||||||
|
{!!open && (
|
||||||
|
<HpAdjustPopover
|
||||||
|
anchorRef={anchorRef}
|
||||||
|
onAdjust={onAdjust}
|
||||||
|
onSetTempHp={onSetTempHp}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function renderPopover(
|
function renderPopover(
|
||||||
overrides: Partial<{
|
overrides: Partial<{
|
||||||
onAdjust: (delta: number) => void;
|
onAdjust: (delta: number) => void;
|
||||||
@@ -19,7 +49,7 @@ function renderPopover(
|
|||||||
const onSetTempHp = overrides.onSetTempHp ?? vi.fn();
|
const onSetTempHp = overrides.onSetTempHp ?? vi.fn();
|
||||||
const onClose = overrides.onClose ?? vi.fn();
|
const onClose = overrides.onClose ?? vi.fn();
|
||||||
const result = render(
|
const result = render(
|
||||||
<HpAdjustPopover
|
<AnchoredPopover
|
||||||
onAdjust={onAdjust}
|
onAdjust={onAdjust}
|
||||||
onSetTempHp={onSetTempHp}
|
onSetTempHp={onSetTempHp}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,11 +4,14 @@ import "@testing-library/jest-dom/vitest";
|
|||||||
import type { Pf2eCreature } from "@initiative/domain";
|
import type { Pf2eCreature } from "@initiative/domain";
|
||||||
import { creatureId } from "@initiative/domain";
|
import { creatureId } from "@initiative/domain";
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { Pf2eStatBlock } from "../pf2e-stat-block.js";
|
import { Pf2eStatBlock } from "../pf2e-stat-block.js";
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const USES_PER_DAY_REGEX = /×3/;
|
||||||
|
const HEAL_DESCRIPTION_REGEX = /channel positive energy/;
|
||||||
const PERCEPTION_SENSES_REGEX = /\+2.*Darkvision/;
|
const PERCEPTION_SENSES_REGEX = /\+2.*Darkvision/;
|
||||||
const SKILLS_REGEX = /Acrobatics \+5.*Stealth \+5/;
|
const SKILLS_REGEX = /Acrobatics \+5.*Stealth \+5/;
|
||||||
const SAVE_CONDITIONAL_REGEX = /\+12.*\+1 status to all saves vs\. magic/;
|
const SAVE_CONDITIONAL_REGEX = /\+12.*\+1 status to all saves vs\. magic/;
|
||||||
@@ -22,6 +25,13 @@ const ABILITY_MID_NAME_REGEX = /Goblin Scuttle/;
|
|||||||
const ABILITY_MID_DESC_REGEX = /The goblin Steps\./;
|
const ABILITY_MID_DESC_REGEX = /The goblin Steps\./;
|
||||||
const CANTRIPS_REGEX = /Cantrips:/;
|
const CANTRIPS_REGEX = /Cantrips:/;
|
||||||
const AC_REGEX = /16/;
|
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 = {
|
const GOBLIN_WARRIOR: Pf2eCreature = {
|
||||||
system: "pf2e",
|
system: "pf2e",
|
||||||
@@ -90,9 +100,13 @@ const NAUNET: Pf2eCreature = {
|
|||||||
name: "Divine Innate Spells",
|
name: "Divine Innate Spells",
|
||||||
headerText: "DC 25, attack +17",
|
headerText: "DC 25, attack +17",
|
||||||
daily: [
|
daily: [
|
||||||
{ uses: 4, each: true, spells: ["Unfettered Movement (Constant)"] },
|
{
|
||||||
|
uses: 4,
|
||||||
|
each: true,
|
||||||
|
spells: [{ name: "Unfettered Movement (Constant)" }],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
atWill: ["Detect Magic"],
|
atWill: [{ name: "Detect Magic" }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -147,6 +161,53 @@ describe("Pf2eStatBlock", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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", () => {
|
describe("perception and senses", () => {
|
||||||
it("renders perception modifier and senses", () => {
|
it("renders perception modifier and senses", () => {
|
||||||
renderStatBlock(GOBLIN_WARRIOR);
|
renderStatBlock(GOBLIN_WARRIOR);
|
||||||
@@ -277,4 +338,160 @@ describe("Pf2eStatBlock", () => {
|
|||||||
expect(screen.queryByText(CANTRIPS_REGEX)).not.toBeInTheDocument();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
|
import type { PlayerCharacter } from "@initiative/domain";
|
||||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { createRef } from "react";
|
import { createRef } from "react";
|
||||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
|
import { buildPlayerCharacter } from "../../__tests__/factories/index.js";
|
||||||
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
import { polyfillDialog } from "../../__tests__/polyfill-dialog.js";
|
||||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +16,7 @@ import {
|
|||||||
} from "../player-character-section.js";
|
} from "../player-character-section.js";
|
||||||
|
|
||||||
const CREATE_FIRST_PC_REGEX = /create your first player character/i;
|
const CREATE_FIRST_PC_REGEX = /create your first player character/i;
|
||||||
|
const ADD_PARTY_REGEX = /add party to encounter/i;
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
polyfillDialog();
|
polyfillDialog();
|
||||||
@@ -33,21 +37,28 @@ beforeAll(() => {
|
|||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
function renderSection() {
|
function renderSection(playerCharacters?: PlayerCharacter[]) {
|
||||||
const ref = createRef<PlayerCharacterSectionHandle>();
|
const ref = createRef<PlayerCharacterSectionHandle>();
|
||||||
|
const adapters = createTestAdapters({ playerCharacters });
|
||||||
const result = render(<PlayerCharacterSection ref={ref} />, {
|
const result = render(<PlayerCharacterSection ref={ref} />, {
|
||||||
wrapper: AllProviders,
|
wrapper: ({ children }) => (
|
||||||
|
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||||
|
),
|
||||||
});
|
});
|
||||||
return { ...result, ref };
|
return { ...result, ref };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openManagement(ref: { current: PlayerCharacterSectionHandle | null }) {
|
||||||
|
const handle = ref.current;
|
||||||
|
if (!handle) throw new Error("ref not set");
|
||||||
|
act(() => handle.openManagement());
|
||||||
|
}
|
||||||
|
|
||||||
describe("PlayerCharacterSection", () => {
|
describe("PlayerCharacterSection", () => {
|
||||||
it("openManagement ref handle opens the management dialog", async () => {
|
it("openManagement ref handle opens the management dialog", async () => {
|
||||||
const { ref } = renderSection();
|
const { ref } = renderSection();
|
||||||
|
|
||||||
const handle = ref.current;
|
openManagement(ref);
|
||||||
if (!handle) throw new Error("ref not set");
|
|
||||||
act(() => handle.openManagement());
|
|
||||||
|
|
||||||
// Management dialog should now be open with its title visible
|
// Management dialog should now be open with its title visible
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -63,9 +74,7 @@ describe("PlayerCharacterSection", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { ref } = renderSection();
|
const { ref } = renderSection();
|
||||||
|
|
||||||
const handle = ref.current;
|
openManagement(ref);
|
||||||
if (!handle) throw new Error("ref not set");
|
|
||||||
act(() => handle.openManagement());
|
|
||||||
|
|
||||||
await user.click(
|
await user.click(
|
||||||
screen.getByRole("button", {
|
screen.getByRole("button", {
|
||||||
@@ -83,9 +92,7 @@ describe("PlayerCharacterSection", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { ref } = renderSection();
|
const { ref } = renderSection();
|
||||||
|
|
||||||
const handle = ref.current;
|
openManagement(ref);
|
||||||
if (!handle) throw new Error("ref not set");
|
|
||||||
act(() => handle.openManagement());
|
|
||||||
|
|
||||||
await user.click(
|
await user.click(
|
||||||
screen.getByRole("button", {
|
screen.getByRole("button", {
|
||||||
@@ -105,4 +112,25 @@ describe("PlayerCharacterSection", () => {
|
|||||||
expect(screen.getByText("Aria")).toBeInTheDocument();
|
expect(screen.getByText("Aria")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("adding the party puts every character into the encounter", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const party = [
|
||||||
|
buildPlayerCharacter({ name: "Thorin" }),
|
||||||
|
buildPlayerCharacter({ name: "Gandalf" }),
|
||||||
|
];
|
||||||
|
const { ref } = renderSection(party);
|
||||||
|
|
||||||
|
openManagement(ref);
|
||||||
|
await user.click(screen.getByRole("button", { name: ADD_PARTY_REGEX }));
|
||||||
|
|
||||||
|
// Dialog closes; reopening shows both characters marked as in the encounter
|
||||||
|
openManagement(ref);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getAllByLabelText("Already in encounter")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: ADD_PARTY_REGEX }),
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
import { type PlayerCharacter, playerCharacterId } from "@initiative/domain";
|
import {
|
||||||
|
type PlayerCharacter,
|
||||||
|
type PlayerCharacterId,
|
||||||
|
playerCharacterId,
|
||||||
|
} 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 { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
@@ -11,6 +15,7 @@ afterEach(cleanup);
|
|||||||
|
|
||||||
const CREATE_FIRST_PC_REGEX = /create your first player character/i;
|
const CREATE_FIRST_PC_REGEX = /create your first player character/i;
|
||||||
const LEVEL_REGEX = /^Lv /;
|
const LEVEL_REGEX = /^Lv /;
|
||||||
|
const ADD_PARTY_REGEX = /add party to encounter/i;
|
||||||
|
|
||||||
import { PlayerManagement } from "../player-management.js";
|
import { PlayerManagement } from "../player-management.js";
|
||||||
|
|
||||||
@@ -47,6 +52,8 @@ function renderManagement(
|
|||||||
onEdit: vi.fn(),
|
onEdit: vi.fn(),
|
||||||
onDelete: vi.fn(),
|
onDelete: vi.fn(),
|
||||||
onCreate: vi.fn(),
|
onCreate: vi.fn(),
|
||||||
|
inEncounterIds: new Set<PlayerCharacterId>(),
|
||||||
|
onAddParty: vi.fn(),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
return { ...render(<PlayerManagement {...props} />), props };
|
return { ...render(<PlayerManagement {...props} />), props };
|
||||||
@@ -117,4 +124,52 @@ describe("PlayerManagement", () => {
|
|||||||
await user.click(screen.getByRole("button", { name: "Add" }));
|
await user.click(screen.getByRole("button", { name: "Add" }));
|
||||||
expect(props.onCreate).toHaveBeenCalled();
|
expect(props.onCreate).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("party button calls onAddParty", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { props } = renderManagement({
|
||||||
|
characters: [PC_WARRIOR, PC_WIZARD],
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: ADD_PARTY_REGEX }));
|
||||||
|
expect(props.onAddParty).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("party button stays enabled while some characters are missing", () => {
|
||||||
|
renderManagement({
|
||||||
|
characters: [PC_WARRIOR, PC_WIZARD],
|
||||||
|
inEncounterIds: new Set([PC_WARRIOR.id]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: ADD_PARTY_REGEX })).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("party button is disabled when every character is in the encounter", () => {
|
||||||
|
renderManagement({
|
||||||
|
characters: [PC_WARRIOR, PC_WIZARD],
|
||||||
|
inEncounterIds: new Set([PC_WARRIOR.id, PC_WIZARD.id]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: ADD_PARTY_REGEX }),
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks only the characters already in the encounter", () => {
|
||||||
|
renderManagement({
|
||||||
|
characters: [PC_WARRIOR, PC_WIZARD],
|
||||||
|
inEncounterIds: new Set([PC_WIZARD.id]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const markers = screen.getAllByLabelText("Already in encounter");
|
||||||
|
expect(markers).toHaveLength(1);
|
||||||
|
expect(markers[0].closest("div")).toHaveTextContent("Gandalf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no party button in the empty state", () => {
|
||||||
|
renderManagement();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: ADD_PARTY_REGEX }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,8 +63,11 @@ describe("SettingsModal", () => {
|
|||||||
renderModal();
|
renderModal();
|
||||||
const btn5e = screen.getByRole("button", { name: "5e (2014)" });
|
const btn5e = screen.getByRole("button", { name: "5e (2014)" });
|
||||||
await user.click(btn5e);
|
await user.click(btn5e);
|
||||||
// After clicking 5e, it should have the active style
|
expect(btn5e).toHaveAttribute("aria-pressed", "true");
|
||||||
expect(btn5e.className).toContain("bg-accent");
|
expect(screen.getByRole("button", { name: "5.5e (2024)" })).toHaveAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
"false",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clicking a theme button switches the active theme", async () => {
|
it("clicking a theme button switches the active theme", async () => {
|
||||||
@@ -72,7 +75,7 @@ describe("SettingsModal", () => {
|
|||||||
renderModal();
|
renderModal();
|
||||||
const darkBtn = screen.getByRole("button", { name: "Dark" });
|
const darkBtn = screen.getByRole("button", { name: "Dark" });
|
||||||
await user.click(darkBtn);
|
await user.click(darkBtn);
|
||||||
expect(darkBtn.className).toContain("bg-accent");
|
expect(darkBtn).toHaveAttribute("aria-pressed", "true");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("close button calls onClose", async () => {
|
it("close button calls onClose", async () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
|
||||||
import type { Creature } from "@initiative/domain";
|
import type { Creature, HpVariant } from "@initiative/domain";
|
||||||
import { creatureId } from "@initiative/domain";
|
import { combatantId, creatureId } 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 { useState } from "react";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { DndStatBlock as StatBlock } from "../dnd-stat-block.js";
|
import { DndStatBlock as StatBlock } from "../dnd-stat-block.js";
|
||||||
|
|
||||||
@@ -111,9 +113,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" }] }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -122,6 +130,19 @@ function renderStatBlock(creature: Creature) {
|
|||||||
return render(<StatBlock creature={creature} />);
|
return render(<StatBlock creature={creature} />);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Owns the variant the way the encounter state does in the real app. */
|
||||||
|
function HpVariantHarness({ creature }: Readonly<{ creature: Creature }>) {
|
||||||
|
const [variant, setVariant] = useState<HpVariant | undefined>(undefined);
|
||||||
|
return (
|
||||||
|
<StatBlock
|
||||||
|
creature={creature}
|
||||||
|
combatantId={combatantId("c-1")}
|
||||||
|
hpVariant={variant}
|
||||||
|
onSetHpVariant={(_id, next) => setVariant(next)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
describe("StatBlock", () => {
|
describe("StatBlock", () => {
|
||||||
describe("header", () => {
|
describe("header", () => {
|
||||||
it("renders creature name", () => {
|
it("renders creature name", () => {
|
||||||
@@ -169,6 +190,81 @@ describe("StatBlock", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("hit point variant", () => {
|
||||||
|
it("offers no variant buttons while browsing without a combatant", () => {
|
||||||
|
renderStatBlock(GOBLIN);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Max" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the variant buttons when the HP formula is not a dice pool", () => {
|
||||||
|
render(
|
||||||
|
<HpVariantHarness
|
||||||
|
creature={{
|
||||||
|
...GOBLIN,
|
||||||
|
hp: { average: 50, formula: "special" },
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Max" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("50")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the printed average until a variant is picked", () => {
|
||||||
|
render(<HpVariantHarness creature={GOBLIN} />);
|
||||||
|
expect(screen.getByText("7")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Avg" })).toHaveAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the maximum roll after picking Max", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<HpVariantHarness creature={GOBLIN} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Max" }));
|
||||||
|
|
||||||
|
expect(screen.getByText("12")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("7")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Max" })).toHaveAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the minimum roll after picking Min", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<HpVariantHarness creature={GOBLIN} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Min" }));
|
||||||
|
|
||||||
|
expect(screen.getByText("2")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns to the average after picking Avg again", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<HpVariantHarness creature={GOBLIN} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Max" }));
|
||||||
|
await user.click(screen.getByRole("button", { name: "Avg" }));
|
||||||
|
|
||||||
|
expect(screen.getByText("7")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps showing the formula for every variant", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<HpVariantHarness creature={GOBLIN} />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Min" }));
|
||||||
|
|
||||||
|
expect(screen.getByText("(2d6)")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("ability scores", () => {
|
describe("ability scores", () => {
|
||||||
it("renders all 6 ability labels", () => {
|
it("renders all 6 ability labels", () => {
|
||||||
renderStatBlock(GOBLIN);
|
renderStatBlock(GOBLIN);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -198,6 +202,7 @@ function ClickableHp({
|
|||||||
onSetTempHp: (value: number) => void;
|
onSetTempHp: (value: number) => void;
|
||||||
}>) {
|
}>) {
|
||||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||||
|
const anchorRef = useRef<HTMLDivElement>(null);
|
||||||
const status = deriveHpStatus(currentHp, maxHp);
|
const status = deriveHpStatus(currentHp, maxHp);
|
||||||
|
|
||||||
if (maxHp === undefined) {
|
if (maxHp === undefined) {
|
||||||
@@ -205,7 +210,7 @@ function ClickableHp({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex items-center">
|
<div ref={anchorRef} className="relative flex items-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPopoverOpen(true)}
|
onClick={() => setPopoverOpen(true)}
|
||||||
@@ -226,6 +231,7 @@ function ClickableHp({
|
|||||||
)}
|
)}
|
||||||
{!!popoverOpen && (
|
{!!popoverOpen && (
|
||||||
<HpAdjustPopover
|
<HpAdjustPopover
|
||||||
|
anchorRef={anchorRef}
|
||||||
onAdjust={onAdjust}
|
onAdjust={onAdjust}
|
||||||
onSetTempHp={onSetTempHp}
|
onSetTempHp={onSetTempHp}
|
||||||
onClose={() => setPopoverOpen(false)}
|
onClose={() => setPopoverOpen(false)}
|
||||||
@@ -415,12 +421,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 +459,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 +483,7 @@ export function CombatantRow({
|
|||||||
if (isStatBlockOpen) {
|
if (isStatBlockOpen) {
|
||||||
toggleCollapse();
|
toggleCollapse();
|
||||||
} else {
|
} else {
|
||||||
showCreature(creatureId);
|
showCreature(creatureId, combatant.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -495,12 +513,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,24 +540,33 @@ 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(
|
||||||
<button
|
"grid items-center gap-1.5 py-3 sm:gap-3 sm:py-2",
|
||||||
type="button"
|
isPf2e
|
||||||
onClick={() => toggleConcentration(id)}
|
? "grid-cols-[3rem_auto_1fr_auto_2rem] pl-3 sm:grid-cols-[3.5rem_auto_1fr_auto_2rem]"
|
||||||
title="Concentrating"
|
: "grid-cols-[2rem_3rem_auto_1fr_auto_2rem] sm:grid-cols-[2rem_3.5rem_auto_1fr_auto_2rem]",
|
||||||
aria-label="Toggle concentration"
|
)}
|
||||||
className={cn(
|
>
|
||||||
"-my-2 -ml-[2px] flex w-full items-center justify-center self-stretch pl-[14px] transition-opacity hover:text-hover-neutral hover:opacity-100",
|
{/* Concentration — hidden in PF2e mode */}
|
||||||
concentrationIconClass(combatant.isConcentrating, dimmed),
|
{!isPf2e && (
|
||||||
)}
|
<button
|
||||||
>
|
type="button"
|
||||||
<Brain size={16} />
|
onClick={() => toggleConcentration(id)}
|
||||||
</button>
|
title="Concentrating"
|
||||||
|
aria-label="Toggle concentration"
|
||||||
|
className={cn(
|
||||||
|
"-my-2 -ml-[2px] flex w-full items-center justify-center self-stretch pl-[14px] transition-opacity hover:text-hover-neutral hover:opacity-100",
|
||||||
|
concentrationIconClass(combatant.isConcentrating, dimmed),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Brain size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Initiative */}
|
{/* Initiative */}
|
||||||
<div className="rounded-md bg-muted/30 px-1">
|
<div className="rounded-md bg-muted/30 px-1">
|
||||||
@@ -589,16 +620,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,96 +160,116 @@ export function ConditionPicker({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<React.Fragment key={def.id}>
|
||||||
key={def.id}
|
{index === persistentDamageInsertIndex && persistentDamageEntry}
|
||||||
content={getConditionDescription(def, edition)}
|
<Tooltip
|
||||||
className="block"
|
content={getConditionDescription(def, edition)}
|
||||||
>
|
className="block"
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors",
|
|
||||||
(isActive || isEditing) && "bg-card/50",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<button
|
<div
|
||||||
type="button"
|
className={cn(
|
||||||
className="flex flex-1 items-center gap-2"
|
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors",
|
||||||
onClick={handleClick}
|
(isActive || isEditing) && "bg-card/50",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Icon
|
<button
|
||||||
size={14}
|
type="button"
|
||||||
className={
|
className="flex flex-1 items-center gap-2"
|
||||||
isActive || isEditing ? colorClass : "text-muted-foreground"
|
onClick={handleClick}
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
isActive || isEditing
|
|
||||||
? "text-foreground"
|
|
||||||
: "text-muted-foreground"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{def.label}
|
<Icon
|
||||||
</span>
|
size={14}
|
||||||
</button>
|
className={
|
||||||
{isActive && def.valued && edition === "pf2e" && !isEditing && (
|
isActive || isEditing
|
||||||
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-primary-foreground text-xs">
|
? colorClass
|
||||||
{activeValue}
|
: "text-muted-foreground"
|
||||||
</span>
|
}
|
||||||
)}
|
/>
|
||||||
{isEditing && (
|
<span
|
||||||
<div className="flex items-center gap-0.5">
|
className={
|
||||||
<button
|
isActive || isEditing
|
||||||
type="button"
|
? "text-foreground"
|
||||||
className="rounded p-0.5 text-foreground hover:bg-accent/40"
|
: "text-muted-foreground"
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
}
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (editing.value > 1) {
|
|
||||||
setEditing({
|
|
||||||
...editing,
|
|
||||||
value: editing.value - 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Minus className="h-3 w-3" />
|
{def.label}
|
||||||
</button>
|
|
||||||
<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}
|
|
||||||
</span>
|
</span>
|
||||||
<button
|
</button>
|
||||||
type="button"
|
{isActive && def.valued && edition === "pf2e" && !isEditing && (
|
||||||
className="rounded p-0.5 text-foreground hover:bg-accent/40"
|
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-primary-foreground text-xs">
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
{activeValue}
|
||||||
onClick={(e) => {
|
</span>
|
||||||
e.stopPropagation();
|
)}
|
||||||
setEditing({
|
{isEditing && (
|
||||||
...editing,
|
<div className="flex items-center gap-0.5">
|
||||||
value: editing.value + 1,
|
<button
|
||||||
});
|
type="button"
|
||||||
}}
|
className="rounded p-0.5 text-foreground hover:bg-accent/40"
|
||||||
>
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
<Plus className="h-3 w-3" />
|
onClick={(e) => {
|
||||||
</button>
|
e.stopPropagation();
|
||||||
<button
|
if (editing.value > 1) {
|
||||||
type="button"
|
setEditing({
|
||||||
className="ml-0.5 rounded p-0.5 text-foreground hover:bg-accent/40"
|
...editing,
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
value: editing.value - 1,
|
||||||
onClick={(e) => {
|
});
|
||||||
e.stopPropagation();
|
}
|
||||||
onSetValue(editing.id, editing.value);
|
}}
|
||||||
setEditing(null);
|
>
|
||||||
}}
|
<Minus className="h-3 w-3" />
|
||||||
>
|
</button>
|
||||||
<Check className="h-3.5 w-3.5" />
|
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-primary-foreground text-xs">
|
||||||
</button>
|
{editing.value}
|
||||||
</div>
|
</span>
|
||||||
)}
|
{(() => {
|
||||||
</div>
|
const atMax =
|
||||||
</Tooltip>
|
def.maxValue !== undefined &&
|
||||||
|
editing.value >= def.maxValue;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!atMax) {
|
||||||
|
setEditing({
|
||||||
|
...editing,
|
||||||
|
value: editing.value + 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ml-0.5 rounded p-0.5 text-foreground hover:bg-accent/40"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSetValue(editing.id, editing.value);
|
||||||
|
setEditing(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</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",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,17 +1,34 @@
|
|||||||
import type { Creature } from "@initiative/domain";
|
import type {
|
||||||
|
CombatantId,
|
||||||
|
Creature,
|
||||||
|
HpRange,
|
||||||
|
HpVariant,
|
||||||
|
} from "@initiative/domain";
|
||||||
import {
|
import {
|
||||||
calculateInitiative,
|
calculateInitiative,
|
||||||
formatInitiativeModifier,
|
formatInitiativeModifier,
|
||||||
|
hpForVariant,
|
||||||
|
hpRange,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
|
import { cn } from "../lib/utils.js";
|
||||||
import {
|
import {
|
||||||
PropertyLine,
|
PropertyLine,
|
||||||
SectionDivider,
|
SectionDivider,
|
||||||
TraitEntry,
|
TraitEntry,
|
||||||
TraitSection,
|
TraitSection,
|
||||||
} from "./stat-block-parts.js";
|
} from "./stat-block-parts.js";
|
||||||
|
import type { SegmentedOption } from "./ui/segmented-control.js";
|
||||||
|
import { SegmentedControl } from "./ui/segmented-control.js";
|
||||||
|
|
||||||
interface DndStatBlockProps {
|
interface DndStatBlockProps {
|
||||||
creature: Creature;
|
creature: Creature;
|
||||||
|
combatantId?: CombatantId;
|
||||||
|
hpVariant?: HpVariant;
|
||||||
|
onSetHpVariant?: (
|
||||||
|
id: CombatantId,
|
||||||
|
variant: HpVariant | undefined,
|
||||||
|
range: HpRange,
|
||||||
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function abilityMod(score: number): string {
|
function abilityMod(score: number): string {
|
||||||
@@ -19,7 +36,25 @@ function abilityMod(score: number): string {
|
|||||||
return mod >= 0 ? `+${mod}` : `${mod}`;
|
return mod >= 0 ? `+${mod}` : `${mod}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DndStatBlock({ creature }: Readonly<DndStatBlockProps>) {
|
/** Text color for a max HP that no longer shows the printed average. */
|
||||||
|
function hpVariantColor(variant: HpVariant | undefined): string {
|
||||||
|
if (variant === "max") return "text-blue-400";
|
||||||
|
if (variant === "min") return "text-red-400";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const HP_VARIANT_OPTIONS: SegmentedOption<HpVariant | undefined>[] = [
|
||||||
|
{ value: "min", label: "Min" },
|
||||||
|
{ value: undefined, label: "Avg" },
|
||||||
|
{ value: "max", label: "Max" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DndStatBlock({
|
||||||
|
creature,
|
||||||
|
combatantId,
|
||||||
|
hpVariant,
|
||||||
|
onSetHpVariant,
|
||||||
|
}: Readonly<DndStatBlockProps>) {
|
||||||
const abilities = [
|
const abilities = [
|
||||||
{ label: "STR", score: creature.abilities.str },
|
{ label: "STR", score: creature.abilities.str },
|
||||||
{ label: "DEX", score: creature.abilities.dex },
|
{ label: "DEX", score: creature.abilities.dex },
|
||||||
@@ -35,6 +70,17 @@ export function DndStatBlock({ creature }: Readonly<DndStatBlockProps>) {
|
|||||||
initiativeProficiency: creature.initiativeProficiency,
|
initiativeProficiency: creature.initiativeProficiency,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Only offer min/max HP when the formula is a real dice pool with a spread.
|
||||||
|
const range = hpRange(creature.hp);
|
||||||
|
const canPickVariant =
|
||||||
|
range !== null &&
|
||||||
|
range.min !== range.max &&
|
||||||
|
combatantId != null &&
|
||||||
|
onSetHpVariant != null;
|
||||||
|
const displayedHp = range
|
||||||
|
? hpForVariant(range, hpVariant)
|
||||||
|
: creature.hp.average;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1 text-foreground">
|
<div className="space-y-1 text-foreground">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -68,8 +114,22 @@ export function DndStatBlock({ creature }: Readonly<DndStatBlockProps>) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold">Hit Points</span>{" "}
|
<span className="font-semibold">Hit Points</span>{" "}
|
||||||
{creature.hp.average}{" "}
|
<span className={cn("font-semibold", hpVariantColor(hpVariant))}>
|
||||||
|
{displayedHp}
|
||||||
|
</span>{" "}
|
||||||
<span className="text-muted-foreground">({creature.hp.formula})</span>
|
<span className="text-muted-foreground">({creature.hp.formula})</span>
|
||||||
|
{canPickVariant ? (
|
||||||
|
<SegmentedControl
|
||||||
|
options={HP_VARIANT_OPTIONS}
|
||||||
|
value={hpVariant}
|
||||||
|
onChange={(variant) =>
|
||||||
|
onSetHpVariant(combatantId, variant, range)
|
||||||
|
}
|
||||||
|
size="xs"
|
||||||
|
label="Hit point variant"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold">Speed</span> {creature.speed}
|
<span className="font-semibold">Speed</span> {creature.speed}
|
||||||
@@ -134,7 +194,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 +203,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 +215,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,18 +6,21 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { useClickOutside } from "../hooks/use-click-outside.js";
|
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||||
import { Input } from "./ui/input";
|
import { Input } from "./ui/input";
|
||||||
|
|
||||||
const DIGITS_ONLY_REGEX = /^\d+$/;
|
const DIGITS_ONLY_REGEX = /^\d+$/;
|
||||||
|
|
||||||
interface HpAdjustPopoverProps {
|
interface HpAdjustPopoverProps {
|
||||||
|
readonly anchorRef: React.RefObject<HTMLElement | null>;
|
||||||
readonly onAdjust: (delta: number) => void;
|
readonly onAdjust: (delta: number) => void;
|
||||||
readonly onSetTempHp: (value: number) => void;
|
readonly onSetTempHp: (value: number) => void;
|
||||||
readonly onClose: () => void;
|
readonly onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HpAdjustPopover({
|
export function HpAdjustPopover({
|
||||||
|
anchorRef,
|
||||||
onAdjust,
|
onAdjust,
|
||||||
onSetTempHp,
|
onSetTempHp,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -29,10 +32,9 @@ export function HpAdjustPopover({
|
|||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
if (!el) return;
|
const anchor = anchorRef.current;
|
||||||
const parent = el.parentElement;
|
if (!el || !anchor) return;
|
||||||
if (!parent) return;
|
const trigger = anchor.getBoundingClientRect();
|
||||||
const trigger = parent.getBoundingClientRect();
|
|
||||||
const popover = el.getBoundingClientRect();
|
const popover = el.getBoundingClientRect();
|
||||||
const vw = document.documentElement.clientWidth;
|
const vw = document.documentElement.clientWidth;
|
||||||
let left = trigger.left;
|
let left = trigger.left;
|
||||||
@@ -43,7 +45,7 @@ export function HpAdjustPopover({
|
|||||||
left = 8;
|
left = 8;
|
||||||
}
|
}
|
||||||
setPos({ top: trigger.bottom + 4, left });
|
setPos({ top: trigger.bottom + 4, left });
|
||||||
}, []);
|
}, [anchorRef]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
requestAnimationFrame(() => inputRef.current?.focus());
|
requestAnimationFrame(() => inputRef.current?.focus());
|
||||||
@@ -82,10 +84,10 @@ export function HpAdjustPopover({
|
|||||||
[applyDelta, onClose],
|
[applyDelta, onClose],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="card-glow fixed z-10 rounded-lg border border-border bg-background p-2"
|
className="card-glow fixed z-50 rounded-lg border border-border bg-background p-2"
|
||||||
style={
|
style={
|
||||||
pos
|
pos
|
||||||
? { top: pos.top, left: pos.left }
|
? { top: pos.top, left: pos.left }
|
||||||
@@ -144,6 +146,7 @@ export function HpAdjustPopover({
|
|||||||
<ShieldPlus size={14} />
|
<ShieldPlus size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,13 +1,33 @@
|
|||||||
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,
|
||||||
TraitSection,
|
TraitSection,
|
||||||
} from "./stat-block-parts.js";
|
} from "./stat-block-parts.js";
|
||||||
|
import type { SegmentedOption } from "./ui/segmented-control.js";
|
||||||
|
import { SegmentedControl } from "./ui/segmented-control.js";
|
||||||
|
|
||||||
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 +54,143 @@ function formatMod(mod: number): string {
|
|||||||
return mod >= 0 ? `+${mod}` : `${mod}`;
|
return mod >= 0 ? `+${mod}` : `${mod}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
const ADJUSTMENT_OPTIONS: SegmentedOption<"weak" | "elite" | undefined>[] = [
|
||||||
|
{ value: "weak", label: "Weak" },
|
||||||
|
{ value: undefined, label: "Normal" },
|
||||||
|
{ value: "elite", label: "Elite" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 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 +205,33 @@ 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 && (
|
||||||
|
<SegmentedControl
|
||||||
|
options={ADJUSTMENT_OPTIONS}
|
||||||
|
value={adjustment}
|
||||||
|
onChange={(value) =>
|
||||||
|
onSetAdjustment(combatantId, value, baseCreature)
|
||||||
|
}
|
||||||
|
size="xs"
|
||||||
|
label="Creature adjustment"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<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 +245,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 +289,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>{" "}
|
||||||
@@ -116,7 +300,7 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
|||||||
{formatMod(creature.saveWill)}
|
{formatMod(creature.saveWill)}
|
||||||
{creature.saveConditional ? `; ${creature.saveConditional}` : ""}
|
{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})` : ""}
|
{creature.hpDetails ? ` (${creature.hpDetails})` : ""}
|
||||||
</div>
|
</div>
|
||||||
@@ -152,23 +336,51 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
|||||||
{sc.headerText}
|
{sc.headerText}
|
||||||
</div>
|
</div>
|
||||||
{sc.daily?.map((d) => (
|
{sc.daily?.map((d) => (
|
||||||
<div key={d.uses} className="pl-2">
|
<SpellListLine
|
||||||
<span className="font-semibold">
|
key={d.uses}
|
||||||
{d.uses === 0 ? "Cantrips" : `Rank ${d.uses}`}:
|
label={d.uses === 0 ? "Cantrips" : `Rank ${d.uses}`}
|
||||||
</span>{" "}
|
spells={d.spells}
|
||||||
{d.spells.join(", ")}
|
onOpen={handleOpenSpell}
|
||||||
</div>
|
/>
|
||||||
))}
|
))}
|
||||||
{sc.atWill && sc.atWill.length > 0 && (
|
{sc.atWill && sc.atWill.length > 0 && (
|
||||||
<div className="pl-2">
|
<SpellListLine
|
||||||
<span className="font-semibold">Cantrips:</span>{" "}
|
label="Cantrips"
|
||||||
{sc.atWill.join(", ")}
|
spells={sc.atWill}
|
||||||
</div>
|
onOpen={handleOpenSpell}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { PlayerCharacter } from "@initiative/domain";
|
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||||
import { type RefObject, useImperativeHandle, useState } from "react";
|
import { type RefObject, useImperativeHandle, useMemo, useState } from "react";
|
||||||
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||||
import { CreatePlayerModal } from "./create-player-modal.js";
|
import { CreatePlayerModal } from "./create-player-modal.js";
|
||||||
import { PlayerManagement } from "./player-management.js";
|
import { PlayerManagement } from "./player-management.js";
|
||||||
@@ -15,6 +16,15 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
|||||||
}) {
|
}) {
|
||||||
const { characters, createCharacter, editCharacter, deleteCharacter } =
|
const { characters, createCharacter, editCharacter, deleteCharacter } =
|
||||||
usePlayerCharactersContext();
|
usePlayerCharactersContext();
|
||||||
|
const { encounter, addParty } = useEncounterContext();
|
||||||
|
|
||||||
|
const inEncounterIds = useMemo(() => {
|
||||||
|
const ids = new Set<PlayerCharacterId>();
|
||||||
|
for (const c of encounter.combatants) {
|
||||||
|
if (c.playerCharacterId) ids.add(c.playerCharacterId);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}, [encounter.combatants]);
|
||||||
|
|
||||||
const [managementOpen, setManagementOpen] = useState(false);
|
const [managementOpen, setManagementOpen] = useState(false);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
@@ -66,6 +76,11 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
|||||||
setCreateOpen(true);
|
setCreateOpen(true);
|
||||||
setManagementOpen(false);
|
setManagementOpen(false);
|
||||||
}}
|
}}
|
||||||
|
inEncounterIds={inEncounterIds}
|
||||||
|
onAddParty={() => {
|
||||||
|
addParty(characters);
|
||||||
|
setManagementOpen(false);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||||
import { Pencil, Plus, Trash2 } from "lucide-react";
|
import { Pencil, Plus, Swords, Trash2 } from "lucide-react";
|
||||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { ConfirmButton } from "./ui/confirm-button";
|
import { ConfirmButton } from "./ui/confirm-button";
|
||||||
@@ -12,8 +12,12 @@ interface PlayerManagementProps {
|
|||||||
onEdit: (pc: PlayerCharacter) => void;
|
onEdit: (pc: PlayerCharacter) => void;
|
||||||
onDelete: (id: PlayerCharacterId) => void;
|
onDelete: (id: PlayerCharacterId) => void;
|
||||||
onCreate: () => void;
|
onCreate: () => void;
|
||||||
|
inEncounterIds: ReadonlySet<PlayerCharacterId>;
|
||||||
|
onAddParty: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const IN_ENCOUNTER_LABEL = "Already in encounter";
|
||||||
|
|
||||||
export function PlayerManagement({
|
export function PlayerManagement({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -21,7 +25,13 @@ export function PlayerManagement({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onCreate,
|
onCreate,
|
||||||
|
inEncounterIds,
|
||||||
|
onAddParty,
|
||||||
}: Readonly<PlayerManagementProps>) {
|
}: Readonly<PlayerManagementProps>) {
|
||||||
|
const addableCount = characters.filter(
|
||||||
|
(pc) => !inEncounterIds.has(pc.id),
|
||||||
|
).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-md">
|
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-md">
|
||||||
<DialogHeader title="Player Characters" onClose={onClose} />
|
<DialogHeader title="Player Characters" onClose={onClose} />
|
||||||
@@ -61,6 +71,16 @@ export function PlayerManagement({
|
|||||||
Lv {pc.level}
|
Lv {pc.level}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{inEncounterIds.has(pc.id) && (
|
||||||
|
<span
|
||||||
|
role="img"
|
||||||
|
aria-label={IN_ENCOUNTER_LABEL}
|
||||||
|
title={IN_ENCOUNTER_LABEL}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Swords size={14} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
@@ -80,7 +100,19 @@ export function PlayerManagement({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<div className="mt-2 flex justify-end">
|
<div className="mt-2 flex items-center justify-between gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={onAddParty}
|
||||||
|
disabled={addableCount === 0}
|
||||||
|
title={
|
||||||
|
addableCount === 0
|
||||||
|
? "All player characters are already in the encounter"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Swords size={16} />
|
||||||
|
Add party to encounter
|
||||||
|
</Button>
|
||||||
<Button onClick={onCreate} variant="ghost">
|
<Button onClick={onCreate} variant="ghost">
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
Add
|
Add
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -2,28 +2,51 @@ import type { RulesEdition } from "@initiative/domain";
|
|||||||
import { Monitor, Moon, Sun } from "lucide-react";
|
import { Monitor, Moon, Sun } from "lucide-react";
|
||||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
import { useThemeContext } from "../contexts/theme-context.js";
|
import { useThemeContext } from "../contexts/theme-context.js";
|
||||||
import { cn } from "../lib/utils.js";
|
|
||||||
import { Dialog, DialogHeader } from "./ui/dialog.js";
|
import { Dialog, DialogHeader } from "./ui/dialog.js";
|
||||||
|
import type { SegmentedOption } from "./ui/segmented-control.js";
|
||||||
|
import { SegmentedControl } from "./ui/segmented-control.js";
|
||||||
|
|
||||||
interface SettingsModalProps {
|
interface SettingsModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EDITION_OPTIONS: { value: RulesEdition; label: string }[] = [
|
const EDITION_OPTIONS: SegmentedOption<RulesEdition>[] = [
|
||||||
{ value: "5e", label: "5e (2014)" },
|
{ value: "5e", label: "5e (2014)" },
|
||||||
{ value: "5.5e", label: "5.5e (2024)" },
|
{ value: "5.5e", label: "5.5e (2024)" },
|
||||||
{ value: "pf2e", label: "Pathfinder 2e" },
|
{ value: "pf2e", label: "Pathfinder 2e" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const THEME_OPTIONS: {
|
type ThemePreference = "system" | "light" | "dark";
|
||||||
value: "system" | "light" | "dark";
|
|
||||||
label: string;
|
const THEME_OPTIONS: SegmentedOption<ThemePreference>[] = [
|
||||||
icon: typeof Sun;
|
{
|
||||||
}[] = [
|
value: "system",
|
||||||
{ value: "system", label: "System", icon: Monitor },
|
label: (
|
||||||
{ value: "light", label: "Light", icon: Sun },
|
<>
|
||||||
{ value: "dark", label: "Dark", icon: Moon },
|
<Monitor size={14} />
|
||||||
|
System
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "light",
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
<Sun size={14} />
|
||||||
|
Light
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "dark",
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
<Moon size={14} />
|
||||||
|
Dark
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
|
export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
|
||||||
@@ -39,50 +62,26 @@ export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
|
|||||||
<span className="mb-2 block font-medium text-muted-foreground text-sm">
|
<span className="mb-2 block font-medium text-muted-foreground text-sm">
|
||||||
Game System
|
Game System
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-1">
|
<SegmentedControl
|
||||||
{EDITION_OPTIONS.map((opt) => (
|
options={EDITION_OPTIONS}
|
||||||
<button
|
value={edition}
|
||||||
key={opt.value}
|
onChange={setEdition}
|
||||||
type="button"
|
stretch
|
||||||
className={cn(
|
label="Game System"
|
||||||
"flex-1 rounded-md px-3 py-1.5 text-sm transition-colors",
|
/>
|
||||||
edition === opt.value
|
|
||||||
? "bg-accent text-primary-foreground"
|
|
||||||
: "bg-card text-muted-foreground hover:bg-hover-neutral-bg hover:text-foreground",
|
|
||||||
)}
|
|
||||||
onClick={() => setEdition(opt.value)}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span className="mb-2 block font-medium text-muted-foreground text-sm">
|
<span className="mb-2 block font-medium text-muted-foreground text-sm">
|
||||||
Theme
|
Theme
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-1">
|
<SegmentedControl
|
||||||
{THEME_OPTIONS.map((opt) => {
|
options={THEME_OPTIONS}
|
||||||
const Icon = opt.icon;
|
value={preference}
|
||||||
return (
|
onChange={setPreference}
|
||||||
<button
|
stretch
|
||||||
key={opt.value}
|
label="Theme"
|
||||||
type="button"
|
/>
|
||||||
className={cn(
|
|
||||||
"flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm transition-colors",
|
|
||||||
preference === opt.value
|
|
||||||
? "bg-accent text-primary-foreground"
|
|
||||||
: "bg-card text-muted-foreground hover:bg-hover-neutral-bg hover:text-foreground",
|
|
||||||
)}
|
|
||||||
onClick={() => setPreference(opt.value)}
|
|
||||||
>
|
|
||||||
<Icon size={14} />
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -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,19 @@
|
|||||||
import type { Creature, CreatureId } from "@initiative/domain";
|
import type {
|
||||||
|
AnyCreature,
|
||||||
|
Combatant,
|
||||||
|
CombatantId,
|
||||||
|
Creature,
|
||||||
|
CreatureId,
|
||||||
|
HpRange,
|
||||||
|
HpVariant,
|
||||||
|
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 +22,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 {
|
||||||
@@ -215,6 +227,8 @@ 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, setHpVariant } =
|
||||||
|
useEncounterContext();
|
||||||
|
|
||||||
const creatureId =
|
const creatureId =
|
||||||
panelRole === "browse"
|
panelRole === "browse"
|
||||||
@@ -222,10 +236,19 @@ 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,
|
||||||
|
setHpVariant,
|
||||||
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 : () => {},
|
||||||
@@ -237,14 +260,59 @@ function usePanelRole(panelRole: "browse" | "pinned") {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StatBlockHandlers {
|
||||||
|
setCreatureAdjustment: (
|
||||||
|
id: CombatantId,
|
||||||
|
adj: "weak" | "elite" | undefined,
|
||||||
|
base: Pf2eCreature,
|
||||||
|
) => void;
|
||||||
|
setHpVariant: (
|
||||||
|
id: CombatantId,
|
||||||
|
variant: HpVariant | undefined,
|
||||||
|
range: HpRange,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStatBlock(
|
||||||
|
creature: AnyCreature,
|
||||||
|
combatant: Combatant | null,
|
||||||
|
handlers: StatBlockHandlers,
|
||||||
|
) {
|
||||||
|
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={handlers.setCreatureAdjustment}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<DndStatBlock
|
||||||
|
creature={creature as Creature}
|
||||||
|
combatantId={combatant?.id}
|
||||||
|
hpVariant={combatant?.hpVariant}
|
||||||
|
onSetHpVariant={handlers.setHpVariant}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
setHpVariant,
|
||||||
isCollapsed,
|
isCollapsed,
|
||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
@@ -260,6 +328,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)");
|
||||||
@@ -280,19 +349,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 = () => {
|
||||||
@@ -311,10 +384,10 @@ export function StatBlockPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (creature) {
|
if (creature) {
|
||||||
if ("system" in creature && creature.system === "pf2e") {
|
return renderStatBlock(creature, combatant, {
|
||||||
return <Pf2eStatBlock creature={creature} />;
|
setCreatureAdjustment,
|
||||||
}
|
setHpVariant,
|
||||||
return <DndStatBlock creature={creature as Creature} />;
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (needsFetch && sourceCode) {
|
if (needsFetch && sourceCode) {
|
||||||
@@ -338,24 +411,36 @@ 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
|
<>
|
||||||
isCollapsed={isCollapsed}
|
<DesktopPanel
|
||||||
side={side}
|
isCollapsed={isCollapsed}
|
||||||
creatureName={creatureName}
|
side={side}
|
||||||
panelRole={panelRole}
|
creatureName={creatureName}
|
||||||
showPinButton={showPinButton}
|
panelRole={panelRole}
|
||||||
onToggleCollapse={onToggleCollapse}
|
showPinButton={showPinButton}
|
||||||
onPin={onPin}
|
onToggleCollapse={onToggleCollapse}
|
||||||
onUnpin={onUnpin}
|
onPin={onPin}
|
||||||
>
|
onUnpin={onUnpin}
|
||||||
{renderContent()}
|
>
|
||||||
</DesktopPanel>
|
{renderContent()}
|
||||||
|
</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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
TraitBlock,
|
TraitBlock,
|
||||||
TraitSegment,
|
TraitSegment,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
|
import { RichDescription } from "./rich-description.js";
|
||||||
|
|
||||||
export function PropertyLine({
|
export function PropertyLine({
|
||||||
label,
|
label,
|
||||||
@@ -39,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>
|
||||||
);
|
);
|
||||||
@@ -61,15 +64,19 @@ function TraitSegments({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_DIAMOND = "M50 2 L96 50 L50 98 L4 50 Z M48 28 L78 50 L48 72 Z";
|
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_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 =
|
const FREE_ACTION_DIAMOND =
|
||||||
"M50 2 L96 50 L50 98 L4 50 Z M50 12 L12 50 L50 88 L88 50 Z";
|
"M50 2 L96 50 L50 98 L4 50 Z M50 12 L12 50 L50 88 L88 50 Z";
|
||||||
const FREE_ACTION_CHEVRON = "M48 28 L78 50 L48 72 Z";
|
const FREE_ACTION_CHEVRON = "M48 27 L71 50 L48 73 Z";
|
||||||
const REACTION_ARROW =
|
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";
|
"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";
|
||||||
|
|
||||||
function ActivityIcon({ activity }: Readonly<{ activity: ActivityCost }>) {
|
export function ActivityIcon({
|
||||||
|
activity,
|
||||||
|
}: Readonly<{ activity: ActivityCost }>) {
|
||||||
const cls = "inline-block h-[1em] align-[-0.1em]";
|
const cls = "inline-block h-[1em] align-[-0.1em]";
|
||||||
if (activity.unit === "free") {
|
if (activity.unit === "free") {
|
||||||
return (
|
return (
|
||||||
@@ -101,7 +108,7 @@ function ActivityIcon({ activity }: Readonly<{ activity: ActivityCost }>) {
|
|||||||
<svg aria-hidden="true" className={cls} viewBox="0 0 140 100">
|
<svg aria-hidden="true" className={cls} viewBox="0 0 140 100">
|
||||||
<path d={ACTION_DIAMOND_SOLID} fill="currentColor" />
|
<path d={ACTION_DIAMOND_SOLID} fill="currentColor" />
|
||||||
<path
|
<path
|
||||||
d="M90 2 L136 50 L90 98 L44 50 Z M88 28 L118 50 L88 72 Z"
|
d={ACTION_DIAMOND_OUTLINE}
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
fillRule="evenodd"
|
fillRule="evenodd"
|
||||||
/>
|
/>
|
||||||
@@ -113,7 +120,7 @@ function ActivityIcon({ activity }: Readonly<{ activity: ActivityCost }>) {
|
|||||||
<path d={ACTION_DIAMOND_SOLID} fill="currentColor" />
|
<path d={ACTION_DIAMOND_SOLID} fill="currentColor" />
|
||||||
<path d="M90 2 L136 50 L90 98 L44 50 Z" fill="currentColor" />
|
<path d="M90 2 L136 50 L90 98 L44 50 Z" fill="currentColor" />
|
||||||
<path
|
<path
|
||||||
d="M130 2 L176 50 L130 98 L84 50 Z M128 28 L158 50 L128 72 Z"
|
d="M130 2 L176 50 L130 98 L84 50 Z M130 29 L151 50 L130 71 L109 50 Z"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
fillRule="evenodd"
|
fillRule="evenodd"
|
||||||
/>
|
/>
|
||||||
@@ -134,6 +141,7 @@ export function TraitEntry({ trait }: Readonly<{ trait: TraitBlock }>) {
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
|
{trait.frequency ? ` (${trait.frequency})` : null}
|
||||||
{trait.trigger ? (
|
{trait.trigger ? (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { cn } from "../../lib/utils.js";
|
||||||
|
|
||||||
|
export interface SegmentedOption<T> {
|
||||||
|
readonly value: T;
|
||||||
|
readonly label: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SegmentedControlProps<T> {
|
||||||
|
options: readonly SegmentedOption<T>[];
|
||||||
|
value: T;
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
/** "sm" for standalone controls, "xs" for controls inline in a stat block. */
|
||||||
|
size?: "sm" | "xs";
|
||||||
|
/** Segments share the full width of the row instead of hugging their label. */
|
||||||
|
stretch?: boolean;
|
||||||
|
label?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A row of mutually exclusive options where exactly one is active — game
|
||||||
|
* system, theme, PF2e adjustment, D&D hit point variant.
|
||||||
|
*/
|
||||||
|
export function SegmentedControl<T>({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
size = "sm",
|
||||||
|
stretch = false,
|
||||||
|
label,
|
||||||
|
className,
|
||||||
|
}: Readonly<SegmentedControlProps<T>>) {
|
||||||
|
return (
|
||||||
|
<fieldset aria-label={label} className={cn("flex gap-1", className)}>
|
||||||
|
{options.map((option) => {
|
||||||
|
const isActive = option.value === value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={String(option.value)}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={isActive}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center gap-1.5 rounded-md font-medium transition-colors",
|
||||||
|
size === "xs" ? "px-2 py-0.5 text-xs" : "px-3 py-1.5 text-sm",
|
||||||
|
stretch ? "flex-1" : "",
|
||||||
|
isActive
|
||||||
|
? "bg-accent text-primary-foreground"
|
||||||
|
: "bg-card text-muted-foreground hover:bg-hover-neutral-bg hover:text-foreground",
|
||||||
|
)}
|
||||||
|
onClick={() => onChange(option.value)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
@@ -336,10 +341,10 @@ describe("useDifficulty", () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current).not.toBeNull();
|
expect(result.current).not.toBeNull();
|
||||||
// Level 3 budget: low=150, mod=225, high=400
|
// Level 3 budget: low=150, mod=225, high=400
|
||||||
// CR 1/4 = 50 XP -> trivial
|
// CR 1/4 = 50 XP ≤ 150 Low budget -> Low
|
||||||
expect(result.current?.thresholds[0].value).toBe(150);
|
expect(result.current?.thresholds[0].value).toBe(150);
|
||||||
expect(result.current?.totalMonsterXp).toBe(50);
|
expect(result.current?.totalMonsterXp).toBe(50);
|
||||||
expect(result.current?.tier).toBe(0);
|
expect(result.current?.tier).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import type { HpRange, HpVariant } 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";
|
||||||
|
|
||||||
|
// An ogre with 3d6 + 6 hit points.
|
||||||
|
const RANGE: HpRange = { min: 9, average: 16, max: 24 };
|
||||||
|
|
||||||
|
function stateWithCreature(
|
||||||
|
maxHp: number,
|
||||||
|
currentHp: number,
|
||||||
|
hpVariant?: HpVariant,
|
||||||
|
): EncounterState {
|
||||||
|
return {
|
||||||
|
encounter: {
|
||||||
|
combatants: [
|
||||||
|
{
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
name: "Ogre",
|
||||||
|
maxHp,
|
||||||
|
currentHp,
|
||||||
|
creatureId: creatureId("mm:ogre"),
|
||||||
|
...(hpVariant !== undefined && { hpVariant }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
},
|
||||||
|
undoRedoState: EMPTY_UNDO_REDO_STATE,
|
||||||
|
events: [],
|
||||||
|
nextId: 1,
|
||||||
|
lastCreatureId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVariant(state: EncounterState, variant: HpVariant | undefined) {
|
||||||
|
return encounterReducer(state, {
|
||||||
|
type: "set-hp-variant",
|
||||||
|
id: combatantId("c-1"),
|
||||||
|
variant,
|
||||||
|
range: RANGE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("set-hp-variant", () => {
|
||||||
|
it("raises max HP to the maximum roll and stores the variant", () => {
|
||||||
|
const next = setVariant(stateWithCreature(16, 16), "max");
|
||||||
|
const c = next.encounter.combatants[0];
|
||||||
|
expect(c.maxHp).toBe(24);
|
||||||
|
expect(c.currentHp).toBe(24);
|
||||||
|
expect(c.hpVariant).toBe("max");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lowers max HP to the minimum roll", () => {
|
||||||
|
const next = setVariant(stateWithCreature(16, 16), "min");
|
||||||
|
const c = next.encounter.combatants[0];
|
||||||
|
expect(c.maxHp).toBe(9);
|
||||||
|
expect(c.hpVariant).toBe("min");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns to the average and clears the variant", () => {
|
||||||
|
const next = setVariant(stateWithCreature(24, 24, "max"), undefined);
|
||||||
|
const c = next.encounter.combatants[0];
|
||||||
|
expect(c.maxHp).toBe(16);
|
||||||
|
expect(c.hpVariant).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not rename the combatant", () => {
|
||||||
|
const next = setVariant(stateWithCreature(16, 16), "max");
|
||||||
|
expect(next.encounter.combatants[0].name).toBe("Ogre");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is undoable", () => {
|
||||||
|
const start = stateWithCreature(16, 16);
|
||||||
|
const next = setVariant(start, "max");
|
||||||
|
const undone = encounterReducer(next, { type: "undo" });
|
||||||
|
expect(undone.encounter.combatants[0].maxHp).toBe(16);
|
||||||
|
expect(undone.encounter.combatants[0].hpVariant).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves state untouched for an unknown combatant", () => {
|
||||||
|
const start = stateWithCreature(16, 16);
|
||||||
|
const next = encounterReducer(start, {
|
||||||
|
type: "set-hp-variant",
|
||||||
|
id: combatantId("c-99"),
|
||||||
|
variant: "max",
|
||||||
|
range: RANGE,
|
||||||
|
});
|
||||||
|
expect(next).toBe(start);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import { act, renderHook } from "@testing-library/react";
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||||
|
import { buildPlayerCharacter } from "../../__tests__/factories/index.js";
|
||||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||||
import type { SearchResult } from "../use-bestiary.js";
|
import type { SearchResult } from "../use-bestiary.js";
|
||||||
import { useEncounter } from "../use-encounter.js";
|
import { useEncounter } from "../use-encounter.js";
|
||||||
@@ -256,4 +257,54 @@ describe("useEncounter", () => {
|
|||||||
expect(combatant.icon).toBe("sword");
|
expect(combatant.icon).toBe("sword");
|
||||||
expect(combatant.playerCharacterId).toBe(playerCharacterId("pc-1"));
|
expect(combatant.playerCharacterId).toBe(playerCharacterId("pc-1"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("addParty adds every party member", () => {
|
||||||
|
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||||
|
const party = [
|
||||||
|
buildPlayerCharacter({ name: "Aria" }),
|
||||||
|
buildPlayerCharacter({ name: "Borin" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
act(() => result.current.addParty(party));
|
||||||
|
|
||||||
|
const names = result.current.encounter.combatants.map((c) => c.name);
|
||||||
|
expect(names).toEqual(["Aria", "Borin"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addParty skips members already in the encounter", () => {
|
||||||
|
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||||
|
const aria = buildPlayerCharacter({ name: "Aria" });
|
||||||
|
const borin = buildPlayerCharacter({ name: "Borin" });
|
||||||
|
|
||||||
|
act(() => result.current.addFromPlayerCharacter(aria));
|
||||||
|
act(() => result.current.addParty([aria, borin]));
|
||||||
|
|
||||||
|
const names = result.current.encounter.combatants.map((c) => c.name);
|
||||||
|
expect(names).toEqual(["Aria", "Borin"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addParty is undone in a single step", () => {
|
||||||
|
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||||
|
const party = [
|
||||||
|
buildPlayerCharacter({ name: "Aria" }),
|
||||||
|
buildPlayerCharacter({ name: "Borin" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
act(() => result.current.addParty(party));
|
||||||
|
act(() => result.current.undo());
|
||||||
|
|
||||||
|
expect(result.current.encounter.combatants).toEqual([]);
|
||||||
|
expect(result.current.canUndo).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addParty does nothing when the whole party is already present", () => {
|
||||||
|
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||||
|
const aria = buildPlayerCharacter({ name: "Aria" });
|
||||||
|
|
||||||
|
act(() => result.current.addParty([aria]));
|
||||||
|
act(() => result.current.addParty([aria]));
|
||||||
|
act(() => result.current.undo());
|
||||||
|
|
||||||
|
expect(result.current.encounter.combatants).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,6 +9,7 @@ import {
|
|||||||
normalizeBestiary,
|
normalizeBestiary,
|
||||||
setSourceDisplayNames,
|
setSourceDisplayNames,
|
||||||
} from "../adapters/bestiary-adapter.js";
|
} from "../adapters/bestiary-adapter.js";
|
||||||
|
import { loadBundledDndCreatures } from "../adapters/dnd-bundled-adapter.js";
|
||||||
import { normalizeFoundryCreatures } from "../adapters/pf2e-bestiary-adapter.js";
|
import { normalizeFoundryCreatures } from "../adapters/pf2e-bestiary-adapter.js";
|
||||||
import { useAdapters } from "../contexts/adapter-context.js";
|
import { useAdapters } from "../contexts/adapter-context.js";
|
||||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||||
@@ -28,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,
|
||||||
@@ -36,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();
|
||||||
@@ -55,7 +161,11 @@ export function useBestiary(): BestiaryHook {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void bestiaryCache.loadAllCachedCreatures().then((map) => {
|
void bestiaryCache.loadAllCachedCreatures().then((map) => {
|
||||||
setCreatureMap(map);
|
const merged = new Map(map);
|
||||||
|
for (const c of loadBundledDndCreatures()) {
|
||||||
|
merged.set(c.id, c);
|
||||||
|
}
|
||||||
|
setCreatureMap(merged);
|
||||||
});
|
});
|
||||||
}, [bestiaryCache, bestiaryIndex, pf2eBestiaryIndex]);
|
}, [bestiaryCache, bestiaryIndex, pf2eBestiaryIndex]);
|
||||||
|
|
||||||
@@ -108,30 +218,25 @@ 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 creatures: AnyCreature[];
|
||||||
|
let skippedNames: string[] = [];
|
||||||
|
|
||||||
if (edition === "pf2e") {
|
if (edition === "pf2e") {
|
||||||
// PF2e: url is a base URL; fetch each creature file in parallel
|
|
||||||
const paths = pf2eBestiaryIndex.getCreaturePathsForSource(sourceCode);
|
const paths = pf2eBestiaryIndex.getCreaturePathsForSource(sourceCode);
|
||||||
const baseUrl = url.endsWith("/") ? url : `${url}/`;
|
|
||||||
const responses = await Promise.all(
|
|
||||||
paths.map(async (path) => {
|
|
||||||
const response = await fetch(`${baseUrl}${path}`);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(
|
|
||||||
`Failed to fetch ${path}: ${response.status} ${response.statusText}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const displayName = pf2eBestiaryIndex.getSourceDisplayName(sourceCode);
|
const displayName = pf2eBestiaryIndex.getSourceDisplayName(sourceCode);
|
||||||
creatures = normalizeFoundryCreatures(
|
const result = await fetchPf2eSource(
|
||||||
responses,
|
paths,
|
||||||
|
url,
|
||||||
sourceCode,
|
sourceCode,
|
||||||
displayName,
|
displayName,
|
||||||
|
pf2eBestiaryIndex.getCreatureNamesByPaths,
|
||||||
);
|
);
|
||||||
|
creatures = result.creatures;
|
||||||
|
skippedNames = result.skippedNames;
|
||||||
} else {
|
} else {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -160,6 +265,7 @@ export function useBestiary(): BestiaryHook {
|
|||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
return { skippedNames };
|
||||||
},
|
},
|
||||||
[bestiaryCache, bestiaryIndex, pf2eBestiaryIndex, edition, system],
|
[bestiaryCache, bestiaryIndex, pf2eBestiaryIndex, edition, system],
|
||||||
);
|
);
|
||||||
@@ -199,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 (!hasPartyLevel || !hasCr) return null;
|
if (edition === "pf2e") {
|
||||||
|
const hasCreatureLevel = descriptors.some(
|
||||||
|
(d) => d.creatureLevel !== undefined,
|
||||||
|
);
|
||||||
|
if (!hasPartyLevel || !hasCreatureLevel) return null;
|
||||||
|
} else {
|
||||||
|
const hasCr = descriptors.some((d) => d.cr !== undefined);
|
||||||
|
if (!hasPartyLevel || !hasCr) return null;
|
||||||
|
}
|
||||||
|
|
||||||
const result = calculateEncounterDifficulty(descriptors, edition);
|
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 (!hasPartyLevel || !hasCr) return null;
|
if (edition === "pf2e") {
|
||||||
|
const hasCreatureLevel = descriptors.some(
|
||||||
|
(d) => d.creatureLevel !== undefined,
|
||||||
|
);
|
||||||
|
if (!hasPartyLevel || !hasCreatureLevel) return null;
|
||||||
|
} else {
|
||||||
|
const hasCr = descriptors.some((d) => d.cr !== undefined);
|
||||||
|
if (!hasPartyLevel || !hasCr) return null;
|
||||||
|
}
|
||||||
|
|
||||||
return calculateEncounterDifficulty(descriptors, edition);
|
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,11 +9,13 @@ import {
|
|||||||
editCombatantUseCase,
|
editCombatantUseCase,
|
||||||
redoUseCase,
|
redoUseCase,
|
||||||
removeCombatantUseCase,
|
removeCombatantUseCase,
|
||||||
|
removePersistentDamageUseCase,
|
||||||
retreatTurnUseCase,
|
retreatTurnUseCase,
|
||||||
setAcUseCase,
|
setAcUseCase,
|
||||||
setConditionValueUseCase,
|
setConditionValueUseCase,
|
||||||
setCrUseCase,
|
setCrUseCase,
|
||||||
setHpUseCase,
|
setHpUseCase,
|
||||||
|
setHpVariantUseCase,
|
||||||
setInitiativeUseCase,
|
setInitiativeUseCase,
|
||||||
setSideUseCase,
|
setSideUseCase,
|
||||||
setTempHpUseCase,
|
setTempHpUseCase,
|
||||||
@@ -28,12 +31,18 @@ import type {
|
|||||||
DomainError,
|
DomainError,
|
||||||
DomainEvent,
|
DomainEvent,
|
||||||
Encounter,
|
Encounter,
|
||||||
|
HpRange,
|
||||||
|
HpVariant,
|
||||||
|
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,
|
||||||
@@ -53,6 +62,12 @@ type EncounterAction =
|
|||||||
| { type: "edit-combatant"; id: CombatantId; newName: string }
|
| { type: "edit-combatant"; id: CombatantId; newName: string }
|
||||||
| { type: "set-initiative"; id: CombatantId; value: number | undefined }
|
| { type: "set-initiative"; id: CombatantId; value: number | undefined }
|
||||||
| { type: "set-hp"; id: CombatantId; maxHp: number | undefined }
|
| { type: "set-hp"; id: CombatantId; maxHp: number | undefined }
|
||||||
|
| {
|
||||||
|
type: "set-hp-variant";
|
||||||
|
id: CombatantId;
|
||||||
|
variant: HpVariant | undefined;
|
||||||
|
range: HpRange;
|
||||||
|
}
|
||||||
| { type: "adjust-hp"; id: CombatantId; delta: number }
|
| { type: "adjust-hp"; id: CombatantId; delta: number }
|
||||||
| { type: "set-temp-hp"; id: CombatantId; tempHp: number | undefined }
|
| { type: "set-temp-hp"; id: CombatantId; tempHp: number | undefined }
|
||||||
| { type: "set-ac"; id: CombatantId; value: number | undefined }
|
| { type: "set-ac"; id: CombatantId; value: number | undefined }
|
||||||
@@ -75,6 +90,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,7 +110,14 @@ 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: "add-party"; pcs: readonly PlayerCharacter[] }
|
||||||
| {
|
| {
|
||||||
type: "import";
|
type: "import";
|
||||||
encounter: Encounter;
|
encounter: Encounter;
|
||||||
@@ -254,13 +287,13 @@ function handleAddFromBestiary(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAddFromPlayerCharacter(
|
function addOneFromPlayerCharacter(
|
||||||
state: EncounterState,
|
store: EncounterStore,
|
||||||
pc: PlayerCharacter,
|
pc: PlayerCharacter,
|
||||||
): EncounterState {
|
nextId: number,
|
||||||
const { store, getEncounter } = makeStoreFromState(state);
|
): DomainEvent[] | null {
|
||||||
const newName = resolveAndRename(store, pc.name);
|
const newName = resolveAndRename(store, pc.name);
|
||||||
const id = combatantId(`c-${state.nextId + 1}`);
|
const id = combatantId(`c-${nextId + 1}`);
|
||||||
const result = addCombatantUseCase(store, id, newName, {
|
const result = addCombatantUseCase(store, id, newName, {
|
||||||
maxHp: pc.maxHp,
|
maxHp: pc.maxHp,
|
||||||
ac: pc.ac > 0 ? pc.ac : undefined,
|
ac: pc.ac > 0 ? pc.ac : undefined,
|
||||||
@@ -268,17 +301,128 @@ function handleAddFromPlayerCharacter(
|
|||||||
icon: pc.icon,
|
icon: pc.icon,
|
||||||
playerCharacterId: pc.id,
|
playerCharacterId: pc.id,
|
||||||
});
|
});
|
||||||
if (isDomainError(result)) return state;
|
return isDomainError(result) ? null : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddFromPlayerCharacter(
|
||||||
|
state: EncounterState,
|
||||||
|
pc: PlayerCharacter,
|
||||||
|
): EncounterState {
|
||||||
|
const { store, getEncounter } = makeStoreFromState(state);
|
||||||
|
const events = addOneFromPlayerCharacter(store, pc, state.nextId);
|
||||||
|
if (!events) return state;
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
encounter: getEncounter(),
|
encounter: getEncounter(),
|
||||||
undoRedoState: pushUndo(state.undoRedoState, state.encounter),
|
undoRedoState: pushUndo(state.undoRedoState, state.encounter),
|
||||||
events: [...state.events, ...result],
|
events: [...state.events, ...events],
|
||||||
nextId: state.nextId + 1,
|
nextId: state.nextId + 1,
|
||||||
lastCreatureId: null,
|
lastCreatureId: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Adds every party member that is not already in the encounter, as one undo step. */
|
||||||
|
function handleAddParty(
|
||||||
|
state: EncounterState,
|
||||||
|
pcs: readonly PlayerCharacter[],
|
||||||
|
): EncounterState {
|
||||||
|
const present = new Set(
|
||||||
|
state.encounter.combatants.map((c) => c.playerCharacterId),
|
||||||
|
);
|
||||||
|
const missing = pcs.filter((pc) => !present.has(pc.id));
|
||||||
|
if (missing.length === 0) return state;
|
||||||
|
|
||||||
|
const { store, getEncounter } = makeStoreFromState(state);
|
||||||
|
const allEvents: DomainEvent[] = [];
|
||||||
|
let nextId = state.nextId;
|
||||||
|
|
||||||
|
for (const pc of missing) {
|
||||||
|
const events = addOneFromPlayerCharacter(store, pc, nextId);
|
||||||
|
if (!events) return state;
|
||||||
|
allEvents.push(...events);
|
||||||
|
nextId += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
encounter: getEncounter(),
|
||||||
|
undoRedoState: pushUndo(state.undoRedoState, state.encounter),
|
||||||
|
events: [...state.events, ...allEvents],
|
||||||
|
nextId,
|
||||||
|
lastCreatureId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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,12 +454,21 @@ 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":
|
||||||
return handleAddFromBestiary(state, action.entry, action.count);
|
return handleAddFromBestiary(state, action.entry, action.count);
|
||||||
case "add-from-player-character":
|
case "add-from-player-character":
|
||||||
return handleAddFromPlayerCharacter(state, action.pc);
|
return handleAddFromPlayerCharacter(state, action.pc);
|
||||||
|
case "add-party":
|
||||||
|
return handleAddParty(state, action.pcs);
|
||||||
default:
|
default:
|
||||||
return dispatchEncounterAction(state, action);
|
return dispatchEncounterAction(state, action);
|
||||||
}
|
}
|
||||||
@@ -332,6 +485,7 @@ function dispatchEncounterAction(
|
|||||||
| { type: "edit-combatant" }
|
| { type: "edit-combatant" }
|
||||||
| { type: "set-initiative" }
|
| { type: "set-initiative" }
|
||||||
| { type: "set-hp" }
|
| { type: "set-hp" }
|
||||||
|
| { type: "set-hp-variant" }
|
||||||
| { type: "adjust-hp" }
|
| { type: "adjust-hp" }
|
||||||
| { type: "set-temp-hp" }
|
| { type: "set-temp-hp" }
|
||||||
| { type: "set-ac" }
|
| { type: "set-ac" }
|
||||||
@@ -341,6 +495,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);
|
||||||
@@ -370,6 +526,14 @@ function dispatchEncounterAction(
|
|||||||
case "set-hp":
|
case "set-hp":
|
||||||
result = setHpUseCase(store, action.id, action.maxHp);
|
result = setHpUseCase(store, action.id, action.maxHp);
|
||||||
break;
|
break;
|
||||||
|
case "set-hp-variant":
|
||||||
|
result = setHpVariantUseCase(
|
||||||
|
store,
|
||||||
|
action.id,
|
||||||
|
action.variant,
|
||||||
|
action.range,
|
||||||
|
);
|
||||||
|
break;
|
||||||
case "adjust-hp":
|
case "adjust-hp":
|
||||||
result = adjustHpUseCase(store, action.id, action.delta);
|
result = adjustHpUseCase(store, action.id, action.delta);
|
||||||
break;
|
break;
|
||||||
@@ -402,6 +566,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 +600,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 +744,35 @@ 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,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
setHpVariant: useCallback(
|
||||||
|
(id: CombatantId, variant: HpVariant | undefined, range: HpRange) =>
|
||||||
|
dispatch({ type: "set-hp-variant", id, variant, range }),
|
||||||
|
[],
|
||||||
|
),
|
||||||
clearEncounter: useCallback(
|
clearEncounter: useCallback(
|
||||||
() => dispatch({ type: "clear-encounter" }),
|
() => dispatch({ type: "clear-encounter" }),
|
||||||
[],
|
[],
|
||||||
@@ -586,6 +797,10 @@ export function useEncounter() {
|
|||||||
dispatch({ type: "add-from-player-character", pc }),
|
dispatch({ type: "add-from-player-character", pc }),
|
||||||
[],
|
[],
|
||||||
),
|
),
|
||||||
|
addParty: useCallback(
|
||||||
|
(pcs: readonly PlayerCharacter[]) => dispatch({ type: "add-party", pcs }),
|
||||||
|
[],
|
||||||
|
),
|
||||||
undo: useCallback(() => dispatch({ type: "undo" }), []),
|
undo: useCallback(() => dispatch({ type: "undo" }), []),
|
||||||
redo: useCallback(() => dispatch({ type: "redo" }), []),
|
redo: useCallback(() => dispatch({ type: "redo" }), []),
|
||||||
setEncounter: useCallback(
|
setEncounter: useCallback(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://unpkg.com/knip@5/schema.json",
|
"$schema": "https://unpkg.com/knip@6/schema.json",
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
".": {
|
".": {
|
||||||
"entry": ["scripts/*.mjs"]
|
"entry": ["scripts/*.mjs"]
|
||||||
|
|||||||
+7
-3
@@ -3,8 +3,12 @@ pre-commit:
|
|||||||
jobs:
|
jobs:
|
||||||
- name: audit
|
- name: audit
|
||||||
run: pnpm audit --audit-level=high
|
run: pnpm audit --audit-level=high
|
||||||
|
# Must go through the npm script, not `pnpm exec knip`: the script sets
|
||||||
|
# KNIP_DISABLE_RAW_TRANSFER=1. Knip 6's default oxc-parser raw-transfer path
|
||||||
|
# allocates a 6 GiB ArrayBuffer, which Linux refuses under heuristic
|
||||||
|
# overcommit on the CI runner (3.7 GB RAM, no swap). See issue #11.
|
||||||
- name: knip
|
- name: knip
|
||||||
run: pnpm exec knip
|
run: pnpm knip
|
||||||
- name: biome
|
- name: biome
|
||||||
run: pnpm exec biome check .
|
run: pnpm exec biome check .
|
||||||
- name: check-ignores
|
- name: check-ignores
|
||||||
@@ -14,7 +18,7 @@ pre-commit:
|
|||||||
- name: check-props
|
- name: check-props
|
||||||
run: node scripts/check-component-props.mjs
|
run: node scripts/check-component-props.mjs
|
||||||
- name: jscpd
|
- name: jscpd
|
||||||
run: pnpm exec jscpd
|
run: pnpm jscpd
|
||||||
- name: jsinspect
|
- name: jsinspect
|
||||||
run: pnpm jsinspect
|
run: pnpm jsinspect
|
||||||
- name: typecheck-oxlint-test
|
- name: typecheck-oxlint-test
|
||||||
@@ -24,6 +28,6 @@ pre-commit:
|
|||||||
- name: typecheck
|
- name: typecheck
|
||||||
run: pnpm exec tsc --build
|
run: pnpm exec tsc --build
|
||||||
- name: oxlint
|
- name: oxlint
|
||||||
run: pnpm oxlint -- --deny warnings
|
run: pnpm oxlint
|
||||||
- name: test
|
- name: test
|
||||||
run: pnpm vitest run --reporter=dot --coverage.reporter=text-summary
|
run: pnpm vitest run --reporter=dot --coverage.reporter=text-summary
|
||||||
|
|||||||
+5
-11
@@ -1,18 +1,12 @@
|
|||||||
{
|
{
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be",
|
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be",
|
||||||
"pnpm": {
|
|
||||||
"overrides": {
|
|
||||||
"undici": ">=7.24.0",
|
|
||||||
"picomatch": ">=4.0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.4.8",
|
"@biomejs/biome": "2.4.8",
|
||||||
"@vitest/coverage-v8": "^4.1.0",
|
"@vitest/coverage-v8": "^4.1.0",
|
||||||
"jscpd": "^4.0.8",
|
"jscpd": "^4.0.8",
|
||||||
"jsinspect-plus": "^3.1.3",
|
"jsinspect-plus": "^3.1.3",
|
||||||
"knip": "^5.88.1",
|
"knip": "^6.31.0",
|
||||||
"lefthook": "^2.1.4",
|
"lefthook": "^2.1.4",
|
||||||
"oxlint": "^1.56.0",
|
"oxlint": "^1.56.0",
|
||||||
"oxlint-tsgolint": "^0.17.1",
|
"oxlint-tsgolint": "^0.17.1",
|
||||||
@@ -28,13 +22,13 @@
|
|||||||
"typecheck": "tsc --build",
|
"typecheck": "tsc --build",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"knip": "knip",
|
"knip": "KNIP_DISABLE_RAW_TRANSFER=1 knip",
|
||||||
"jscpd": "jscpd",
|
"jscpd": "jscpd apps/web/src packages/domain/src packages/application/src",
|
||||||
"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 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",
|
||||||
"check": "pnpm audit --audit-level=high && knip && biome check . && node scripts/check-lint-ignores.mjs && node scripts/check-cn-classnames.mjs && node scripts/check-component-props.mjs && jscpd && pnpm jsinspect && tsc --build && oxlint --tsconfig apps/web/tsconfig.json --type-aware --deny warnings && vitest run"
|
"check": "pnpm audit --audit-level=high && pnpm knip && biome check . && node scripts/check-lint-ignores.mjs && node scripts/check-cn-classnames.mjs && node scripts/check-component-props.mjs && pnpm jscpd && pnpm jsinspect && tsc --build && pnpm oxlint && vitest run"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -25,6 +27,7 @@ export { setAcUseCase } from "./set-ac-use-case.js";
|
|||||||
export { setConditionValueUseCase } from "./set-condition-value-use-case.js";
|
export { setConditionValueUseCase } from "./set-condition-value-use-case.js";
|
||||||
export { setCrUseCase } from "./set-cr-use-case.js";
|
export { setCrUseCase } from "./set-cr-use-case.js";
|
||||||
export { setHpUseCase } from "./set-hp-use-case.js";
|
export { setHpUseCase } from "./set-hp-use-case.js";
|
||||||
|
export { setHpVariantUseCase } from "./set-hp-variant-use-case.js";
|
||||||
export { setInitiativeUseCase } from "./set-initiative-use-case.js";
|
export { setInitiativeUseCase } from "./set-initiative-use-case.js";
|
||||||
export { setSideUseCase } from "./set-side-use-case.js";
|
export { setSideUseCase } from "./set-side-use-case.js";
|
||||||
export { setTempHpUseCase } from "./set-temp-hp-use-case.js";
|
export { setTempHpUseCase } from "./set-temp-hp-use-case.js";
|
||||||
|
|||||||
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type DomainError,
|
||||||
|
type DomainEvent,
|
||||||
|
type HpRange,
|
||||||
|
type HpVariant,
|
||||||
|
setHpVariant,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
import { runEncounterAction } from "./run-encounter-action.js";
|
||||||
|
|
||||||
|
export function setHpVariantUseCase(
|
||||||
|
store: EncounterStore,
|
||||||
|
combatantId: CombatantId,
|
||||||
|
variant: HpVariant | undefined,
|
||||||
|
range: HpRange,
|
||||||
|
): DomainEvent[] | DomainError {
|
||||||
|
return runEncounterAction(store, (encounter) =>
|
||||||
|
setHpVariant(encounter, combatantId, variant, range),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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", () => {
|
||||||
@@ -47,9 +49,9 @@ function enemy(cr: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("calculateEncounterDifficulty — 5.5e edition", () => {
|
describe("calculateEncounterDifficulty — 5.5e edition", () => {
|
||||||
it("returns tier 0 when monster XP is below Low threshold", () => {
|
it("returns tier 0 when monster XP is 0", () => {
|
||||||
// 4x level 1: Low = 200, Moderate = 300, High = 400
|
// 4x level 1: Low = 200, Moderate = 300, High = 400
|
||||||
// 1x CR 0 = 0 XP -> tier 0
|
// 1x CR 0 = 0 XP -> tier 0 (5.5e has no Trivial tier; 0 bars only at 0 XP)
|
||||||
const result = calculateEncounterDifficulty(
|
const result = calculateEncounterDifficulty(
|
||||||
[party(1), party(1), party(1), party(1), enemy("0")],
|
[party(1), party(1), party(1), party(1), enemy("0")],
|
||||||
"5.5e",
|
"5.5e",
|
||||||
@@ -75,9 +77,29 @@ describe("calculateEncounterDifficulty — 5.5e edition", () => {
|
|||||||
expect(result.totalMonsterXp).toBe(200);
|
expect(result.totalMonsterXp).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns tier 2 for 5x level 3 vs 1150 XP", () => {
|
it("returns tier 2 when XP spends exactly the Moderate budget", () => {
|
||||||
// 5x level 3: Low = 750, Moderate = 1125, High = 2000
|
// 5x level 3: Low = 750, Moderate = 1125, High = 2000
|
||||||
// CR 3 (700) + CR 2 (450) = 1150 XP >= 1125 Moderate
|
// CR 4 (1100) + CR 1/8 (25) = 1125 XP = Moderate budget
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[
|
||||||
|
party(3),
|
||||||
|
party(3),
|
||||||
|
party(3),
|
||||||
|
party(3),
|
||||||
|
party(3),
|
||||||
|
enemy("4"),
|
||||||
|
enemy("1/8"),
|
||||||
|
],
|
||||||
|
"5.5e",
|
||||||
|
);
|
||||||
|
expect(result.tier).toBe(2);
|
||||||
|
expect(result.totalMonsterXp).toBe(1125);
|
||||||
|
expect(result.thresholds[1].value).toBe(1125);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns tier 3 when XP exceeds the Moderate budget", () => {
|
||||||
|
// 5x level 3: Low = 750, Moderate = 1125, High = 2000
|
||||||
|
// CR 3 (700) + CR 2 (450) = 1150 XP > 1125 Moderate → High
|
||||||
const result = calculateEncounterDifficulty(
|
const result = calculateEncounterDifficulty(
|
||||||
[
|
[
|
||||||
party(3),
|
party(3),
|
||||||
@@ -90,14 +112,24 @@ describe("calculateEncounterDifficulty — 5.5e edition", () => {
|
|||||||
],
|
],
|
||||||
"5.5e",
|
"5.5e",
|
||||||
);
|
);
|
||||||
expect(result.tier).toBe(2);
|
expect(result.tier).toBe(3);
|
||||||
expect(result.totalMonsterXp).toBe(1150);
|
expect(result.totalMonsterXp).toBe(1150);
|
||||||
expect(result.thresholds[1].value).toBe(1125);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns tier 3 when XP meets High threshold", () => {
|
it("returns tier 3 for 5x level 5 vs 5000 XP (over Moderate budget)", () => {
|
||||||
// 4x level 1: High = 400
|
// 5x level 5: Low = 2500, Moderate = 3750, High = 5500
|
||||||
// 2x CR 1 = 400 XP -> tier 3
|
// CR 9 = 5000 XP > 3750 Moderate → High, even though below the High budget
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(5), party(5), party(5), party(5), party(5), enemy("9")],
|
||||||
|
"5.5e",
|
||||||
|
);
|
||||||
|
expect(result.tier).toBe(3);
|
||||||
|
expect(result.totalMonsterXp).toBe(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns tier 3 when XP exceeds the Moderate budget", () => {
|
||||||
|
// 4x level 1: Moderate = 300
|
||||||
|
// 2x CR 1 = 400 XP > 300 -> tier 3
|
||||||
const result = calculateEncounterDifficulty(
|
const result = calculateEncounterDifficulty(
|
||||||
[party(1), party(1), party(1), party(1), enemy("1"), enemy("1")],
|
[party(1), party(1), party(1), party(1), enemy("1"), enemy("1")],
|
||||||
"5.5e",
|
"5.5e",
|
||||||
@@ -118,6 +150,7 @@ describe("calculateEncounterDifficulty — 5.5e edition", () => {
|
|||||||
it("handles mixed party levels", () => {
|
it("handles mixed party levels", () => {
|
||||||
// 3x level 3 + 1x level 2
|
// 3x level 3 + 1x level 2
|
||||||
// Total: low=550, mod=825, high=1400
|
// Total: low=550, mod=825, high=1400
|
||||||
|
// 700 XP > 550 Low, ≤ 825 Moderate → Moderate
|
||||||
const result = calculateEncounterDifficulty(
|
const result = calculateEncounterDifficulty(
|
||||||
[party(3), party(3), party(3), party(2), enemy("3")],
|
[party(3), party(3), party(3), party(2), enemy("3")],
|
||||||
"5.5e",
|
"5.5e",
|
||||||
@@ -128,7 +161,7 @@ describe("calculateEncounterDifficulty — 5.5e edition", () => {
|
|||||||
{ label: "High", value: 1400 },
|
{ label: "High", value: 1400 },
|
||||||
]);
|
]);
|
||||||
expect(result.totalMonsterXp).toBe(700);
|
expect(result.totalMonsterXp).toBe(700);
|
||||||
expect(result.tier).toBe(1);
|
expect(result.tier).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns tier 0 with no enemies", () => {
|
it("returns tier 0 with no enemies", () => {
|
||||||
@@ -162,7 +195,7 @@ describe("calculateEncounterDifficulty — 5.5e edition", () => {
|
|||||||
"5.5e",
|
"5.5e",
|
||||||
);
|
);
|
||||||
expect(result.totalMonsterXp).toBe(175); // 25 + 50 + 100
|
expect(result.totalMonsterXp).toBe(175); // 25 + 50 + 100
|
||||||
expect(result.tier).toBe(0); // 175 < 200 Low
|
expect(result.tier).toBe(1); // 175 ≤ 200 Low budget → Low
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores unknown CRs (0 XP)", () => {
|
it("ignores unknown CRs (0 XP)", () => {
|
||||||
@@ -189,7 +222,7 @@ describe("calculateEncounterDifficulty — 5.5e edition", () => {
|
|||||||
"5.5e",
|
"5.5e",
|
||||||
);
|
);
|
||||||
expect(result.totalMonsterXp).toBe(250);
|
expect(result.totalMonsterXp).toBe(250);
|
||||||
expect(result.tier).toBe(1); // 250 >= 200 Low, < 300 Moderate
|
expect(result.tier).toBe(2); // 250 > 200 Low budget, ≤ 300 Moderate → Moderate
|
||||||
});
|
});
|
||||||
|
|
||||||
it("floors net monster XP at 0", () => {
|
it("floors net monster XP at 0", () => {
|
||||||
@@ -252,7 +285,7 @@ describe("calculateEncounterDifficulty — 5.5e edition", () => {
|
|||||||
{ label: "High", value: 800 },
|
{ label: "High", value: 800 },
|
||||||
]);
|
]);
|
||||||
expect(result.totalMonsterXp).toBe(700);
|
expect(result.totalMonsterXp).toBe(700);
|
||||||
expect(result.tier).toBe(2); // 700 >= 450 Moderate, < 800 High
|
expect(result.tier).toBe(3); // 700 > 450 Moderate budget → High
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -386,3 +419,263 @@ 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("returns 0 XP for creatures more than 4 levels below the party", () => {
|
||||||
|
expect(pf2eCreatureXp(0, 10)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
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, within the Trivial band (40 or less)
|
||||||
|
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 > Severe budget (120)
|
||||||
|
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 Moderate (tier 2) for XP between the Low and Moderate budgets", () => {
|
||||||
|
// +1 (60) + −4 (10) = 70 XP > Low (60), ≤ Moderate (80)
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(5), party(5), party(5), party(5), pf2eEnemy(6), pf2eEnemy(1)],
|
||||||
|
"pf2e",
|
||||||
|
);
|
||||||
|
expect(result.tier).toBe(2);
|
||||||
|
expect(result.totalMonsterXp).toBe(70);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns Extreme (tier 4) for XP just over the Severe budget", () => {
|
||||||
|
// +3 (120) + −4 (10) = 130 XP > Severe (120)
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(5), party(5), party(5), party(5), pf2eEnemy(8), pf2eEnemy(1)],
|
||||||
|
"pf2e",
|
||||||
|
);
|
||||||
|
expect(result.tier).toBe(4);
|
||||||
|
expect(result.totalMonsterXp).toBe(130);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns tier 0 when XP is within the Trivial band", () => {
|
||||||
|
// 1 creature at −4 = 10 XP ≤ Trivial (40)
|
||||||
|
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("counts creatures more than 4 levels below the party as 0 XP", () => {
|
||||||
|
// Party level 6: a level-1 creature is 5 below → 0 XP
|
||||||
|
const result = calculateEncounterDifficulty(
|
||||||
|
[party(6), party(6), party(6), party(6), pf2eEnemy(1), pf2eEnemy(6)],
|
||||||
|
"pf2e",
|
||||||
|
);
|
||||||
|
expect(result.totalMonsterXp).toBe(40); // only the at-level creature counts
|
||||||
|
});
|
||||||
|
|
||||||
|
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: 80 },
|
||||||
|
{ 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: 40 },
|
||||||
|
{ 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 * 20) = 0
|
||||||
|
expect(result.thresholds[0].value).toBe(10);
|
||||||
|
expect(result.thresholds[1].value).toBe(0);
|
||||||
|
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,14 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { checkGates } from "../../../../scripts/check-gates.mjs";
|
||||||
|
|
||||||
|
describe("quality gates", () => {
|
||||||
|
it("fail on violations instead of silently passing", () => {
|
||||||
|
const violations = checkGates();
|
||||||
|
if (violations.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Gate integrity violations:\n ${violations.join("\n ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(violations).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { hpForVariant, hpRange } from "../hp-range.js";
|
||||||
|
|
||||||
|
describe("hpRange", () => {
|
||||||
|
it("derives min and max from a formula with a positive modifier", () => {
|
||||||
|
const range = hpRange({ average: 16, formula: "3d6 + 6" });
|
||||||
|
expect(range).toEqual({ min: 9, average: 16, max: 24 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives min and max from a formula without a modifier", () => {
|
||||||
|
const range = hpRange({ average: 40, formula: "9d8" });
|
||||||
|
expect(range).toEqual({ min: 9, average: 40, max: 72 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subtracts a negative modifier", () => {
|
||||||
|
const range = hpRange({ average: 45, formula: "10d8 - 5" });
|
||||||
|
expect(range).toEqual({ min: 5, average: 45, max: 75 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an en dash as a minus sign", () => {
|
||||||
|
// Bundled bestiary data contains e.g. "2d6 – 2" (U+2013).
|
||||||
|
const range = hpRange({ average: 5, formula: "2d6 – 2" });
|
||||||
|
expect(range).toEqual({ min: 1, average: 5, max: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never drops below 1 HP when the modifier exceeds the dice", () => {
|
||||||
|
const range = hpRange({ average: 1, formula: "1d4 - 10" });
|
||||||
|
expect(range?.min).toBe(1);
|
||||||
|
expect(range?.max).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles large pools without whitespace", () => {
|
||||||
|
expect(hpRange({ average: 256, formula: "19d12+133" })).toEqual({
|
||||||
|
min: 152,
|
||||||
|
average: 256,
|
||||||
|
max: 361,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a missing formula", () => {
|
||||||
|
expect(hpRange({ average: 7, formula: "" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for prose instead of a dice pool", () => {
|
||||||
|
expect(
|
||||||
|
hpRange({ average: 0, formula: "equal to the summoner's" }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a flat number", () => {
|
||||||
|
expect(hpRange({ average: 50, formula: "50" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a pool with zero dice or zero sides", () => {
|
||||||
|
expect(hpRange({ average: 0, formula: "0d6" })).toBeNull();
|
||||||
|
expect(hpRange({ average: 0, formula: "2d0" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for compound formulas it cannot reason about", () => {
|
||||||
|
expect(hpRange({ average: 20, formula: "2d6 + 2d8" })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hpForVariant", () => {
|
||||||
|
const range = { min: 9, average: 16, max: 24 };
|
||||||
|
|
||||||
|
it("returns the average when no variant is set", () => {
|
||||||
|
expect(hpForVariant(range, undefined)).toBe(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the minimum for the min variant", () => {
|
||||||
|
expect(hpForVariant(range, "min")).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the maximum for the max variant", () => {
|
||||||
|
expect(hpForVariant(range, "max")).toBe(24);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user