Compare commits
| 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 | ||
|
|
1c107a500b | ||
|
|
0c235112ee | ||
|
|
57278e0c82 | ||
|
|
f9cfaa2570 | ||
|
|
3e62e54274 | ||
|
|
12a089dfd7 | ||
|
|
65e4db153b | ||
|
|
8dbff66ce1 | ||
|
|
e62c49434c | ||
|
|
8f6eebc43b |
@@ -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
|
||||
description: Create a git commit with pre-commit hooks (bypasses sandbox restrictions).
|
||||
disable-model-invocation: true
|
||||
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,
|
||||
"minTokens": 50,
|
||||
"pattern": ["**/*.ts", "**/*.tsx"],
|
||||
"ignore": ["node_modules", "dist", "build", "coverage", ".specify", "specs"],
|
||||
"pattern": "**/*.{ts,tsx}",
|
||||
"ignore": [
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"**/build/**",
|
||||
"**/coverage/**",
|
||||
"**/__tests__/**"
|
||||
],
|
||||
"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",
|
||||
"plugins": ["typescript", "unicorn", "jest"],
|
||||
"plugins": ["typescript", "unicorn", "jest", "import"],
|
||||
"categories": {},
|
||||
"rules": {
|
||||
"typescript/no-unnecessary-type-assertion": "error",
|
||||
@@ -8,6 +8,7 @@
|
||||
"typescript/prefer-regexp-exec": "error",
|
||||
"unicorn/prefer-string-replace-all": "error",
|
||||
"unicorn/prefer-string-raw": "error",
|
||||
"import/no-cycle": "error",
|
||||
"jest/expect-expect": [
|
||||
"error",
|
||||
{
|
||||
@@ -21,7 +22,6 @@
|
||||
".claude",
|
||||
".specify",
|
||||
"specs",
|
||||
".pnpm-store",
|
||||
"scripts"
|
||||
".pnpm-store"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"jsdom": "^29.0.1",
|
||||
"jsdom": "^30.0.1",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"vite": "^8.0.1"
|
||||
"vite": "^8.0.16"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
type Creature,
|
||||
type AnyCreature,
|
||||
type CreatureId,
|
||||
EMPTY_UNDO_REDO_STATE,
|
||||
type Encounter,
|
||||
@@ -12,10 +12,10 @@ export function createTestAdapters(options?: {
|
||||
encounter?: Encounter | null;
|
||||
undoRedoState?: UndoRedoState;
|
||||
playerCharacters?: PlayerCharacter[];
|
||||
creatures?: Map<CreatureId, Creature>;
|
||||
creatures?: Map<CreatureId, AnyCreature>;
|
||||
sources?: Map<
|
||||
string,
|
||||
{ displayName: string; creatures: Creature[]; cachedAt: number }
|
||||
{ displayName: string; creatures: AnyCreature[]; cachedAt: number }
|
||||
>;
|
||||
}): Adapters {
|
||||
let storedEncounter = options?.encounter ?? null;
|
||||
@@ -25,7 +25,7 @@ export function createTestAdapters(options?: {
|
||||
options?.sources ??
|
||||
new Map<
|
||||
string,
|
||||
{ displayName: string; creatures: Creature[]; cachedAt: number }
|
||||
{ displayName: string; creatures: AnyCreature[]; cachedAt: number }
|
||||
>();
|
||||
|
||||
// Pre-populate sourceStore from creatures map if provided
|
||||
@@ -33,7 +33,7 @@ export function createTestAdapters(options?: {
|
||||
// No-op: creatures are accessed directly from the map
|
||||
}
|
||||
|
||||
const creatureMap = options?.creatures ?? new Map<CreatureId, Creature>();
|
||||
const creatureMap = options?.creatures ?? new Map<CreatureId, AnyCreature>();
|
||||
|
||||
return {
|
||||
encounterPersistence: {
|
||||
@@ -55,8 +55,9 @@ export function createTestAdapters(options?: {
|
||||
},
|
||||
},
|
||||
bestiaryCache: {
|
||||
cacheSource(sourceCode, displayName, creatures) {
|
||||
sourceStore.set(sourceCode, {
|
||||
cacheSource(system, sourceCode, displayName, creatures) {
|
||||
const key = `${system}:${sourceCode}`;
|
||||
sourceStore.set(key, {
|
||||
displayName,
|
||||
creatures,
|
||||
cachedAt: Date.now(),
|
||||
@@ -66,21 +67,25 @@ export function createTestAdapters(options?: {
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
isSourceCached(sourceCode) {
|
||||
return Promise.resolve(sourceStore.has(sourceCode));
|
||||
isSourceCached(system, sourceCode) {
|
||||
return Promise.resolve(sourceStore.has(`${system}:${sourceCode}`));
|
||||
},
|
||||
getCachedSources() {
|
||||
getCachedSources(system) {
|
||||
return Promise.resolve(
|
||||
[...sourceStore.entries()].map(([sourceCode, info]) => ({
|
||||
sourceCode,
|
||||
displayName: info.displayName,
|
||||
creatureCount: info.creatures.length,
|
||||
cachedAt: info.cachedAt,
|
||||
})),
|
||||
[...sourceStore.entries()]
|
||||
.filter(([key]) => !system || key.startsWith(`${system}:`))
|
||||
.map(([key, info]) => ({
|
||||
sourceCode: key.includes(":")
|
||||
? key.slice(key.indexOf(":") + 1)
|
||||
: key,
|
||||
displayName: info.displayName,
|
||||
creatureCount: info.creatures.length,
|
||||
cachedAt: info.cachedAt,
|
||||
})),
|
||||
);
|
||||
},
|
||||
clearSource(sourceCode) {
|
||||
sourceStore.delete(sourceCode);
|
||||
clearSource(system, sourceCode) {
|
||||
sourceStore.delete(`${system}:${sourceCode}`);
|
||||
return Promise.resolve();
|
||||
},
|
||||
clearAll() {
|
||||
@@ -104,5 +109,14 @@ export function createTestAdapters(options?: {
|
||||
},
|
||||
getSourceDisplayName: (sourceCode) => sourceCode,
|
||||
},
|
||||
pf2eBestiaryIndex: {
|
||||
loadIndex: () => ({ sources: {}, creatures: [] }),
|
||||
getAllSourceCodes: () => [],
|
||||
getDefaultFetchUrl: (sourceCode) =>
|
||||
`https://example.com/creatures-${sourceCode.toLowerCase()}.json`,
|
||||
getSourceDisplayName: (sourceCode) => sourceCode,
|
||||
getCreaturePathsForSource: () => [],
|
||||
getCreatureNamesByPaths: () => new Map(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,8 +33,7 @@ async function addCombatant(
|
||||
opts?: { maxHp?: string },
|
||||
) {
|
||||
const inputs = screen.getAllByPlaceholderText("+ Add combatants");
|
||||
// biome-ignore lint/style/noNonNullAssertion: getAllBy always returns at least one
|
||||
const input = inputs.at(-1)!;
|
||||
const input = inputs.at(-1) ?? inputs[0];
|
||||
await user.type(input, name);
|
||||
|
||||
if (opts?.maxHp) {
|
||||
|
||||
@@ -198,21 +198,23 @@ describe("ConfirmButton", () => {
|
||||
|
||||
it("Enter/Space keydown stops propagation to prevent parent handlers", () => {
|
||||
const parentHandler = vi.fn();
|
||||
render(
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: test wrapper
|
||||
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: test wrapper
|
||||
<div onKeyDown={parentHandler}>
|
||||
<ConfirmButton
|
||||
icon={<XIcon />}
|
||||
label="Remove combatant"
|
||||
onConfirm={vi.fn()}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
const button = screen.getByRole("button");
|
||||
function Wrapper() {
|
||||
return (
|
||||
<button type="button" onKeyDown={parentHandler}>
|
||||
<ConfirmButton
|
||||
icon={<XIcon />}
|
||||
label="Remove combatant"
|
||||
onConfirm={vi.fn()}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
render(<Wrapper />);
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const confirmButton = buttons.at(-1) ?? buttons[0];
|
||||
|
||||
fireEvent.keyDown(button, { key: "Enter" });
|
||||
fireEvent.keyDown(button, { key: " " });
|
||||
fireEvent.keyDown(confirmButton, { key: "Enter" });
|
||||
fireEvent.keyDown(confirmButton, { key: " " });
|
||||
|
||||
expect(parentHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -266,6 +266,45 @@ describe("round-trip: export then import", () => {
|
||||
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", () => {
|
||||
const encounterNoSide: Encounter = {
|
||||
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 { buildCreature } from "./build-creature.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(),
|
||||
}));
|
||||
|
||||
vi.mock("../contexts/encounter-context.js", () => ({
|
||||
useEncounterContext: vi.fn(),
|
||||
}));
|
||||
|
||||
import { StatBlockPanel } from "../components/stat-block-panel.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
|
||||
const mockUseSidePanelContext = vi.mocked(useSidePanelContext);
|
||||
const mockUseBestiaryContext = vi.mocked(useBestiaryContext);
|
||||
const mockUseEncounterContext = vi.mocked(useEncounterContext);
|
||||
|
||||
const CLOSE_REGEX = /close/i;
|
||||
const COLLAPSE_REGEX = /collapse/i;
|
||||
@@ -82,6 +88,7 @@ function setupMocks(overrides: PanelOverrides = {}) {
|
||||
|
||||
mockUseSidePanelContext.mockReturnValue({
|
||||
selectedCreatureId: panelRole === "browse" ? creatureId : null,
|
||||
selectedCombatantId: null,
|
||||
pinnedCreatureId: panelRole === "pinned" ? creatureId : null,
|
||||
isRightPanelCollapsed: panelRole === "browse" ? isCollapsed : false,
|
||||
isWideDesktop: false,
|
||||
@@ -110,6 +117,11 @@ function setupMocks(overrides: PanelOverrides = {}) {
|
||||
refreshCache: vi.fn(),
|
||||
} 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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
import type { TraitBlock } from "@initiative/domain";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
normalizeBestiary,
|
||||
setSourceDisplayNames,
|
||||
} from "../bestiary-adapter.js";
|
||||
|
||||
/** Flatten segments to a single string for simple text assertions. */
|
||||
function flatText(trait: TraitBlock | undefined): string {
|
||||
if (!trait) return "";
|
||||
return trait.segments
|
||||
.map((s) =>
|
||||
s.type === "text"
|
||||
? s.value
|
||||
: s.items
|
||||
.map((i) => (i.label ? `${i.label}. ${i.text}` : i.text))
|
||||
.join(" "),
|
||||
)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
setSourceDisplayNames({ XMM: "MM 2024" });
|
||||
});
|
||||
@@ -74,11 +89,11 @@ describe("normalizeBestiary", () => {
|
||||
expect(c.senses).toBe("Darkvision 60 ft.");
|
||||
expect(c.languages).toBe("Common, Goblin");
|
||||
expect(c.actions).toHaveLength(1);
|
||||
expect(c.actions?.[0].text).toContain("Melee Attack Roll:");
|
||||
expect(c.actions?.[0].text).not.toContain("{@");
|
||||
expect(flatText(c.actions?.[0])).toContain("Melee Attack Roll:");
|
||||
expect(flatText(c.actions?.[0])).not.toContain("{@");
|
||||
expect(c.bonusActions).toHaveLength(1);
|
||||
expect(c.bonusActions?.[0].text).toContain("Disengage");
|
||||
expect(c.bonusActions?.[0].text).not.toContain("{@");
|
||||
expect(flatText(c.bonusActions?.[0])).toContain("Disengage");
|
||||
expect(flatText(c.bonusActions?.[0])).not.toContain("{@");
|
||||
});
|
||||
|
||||
it("normalizes a creature with legendary actions", () => {
|
||||
@@ -166,17 +181,20 @@ describe("normalizeBestiary", () => {
|
||||
expect(sc?.name).toBe("Spellcasting");
|
||||
expect(sc?.headerText).toContain("DC 15");
|
||||
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).toContainEqual({
|
||||
uses: 2,
|
||||
each: true,
|
||||
spells: ["Fireball"],
|
||||
spells: [{ name: "Fireball" }],
|
||||
});
|
||||
expect(sc?.daily).toContainEqual({
|
||||
uses: 1,
|
||||
each: false,
|
||||
spells: ["Dimension Door"],
|
||||
spells: [{ name: "Dimension Door" }],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -333,9 +351,9 @@ describe("normalizeBestiary", () => {
|
||||
|
||||
const creatures = normalizeBestiary(raw);
|
||||
const bite = creatures[0].actions?.[0];
|
||||
expect(bite?.text).toContain("Melee Weapon Attack:");
|
||||
expect(bite?.text).not.toContain("mw");
|
||||
expect(bite?.text).not.toContain("{@");
|
||||
expect(flatText(bite)).toContain("Melee Weapon Attack:");
|
||||
expect(flatText(bite)).not.toContain("mw");
|
||||
expect(flatText(bite)).not.toContain("{@");
|
||||
});
|
||||
|
||||
it("handles fly speed with hover condition", () => {
|
||||
@@ -368,4 +386,131 @@ describe("normalizeBestiary", () => {
|
||||
const creatures = normalizeBestiary(raw);
|
||||
expect(creatures[0].speed).toBe("10 ft., fly 90 ft. (hover)");
|
||||
});
|
||||
|
||||
it("renders list items with singular entry field (e.g. Confusing Burble d4 effects)", () => {
|
||||
const raw = {
|
||||
monster: [
|
||||
{
|
||||
name: "Jabberwock",
|
||||
source: "WBtW",
|
||||
size: ["H"],
|
||||
type: "dragon",
|
||||
ac: [18],
|
||||
hp: { average: 115, formula: "10d12 + 50" },
|
||||
speed: { walk: 30 },
|
||||
str: 22,
|
||||
dex: 15,
|
||||
con: 20,
|
||||
int: 8,
|
||||
wis: 14,
|
||||
cha: 16,
|
||||
passive: 12,
|
||||
cr: "13",
|
||||
trait: [
|
||||
{
|
||||
name: "Confusing Burble",
|
||||
entries: [
|
||||
"The jabberwock burbles unless {@condition incapacitated}. Roll a {@dice d4}:",
|
||||
{
|
||||
type: "list",
|
||||
style: "list-hang-notitle",
|
||||
items: [
|
||||
{
|
||||
type: "item",
|
||||
name: "1-2",
|
||||
entry: "The creature does nothing.",
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
name: "3",
|
||||
entry:
|
||||
"The creature uses all its movement to move in a random direction.",
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
name: "4",
|
||||
entry:
|
||||
"The creature makes one melee attack against a random creature.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const creatures = normalizeBestiary(raw);
|
||||
const trait = creatures[0].traits?.[0];
|
||||
expect(trait).toBeDefined();
|
||||
expect(trait?.name).toBe("Confusing Burble");
|
||||
expect(trait?.segments).toHaveLength(2);
|
||||
expect(trait?.segments[0]).toEqual({
|
||||
type: "text",
|
||||
value: expect.stringContaining("d4"),
|
||||
});
|
||||
expect(trait?.segments[1]).toEqual({
|
||||
type: "list",
|
||||
items: [
|
||||
{ label: "1-2", text: "The creature does nothing." },
|
||||
{
|
||||
label: "3",
|
||||
text: expect.stringContaining("random direction"),
|
||||
},
|
||||
{ label: "4", text: expect.stringContaining("melee attack") },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("renders table entries as structured list segments", () => {
|
||||
const raw = {
|
||||
monster: [
|
||||
{
|
||||
name: "Test Creature",
|
||||
source: "XMM",
|
||||
size: ["M"],
|
||||
type: "humanoid",
|
||||
ac: [12],
|
||||
hp: { average: 40, formula: "9d8" },
|
||||
speed: { walk: 30 },
|
||||
str: 10,
|
||||
dex: 10,
|
||||
con: 10,
|
||||
int: 10,
|
||||
wis: 10,
|
||||
cha: 10,
|
||||
passive: 10,
|
||||
cr: "1",
|
||||
trait: [
|
||||
{
|
||||
name: "Random Effect",
|
||||
entries: [
|
||||
"Roll on the table:",
|
||||
{
|
||||
type: "table",
|
||||
colLabels: ["d4", "Effect"],
|
||||
rows: [
|
||||
["1", "Nothing happens."],
|
||||
["2", "Something happens."],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const creatures = normalizeBestiary(raw);
|
||||
const trait = creatures[0].traits?.[0];
|
||||
expect(trait).toBeDefined();
|
||||
expect(trait?.segments[1]).toEqual({
|
||||
type: "list",
|
||||
items: [
|
||||
{ label: "1", text: "Nothing happens." },
|
||||
{ label: "2", text: "Something happens." },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,17 +46,17 @@ describe("bestiary-cache fallback (IndexedDB unavailable)", () => {
|
||||
|
||||
it("cacheSource falls back to in-memory store", async () => {
|
||||
const creatures = [makeCreature("mm:goblin", "Goblin")];
|
||||
await cacheSource("MM", "Monster Manual", creatures);
|
||||
await cacheSource("dnd", "MM", "Monster Manual", creatures);
|
||||
|
||||
expect(await isSourceCached("MM")).toBe(true);
|
||||
expect(await isSourceCached("dnd", "MM")).toBe(true);
|
||||
});
|
||||
|
||||
it("isSourceCached returns false for uncached source", async () => {
|
||||
expect(await isSourceCached("XGE")).toBe(false);
|
||||
expect(await isSourceCached("dnd", "XGE")).toBe(false);
|
||||
});
|
||||
|
||||
it("getCachedSources returns sources from in-memory store", async () => {
|
||||
await cacheSource("MM", "Monster Manual", [
|
||||
await cacheSource("dnd", "MM", "Monster Manual", [
|
||||
makeCreature("mm:goblin", "Goblin"),
|
||||
]);
|
||||
|
||||
@@ -68,7 +68,7 @@ describe("bestiary-cache fallback (IndexedDB unavailable)", () => {
|
||||
|
||||
it("loadAllCachedCreatures assembles creatures from in-memory store", async () => {
|
||||
const goblin = makeCreature("mm:goblin", "Goblin");
|
||||
await cacheSource("MM", "Monster Manual", [goblin]);
|
||||
await cacheSource("dnd", "MM", "Monster Manual", [goblin]);
|
||||
|
||||
const map = await loadAllCachedCreatures();
|
||||
expect(map.size).toBe(1);
|
||||
@@ -76,17 +76,17 @@ describe("bestiary-cache fallback (IndexedDB unavailable)", () => {
|
||||
});
|
||||
|
||||
it("clearSource removes a single source from in-memory store", async () => {
|
||||
await cacheSource("MM", "Monster Manual", []);
|
||||
await cacheSource("VGM", "Volo's Guide", []);
|
||||
await cacheSource("dnd", "MM", "Monster Manual", []);
|
||||
await cacheSource("dnd", "VGM", "Volo's Guide", []);
|
||||
|
||||
await clearSource("MM");
|
||||
await clearSource("dnd", "MM");
|
||||
|
||||
expect(await isSourceCached("MM")).toBe(false);
|
||||
expect(await isSourceCached("VGM")).toBe(true);
|
||||
expect(await isSourceCached("dnd", "MM")).toBe(false);
|
||||
expect(await isSourceCached("dnd", "VGM")).toBe(true);
|
||||
});
|
||||
|
||||
it("clearAll removes all data from in-memory store", async () => {
|
||||
await cacheSource("MM", "Monster Manual", []);
|
||||
await cacheSource("dnd", "MM", "Monster Manual", []);
|
||||
await clearAll();
|
||||
|
||||
const sources = await getCachedSources();
|
||||
|
||||
@@ -69,17 +69,17 @@ describe("bestiary-cache", () => {
|
||||
describe("cacheSource", () => {
|
||||
it("stores creatures and metadata", async () => {
|
||||
const creatures = [makeCreature("mm:goblin", "Goblin")];
|
||||
await cacheSource("MM", "Monster Manual", creatures);
|
||||
await cacheSource("dnd", "MM", "Monster Manual", creatures);
|
||||
|
||||
expect(fakeStore.has("MM")).toBe(true);
|
||||
const record = fakeStore.get("MM") as {
|
||||
expect(fakeStore.has("dnd:MM")).toBe(true);
|
||||
const record = fakeStore.get("dnd:MM") as {
|
||||
sourceCode: string;
|
||||
displayName: string;
|
||||
creatures: Creature[];
|
||||
creatureCount: number;
|
||||
cachedAt: number;
|
||||
};
|
||||
expect(record.sourceCode).toBe("MM");
|
||||
expect(record.sourceCode).toBe("dnd:MM");
|
||||
expect(record.displayName).toBe("Monster Manual");
|
||||
expect(record.creatures).toHaveLength(1);
|
||||
expect(record.creatureCount).toBe(1);
|
||||
@@ -89,12 +89,12 @@ describe("bestiary-cache", () => {
|
||||
|
||||
describe("isSourceCached", () => {
|
||||
it("returns false for uncached source", async () => {
|
||||
expect(await isSourceCached("XGE")).toBe(false);
|
||||
expect(await isSourceCached("dnd", "XGE")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true after caching", async () => {
|
||||
await cacheSource("MM", "Monster Manual", []);
|
||||
expect(await isSourceCached("MM")).toBe(true);
|
||||
await cacheSource("dnd", "MM", "Monster Manual", []);
|
||||
expect(await isSourceCached("dnd", "MM")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,11 +105,11 @@ describe("bestiary-cache", () => {
|
||||
});
|
||||
|
||||
it("returns source info with creature counts", async () => {
|
||||
await cacheSource("MM", "Monster Manual", [
|
||||
await cacheSource("dnd", "MM", "Monster Manual", [
|
||||
makeCreature("mm:goblin", "Goblin"),
|
||||
makeCreature("mm:orc", "Orc"),
|
||||
]);
|
||||
await cacheSource("VGM", "Volo's Guide", [
|
||||
await cacheSource("dnd", "VGM", "Volo's Guide", [
|
||||
makeCreature("vgm:flind", "Flind"),
|
||||
]);
|
||||
|
||||
@@ -137,8 +137,8 @@ describe("bestiary-cache", () => {
|
||||
const orc = makeCreature("mm:orc", "Orc");
|
||||
const flind = makeCreature("vgm:flind", "Flind");
|
||||
|
||||
await cacheSource("MM", "Monster Manual", [goblin, orc]);
|
||||
await cacheSource("VGM", "Volo's Guide", [flind]);
|
||||
await cacheSource("dnd", "MM", "Monster Manual", [goblin, orc]);
|
||||
await cacheSource("dnd", "VGM", "Volo's Guide", [flind]);
|
||||
|
||||
const map = await loadAllCachedCreatures();
|
||||
expect(map.size).toBe(3);
|
||||
@@ -150,20 +150,20 @@ describe("bestiary-cache", () => {
|
||||
|
||||
describe("clearSource", () => {
|
||||
it("removes a single source", async () => {
|
||||
await cacheSource("MM", "Monster Manual", []);
|
||||
await cacheSource("VGM", "Volo's Guide", []);
|
||||
await cacheSource("dnd", "MM", "Monster Manual", []);
|
||||
await cacheSource("dnd", "VGM", "Volo's Guide", []);
|
||||
|
||||
await clearSource("MM");
|
||||
await clearSource("dnd", "MM");
|
||||
|
||||
expect(await isSourceCached("MM")).toBe(false);
|
||||
expect(await isSourceCached("VGM")).toBe(true);
|
||||
expect(await isSourceCached("dnd", "MM")).toBe(false);
|
||||
expect(await isSourceCached("dnd", "VGM")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearAll", () => {
|
||||
it("removes all cached data", async () => {
|
||||
await cacheSource("MM", "Monster Manual", []);
|
||||
await cacheSource("VGM", "Volo's Guide", []);
|
||||
await cacheSource("dnd", "MM", "Monster Manual", []);
|
||||
await cacheSource("dnd", "VGM", "Volo's Guide", []);
|
||||
|
||||
await clearAll();
|
||||
|
||||
|
||||
@@ -49,10 +49,9 @@ describe("loadBestiaryIndex", () => {
|
||||
});
|
||||
|
||||
describe("getAllSourceCodes", () => {
|
||||
it("returns all keys from the index sources", () => {
|
||||
it("returns all index sources except bundled ones", () => {
|
||||
const codes = getAllSourceCodes();
|
||||
const index = loadBestiaryIndex();
|
||||
expect(codes).toEqual(Object.keys(index.sources));
|
||||
expect(codes).not.toContain("TGL");
|
||||
});
|
||||
|
||||
it("returns only strings", () => {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getBundledDndSources,
|
||||
loadBundledDndCreatures,
|
||||
loadBundledDndIndexEntries,
|
||||
} from "../dnd-bundled-adapter.js";
|
||||
|
||||
describe("dnd-bundled-adapter", () => {
|
||||
it("loads bundled creatures with a valid shape", () => {
|
||||
const creatures = loadBundledDndCreatures();
|
||||
const sources = getBundledDndSources();
|
||||
for (const c of creatures) {
|
||||
expect(sources.has(c.source)).toBe(true);
|
||||
expect(c.sourceDisplayName).toBe(sources.get(c.source));
|
||||
expect(c.id.startsWith(`${c.source.toLowerCase()}:`)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("derives source codes from the creature data", () => {
|
||||
const creatures = loadBundledDndCreatures();
|
||||
const sources = getBundledDndSources();
|
||||
const seen = new Set(creatures.map((c) => c.source));
|
||||
expect(sources.size).toBe(seen.size);
|
||||
for (const s of seen) {
|
||||
expect(sources.has(s)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("derives index entries that match the bundled creatures", () => {
|
||||
const creatures = loadBundledDndCreatures();
|
||||
const entries = loadBundledDndIndexEntries();
|
||||
expect(entries.length).toBe(creatures.length);
|
||||
const entryNames = new Set(entries.map((e) => e.name));
|
||||
for (const c of creatures) {
|
||||
expect(entryNames.has(c.name)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("abbreviates sizes to single-letter codes in index entries", () => {
|
||||
const entries = loadBundledDndIndexEntries();
|
||||
for (const e of entries) {
|
||||
expect(["T", "S", "M", "L", "H", "G"]).toContain(e.size);
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const PACK_DIR_PREFIX = /^pathfinder-monster-core\//;
|
||||
const JSON_EXTENSION = /\.json$/;
|
||||
|
||||
import {
|
||||
getAllPf2eSourceCodes,
|
||||
getCreaturePathsForSource,
|
||||
getDefaultPf2eFetchUrl,
|
||||
getPf2eSourceDisplayName,
|
||||
loadPf2eBestiaryIndex,
|
||||
} from "../pf2e-bestiary-index-adapter.js";
|
||||
|
||||
describe("loadPf2eBestiaryIndex", () => {
|
||||
it("returns an object with sources and creatures", () => {
|
||||
const index = loadPf2eBestiaryIndex();
|
||||
expect(index.sources).toBeDefined();
|
||||
expect(index.creatures).toBeDefined();
|
||||
expect(Array.isArray(index.creatures)).toBe(true);
|
||||
});
|
||||
|
||||
it("creatures have the expected PF2e shape", () => {
|
||||
const index = loadPf2eBestiaryIndex();
|
||||
expect(index.creatures.length).toBeGreaterThan(0);
|
||||
const first = index.creatures[0];
|
||||
expect(first).toHaveProperty("name");
|
||||
expect(first).toHaveProperty("source");
|
||||
expect(first).toHaveProperty("level");
|
||||
expect(first).toHaveProperty("ac");
|
||||
expect(first).toHaveProperty("hp");
|
||||
expect(first).toHaveProperty("perception");
|
||||
expect(first).toHaveProperty("size");
|
||||
expect(first).toHaveProperty("type");
|
||||
});
|
||||
|
||||
it("contains a substantial number of creatures", () => {
|
||||
const index = loadPf2eBestiaryIndex();
|
||||
expect(index.creatures.length).toBeGreaterThan(2500);
|
||||
});
|
||||
|
||||
it("creatures have size and type populated", () => {
|
||||
const index = loadPf2eBestiaryIndex();
|
||||
const withSize = index.creatures.filter((c) => c.size !== "");
|
||||
const withType = index.creatures.filter((c) => c.type !== "");
|
||||
expect(withSize.length).toBeGreaterThan(index.creatures.length * 0.9);
|
||||
expect(withType.length).toBeGreaterThan(index.creatures.length * 0.8);
|
||||
});
|
||||
|
||||
it("returns the same cached instance on subsequent calls", () => {
|
||||
const a = loadPf2eBestiaryIndex();
|
||||
const b = loadPf2eBestiaryIndex();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllPf2eSourceCodes", () => {
|
||||
it("returns all keys from the index sources", () => {
|
||||
const codes = getAllPf2eSourceCodes();
|
||||
const index = loadPf2eBestiaryIndex();
|
||||
expect(codes).toEqual(Object.keys(index.sources));
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDefaultPf2eFetchUrl", () => {
|
||||
it("returns Foundry VTT PF2e base URL", () => {
|
||||
const url = getDefaultPf2eFetchUrl("pathfinder-monster-core");
|
||||
expect(url).toBe(
|
||||
"https://raw.githubusercontent.com/foundryvtt/pf2e/v13-dev/packs/pf2e/",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes custom base URL with trailing slash", () => {
|
||||
const url = getDefaultPf2eFetchUrl(
|
||||
"pathfinder-monster-core",
|
||||
"https://example.com/pf2e",
|
||||
);
|
||||
expect(url).toBe("https://example.com/pf2e/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPf2eSourceDisplayName", () => {
|
||||
it("returns display name for a known source", () => {
|
||||
const name = getPf2eSourceDisplayName("pathfinder-monster-core");
|
||||
expect(name).toBe("Monster Core");
|
||||
});
|
||||
|
||||
it("falls back to source code for unknown source", () => {
|
||||
expect(getPf2eSourceDisplayName("UNKNOWN")).toBe("UNKNOWN");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCreaturePathsForSource", () => {
|
||||
it("returns file paths for a known source", () => {
|
||||
const paths = getCreaturePathsForSource("pathfinder-monster-core");
|
||||
expect(paths.length).toBeGreaterThan(100);
|
||||
expect(paths[0]).toMatch(PACK_DIR_PREFIX);
|
||||
expect(paths[0]).toMatch(JSON_EXTENSION);
|
||||
});
|
||||
|
||||
it("returns empty array for unknown source", () => {
|
||||
expect(getCreaturePathsForSource("nonexistent")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { stripFoundryTags } from "../strip-foundry-tags.js";
|
||||
|
||||
describe("stripFoundryTags", () => {
|
||||
describe("@Damage tags", () => {
|
||||
it("formats damage with type bracket", () => {
|
||||
expect(stripFoundryTags("@Damage[3d6+10[fire]]")).toBe("3d6+10 fire");
|
||||
});
|
||||
|
||||
it("prefers display text when present", () => {
|
||||
expect(
|
||||
stripFoundryTags("@Damage[3d6+10[fire]]{3d6+10 fire damage}"),
|
||||
).toBe("3d6+10 fire damage");
|
||||
});
|
||||
|
||||
it("handles multiple damage types", () => {
|
||||
expect(
|
||||
stripFoundryTags("@Damage[2d8+5[slashing]] plus @Damage[1d6[fire]]"),
|
||||
).toBe("2d8+5 slashing plus 1d6 fire");
|
||||
});
|
||||
});
|
||||
|
||||
describe("@Check tags", () => {
|
||||
it("formats basic saving throw", () => {
|
||||
expect(stripFoundryTags("@Check[reflex|dc:33|basic]")).toBe(
|
||||
"DC 33 basic Reflex",
|
||||
);
|
||||
});
|
||||
|
||||
it("formats non-basic check", () => {
|
||||
expect(stripFoundryTags("@Check[athletics|dc:25]")).toBe(
|
||||
"DC 25 Athletics",
|
||||
);
|
||||
});
|
||||
|
||||
it("formats check without DC", () => {
|
||||
expect(stripFoundryTags("@Check[fortitude]")).toBe("Fortitude");
|
||||
});
|
||||
});
|
||||
|
||||
describe("@UUID tags", () => {
|
||||
it("extracts display text", () => {
|
||||
expect(
|
||||
stripFoundryTags(
|
||||
"@UUID[Compendium.pf2e.conditionitems.Item.Grabbed]{Grabbed}",
|
||||
),
|
||||
).toBe("Grabbed");
|
||||
});
|
||||
|
||||
it("extracts last segment when no display text", () => {
|
||||
expect(
|
||||
stripFoundryTags("@UUID[Compendium.pf2e.conditionitems.Item.Grabbed]"),
|
||||
).toBe("Grabbed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("@Template tags", () => {
|
||||
it("formats cone template", () => {
|
||||
expect(stripFoundryTags("@Template[cone|distance:40]")).toBe(
|
||||
"40-foot cone",
|
||||
);
|
||||
});
|
||||
|
||||
it("formats emanation template", () => {
|
||||
expect(stripFoundryTags("@Template[emanation|distance:10]")).toBe(
|
||||
"10-foot emanation",
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers display text", () => {
|
||||
expect(
|
||||
stripFoundryTags("@Template[cone|distance:40]{40-foot cone}"),
|
||||
).toBe("40-foot cone");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unknown @Tag patterns", () => {
|
||||
it("uses display text for unknown tags", () => {
|
||||
expect(stripFoundryTags("@Localize[some.key]{Some Text}")).toBe(
|
||||
"Some Text",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips unknown tags without display text", () => {
|
||||
expect(stripFoundryTags("@Localize[some.key]")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTML stripping", () => {
|
||||
it("strips paragraph tags", () => {
|
||||
expect(stripFoundryTags("<p>text</p>")).toBe("text");
|
||||
});
|
||||
|
||||
it("converts br to newline", () => {
|
||||
expect(stripFoundryTags("line1<br />line2")).toBe("line1\nline2");
|
||||
});
|
||||
|
||||
it("converts hr to newline", () => {
|
||||
expect(stripFoundryTags("before<hr />after")).toBe("before\nafter");
|
||||
});
|
||||
|
||||
it("preserves strong and em tags", () => {
|
||||
expect(stripFoundryTags("<strong>bold</strong> <em>italic</em>")).toBe(
|
||||
"<strong>bold</strong> <em>italic</em>",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves list tags", () => {
|
||||
expect(stripFoundryTags("<ul><li>first</li><li>second</li></ul>")).toBe(
|
||||
"<ul><li>first</li><li>second</li></ul>",
|
||||
);
|
||||
});
|
||||
|
||||
it("converts p-to-p transitions to newlines", () => {
|
||||
expect(stripFoundryTags("<p>first</p><p>second</p>")).toBe(
|
||||
"first\nsecond",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips action-glyph spans", () => {
|
||||
expect(
|
||||
stripFoundryTags('<span class="action-glyph">1</span> Strike'),
|
||||
).toBe("Strike");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTML entities", () => {
|
||||
it("decodes &", () => {
|
||||
expect(stripFoundryTags("fire & ice")).toBe("fire & ice");
|
||||
});
|
||||
|
||||
it("decodes < and >", () => {
|
||||
expect(stripFoundryTags("<tag>")).toBe("<tag>");
|
||||
});
|
||||
|
||||
it("decodes "", () => {
|
||||
expect(stripFoundryTags(""hello"")).toBe('"hello"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("whitespace handling", () => {
|
||||
it("collapses multiple spaces", () => {
|
||||
expect(stripFoundryTags("a b c")).toBe("a b c");
|
||||
});
|
||||
|
||||
it("collapses multiple blank lines", () => {
|
||||
expect(stripFoundryTags("a\n\n\nb")).toBe("a\nb");
|
||||
});
|
||||
|
||||
it("trims leading and trailing whitespace", () => {
|
||||
expect(stripFoundryTags(" hello ")).toBe("hello");
|
||||
});
|
||||
});
|
||||
|
||||
describe("combined/edge cases", () => {
|
||||
it("handles enrichment tags inside HTML", () => {
|
||||
expect(
|
||||
stripFoundryTags(
|
||||
"<p>Deal @Damage[2d6[fire]] damage, @Check[reflex|dc:20|basic] save.</p>",
|
||||
),
|
||||
).toBe("Deal 2d6 fire damage, DC 20 basic Reflex save.");
|
||||
});
|
||||
|
||||
it("handles empty string", () => {
|
||||
expect(stripFoundryTags("")).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -138,12 +138,20 @@ describe("stripTags", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("handles nested tags gracefully", () => {
|
||||
it("handles sibling tags in the same string", () => {
|
||||
expect(
|
||||
stripTags("The spell {@spell Fireball|XPHB} deals {@damage 8d6}."),
|
||||
).toBe("The spell Fireball deals 8d6.");
|
||||
});
|
||||
|
||||
it("handles nested tags (outer wrapping inner)", () => {
|
||||
expect(
|
||||
stripTags(
|
||||
"{@b Arcane Innate Spells DC 24; 3rd {@spell fireball}, {@spell slow}}",
|
||||
),
|
||||
).toBe("Arcane Innate Spells DC 24; 3rd fireball, slow");
|
||||
});
|
||||
|
||||
it("handles text with no tags", () => {
|
||||
expect(stripTags("Just plain text.")).toBe("Just plain text.");
|
||||
});
|
||||
|
||||
@@ -4,7 +4,10 @@ import type {
|
||||
DailySpells,
|
||||
LegendaryBlock,
|
||||
SpellcastingBlock,
|
||||
SpellReference,
|
||||
TraitBlock,
|
||||
TraitListItem,
|
||||
TraitSegment,
|
||||
} from "@initiative/domain";
|
||||
import { creatureId, proficiencyBonus } from "@initiative/domain";
|
||||
import { stripTags } from "./strip-tags.js";
|
||||
@@ -63,11 +66,18 @@ interface RawEntryObject {
|
||||
type: string;
|
||||
items?: (
|
||||
| string
|
||||
| { type: string; name?: string; entries?: (string | RawEntryObject)[] }
|
||||
| {
|
||||
type: string;
|
||||
name?: string;
|
||||
entry?: string;
|
||||
entries?: (string | RawEntryObject)[];
|
||||
}
|
||||
)[];
|
||||
style?: string;
|
||||
name?: string;
|
||||
entries?: (string | RawEntryObject)[];
|
||||
colLabels?: string[];
|
||||
rows?: (string | RawEntryObject)[][];
|
||||
}
|
||||
|
||||
interface RawSpellcasting {
|
||||
@@ -257,23 +267,34 @@ function formatConditionImmunities(
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function renderListItem(item: string | RawEntryObject): string | undefined {
|
||||
function toListItem(
|
||||
item:
|
||||
| string
|
||||
| {
|
||||
type: string;
|
||||
name?: string;
|
||||
entry?: string;
|
||||
entries?: (string | RawEntryObject)[];
|
||||
},
|
||||
): TraitListItem | undefined {
|
||||
if (typeof item === "string") {
|
||||
return `• ${stripTags(item)}`;
|
||||
return { text: stripTags(item) };
|
||||
}
|
||||
if (item.name && item.entries) {
|
||||
return `• ${stripTags(item.name)}: ${renderEntries(item.entries)}`;
|
||||
return { label: stripTags(item.name), text: renderEntries(item.entries) };
|
||||
}
|
||||
if (item.name && item.entry) {
|
||||
return { label: stripTags(item.name), text: stripTags(item.entry) };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function renderEntryObject(entry: RawEntryObject, parts: string[]): void {
|
||||
if (entry.type === "list") {
|
||||
for (const item of entry.items ?? []) {
|
||||
const rendered = renderListItem(item);
|
||||
if (rendered) parts.push(rendered);
|
||||
}
|
||||
} else if (entry.type === "item" && entry.name && entry.entries) {
|
||||
if (entry.type === "list" || entry.type === "table") {
|
||||
// Handled structurally in segmentizeEntries
|
||||
return;
|
||||
}
|
||||
if (entry.type === "item" && entry.name && entry.entries) {
|
||||
parts.push(`${stripTags(entry.name)}: ${renderEntries(entry.entries)}`);
|
||||
} else if (entry.entries) {
|
||||
parts.push(renderEntries(entry.entries));
|
||||
@@ -292,11 +313,67 @@ function renderEntries(entries: (string | RawEntryObject)[]): string {
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function tableRowToListItem(row: (string | RawEntryObject)[]): TraitListItem {
|
||||
return {
|
||||
label: typeof row[0] === "string" ? stripTags(row[0]) : undefined,
|
||||
text: row
|
||||
.slice(1)
|
||||
.map((cell) =>
|
||||
typeof cell === "string" ? stripTags(cell) : renderEntries([cell]),
|
||||
)
|
||||
.join(" "),
|
||||
};
|
||||
}
|
||||
|
||||
function entryToListSegment(entry: RawEntryObject): TraitSegment | undefined {
|
||||
if (entry.type === "list") {
|
||||
const items = (entry.items ?? [])
|
||||
.map(toListItem)
|
||||
.filter((i): i is TraitListItem => i !== undefined);
|
||||
return items.length > 0 ? { type: "list", items } : undefined;
|
||||
}
|
||||
if (entry.type === "table" && entry.rows) {
|
||||
const items = entry.rows.map(tableRowToListItem);
|
||||
return items.length > 0 ? { type: "list", items } : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function segmentizeEntries(
|
||||
entries: (string | RawEntryObject)[],
|
||||
): TraitSegment[] {
|
||||
const segments: TraitSegment[] = [];
|
||||
const textParts: string[] = [];
|
||||
|
||||
const flushText = () => {
|
||||
if (textParts.length > 0) {
|
||||
segments.push({ type: "text", value: textParts.join(" ") });
|
||||
textParts.length = 0;
|
||||
}
|
||||
};
|
||||
|
||||
for (const entry of entries) {
|
||||
if (typeof entry === "string") {
|
||||
textParts.push(stripTags(entry));
|
||||
continue;
|
||||
}
|
||||
const listSeg = entryToListSegment(entry);
|
||||
if (listSeg) {
|
||||
flushText();
|
||||
segments.push(listSeg);
|
||||
} else {
|
||||
renderEntryObject(entry, textParts);
|
||||
}
|
||||
}
|
||||
flushText();
|
||||
return segments;
|
||||
}
|
||||
|
||||
function normalizeTraits(raw?: RawEntry[]): TraitBlock[] | undefined {
|
||||
if (!raw || raw.length === 0) return undefined;
|
||||
return raw.map((t) => ({
|
||||
name: stripTags(t.name),
|
||||
text: renderEntries(t.entries),
|
||||
segments: segmentizeEntries(t.entries),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -309,7 +386,7 @@ function normalizeSpellcasting(
|
||||
const block: {
|
||||
name: string;
|
||||
headerText: string;
|
||||
atWill?: string[];
|
||||
atWill?: SpellReference[];
|
||||
daily?: DailySpells[];
|
||||
restLong?: DailySpells[];
|
||||
} = {
|
||||
@@ -320,7 +397,7 @@ function normalizeSpellcasting(
|
||||
const hidden = new Set(sc.hidden ?? []);
|
||||
|
||||
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) {
|
||||
@@ -342,7 +419,7 @@ function parseDailyMap(map: Record<string, string[]>): DailySpells[] {
|
||||
return {
|
||||
uses,
|
||||
each,
|
||||
spells: spells.map((s) => stripTags(s)),
|
||||
spells: spells.map((s) => ({ name: stripTags(s) })),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -361,7 +438,7 @@ function normalizeLegendary(
|
||||
preamble,
|
||||
entries: raw.map((e) => ({
|
||||
name: stripTags(e.name),
|
||||
text: renderEntries(e.entries),
|
||||
segments: segmentizeEntries(e.entries),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
import type { Creature, CreatureId } from "@initiative/domain";
|
||||
import type { AnyCreature, CreatureId } from "@initiative/domain";
|
||||
import { type IDBPDatabase, openDB } from "idb";
|
||||
|
||||
const DB_NAME = "initiative-bestiary";
|
||||
const STORE_NAME = "sources";
|
||||
const DB_VERSION = 2;
|
||||
// v8 (2026-04-10): Attack effects, ability frequency, perception details added to PF2e creatures
|
||||
const DB_VERSION = 8;
|
||||
|
||||
interface CachedSourceInfo {
|
||||
readonly sourceCode: string;
|
||||
readonly displayName: string;
|
||||
readonly creatureCount: number;
|
||||
readonly cachedAt: number;
|
||||
readonly system?: string;
|
||||
}
|
||||
|
||||
interface CachedSourceRecord {
|
||||
sourceCode: string;
|
||||
displayName: string;
|
||||
creatures: Creature[];
|
||||
creatures: AnyCreature[];
|
||||
cachedAt: number;
|
||||
creatureCount: number;
|
||||
system?: string;
|
||||
}
|
||||
|
||||
let db: IDBPDatabase | null = null;
|
||||
@@ -26,6 +29,10 @@ let dbFailed = false;
|
||||
// In-memory fallback when IndexedDB is unavailable
|
||||
const memoryStore = new Map<string, CachedSourceRecord>();
|
||||
|
||||
function scopedKey(system: string, sourceCode: string): string {
|
||||
return `${system}:${sourceCode}`;
|
||||
}
|
||||
|
||||
async function getDb(): Promise<IDBPDatabase | null> {
|
||||
if (db) return db;
|
||||
if (dbFailed) return null;
|
||||
@@ -38,8 +45,11 @@ async function getDb(): Promise<IDBPDatabase | null> {
|
||||
keyPath: "sourceCode",
|
||||
});
|
||||
}
|
||||
if (oldVersion < 2 && database.objectStoreNames.contains(STORE_NAME)) {
|
||||
// Clear cached creatures to pick up improved tag processing
|
||||
if (
|
||||
oldVersion < DB_VERSION &&
|
||||
database.objectStoreNames.contains(STORE_NAME)
|
||||
) {
|
||||
// Clear cached creatures so they get re-normalized with latest rendering
|
||||
void transaction.objectStore(STORE_NAME).clear();
|
||||
}
|
||||
},
|
||||
@@ -55,60 +65,77 @@ async function getDb(): Promise<IDBPDatabase | null> {
|
||||
}
|
||||
|
||||
export async function cacheSource(
|
||||
system: string,
|
||||
sourceCode: string,
|
||||
displayName: string,
|
||||
creatures: Creature[],
|
||||
creatures: AnyCreature[],
|
||||
): Promise<void> {
|
||||
const key = scopedKey(system, sourceCode);
|
||||
const record: CachedSourceRecord = {
|
||||
sourceCode,
|
||||
sourceCode: key,
|
||||
displayName,
|
||||
creatures,
|
||||
cachedAt: Date.now(),
|
||||
creatureCount: creatures.length,
|
||||
system,
|
||||
};
|
||||
|
||||
const database = await getDb();
|
||||
if (database) {
|
||||
await database.put(STORE_NAME, record);
|
||||
} else {
|
||||
memoryStore.set(sourceCode, record);
|
||||
memoryStore.set(key, record);
|
||||
}
|
||||
}
|
||||
|
||||
export async function isSourceCached(sourceCode: string): Promise<boolean> {
|
||||
export async function isSourceCached(
|
||||
system: string,
|
||||
sourceCode: string,
|
||||
): Promise<boolean> {
|
||||
const key = scopedKey(system, sourceCode);
|
||||
const database = await getDb();
|
||||
if (database) {
|
||||
const record = await database.get(STORE_NAME, sourceCode);
|
||||
const record = await database.get(STORE_NAME, key);
|
||||
return record !== undefined;
|
||||
}
|
||||
return memoryStore.has(sourceCode);
|
||||
return memoryStore.has(key);
|
||||
}
|
||||
|
||||
export async function getCachedSources(): Promise<CachedSourceInfo[]> {
|
||||
export async function getCachedSources(
|
||||
system?: string,
|
||||
): Promise<CachedSourceInfo[]> {
|
||||
const database = await getDb();
|
||||
let records: CachedSourceRecord[];
|
||||
if (database) {
|
||||
const all: CachedSourceRecord[] = await database.getAll(STORE_NAME);
|
||||
return all.map((r) => ({
|
||||
sourceCode: r.sourceCode,
|
||||
displayName: r.displayName,
|
||||
creatureCount: r.creatureCount,
|
||||
cachedAt: r.cachedAt,
|
||||
}));
|
||||
records = await database.getAll(STORE_NAME);
|
||||
} else {
|
||||
records = [...memoryStore.values()];
|
||||
}
|
||||
return [...memoryStore.values()].map((r) => ({
|
||||
sourceCode: r.sourceCode,
|
||||
|
||||
const filtered = system
|
||||
? records.filter((r) => r.system === system)
|
||||
: records;
|
||||
return filtered.map((r) => ({
|
||||
sourceCode: r.system
|
||||
? r.sourceCode.slice(r.system.length + 1)
|
||||
: r.sourceCode,
|
||||
displayName: r.displayName,
|
||||
creatureCount: r.creatureCount,
|
||||
cachedAt: r.cachedAt,
|
||||
system: r.system,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function clearSource(sourceCode: string): Promise<void> {
|
||||
export async function clearSource(
|
||||
system: string,
|
||||
sourceCode: string,
|
||||
): Promise<void> {
|
||||
const key = scopedKey(system, sourceCode);
|
||||
const database = await getDb();
|
||||
if (database) {
|
||||
await database.delete(STORE_NAME, sourceCode);
|
||||
await database.delete(STORE_NAME, key);
|
||||
} else {
|
||||
memoryStore.delete(sourceCode);
|
||||
memoryStore.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,9 +149,9 @@ export async function clearAll(): Promise<void> {
|
||||
}
|
||||
|
||||
export async function loadAllCachedCreatures(): Promise<
|
||||
Map<CreatureId, Creature>
|
||||
Map<CreatureId, AnyCreature>
|
||||
> {
|
||||
const map = new Map<CreatureId, Creature>();
|
||||
const map = new Map<CreatureId, AnyCreature>();
|
||||
const database = await getDb();
|
||||
|
||||
let records: CachedSourceRecord[];
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { BestiaryIndex, BestiaryIndexEntry } from "@initiative/domain";
|
||||
|
||||
import rawIndex from "../../../../data/bestiary/index.json";
|
||||
import {
|
||||
getBundledDndSources,
|
||||
loadBundledDndIndexEntries,
|
||||
} from "./dnd-bundled-adapter.js";
|
||||
|
||||
interface CompactCreature {
|
||||
readonly n: string;
|
||||
@@ -55,23 +59,32 @@ export function loadBestiaryIndex(): BestiaryIndex {
|
||||
if (cachedIndex) return cachedIndex;
|
||||
|
||||
const compact = rawIndex as unknown as CompactIndex;
|
||||
const sources = Object.fromEntries(
|
||||
const sources: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(compact.sources).filter(
|
||||
([code]) => !EXCLUDED_SOURCES.has(code),
|
||||
),
|
||||
);
|
||||
for (const [code, name] of getBundledDndSources()) {
|
||||
sources[code] = name;
|
||||
}
|
||||
cachedIndex = {
|
||||
sources,
|
||||
creatures: compact.creatures
|
||||
.filter((c) => !EXCLUDED_SOURCES.has(c.s))
|
||||
.map(mapCreature),
|
||||
creatures: [
|
||||
...compact.creatures
|
||||
.filter((c) => !EXCLUDED_SOURCES.has(c.s))
|
||||
.map(mapCreature),
|
||||
...loadBundledDndIndexEntries(),
|
||||
],
|
||||
};
|
||||
return cachedIndex;
|
||||
}
|
||||
|
||||
export function getAllSourceCodes(): string[] {
|
||||
const index = loadBestiaryIndex();
|
||||
return Object.keys(index.sources).filter((c) => !EXCLUDED_SOURCES.has(c));
|
||||
const bundled = getBundledDndSources();
|
||||
return Object.keys(index.sources).filter(
|
||||
(c) => !EXCLUDED_SOURCES.has(c) && !bundled.has(c),
|
||||
);
|
||||
}
|
||||
|
||||
function sourceCodeToFilename(sourceCode: string): string {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { BestiaryIndexEntry, Creature } from "@initiative/domain";
|
||||
import { creatureId } from "@initiative/domain";
|
||||
|
||||
import rawBundled from "../../../../data/bestiary/dnd-bundled.json";
|
||||
|
||||
type RawBundledCreature = Omit<Creature, "id"> & { id: string };
|
||||
|
||||
const SIZE_TO_CODE: Record<string, string> = {
|
||||
Tiny: "T",
|
||||
Small: "S",
|
||||
Medium: "M",
|
||||
Large: "L",
|
||||
Huge: "H",
|
||||
Gargantuan: "G",
|
||||
};
|
||||
|
||||
/** Full normalized stat blocks for bundled D&D creatures. */
|
||||
export function loadBundledDndCreatures(): Creature[] {
|
||||
return (rawBundled as RawBundledCreature[]).map((c) => ({
|
||||
...c,
|
||||
id: creatureId(c.id),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Index entries derived from the bundled creatures, in the compact shape
|
||||
* used by the search index. */
|
||||
export function loadBundledDndIndexEntries(): BestiaryIndexEntry[] {
|
||||
return (rawBundled as RawBundledCreature[]).map((c) => ({
|
||||
name: c.name,
|
||||
source: c.source,
|
||||
ac: c.ac,
|
||||
hp: c.hp.average,
|
||||
dex: c.abilities.dex,
|
||||
cr: c.cr,
|
||||
initiativeProficiency: c.initiativeProficiency,
|
||||
size: SIZE_TO_CODE[c.size.split(" ")[0]] ?? "M",
|
||||
type: c.type.split(" ")[0].toLowerCase(),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Source codes → display names, derived from the bundled creatures' own
|
||||
* `source` and `sourceDisplayName` fields. Adding a new book just means
|
||||
* appending creatures with the right `source` field to dnd-bundled.json;
|
||||
* no code change is required here. */
|
||||
export function getBundledDndSources(): ReadonlyMap<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
for (const c of rawBundled as RawBundledCreature[]) {
|
||||
if (!map.has(c.source)) {
|
||||
map.set(c.source, c.sourceDisplayName);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -0,0 +1,774 @@
|
||||
import type {
|
||||
CreatureId,
|
||||
EquipmentItem,
|
||||
Pf2eCreature,
|
||||
SpellcastingBlock,
|
||||
SpellReference,
|
||||
TraitBlock,
|
||||
} from "@initiative/domain";
|
||||
import { creatureId } from "@initiative/domain";
|
||||
import { stripFoundryTags } from "./strip-foundry-tags.js";
|
||||
|
||||
// -- Raw Foundry VTT types (minimal, for parsing) --
|
||||
|
||||
interface RawFoundryCreature {
|
||||
_id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
system: {
|
||||
abilities: Record<string, { mod: number }>;
|
||||
attributes: {
|
||||
ac: { value: number; details?: string };
|
||||
hp: { max: number; details?: string };
|
||||
speed: {
|
||||
value: number;
|
||||
otherSpeeds?: { type: string; value: number }[];
|
||||
details?: string;
|
||||
};
|
||||
immunities?: { type: string; exceptions?: string[] }[];
|
||||
resistances?: { type: string; value: number; exceptions?: string[] }[];
|
||||
weaknesses?: { type: string; value: number }[];
|
||||
allSaves?: { value: string };
|
||||
};
|
||||
details: {
|
||||
level: { value: number };
|
||||
languages: { value?: string[]; details?: string };
|
||||
publication: { license: string; remaster: boolean; title: string };
|
||||
};
|
||||
perception: {
|
||||
mod: number;
|
||||
details?: string;
|
||||
senses?: { type: string; acuity?: string; range?: number }[];
|
||||
};
|
||||
saves: {
|
||||
fortitude: { value: number; saveDetail?: string };
|
||||
reflex: { value: number; saveDetail?: string };
|
||||
will: { value: number; saveDetail?: string };
|
||||
};
|
||||
skills: Record<string, { base: number; note?: string }>;
|
||||
traits: { rarity: string; size: { value: string }; value: string[] };
|
||||
};
|
||||
items: RawFoundryItem[];
|
||||
}
|
||||
|
||||
interface RawFoundryItem {
|
||||
_id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
system: Record<string, unknown>;
|
||||
sort?: number;
|
||||
}
|
||||
|
||||
interface MeleeSystem {
|
||||
bonus?: { value: number };
|
||||
damageRolls?: Record<string, { damage: string; damageType: string }>;
|
||||
traits?: { value: string[] };
|
||||
attackEffects?: { value: string[] };
|
||||
}
|
||||
|
||||
interface ActionSystem {
|
||||
category?: string;
|
||||
actionType?: { value: string };
|
||||
actions?: { value: number | null };
|
||||
traits?: { value: string[] };
|
||||
description?: { value: string };
|
||||
frequency?: { max: number; per: string };
|
||||
}
|
||||
|
||||
interface SpellcastingEntrySystem {
|
||||
tradition?: { value: string };
|
||||
prepared?: { value: string };
|
||||
spelldc?: { dc: number; value?: number };
|
||||
}
|
||||
|
||||
interface SpellSystem {
|
||||
slug?: string;
|
||||
location?: {
|
||||
value: string;
|
||||
heightenedLevel?: 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 };
|
||||
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> = {
|
||||
tiny: "tiny",
|
||||
sm: "small",
|
||||
med: "medium",
|
||||
lg: "large",
|
||||
huge: "huge",
|
||||
grg: "gargantuan",
|
||||
};
|
||||
|
||||
// -- Helpers --
|
||||
|
||||
function capitalize(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
function makeCreatureId(source: string, name: string): CreatureId {
|
||||
const slug = name
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||
.replaceAll(/(^-|-$)/g, "");
|
||||
return creatureId(`${source}:${slug}`);
|
||||
}
|
||||
|
||||
const NUMERIC_SLUG = /^(.+)-(\d+)$/;
|
||||
const LETTER_SLUG = /^(.+)-([a-z])$/;
|
||||
|
||||
/** Format rules for traits with a numeric suffix: "reach-10" → "reach 10 feet" */
|
||||
const NUMERIC_TRAIT_FORMATS: Record<string, (n: string) => string> = {
|
||||
reach: (n) => `reach ${n} feet`,
|
||||
range: (n) => `range ${n} feet`,
|
||||
"range-increment": (n) => `range increment ${n} feet`,
|
||||
versatile: (n) => `versatile ${n}`,
|
||||
deadly: (n) => `deadly d${n}`,
|
||||
fatal: (n) => `fatal d${n}`,
|
||||
"fatal-aim": (n) => `fatal aim d${n}`,
|
||||
reload: (n) => `reload ${n}`,
|
||||
};
|
||||
|
||||
/** Format rules for traits with a letter suffix: "versatile-p" → "versatile P" */
|
||||
const LETTER_TRAIT_FORMATS: Record<string, (l: string) => string> = {
|
||||
versatile: (l) => `versatile ${l.toUpperCase()}`,
|
||||
deadly: (l) => `deadly d${l}`,
|
||||
};
|
||||
|
||||
/** Expand slugified trait names: "reach-10" → "reach 10 feet" */
|
||||
function formatTrait(slug: string): string {
|
||||
const numMatch = NUMERIC_SLUG.exec(slug);
|
||||
if (numMatch) {
|
||||
const [, base, num] = numMatch;
|
||||
const fmt = NUMERIC_TRAIT_FORMATS[base];
|
||||
return fmt ? fmt(num) : `${base} ${num}`;
|
||||
}
|
||||
const letterMatch = LETTER_SLUG.exec(slug);
|
||||
if (letterMatch) {
|
||||
const [, base, letter] = letterMatch;
|
||||
const fmt = LETTER_TRAIT_FORMATS[base];
|
||||
if (fmt) return fmt(letter);
|
||||
}
|
||||
return slug.replaceAll("-", " ");
|
||||
}
|
||||
|
||||
// -- Formatting --
|
||||
|
||||
function formatSenses(
|
||||
senses: { type: string; acuity?: string; range?: number }[] | undefined,
|
||||
): string | undefined {
|
||||
if (!senses || senses.length === 0) return undefined;
|
||||
return senses
|
||||
.map((s) => {
|
||||
const parts = [capitalize(s.type.replaceAll("-", " "))];
|
||||
if (s.acuity && s.acuity !== "precise") {
|
||||
parts.push(`(${s.acuity})`);
|
||||
}
|
||||
if (s.range != null) parts.push(`${s.range} feet`);
|
||||
return parts.join(" ");
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function formatLanguages(
|
||||
languages: { value?: string[]; details?: string } | undefined,
|
||||
): string | undefined {
|
||||
if (!languages?.value || languages.value.length === 0) return undefined;
|
||||
const list = languages.value.map(capitalize).join(", ");
|
||||
return languages.details ? `${list} (${languages.details})` : list;
|
||||
}
|
||||
|
||||
function formatSkills(
|
||||
skills: Record<string, { base: number; note?: string }> | undefined,
|
||||
): string | undefined {
|
||||
if (!skills) return undefined;
|
||||
const entries = Object.entries(skills);
|
||||
if (entries.length === 0) return undefined;
|
||||
return entries
|
||||
.map(([name, val]) => {
|
||||
const label = capitalize(name.replaceAll("-", " "));
|
||||
return `${label} +${val.base}`;
|
||||
})
|
||||
.sort()
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function formatImmunities(
|
||||
immunities: { type: string; exceptions?: string[] }[] | undefined,
|
||||
): string | undefined {
|
||||
if (!immunities || immunities.length === 0) return undefined;
|
||||
return immunities
|
||||
.map((i) => {
|
||||
const base = capitalize(i.type.replaceAll("-", " "));
|
||||
if (i.exceptions && i.exceptions.length > 0) {
|
||||
return `${base} (except ${i.exceptions.map((e) => capitalize(e.replaceAll("-", " "))).join(", ")})`;
|
||||
}
|
||||
return base;
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function formatResistances(
|
||||
resistances:
|
||||
| { type: string; value: number; exceptions?: string[] }[]
|
||||
| undefined,
|
||||
): string | undefined {
|
||||
if (!resistances || resistances.length === 0) return undefined;
|
||||
return resistances
|
||||
.map((r) => {
|
||||
const base = `${capitalize(r.type.replaceAll("-", " "))} ${r.value}`;
|
||||
if (r.exceptions && r.exceptions.length > 0) {
|
||||
return `${base} (except ${r.exceptions.map((e) => capitalize(e.replaceAll("-", " "))).join(", ")})`;
|
||||
}
|
||||
return base;
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function formatWeaknesses(
|
||||
weaknesses: { type: string; value: number }[] | undefined,
|
||||
): string | undefined {
|
||||
if (!weaknesses || weaknesses.length === 0) return undefined;
|
||||
return weaknesses
|
||||
.map((w) => `${capitalize(w.type.replaceAll("-", " "))} ${w.value}`)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function formatSpeed(speed: {
|
||||
value: number;
|
||||
otherSpeeds?: { type: string; value: number }[];
|
||||
details?: string;
|
||||
}): string {
|
||||
const parts = [`${speed.value} feet`];
|
||||
if (speed.otherSpeeds) {
|
||||
for (const s of speed.otherSpeeds) {
|
||||
parts.push(`${capitalize(s.type)} ${s.value} feet`);
|
||||
}
|
||||
}
|
||||
const base = parts.join(", ");
|
||||
return speed.details ? `${base} (${speed.details})` : base;
|
||||
}
|
||||
|
||||
// -- Attack normalization --
|
||||
|
||||
/** 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 bonus = sys.bonus?.value ?? 0;
|
||||
const traits = sys.traits?.value ?? [];
|
||||
const damageEntries = Object.values(sys.damageRolls ?? {});
|
||||
const damage = damageEntries
|
||||
.map((d) => `${d.damage} ${d.damageType}`)
|
||||
.join(" plus ");
|
||||
const traitStr =
|
||||
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 {
|
||||
name: capitalize(item.name),
|
||||
activity: { number: 1, unit: "action" },
|
||||
segments: [
|
||||
{
|
||||
type: "text",
|
||||
value: `+${bonus}${traitStr}, ${damage}${effectStr}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function parseActivity(
|
||||
actionType: string | undefined,
|
||||
actionCount: number | null | undefined,
|
||||
): { number: number; unit: "action" | "free" | "reaction" } | undefined {
|
||||
if (actionType === "action") {
|
||||
return { number: actionCount ?? 1, unit: "action" };
|
||||
}
|
||||
if (actionType === "reaction") {
|
||||
return { number: 1, unit: "reaction" };
|
||||
}
|
||||
if (actionType === "free") {
|
||||
return { number: 1, unit: "free" };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// -- 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 {
|
||||
const sys = item.system as unknown as ActionSystem;
|
||||
const actionType = sys.actionType?.value;
|
||||
const actionCount = sys.actions?.value;
|
||||
let description = stripFoundryTags(sys.description?.value ?? "");
|
||||
const traits = sys.traits?.value ?? [];
|
||||
|
||||
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 =
|
||||
traits.length > 0
|
||||
? `(${traits.map((t) => capitalize(formatTrait(t))).join(", ")}) `
|
||||
: "";
|
||||
|
||||
const text = traitStr ? `${traitStr}${description}` : description;
|
||||
const segments: { type: "text"; value: string }[] = text
|
||||
? [{ type: "text", value: text }]
|
||||
: [];
|
||||
|
||||
return { name: item.name, activity, frequency, segments };
|
||||
}
|
||||
|
||||
// -- Spellcasting normalization --
|
||||
|
||||
function formatRange(range: { value: string } | undefined): string | undefined {
|
||||
if (!range?.value) return undefined;
|
||||
return range.value;
|
||||
}
|
||||
|
||||
function formatArea(
|
||||
area: { type?: string; value?: number; details?: string } | undefined,
|
||||
): string | undefined {
|
||||
if (!area) return undefined;
|
||||
if (area.value && area.type) return `${area.value}-foot ${area.type}`;
|
||||
return area.details ?? undefined;
|
||||
}
|
||||
|
||||
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(
|
||||
entry: RawFoundryItem,
|
||||
allSpells: readonly RawFoundryItem[],
|
||||
creatureLevel: number,
|
||||
): SpellcastingBlock {
|
||||
const sys = entry.system as unknown as SpellcastingEntrySystem;
|
||||
const tradition = capitalize(sys.tradition?.value ?? "");
|
||||
const prepared = sys.prepared?.value ?? "";
|
||||
const dc = sys.spelldc?.dc ?? 0;
|
||||
const attack = sys.spelldc?.value ?? 0;
|
||||
|
||||
const name = entry.name || `${tradition} ${capitalize(prepared)} Spells`;
|
||||
const headerText = `DC ${dc}${attack ? `, attack +${attack}` : ""}`;
|
||||
|
||||
const linkedSpells = allSpells.filter(
|
||||
(s) => (s.system as unknown as SpellSystem).location?.value === entry._id,
|
||||
);
|
||||
|
||||
const byRank = new Map<number, SpellReference[]>();
|
||||
const cantrips: SpellReference[] = [];
|
||||
|
||||
for (const spell of linkedSpells) {
|
||||
const ref = normalizeSpell(spell, creatureLevel);
|
||||
const isCantrip =
|
||||
(spell.system as unknown as SpellSystem).traits?.value?.includes(
|
||||
"cantrip",
|
||||
) ?? false;
|
||||
if (isCantrip) {
|
||||
cantrips.push(ref);
|
||||
continue;
|
||||
}
|
||||
const rank = ref.rank ?? 0;
|
||||
const existing = byRank.get(rank) ?? [];
|
||||
existing.push(ref);
|
||||
byRank.set(rank, existing);
|
||||
}
|
||||
|
||||
const daily = [...byRank.entries()]
|
||||
.sort(([a], [b]) => b - a)
|
||||
.map(([rank, spells]) => ({
|
||||
uses: rank,
|
||||
each: true,
|
||||
spells,
|
||||
}));
|
||||
|
||||
return {
|
||||
name,
|
||||
headerText,
|
||||
atWill: orUndefined(cantrips),
|
||||
daily: orUndefined(daily),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSpellcasting(
|
||||
items: readonly RawFoundryItem[],
|
||||
creatureLevel: number,
|
||||
): SpellcastingBlock[] {
|
||||
const entries = items.filter((i) => i.type === "spellcastingEntry");
|
||||
const spells = items.filter((i) => i.type === "spell");
|
||||
return entries.map((entry) =>
|
||||
normalizeSpellcastingEntry(entry, spells, creatureLevel),
|
||||
);
|
||||
}
|
||||
|
||||
// -- Main normalization --
|
||||
|
||||
function orUndefined<T>(arr: T[]): T[] | undefined {
|
||||
return arr.length > 0 ? arr : undefined;
|
||||
}
|
||||
|
||||
/** Build display traits: [rarity (if not common), size, ...type traits] */
|
||||
function buildTraits(traits: {
|
||||
rarity: string;
|
||||
size: { value: string };
|
||||
value: string[];
|
||||
}): string[] {
|
||||
const result: string[] = [];
|
||||
if (traits.rarity && traits.rarity !== "common") {
|
||||
result.push(traits.rarity);
|
||||
}
|
||||
const size = SIZE_MAP[traits.size.value] ?? "medium";
|
||||
result.push(size);
|
||||
result.push(...traits.value);
|
||||
return result;
|
||||
}
|
||||
|
||||
const HEALING_GLOSSARY =
|
||||
/^<p>@Localize\[PF2E\.NPC\.Abilities\.Glossary\.(FastHealing|Regeneration|NegativeHealing)\]/;
|
||||
|
||||
/** Glossary-only abilities that duplicate structured data shown elsewhere. */
|
||||
const REDUNDANT_GLOSSARY =
|
||||
/^<p>@Localize\[PF2E\.NPC\.Abilities\.Glossary\.(ConstantSpells|AtWillSpells)\]/;
|
||||
|
||||
const STRIP_GLOSSARY_AND_P = /<p>@Localize\[[^\]]+\]<\/p>|<\/?p>/g;
|
||||
|
||||
/** True when the description has no user-visible content beyond glossary tags. */
|
||||
function isGlossaryOnly(desc: string | undefined): boolean {
|
||||
if (!desc) return true;
|
||||
return desc.replace(STRIP_GLOSSARY_AND_P, "").trim() === "";
|
||||
}
|
||||
|
||||
function isRedundantAbility(
|
||||
item: RawFoundryItem,
|
||||
excludeName: string | undefined,
|
||||
hpDetails: string | undefined,
|
||||
): boolean {
|
||||
const sys = item.system as unknown as ActionSystem;
|
||||
const desc = sys.description?.value;
|
||||
// Ability duplicates the allSaves line — suppress only if glossary-only
|
||||
if (excludeName && item.name.toLowerCase() === excludeName.toLowerCase()) {
|
||||
return isGlossaryOnly(desc);
|
||||
}
|
||||
if (!desc) return false;
|
||||
// Healing/regen glossary when hp.details already shows the info
|
||||
if (hpDetails && HEALING_GLOSSARY.test(desc)) return true;
|
||||
// Spell mechanic glossary reminders shown in the spellcasting section
|
||||
if (REDUNDANT_GLOSSARY.test(desc)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function actionsByCategory(
|
||||
items: readonly RawFoundryItem[],
|
||||
category: string,
|
||||
excludeName?: string,
|
||||
hpDetails?: string,
|
||||
): TraitBlock[] {
|
||||
return items
|
||||
.filter(
|
||||
(a) =>
|
||||
a.type === "action" &&
|
||||
(a.system as unknown as ActionSystem).category === category &&
|
||||
!isRedundantAbility(a, excludeName, hpDetails),
|
||||
)
|
||||
.map(normalizeAbility);
|
||||
}
|
||||
|
||||
function extractAbilityMods(
|
||||
mods: Record<string, { mod: number }>,
|
||||
): Pf2eCreature["abilityMods"] {
|
||||
return {
|
||||
str: mods.str?.mod ?? 0,
|
||||
dex: mods.dex?.mod ?? 0,
|
||||
con: mods.con?.mod ?? 0,
|
||||
int: mods.int?.mod ?? 0,
|
||||
wis: mods.wis?.mod ?? 0,
|
||||
cha: mods.cha?.mod ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeFoundryCreature(
|
||||
raw: unknown,
|
||||
sourceCode?: string,
|
||||
sourceDisplayName?: string,
|
||||
): Pf2eCreature {
|
||||
const r = raw as RawFoundryCreature;
|
||||
const sys = r.system;
|
||||
const publication = sys.details?.publication;
|
||||
|
||||
const source = sourceCode ?? publication?.title ?? "";
|
||||
const items = r.items ?? [];
|
||||
const allSavesText = sys.attributes.allSaves?.value ?? "";
|
||||
|
||||
return {
|
||||
system: "pf2e",
|
||||
id: makeCreatureId(source, r.name),
|
||||
name: r.name,
|
||||
source,
|
||||
sourceDisplayName: sourceDisplayName ?? publication?.title ?? "",
|
||||
level: sys.details?.level?.value ?? 0,
|
||||
traits: buildTraits(sys.traits),
|
||||
perception: sys.perception?.mod ?? 0,
|
||||
perceptionDetails: sys.perception?.details || undefined,
|
||||
senses: formatSenses(sys.perception?.senses),
|
||||
languages: formatLanguages(sys.details?.languages),
|
||||
skills: formatSkills(sys.skills),
|
||||
abilityMods: extractAbilityMods(sys.abilities ?? {}),
|
||||
ac: sys.attributes.ac.value,
|
||||
acConditional: sys.attributes.ac.details || undefined,
|
||||
saveFort: sys.saves.fortitude.value,
|
||||
saveRef: sys.saves.reflex.value,
|
||||
saveWill: sys.saves.will.value,
|
||||
saveConditional: allSavesText || undefined,
|
||||
hp: sys.attributes.hp.max,
|
||||
hpDetails: sys.attributes.hp.details || undefined,
|
||||
immunities: formatImmunities(sys.attributes.immunities),
|
||||
resistances: formatResistances(sys.attributes.resistances),
|
||||
weaknesses: formatWeaknesses(sys.attributes.weaknesses),
|
||||
speed: formatSpeed(sys.attributes.speed),
|
||||
attacks: orUndefined(
|
||||
items
|
||||
.filter((i) => i.type === "melee")
|
||||
.map((i) => normalizeAttack(i, r.name)),
|
||||
),
|
||||
abilitiesTop: orUndefined(actionsByCategory(items, "interaction")),
|
||||
abilitiesMid: orUndefined(
|
||||
actionsByCategory(
|
||||
items,
|
||||
"defensive",
|
||||
allSavesText || undefined,
|
||||
sys.attributes.hp.details || undefined,
|
||||
),
|
||||
),
|
||||
abilitiesBot: orUndefined(actionsByCategory(items, "offensive")),
|
||||
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),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeFoundryCreatures(
|
||||
rawCreatures: unknown[],
|
||||
sourceCode?: string,
|
||||
sourceDisplayName?: string,
|
||||
): Pf2eCreature[] {
|
||||
return rawCreatures.map((raw) =>
|
||||
normalizeFoundryCreature(raw, sourceCode, sourceDisplayName),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type {
|
||||
Pf2eBestiaryIndex,
|
||||
Pf2eBestiaryIndexEntry,
|
||||
} from "@initiative/domain";
|
||||
|
||||
import rawIndex from "../../../../data/bestiary/pf2e-index.json";
|
||||
|
||||
interface CompactCreature {
|
||||
readonly n: string;
|
||||
readonly s: string;
|
||||
readonly lv: number;
|
||||
readonly ac: number;
|
||||
readonly hp: number;
|
||||
readonly pc: number;
|
||||
readonly sz: string;
|
||||
readonly tp: string;
|
||||
readonly f: string;
|
||||
readonly li: string;
|
||||
}
|
||||
|
||||
interface CompactIndex {
|
||||
readonly sources: Record<string, string>;
|
||||
readonly creatures: readonly CompactCreature[];
|
||||
}
|
||||
|
||||
function mapCreature(c: CompactCreature): Pf2eBestiaryIndexEntry {
|
||||
return {
|
||||
name: c.n,
|
||||
source: c.s,
|
||||
level: c.lv,
|
||||
ac: c.ac,
|
||||
hp: c.hp,
|
||||
perception: c.pc,
|
||||
size: c.sz,
|
||||
type: c.tp,
|
||||
};
|
||||
}
|
||||
|
||||
let cachedIndex: Pf2eBestiaryIndex | undefined;
|
||||
|
||||
export function loadPf2eBestiaryIndex(): Pf2eBestiaryIndex {
|
||||
if (cachedIndex) return cachedIndex;
|
||||
|
||||
const compact = rawIndex as unknown as CompactIndex;
|
||||
cachedIndex = {
|
||||
sources: compact.sources,
|
||||
creatures: compact.creatures.map(mapCreature),
|
||||
};
|
||||
return cachedIndex;
|
||||
}
|
||||
|
||||
export function getAllPf2eSourceCodes(): string[] {
|
||||
const index = loadPf2eBestiaryIndex();
|
||||
return Object.keys(index.sources);
|
||||
}
|
||||
|
||||
export function getDefaultPf2eFetchUrl(
|
||||
_sourceCode: string,
|
||||
baseUrl?: string,
|
||||
): string {
|
||||
if (baseUrl !== undefined) {
|
||||
return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
||||
}
|
||||
return "https://raw.githubusercontent.com/foundryvtt/pf2e/v13-dev/packs/pf2e/";
|
||||
}
|
||||
|
||||
export function getCreaturePathsForSource(sourceCode: string): string[] {
|
||||
const compact = rawIndex as unknown as CompactIndex;
|
||||
return compact.creatures.filter((c) => c.s === sourceCode).map((c) => c.f);
|
||||
}
|
||||
|
||||
export function getCreatureNamesByPaths(paths: string[]): Map<string, string> {
|
||||
const compact = rawIndex as unknown as CompactIndex;
|
||||
const pathSet = new Set(paths);
|
||||
const result = new Map<string, string>();
|
||||
for (const c of compact.creatures) {
|
||||
if (pathSet.has(c.f)) {
|
||||
result.set(c.f, c.n);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getPf2eSourceDisplayName(sourceCode: string): string {
|
||||
const index = loadPf2eBestiaryIndex();
|
||||
return index.sources[sourceCode] ?? sourceCode;
|
||||
}
|
||||
@@ -1,25 +1,26 @@
|
||||
import type {
|
||||
AnyCreature,
|
||||
BestiaryIndex,
|
||||
Creature,
|
||||
CreatureId,
|
||||
Encounter,
|
||||
Pf2eBestiaryIndex,
|
||||
PlayerCharacter,
|
||||
UndoRedoState,
|
||||
} from "@initiative/domain";
|
||||
|
||||
export interface EncounterPersistence {
|
||||
load(): Encounter | null;
|
||||
save(encounter: Encounter): void;
|
||||
readonly load: () => Encounter | null;
|
||||
readonly save: (encounter: Encounter) => void;
|
||||
}
|
||||
|
||||
export interface UndoRedoPersistence {
|
||||
load(): UndoRedoState;
|
||||
save(state: UndoRedoState): void;
|
||||
readonly load: () => UndoRedoState;
|
||||
readonly save: (state: UndoRedoState) => void;
|
||||
}
|
||||
|
||||
export interface PlayerCharacterPersistence {
|
||||
load(): PlayerCharacter[];
|
||||
save(characters: PlayerCharacter[]): void;
|
||||
readonly load: () => PlayerCharacter[];
|
||||
readonly save: (characters: PlayerCharacter[]) => void;
|
||||
}
|
||||
|
||||
export interface CachedSourceInfo {
|
||||
@@ -30,21 +31,34 @@ export interface CachedSourceInfo {
|
||||
}
|
||||
|
||||
export interface BestiaryCachePort {
|
||||
cacheSource(
|
||||
readonly cacheSource: (
|
||||
system: string,
|
||||
sourceCode: string,
|
||||
displayName: string,
|
||||
creatures: Creature[],
|
||||
): Promise<void>;
|
||||
isSourceCached(sourceCode: string): Promise<boolean>;
|
||||
getCachedSources(): Promise<CachedSourceInfo[]>;
|
||||
clearSource(sourceCode: string): Promise<void>;
|
||||
clearAll(): Promise<void>;
|
||||
loadAllCachedCreatures(): Promise<Map<CreatureId, Creature>>;
|
||||
creatures: AnyCreature[],
|
||||
) => Promise<void>;
|
||||
readonly isSourceCached: (
|
||||
system: string,
|
||||
sourceCode: string,
|
||||
) => Promise<boolean>;
|
||||
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 {
|
||||
loadIndex(): BestiaryIndex;
|
||||
getAllSourceCodes(): string[];
|
||||
getDefaultFetchUrl(sourceCode: string, baseUrl?: string): string;
|
||||
getSourceDisplayName(sourceCode: string): string;
|
||||
readonly loadIndex: () => BestiaryIndex;
|
||||
readonly getAllSourceCodes: () => string[];
|
||||
readonly getDefaultFetchUrl: (sourceCode: string, baseUrl?: string) => string;
|
||||
readonly getSourceDisplayName: (sourceCode: string) => string;
|
||||
}
|
||||
|
||||
export interface Pf2eBestiaryIndexPort {
|
||||
readonly loadIndex: () => Pf2eBestiaryIndex;
|
||||
readonly getAllSourceCodes: () => string[];
|
||||
readonly getDefaultFetchUrl: (sourceCode: string, baseUrl?: string) => string;
|
||||
readonly getSourceDisplayName: (sourceCode: string) => string;
|
||||
readonly getCreaturePathsForSource: (sourceCode: string) => string[];
|
||||
readonly getCreatureNamesByPaths: (paths: string[]) => Map<string, string>;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "../persistence/undo-redo-storage.js";
|
||||
import * as bestiaryCache from "./bestiary-cache.js";
|
||||
import * as bestiaryIndex from "./bestiary-index-adapter.js";
|
||||
import * as pf2eBestiaryIndex from "./pf2e-bestiary-index-adapter.js";
|
||||
|
||||
export const productionAdapters: Adapters = {
|
||||
encounterPersistence: {
|
||||
@@ -41,4 +42,12 @@ export const productionAdapters: Adapters = {
|
||||
getDefaultFetchUrl: bestiaryIndex.getDefaultFetchUrl,
|
||||
getSourceDisplayName: bestiaryIndex.getSourceDisplayName,
|
||||
},
|
||||
pf2eBestiaryIndex: {
|
||||
loadIndex: pf2eBestiaryIndex.loadPf2eBestiaryIndex,
|
||||
getAllSourceCodes: pf2eBestiaryIndex.getAllPf2eSourceCodes,
|
||||
getDefaultFetchUrl: pf2eBestiaryIndex.getDefaultPf2eFetchUrl,
|
||||
getSourceDisplayName: pf2eBestiaryIndex.getPf2eSourceDisplayName,
|
||||
getCreaturePathsForSource: pf2eBestiaryIndex.getCreaturePathsForSource,
|
||||
getCreatureNamesByPaths: pf2eBestiaryIndex.getCreatureNamesByPaths,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Strips Foundry VTT HTML descriptions with enrichment syntax to plain
|
||||
* readable text. Handles @Damage, @Check, @UUID, @Template and generic
|
||||
* @Tag patterns as well as common HTML elements.
|
||||
*/
|
||||
|
||||
// -- Enrichment-param helpers --
|
||||
|
||||
function formatDamage(params: string): string {
|
||||
// "3d6+10[fire]" → "3d6+10 fire"
|
||||
// "d4[persistent,fire]" → "d4 persistent fire"
|
||||
return params
|
||||
.replaceAll(
|
||||
/\[([^\]]*)\]/g,
|
||||
(_, type: string) => ` ${type.replaceAll(",", " ")}`,
|
||||
)
|
||||
.trim();
|
||||
}
|
||||
|
||||
function formatCheck(params: string): string {
|
||||
// "reflex|dc:33|basic" → "DC 33 basic Reflex"
|
||||
const parts = params.split("|");
|
||||
const type = parts[0] ?? "";
|
||||
let dc = "";
|
||||
let basic = false;
|
||||
for (const part of parts.slice(1)) {
|
||||
if (part.startsWith("dc:")) {
|
||||
dc = part.slice(3);
|
||||
} else if (part === "basic") {
|
||||
basic = true;
|
||||
}
|
||||
}
|
||||
const label = type.charAt(0).toUpperCase() + type.slice(1);
|
||||
const dcStr = dc ? `DC ${dc} ` : "";
|
||||
const basicStr = basic ? "basic " : "";
|
||||
return `${dcStr}${basicStr}${label}`;
|
||||
}
|
||||
|
||||
function formatTemplate(params: string): string {
|
||||
// "cone|distance:40" → "40-foot cone"
|
||||
const parts = params.split("|");
|
||||
const shape = parts[0] ?? "";
|
||||
let distance = "";
|
||||
for (const part of parts.slice(1)) {
|
||||
if (part.startsWith("distance:")) {
|
||||
distance = part.slice(9);
|
||||
}
|
||||
}
|
||||
return distance ? `${distance}-foot ${shape}` : shape;
|
||||
}
|
||||
|
||||
export function stripFoundryTags(html: string): string {
|
||||
if (typeof html !== "string") return String(html);
|
||||
let result = html;
|
||||
|
||||
// Strip Foundry enrichment tags (with optional display text)
|
||||
// @Tag[params]{display} → display (prefer display text)
|
||||
// @Tag[params] → extracted content
|
||||
|
||||
// @Damage has nested brackets: @Damage[3d6+10[fire]]
|
||||
result = result.replaceAll(
|
||||
/@Damage\[((?:[^[\]]|\[[^\]]*\])*)\](?:\{([^}]+)\})?/g,
|
||||
(_, params: string, display: string | undefined) =>
|
||||
display ?? formatDamage(params),
|
||||
);
|
||||
result = result.replaceAll(
|
||||
/@Check\[([^\]]+)\](?:\{([^}]*)\})?/g,
|
||||
(_, params: string) => formatCheck(params),
|
||||
);
|
||||
result = result.replaceAll(
|
||||
/@UUID\[[^\]]+?([^./\]]+)\](?:\{([^}]+)\})?/g,
|
||||
(_, lastSegment: string, display: string | undefined) =>
|
||||
display ?? lastSegment,
|
||||
);
|
||||
result = result.replaceAll(
|
||||
/@Template\[([^\]]+)\](?:\{([^}]+)\})?/g,
|
||||
(_, params: string, display: string | undefined) =>
|
||||
display ?? formatTemplate(params),
|
||||
);
|
||||
// Catch-all for unknown @Tag patterns
|
||||
result = result.replaceAll(
|
||||
/@\w+\[[^\]]*\](?:\{([^}]+)\})?/g,
|
||||
(_, display: string | undefined) => display ?? "",
|
||||
);
|
||||
|
||||
// Strip action-glyph spans (content is a number the renderer handles)
|
||||
result = result.replaceAll(/<span class="action-glyph">[^<]*<\/span>/gi, "");
|
||||
|
||||
// Strip HTML tags (preserve <strong> for UI rendering)
|
||||
result = result.replaceAll(/<br\s*\/?>/gi, "\n");
|
||||
result = result.replaceAll(/<hr\s*\/?>/gi, "\n");
|
||||
result = result.replaceAll(/<\/p>\s*<p[^>]*>/gi, "\n");
|
||||
result = result.replaceAll(/<(?!\/?(?:strong|em|ul|ol|li)\b)[^>]+>/g, "");
|
||||
|
||||
// Decode common HTML entities
|
||||
result = result.replaceAll("&", "&");
|
||||
result = result.replaceAll("<", "<");
|
||||
result = result.replaceAll(">", ">");
|
||||
result = result.replaceAll(""", '"');
|
||||
|
||||
// Collapse whitespace around list tags so they don't create extra
|
||||
// line breaks when rendered with whitespace-pre-line
|
||||
result = result.replaceAll(/\s*(<\/?(?:ul|ol)>)\s*/g, "$1");
|
||||
result = result.replaceAll(/\s*(<\/?li>)\s*/g, "$1");
|
||||
|
||||
// Collapse whitespace
|
||||
result = result.replaceAll(/[ \t]+/g, " ");
|
||||
result = result.replaceAll(/\n\s*\n/g, "\n");
|
||||
return result.trim();
|
||||
}
|
||||
@@ -98,20 +98,26 @@ export function stripTags(text: string): string {
|
||||
// Generic tags: {@tag Display|Source|...} → Display (first segment before |)
|
||||
// Covers: spell, condition, damage, dice, variantrule, action, skill,
|
||||
// creature, hazard, status, plus any unknown tags
|
||||
result = result.replaceAll(
|
||||
/\{@(\w+)\s+([^}]+)\}/g,
|
||||
(_, tag: string, content: string) => {
|
||||
// For tags with Display|Source format, extract first segment
|
||||
const segments = content.split("|");
|
||||
// Run in a loop to resolve nested tags (e.g. {@b ... {@spell fireball} ...})
|
||||
// from innermost to outermost.
|
||||
const tagPattern = /\{@(\w+)\s+([^}]+)\}/g;
|
||||
while (tagPattern.test(result)) {
|
||||
result = result.replaceAll(
|
||||
tagPattern,
|
||||
(_, tag: string, content: string) => {
|
||||
const segments = content.split("|");
|
||||
|
||||
// Some tags have a third segment as display text: {@variantrule Name|Source|Display}
|
||||
if ((tag === "variantrule" || tag === "action") && segments.length >= 3) {
|
||||
return segments[2];
|
||||
}
|
||||
if (
|
||||
(tag === "variantrule" || tag === "action") &&
|
||||
segments.length >= 3
|
||||
) {
|
||||
return segments[2];
|
||||
}
|
||||
|
||||
return segments[0];
|
||||
},
|
||||
);
|
||||
return segments[0];
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||
import { AdapterProvider } from "../../contexts/adapter-context.js";
|
||||
import { RulesEditionProvider } from "../../contexts/rules-edition-context.js";
|
||||
import { BulkImportPrompt } from "../bulk-import-prompt.js";
|
||||
|
||||
const THREE_SOURCES_REGEX = /3 sources/;
|
||||
@@ -68,9 +69,11 @@ function createAdaptersWithSources() {
|
||||
function renderWithAdapters() {
|
||||
const adapters = createAdaptersWithSources();
|
||||
return render(
|
||||
<AdapterProvider adapters={adapters}>
|
||||
<BulkImportPrompt />
|
||||
</AdapterProvider>,
|
||||
<RulesEditionProvider>
|
||||
<AdapterProvider adapters={adapters}>
|
||||
<BulkImportPrompt />
|
||||
</AdapterProvider>
|
||||
</RulesEditionProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -373,6 +373,26 @@ describe("CombatantRow", () => {
|
||||
).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", () => {
|
||||
renderRow({
|
||||
combatant: {
|
||||
|
||||
@@ -1,54 +1,92 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
|
||||
import {
|
||||
type ConditionEntry,
|
||||
type ConditionId,
|
||||
getConditionsForEdition,
|
||||
type PersistentDamageEntry,
|
||||
type PersistentDamageType,
|
||||
type RulesEdition,
|
||||
} from "@initiative/domain";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
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 { RulesEditionProvider } from "../../contexts/index.js";
|
||||
import { useRulesEditionContext } from "../../contexts/rules-edition-context.js";
|
||||
import { ConditionPicker } from "../condition-picker";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function EditionSetter({
|
||||
edition,
|
||||
children,
|
||||
}: {
|
||||
edition: RulesEdition;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { setEdition } = useRulesEditionContext();
|
||||
useEffect(() => {
|
||||
setEdition(edition);
|
||||
}, [edition, setEdition]);
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function renderPicker(
|
||||
overrides: Partial<{
|
||||
activeConditions: readonly ConditionId[];
|
||||
activeConditions: readonly ConditionEntry[];
|
||||
activePersistentDamage: readonly PersistentDamageEntry[];
|
||||
onToggle: (conditionId: ConditionId) => void;
|
||||
onSetValue: (conditionId: ConditionId, value: number) => void;
|
||||
onAddPersistentDamage: (
|
||||
damageType: PersistentDamageType,
|
||||
formula: string,
|
||||
) => void;
|
||||
onClose: () => void;
|
||||
edition: RulesEdition;
|
||||
}> = {},
|
||||
) {
|
||||
const onToggle = overrides.onToggle ?? vi.fn();
|
||||
const onSetValue = overrides.onSetValue ?? vi.fn();
|
||||
const onAddPersistentDamage = overrides.onAddPersistentDamage ?? vi.fn();
|
||||
const onClose = overrides.onClose ?? vi.fn();
|
||||
const edition = overrides.edition ?? "5.5e";
|
||||
const anchorRef = createRef<HTMLElement>() as RefObject<HTMLElement>;
|
||||
const anchor = document.createElement("div");
|
||||
document.body.appendChild(anchor);
|
||||
(anchorRef as { current: HTMLElement }).current = anchor;
|
||||
const result = render(
|
||||
<RulesEditionProvider>
|
||||
<ConditionPicker
|
||||
anchorRef={anchorRef}
|
||||
activeConditions={overrides.activeConditions ?? []}
|
||||
onToggle={onToggle}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<EditionSetter edition={edition}>
|
||||
<ConditionPicker
|
||||
anchorRef={anchorRef}
|
||||
activeConditions={overrides.activeConditions ?? []}
|
||||
activePersistentDamage={overrides.activePersistentDamage}
|
||||
onToggle={onToggle}
|
||||
onSetValue={onSetValue}
|
||||
onAddPersistentDamage={onAddPersistentDamage}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</EditionSetter>
|
||||
</RulesEditionProvider>,
|
||||
);
|
||||
return { ...result, onToggle, onClose };
|
||||
return { ...result, onToggle, onSetValue, onAddPersistentDamage, onClose };
|
||||
}
|
||||
|
||||
describe("ConditionPicker", () => {
|
||||
it("renders all condition definitions from domain", () => {
|
||||
it("renders edition-specific conditions from domain", () => {
|
||||
renderPicker();
|
||||
for (const def of CONDITION_DEFINITIONS) {
|
||||
const editionConditions = getConditionsForEdition("5.5e");
|
||||
for (const def of editionConditions) {
|
||||
expect(screen.getByText(def.label)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("active conditions are visually distinguished", () => {
|
||||
renderPicker({ activeConditions: ["blinded"] });
|
||||
const blindedButton = screen.getByText("Blinded").closest("button");
|
||||
expect(blindedButton?.className).toContain("bg-card/50");
|
||||
renderPicker({ activeConditions: [{ id: "blinded" }] });
|
||||
const row = screen.getByText("Blinded").closest("div[class]");
|
||||
expect(row?.className).toContain("bg-card/50");
|
||||
});
|
||||
|
||||
it("clicking a condition calls onToggle with that condition's ID", async () => {
|
||||
@@ -65,8 +103,115 @@ describe("ConditionPicker", () => {
|
||||
});
|
||||
|
||||
it("active condition labels use foreground color", () => {
|
||||
renderPicker({ activeConditions: ["charmed"] });
|
||||
renderPicker({ activeConditions: [{ id: "charmed" }] });
|
||||
const label = screen.getByText("Charmed");
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { ConditionId } from "@initiative/domain";
|
||||
import type { ConditionEntry } 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";
|
||||
@@ -14,6 +14,7 @@ function renderTags(props: Partial<Parameters<typeof ConditionTags>[0]> = {}) {
|
||||
<ConditionTags
|
||||
conditions={props.conditions}
|
||||
onRemove={props.onRemove ?? (() => {})}
|
||||
onDecrement={props.onDecrement ?? (() => {})}
|
||||
onOpenPicker={props.onOpenPicker ?? (() => {})}
|
||||
/>
|
||||
</RulesEditionProvider>,
|
||||
@@ -28,7 +29,7 @@ describe("ConditionTags", () => {
|
||||
});
|
||||
|
||||
it("renders a button per condition", () => {
|
||||
const conditions: ConditionId[] = ["blinded", "prone"];
|
||||
const conditions: ConditionEntry[] = [{ id: "blinded" }, { id: "prone" }];
|
||||
renderTags({ conditions });
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Remove Blinded" }),
|
||||
@@ -39,7 +40,7 @@ describe("ConditionTags", () => {
|
||||
it("calls onRemove with condition id when clicked", async () => {
|
||||
const onRemove = vi.fn();
|
||||
renderTags({
|
||||
conditions: ["blinded"] as ConditionId[],
|
||||
conditions: [{ id: "blinded" }] as ConditionEntry[],
|
||||
onRemove,
|
||||
});
|
||||
|
||||
@@ -66,4 +67,37 @@ describe("ConditionTags", () => {
|
||||
// Only add button
|
||||
expect(screen.getByRole("button", { name: "Add condition" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("displays value badge for valued conditions", () => {
|
||||
renderTags({ conditions: [{ id: "frightened", value: 3 }] });
|
||||
expect(screen.getByText("3")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onDecrement for valued condition click", async () => {
|
||||
const onDecrement = vi.fn();
|
||||
renderTags({
|
||||
conditions: [{ id: "frightened", value: 2 }],
|
||||
onDecrement,
|
||||
});
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Remove Frightened" }),
|
||||
);
|
||||
|
||||
expect(onDecrement).toHaveBeenCalledWith("frightened");
|
||||
});
|
||||
|
||||
it("calls onRemove for non-valued condition click", async () => {
|
||||
const onRemove = vi.fn();
|
||||
renderTags({
|
||||
conditions: [{ id: "blinded" }],
|
||||
onRemove,
|
||||
});
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Remove Blinded" }),
|
||||
);
|
||||
|
||||
expect(onRemove).toHaveBeenCalledWith("blinded");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,22 @@ describe("Dialog", () => {
|
||||
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", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||
import type {
|
||||
AnyCreature,
|
||||
CreatureId,
|
||||
PlayerCharacter,
|
||||
} from "@initiative/domain";
|
||||
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||
import {
|
||||
cleanup,
|
||||
@@ -17,6 +21,7 @@ import {
|
||||
buildCombatant,
|
||||
buildCreature,
|
||||
buildEncounter,
|
||||
buildPf2eCreature,
|
||||
} from "../../__tests__/factories/index.js";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import { useRulesEdition } from "../../hooks/use-rules-edition.js";
|
||||
@@ -52,7 +57,7 @@ const goblinCreature = buildCreature({
|
||||
function renderPanel(options: {
|
||||
encounter: ReturnType<typeof buildEncounter>;
|
||||
playerCharacters?: PlayerCharacter[];
|
||||
creatures?: Map<CreatureId, Creature>;
|
||||
creatures?: Map<CreatureId, AnyCreature>;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const adapters = createTestAdapters({
|
||||
@@ -357,4 +362,157 @@ describe("DifficultyBreakdownPanel", () => {
|
||||
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
describe("PF2e edition", () => {
|
||||
const orcWarrior = buildPf2eCreature({
|
||||
id: creatureId("pf2e:orc-warrior"),
|
||||
name: "Orc Warrior",
|
||||
level: 3,
|
||||
source: "crb",
|
||||
sourceDisplayName: "Core Rulebook",
|
||||
});
|
||||
|
||||
function pf2eEncounter() {
|
||||
return buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c-1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId1,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c-2"),
|
||||
name: "Orc Warrior",
|
||||
creatureId: orcWarrior.id,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
it("shows PF2e tier label", async () => {
|
||||
const { result: editionResult } = renderHook(() => useRulesEdition());
|
||||
editionResult.current.setEdition("pf2e");
|
||||
|
||||
try {
|
||||
renderPanel({
|
||||
encounter: pf2eEncounter(),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||
],
|
||||
creatures: new Map([[orcWarrior.id, orcWarrior]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("Encounter Difficulty:", { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
} finally {
|
||||
editionResult.current.setEdition("5.5e");
|
||||
}
|
||||
});
|
||||
|
||||
it("shows party level", async () => {
|
||||
const { result: editionResult } = renderHook(() => useRulesEdition());
|
||||
editionResult.current.setEdition("pf2e");
|
||||
|
||||
try {
|
||||
renderPanel({
|
||||
encounter: pf2eEncounter(),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||
],
|
||||
creatures: new Map([[orcWarrior.id, orcWarrior]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("Party Level: 5", { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
} finally {
|
||||
editionResult.current.setEdition("5.5e");
|
||||
}
|
||||
});
|
||||
|
||||
it("shows creature level and level difference", async () => {
|
||||
const { result: editionResult } = renderHook(() => useRulesEdition());
|
||||
editionResult.current.setEdition("pf2e");
|
||||
|
||||
try {
|
||||
renderPanel({
|
||||
encounter: pf2eEncounter(),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||
],
|
||||
creatures: new Map([[orcWarrior.id, orcWarrior]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Orc Warrior level 3, party level 5 → diff −2
|
||||
expect(
|
||||
screen.getByText("Lv 3 (-2)", { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
} finally {
|
||||
editionResult.current.setEdition("5.5e");
|
||||
}
|
||||
});
|
||||
|
||||
it("shows 5 thresholds with short labels", async () => {
|
||||
const { result: editionResult } = renderHook(() => useRulesEdition());
|
||||
editionResult.current.setEdition("pf2e");
|
||||
|
||||
try {
|
||||
renderPanel({
|
||||
encounter: pf2eEncounter(),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||
],
|
||||
creatures: new Map([[orcWarrior.id, orcWarrior]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("Triv:", { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Low:", { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Mod:", { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Sev:", { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Ext:", { exact: false }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
} finally {
|
||||
editionResult.current.setEdition("5.5e");
|
||||
}
|
||||
});
|
||||
|
||||
it("shows Net Creature XP label in PF2e mode", async () => {
|
||||
const { result: editionResult } = renderHook(() => useRulesEdition());
|
||||
editionResult.current.setEdition("pf2e");
|
||||
|
||||
try {
|
||||
renderPanel({
|
||||
encounter: pf2eEncounter(),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||
],
|
||||
creatures: new Map([[orcWarrior.id, orcWarrior]]),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Net Creature XP")).toBeInTheDocument();
|
||||
});
|
||||
} finally {
|
||||
editionResult.current.setEdition("5.5e");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
DifficultyIndicator,
|
||||
TIER_LABELS_5_5E,
|
||||
TIER_LABELS_2014,
|
||||
TIER_LABELS_PF2E,
|
||||
} from "../difficulty-indicator.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
@@ -23,6 +24,7 @@ function makeResult(tier: DifficultyResult["tier"]): DifficultyResult {
|
||||
encounterMultiplier: undefined,
|
||||
adjustedXp: undefined,
|
||||
partySizeAdjusted: undefined,
|
||||
partyLevel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -125,4 +127,64 @@ describe("DifficultyIndicator", () => {
|
||||
const element = container.querySelector("[role='img']");
|
||||
expect(element?.tagName).toBe("BUTTON");
|
||||
});
|
||||
|
||||
it("renders 4 bars when barCount is 4", () => {
|
||||
const { container } = render(
|
||||
<DifficultyIndicator
|
||||
result={makeResult(2)}
|
||||
labels={TIER_LABELS_PF2E}
|
||||
barCount={4}
|
||||
/>,
|
||||
);
|
||||
const bars = container.querySelectorAll("[class*='rounded-sm']");
|
||||
expect(bars).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("shows 0 filled bars for tier 0 with 4 bars", () => {
|
||||
const { container } = render(
|
||||
<DifficultyIndicator
|
||||
result={makeResult(0)}
|
||||
labels={TIER_LABELS_PF2E}
|
||||
barCount={4}
|
||||
/>,
|
||||
);
|
||||
const bars = container.querySelectorAll("[class*='rounded-sm']");
|
||||
for (const bar of bars) {
|
||||
expect(bar.className).toContain("bg-muted");
|
||||
}
|
||||
});
|
||||
|
||||
it("shows correct PF2e tooltip for Severe tier", () => {
|
||||
render(
|
||||
<DifficultyIndicator
|
||||
result={makeResult(3)}
|
||||
labels={TIER_LABELS_PF2E}
|
||||
barCount={4}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("img", { name: "Severe encounter difficulty" }),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows correct PF2e tooltip for Extreme tier", () => {
|
||||
render(
|
||||
<DifficultyIndicator
|
||||
result={makeResult(4)}
|
||||
labels={TIER_LABELS_PF2E}
|
||||
barCount={4}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("img", { name: "Extreme encounter difficulty" }),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("D&D indicator still renders 3 bars (no regression)", () => {
|
||||
const { container } = render(
|
||||
<DifficultyIndicator result={makeResult(3)} labels={TIER_LABELS_5_5E} />,
|
||||
);
|
||||
const bars = container.querySelectorAll("[class*='rounded-sm']");
|
||||
expect(bars).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 userEvent from "@testing-library/user-event";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { HpAdjustPopover } from "../hp-adjust-popover";
|
||||
|
||||
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(
|
||||
overrides: Partial<{
|
||||
onAdjust: (delta: number) => void;
|
||||
@@ -19,7 +49,7 @@ function renderPopover(
|
||||
const onSetTempHp = overrides.onSetTempHp ?? vi.fn();
|
||||
const onClose = overrides.onClose ?? vi.fn();
|
||||
const result = render(
|
||||
<HpAdjustPopover
|
||||
<AnchoredPopover
|
||||
onAdjust={onAdjust}
|
||||
onSetTempHp={onSetTempHp}
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,497 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import type { Pf2eCreature } from "@initiative/domain";
|
||||
import { creatureId } from "@initiative/domain";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Pf2eStatBlock } from "../pf2e-stat-block.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const USES_PER_DAY_REGEX = /×3/;
|
||||
const HEAL_DESCRIPTION_REGEX = /channel positive energy/;
|
||||
const PERCEPTION_SENSES_REGEX = /\+2.*Darkvision/;
|
||||
const SKILLS_REGEX = /Acrobatics \+5.*Stealth \+5/;
|
||||
const SAVE_CONDITIONAL_REGEX = /\+12.*\+1 status to all saves vs\. magic/;
|
||||
const SAVE_CONDITIONAL_ABSENT_REGEX = /status to all saves/;
|
||||
const HP_DETAILS_REGEX = /115.*regeneration 20/;
|
||||
const REGEN_REGEX = /regeneration/;
|
||||
const ATTACK_NAME_REGEX = /Dogslicer/;
|
||||
const ATTACK_DAMAGE_REGEX = /1d6 slashing/;
|
||||
const SPELLCASTING_ENTRY_REGEX = /Divine Innate Spells\./;
|
||||
const ABILITY_MID_NAME_REGEX = /Goblin Scuttle/;
|
||||
const ABILITY_MID_DESC_REGEX = /The goblin Steps\./;
|
||||
const CANTRIPS_REGEX = /Cantrips:/;
|
||||
const AC_REGEX = /16/;
|
||||
const RK_DC_13_REGEX = /DC 13/;
|
||||
const RK_DC_15_REGEX = /DC 15/;
|
||||
const RK_DC_25_REGEX = /DC 25/;
|
||||
const RK_HUMANOID_SOCIETY_REGEX = /Humanoid \(Society\)/;
|
||||
const RK_UNDEAD_RELIGION_REGEX = /Undead \(Religion\)/;
|
||||
const RK_BEAST_SKILLS_REGEX = /Beast \(Arcana\/Nature\)/;
|
||||
const SCROLL_NAME_REGEX = /Scroll of Teleport/;
|
||||
|
||||
const GOBLIN_WARRIOR: Pf2eCreature = {
|
||||
system: "pf2e",
|
||||
id: creatureId("pathfinder-monster-core:goblin-warrior"),
|
||||
name: "Goblin Warrior",
|
||||
source: "pathfinder-monster-core",
|
||||
sourceDisplayName: "Monster Core",
|
||||
level: -1,
|
||||
traits: ["small", "goblin", "humanoid"],
|
||||
perception: 2,
|
||||
senses: "Darkvision",
|
||||
languages: "Common, Goblin",
|
||||
skills: "Acrobatics +5, Athletics +2, Nature +1, Stealth +5",
|
||||
abilityMods: { str: 0, dex: 3, con: 1, int: 0, wis: -1, cha: 1 },
|
||||
ac: 16,
|
||||
saveFort: 5,
|
||||
saveRef: 7,
|
||||
saveWill: 3,
|
||||
hp: 6,
|
||||
speed: "25 feet",
|
||||
attacks: [
|
||||
{
|
||||
name: "Dogslicer",
|
||||
activity: { number: 1, unit: "action" },
|
||||
segments: [
|
||||
{
|
||||
type: "text",
|
||||
value: "+7 (agile, backstabber, finesse), 1d6 slashing",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
abilitiesMid: [
|
||||
{
|
||||
name: "Goblin Scuttle",
|
||||
activity: { number: 1, unit: "reaction" },
|
||||
segments: [{ type: "text", value: "The goblin Steps." }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const NAUNET: Pf2eCreature = {
|
||||
system: "pf2e",
|
||||
id: creatureId("pathfinder-monster-core-2:naunet"),
|
||||
name: "Naunet",
|
||||
source: "pathfinder-monster-core-2",
|
||||
sourceDisplayName: "Monster Core 2",
|
||||
level: 7,
|
||||
traits: ["large", "monitor", "protean"],
|
||||
perception: 14,
|
||||
senses: "Darkvision",
|
||||
languages: "Chthonian, Empyrean, Protean",
|
||||
skills:
|
||||
"Acrobatics +14, Athletics +16, Intimidation +16, Stealth +14, Survival +12",
|
||||
abilityMods: { str: 5, dex: 3, con: 5, int: 0, wis: 3, cha: 3 },
|
||||
ac: 24,
|
||||
saveFort: 18,
|
||||
saveRef: 14,
|
||||
saveWill: 12,
|
||||
saveConditional: "+1 status to all saves vs. magic",
|
||||
hp: 120,
|
||||
resistances: "Precision 5, Protean anatomy 10",
|
||||
speed: "25 feet, Fly 30 feet, Swim 25 feet (unfettered movement)",
|
||||
spellcasting: [
|
||||
{
|
||||
name: "Divine Innate Spells",
|
||||
headerText: "DC 25, attack +17",
|
||||
daily: [
|
||||
{
|
||||
uses: 4,
|
||||
each: true,
|
||||
spells: [{ name: "Unfettered Movement (Constant)" }],
|
||||
},
|
||||
],
|
||||
atWill: [{ name: "Detect Magic" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const TROLL: Pf2eCreature = {
|
||||
system: "pf2e",
|
||||
id: creatureId("pathfinder-monster-core:forest-troll"),
|
||||
name: "Forest Troll",
|
||||
source: "pathfinder-monster-core",
|
||||
sourceDisplayName: "Monster Core",
|
||||
level: 5,
|
||||
traits: ["large", "giant", "troll"],
|
||||
perception: 11,
|
||||
senses: "Darkvision",
|
||||
languages: "Jotun",
|
||||
skills: "Athletics +12, Intimidation +12",
|
||||
abilityMods: { str: 5, dex: 2, con: 6, int: -2, wis: 0, cha: -2 },
|
||||
ac: 20,
|
||||
saveFort: 17,
|
||||
saveRef: 11,
|
||||
saveWill: 7,
|
||||
hp: 115,
|
||||
hpDetails: "regeneration 20 (deactivated by acid or fire)",
|
||||
weaknesses: "Fire 10",
|
||||
speed: "30 feet",
|
||||
};
|
||||
|
||||
function renderStatBlock(creature: Pf2eCreature) {
|
||||
return render(<Pf2eStatBlock creature={creature} />);
|
||||
}
|
||||
|
||||
describe("Pf2eStatBlock", () => {
|
||||
describe("header", () => {
|
||||
it("renders creature name and level", () => {
|
||||
renderStatBlock(GOBLIN_WARRIOR);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Goblin Warrior" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Level -1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders traits as tags", () => {
|
||||
renderStatBlock(GOBLIN_WARRIOR);
|
||||
expect(screen.getByText("Small")).toBeInTheDocument();
|
||||
expect(screen.getByText("Goblin")).toBeInTheDocument();
|
||||
expect(screen.getByText("Humanoid")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders source display name", () => {
|
||||
renderStatBlock(GOBLIN_WARRIOR);
|
||||
expect(screen.getByText("Monster Core")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("recall knowledge", () => {
|
||||
it("renders Recall Knowledge line for a creature with a recognized type trait", () => {
|
||||
renderStatBlock(GOBLIN_WARRIOR);
|
||||
expect(screen.getByText("Recall Knowledge")).toBeInTheDocument();
|
||||
expect(screen.getByText(RK_DC_13_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(RK_HUMANOID_SOCIETY_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adjusts DC for uncommon rarity", () => {
|
||||
const uncommonCreature: Pf2eCreature = {
|
||||
...GOBLIN_WARRIOR,
|
||||
traits: ["uncommon", "small", "humanoid"],
|
||||
};
|
||||
renderStatBlock(uncommonCreature);
|
||||
expect(screen.getByText(RK_DC_15_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adjusts DC for rare rarity", () => {
|
||||
const rareCreature: Pf2eCreature = {
|
||||
...GOBLIN_WARRIOR,
|
||||
level: 5,
|
||||
traits: ["rare", "medium", "undead"],
|
||||
};
|
||||
renderStatBlock(rareCreature);
|
||||
expect(screen.getByText(RK_DC_25_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(RK_UNDEAD_RELIGION_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows multiple skills for types with dual skill mapping", () => {
|
||||
const beastCreature: Pf2eCreature = {
|
||||
...GOBLIN_WARRIOR,
|
||||
traits: ["small", "beast"],
|
||||
};
|
||||
renderStatBlock(beastCreature);
|
||||
expect(screen.getByText(RK_BEAST_SKILLS_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits Recall Knowledge when no type trait is recognized", () => {
|
||||
const noTypeCreature: Pf2eCreature = {
|
||||
...GOBLIN_WARRIOR,
|
||||
traits: ["small", "goblin"],
|
||||
};
|
||||
renderStatBlock(noTypeCreature);
|
||||
expect(screen.queryByText("Recall Knowledge")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("perception and senses", () => {
|
||||
it("renders perception modifier and senses", () => {
|
||||
renderStatBlock(GOBLIN_WARRIOR);
|
||||
expect(screen.getByText("Perception")).toBeInTheDocument();
|
||||
expect(screen.getByText(PERCEPTION_SENSES_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders languages", () => {
|
||||
renderStatBlock(GOBLIN_WARRIOR);
|
||||
expect(screen.getByText("Languages")).toBeInTheDocument();
|
||||
expect(screen.getByText("Common, Goblin")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders skills", () => {
|
||||
renderStatBlock(GOBLIN_WARRIOR);
|
||||
expect(screen.getByText("Skills")).toBeInTheDocument();
|
||||
expect(screen.getByText(SKILLS_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ability modifiers", () => {
|
||||
it("renders all six ability labels", () => {
|
||||
renderStatBlock(GOBLIN_WARRIOR);
|
||||
for (const label of ["Str", "Dex", "Con", "Int", "Wis", "Cha"]) {
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders positive and negative modifiers", () => {
|
||||
renderStatBlock(GOBLIN_WARRIOR);
|
||||
expect(screen.getByText("+3")).toBeInTheDocument();
|
||||
expect(screen.getByText("-1")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("defenses", () => {
|
||||
it("renders AC and saves", () => {
|
||||
renderStatBlock(GOBLIN_WARRIOR);
|
||||
expect(screen.getByText("AC")).toBeInTheDocument();
|
||||
expect(screen.getByText(AC_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText("Fort")).toBeInTheDocument();
|
||||
expect(screen.getByText("Ref")).toBeInTheDocument();
|
||||
expect(screen.getByText("Will")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders HP", () => {
|
||||
renderStatBlock(GOBLIN_WARRIOR);
|
||||
expect(screen.getByText("HP")).toBeInTheDocument();
|
||||
expect(screen.getByText("6")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders saveConditional inline with saves", () => {
|
||||
renderStatBlock(NAUNET);
|
||||
expect(screen.getByText(SAVE_CONDITIONAL_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits saveConditional when absent", () => {
|
||||
renderStatBlock(GOBLIN_WARRIOR);
|
||||
expect(
|
||||
screen.queryByText(SAVE_CONDITIONAL_ABSENT_REGEX),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders hpDetails in parentheses after HP", () => {
|
||||
renderStatBlock(TROLL);
|
||||
expect(screen.getByText(HP_DETAILS_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits hpDetails when absent", () => {
|
||||
renderStatBlock(GOBLIN_WARRIOR);
|
||||
expect(screen.queryByText(REGEN_REGEX)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders resistances and weaknesses", () => {
|
||||
renderStatBlock(NAUNET);
|
||||
expect(screen.getByText("Resistances")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Precision 5, Protean anatomy 10"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("abilities", () => {
|
||||
it("renders mid (defensive) abilities", () => {
|
||||
renderStatBlock(GOBLIN_WARRIOR);
|
||||
expect(screen.getByText(ABILITY_MID_NAME_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(ABILITY_MID_DESC_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("speed and attacks", () => {
|
||||
it("renders speed", () => {
|
||||
renderStatBlock(GOBLIN_WARRIOR);
|
||||
expect(screen.getByText("Speed")).toBeInTheDocument();
|
||||
expect(screen.getByText("25 feet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders attacks", () => {
|
||||
renderStatBlock(GOBLIN_WARRIOR);
|
||||
expect(screen.getByText(ATTACK_NAME_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(ATTACK_DAMAGE_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("spellcasting", () => {
|
||||
it("renders spellcasting entry with header", () => {
|
||||
renderStatBlock(NAUNET);
|
||||
expect(screen.getByText(SPELLCASTING_ENTRY_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText("DC 25, attack +17")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders ranked spells", () => {
|
||||
renderStatBlock(NAUNET);
|
||||
expect(screen.getByText("Rank 4:")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Unfettered Movement (Constant)"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders cantrips", () => {
|
||||
renderStatBlock(NAUNET);
|
||||
expect(screen.getByText("Cantrips:")).toBeInTheDocument();
|
||||
expect(screen.getByText("Detect Magic")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits spellcasting when absent", () => {
|
||||
renderStatBlock(GOBLIN_WARRIOR);
|
||||
expect(screen.queryByText(CANTRIPS_REGEX)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("equipment section", () => {
|
||||
const CREATURE_WITH_EQUIPMENT: Pf2eCreature = {
|
||||
...GOBLIN_WARRIOR,
|
||||
id: creatureId("test:equipped"),
|
||||
name: "Equipped NPC",
|
||||
items: "longsword, leather armor",
|
||||
equipment: [
|
||||
{
|
||||
name: "Giant Wasp Venom",
|
||||
level: 7,
|
||||
category: "poison",
|
||||
traits: ["consumable", "poison"],
|
||||
description: "A deadly poison extracted from giant wasps.",
|
||||
},
|
||||
{
|
||||
name: "Scroll of Teleport",
|
||||
level: 11,
|
||||
category: "scroll",
|
||||
traits: ["consumable", "magical", "scroll"],
|
||||
description: "A scroll containing Teleport.",
|
||||
spellName: "Teleport",
|
||||
spellRank: 6,
|
||||
},
|
||||
{
|
||||
name: "Plain Talisman",
|
||||
level: 1,
|
||||
traits: ["magical"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("renders Equipment section with item names", () => {
|
||||
renderStatBlock(CREATURE_WITH_EQUIPMENT);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Equipment" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Giant Wasp Venom")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders scroll name as-is from Foundry data", () => {
|
||||
renderStatBlock(CREATURE_WITH_EQUIPMENT);
|
||||
expect(screen.getByText(SCROLL_NAME_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render Equipment section when creature has no equipment", () => {
|
||||
renderStatBlock(GOBLIN_WARRIOR);
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "Equipment" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders equipment items with descriptions as clickable buttons", () => {
|
||||
renderStatBlock(CREATURE_WITH_EQUIPMENT);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Giant Wasp Venom" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders equipment items without descriptions as plain text", () => {
|
||||
renderStatBlock(CREATURE_WITH_EQUIPMENT);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Plain Talisman" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Plain Talisman")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Items line with mundane item names", () => {
|
||||
renderStatBlock(CREATURE_WITH_EQUIPMENT);
|
||||
expect(screen.getByText("Items")).toBeInTheDocument();
|
||||
expect(screen.getByText("longsword, leather armor")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clickable spells", () => {
|
||||
const SPELLCASTER: Pf2eCreature = {
|
||||
...NAUNET,
|
||||
id: creatureId("test:spellcaster"),
|
||||
name: "Spellcaster",
|
||||
spellcasting: [
|
||||
{
|
||||
name: "Divine Innate Spells",
|
||||
headerText: "DC 30, attack +20",
|
||||
atWill: [{ name: "Detect Magic", rank: 1 }],
|
||||
daily: [
|
||||
{
|
||||
uses: 4,
|
||||
each: true,
|
||||
spells: [
|
||||
{
|
||||
name: "Heal",
|
||||
description: "You channel positive energy to heal.",
|
||||
rank: 4,
|
||||
usesPerDay: 3,
|
||||
},
|
||||
{ name: "Restoration", rank: 4 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(globalThis, "matchMedia", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: vi.fn().mockImplementation(() => ({
|
||||
matches: true,
|
||||
media: "(min-width: 1024px)",
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a spell with a description as a clickable button", () => {
|
||||
renderStatBlock(SPELLCASTER);
|
||||
expect(screen.getByRole("button", { name: "Heal" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a spell without description as plain text (not a button)", () => {
|
||||
renderStatBlock(SPELLCASTER);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Restoration" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Restoration")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders usesPerDay as plain text alongside the spell button", () => {
|
||||
renderStatBlock(SPELLCASTER);
|
||||
expect(screen.getByText(USES_PER_DAY_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the spell popover when a spell button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderStatBlock(SPELLCASTER);
|
||||
await user.click(screen.getByRole("button", { name: "Heal" }));
|
||||
expect(screen.getByText(HEAL_DESCRIPTION_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes the popover when Escape is pressed", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderStatBlock(SPELLCASTER);
|
||||
await user.click(screen.getByRole("button", { name: "Heal" }));
|
||||
expect(screen.getByText(HEAL_DESCRIPTION_REGEX)).toBeInTheDocument();
|
||||
await user.keyboard("{Escape}");
|
||||
expect(
|
||||
screen.queryByText(HEAL_DESCRIPTION_REGEX),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,13 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createRef } from "react";
|
||||
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 { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import {
|
||||
@@ -13,6 +16,7 @@ import {
|
||||
} from "../player-character-section.js";
|
||||
|
||||
const CREATE_FIRST_PC_REGEX = /create your first player character/i;
|
||||
const ADD_PARTY_REGEX = /add party to encounter/i;
|
||||
|
||||
beforeAll(() => {
|
||||
polyfillDialog();
|
||||
@@ -33,21 +37,28 @@ beforeAll(() => {
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function renderSection() {
|
||||
function renderSection(playerCharacters?: PlayerCharacter[]) {
|
||||
const ref = createRef<PlayerCharacterSectionHandle>();
|
||||
const adapters = createTestAdapters({ playerCharacters });
|
||||
const result = render(<PlayerCharacterSection ref={ref} />, {
|
||||
wrapper: AllProviders,
|
||||
wrapper: ({ children }) => (
|
||||
<AllProviders adapters={adapters}>{children}</AllProviders>
|
||||
),
|
||||
});
|
||||
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", () => {
|
||||
it("openManagement ref handle opens the management dialog", async () => {
|
||||
const { ref } = renderSection();
|
||||
|
||||
const handle = ref.current;
|
||||
if (!handle) throw new Error("ref not set");
|
||||
act(() => handle.openManagement());
|
||||
openManagement(ref);
|
||||
|
||||
// Management dialog should now be open with its title visible
|
||||
await waitFor(() => {
|
||||
@@ -63,9 +74,7 @@ describe("PlayerCharacterSection", () => {
|
||||
const user = userEvent.setup();
|
||||
const { ref } = renderSection();
|
||||
|
||||
const handle = ref.current;
|
||||
if (!handle) throw new Error("ref not set");
|
||||
act(() => handle.openManagement());
|
||||
openManagement(ref);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
@@ -83,9 +92,7 @@ describe("PlayerCharacterSection", () => {
|
||||
const user = userEvent.setup();
|
||||
const { ref } = renderSection();
|
||||
|
||||
const handle = ref.current;
|
||||
if (!handle) throw new Error("ref not set");
|
||||
act(() => handle.openManagement());
|
||||
openManagement(ref);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
@@ -105,4 +112,25 @@ describe("PlayerCharacterSection", () => {
|
||||
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
|
||||
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 userEvent from "@testing-library/user-event";
|
||||
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 LEVEL_REGEX = /^Lv /;
|
||||
const ADD_PARTY_REGEX = /add party to encounter/i;
|
||||
|
||||
import { PlayerManagement } from "../player-management.js";
|
||||
|
||||
@@ -47,6 +52,8 @@ function renderManagement(
|
||||
onEdit: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onCreate: vi.fn(),
|
||||
inEncounterIds: new Set<PlayerCharacterId>(),
|
||||
onAddParty: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
return { ...render(<PlayerManagement {...props} />), props };
|
||||
@@ -117,4 +124,52 @@ describe("PlayerManagement", () => {
|
||||
await user.click(screen.getByRole("button", { name: "Add" }));
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,15 +37,18 @@ function renderModal(open = true) {
|
||||
}
|
||||
|
||||
describe("SettingsModal", () => {
|
||||
it("renders edition section with 'Rules Edition' label", () => {
|
||||
it("renders game system section with all three options", () => {
|
||||
renderModal();
|
||||
expect(screen.getByText("Rules Edition")).toBeInTheDocument();
|
||||
expect(screen.getByText("Game System")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "5e (2014)" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "5.5e (2024)" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Pathfinder 2e" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders theme toggle buttons", () => {
|
||||
@@ -60,8 +63,11 @@ describe("SettingsModal", () => {
|
||||
renderModal();
|
||||
const btn5e = screen.getByRole("button", { name: "5e (2014)" });
|
||||
await user.click(btn5e);
|
||||
// After clicking 5e, it should have the active style
|
||||
expect(btn5e.className).toContain("bg-accent");
|
||||
expect(btn5e).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByRole("button", { name: "5.5e (2024)" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
it("clicking a theme button switches the active theme", async () => {
|
||||
@@ -69,7 +75,7 @@ describe("SettingsModal", () => {
|
||||
renderModal();
|
||||
const darkBtn = screen.getByRole("button", { name: "Dark" });
|
||||
await user.click(darkBtn);
|
||||
expect(darkBtn.className).toContain("bg-accent");
|
||||
expect(darkBtn).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
|
||||
it("close button calls onClose", async () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||
import { AdapterProvider } from "../../contexts/adapter-context.js";
|
||||
import { RulesEditionProvider } from "../../contexts/rules-edition-context.js";
|
||||
import { SourceFetchPrompt } from "../source-fetch-prompt.js";
|
||||
|
||||
const MONSTER_MANUAL_REGEX = /Monster Manual/;
|
||||
@@ -36,12 +37,14 @@ function renderPrompt(sourceCode = "MM") {
|
||||
code === "MM" ? "Monster Manual" : code,
|
||||
};
|
||||
const result = render(
|
||||
<AdapterProvider adapters={adapters}>
|
||||
<SourceFetchPrompt
|
||||
sourceCode={sourceCode}
|
||||
onSourceLoaded={onSourceLoaded}
|
||||
/>
|
||||
</AdapterProvider>,
|
||||
<RulesEditionProvider>
|
||||
<AdapterProvider adapters={adapters}>
|
||||
<SourceFetchPrompt
|
||||
sourceCode={sourceCode}
|
||||
onSourceLoaded={onSourceLoaded}
|
||||
/>
|
||||
</AdapterProvider>
|
||||
</RulesEditionProvider>,
|
||||
);
|
||||
return { ...result, onSourceLoaded };
|
||||
}
|
||||
@@ -62,7 +65,7 @@ describe("SourceFetchPrompt", () => {
|
||||
});
|
||||
|
||||
it("Load calls fetchAndCacheSource and onSourceLoaded on success", async () => {
|
||||
mockFetchAndCacheSource.mockResolvedValueOnce(undefined);
|
||||
mockFetchAndCacheSource.mockResolvedValueOnce({ skippedNames: [] });
|
||||
const user = userEvent.setup();
|
||||
const { onSourceLoaded } = renderPrompt();
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ beforeAll(() => {
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function renderWithSources(sources: CachedSourceInfo[] = []) {
|
||||
function renderWithSources(sources: CachedSourceInfo[] = []): void {
|
||||
const adapters = createTestAdapters();
|
||||
// Wire getCachedSources to return the provided sources initially,
|
||||
// then empty after clear operations
|
||||
@@ -36,7 +36,7 @@ function renderWithSources(sources: CachedSourceInfo[] = []) {
|
||||
adapters.bestiaryCache = {
|
||||
...adapters.bestiaryCache,
|
||||
getCachedSources: () => Promise.resolve(currentSources),
|
||||
clearSource(sourceCode) {
|
||||
clearSource(_system, sourceCode) {
|
||||
currentSources = currentSources.filter(
|
||||
(s) => s.sourceCode !== sourceCode,
|
||||
);
|
||||
@@ -57,14 +57,14 @@ function renderWithSources(sources: CachedSourceInfo[] = []) {
|
||||
|
||||
describe("SourceManager", () => {
|
||||
it("shows 'No cached sources' empty state when no sources", async () => {
|
||||
void renderWithSources([]);
|
||||
renderWithSources([]);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No cached sources")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("lists cached sources with display name and creature count", async () => {
|
||||
void renderWithSources([
|
||||
renderWithSources([
|
||||
{
|
||||
sourceCode: "mm",
|
||||
displayName: "Monster Manual",
|
||||
@@ -88,7 +88,7 @@ describe("SourceManager", () => {
|
||||
|
||||
it("Clear All button removes all sources", async () => {
|
||||
const user = userEvent.setup();
|
||||
void renderWithSources([
|
||||
renderWithSources([
|
||||
{
|
||||
sourceCode: "mm",
|
||||
displayName: "Monster Manual",
|
||||
@@ -110,7 +110,7 @@ describe("SourceManager", () => {
|
||||
|
||||
it("individual source delete button removes that source", async () => {
|
||||
const user = userEvent.setup();
|
||||
void renderWithSources([
|
||||
renderWithSources([
|
||||
{
|
||||
sourceCode: "mm",
|
||||
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,11 +1,13 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import type { Creature } from "@initiative/domain";
|
||||
import { creatureId } from "@initiative/domain";
|
||||
import type { Creature, HpVariant } from "@initiative/domain";
|
||||
import { combatantId, creatureId } from "@initiative/domain";
|
||||
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 { StatBlock } from "../stat-block.js";
|
||||
import { DndStatBlock as StatBlock } from "../dnd-stat-block.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
@@ -46,10 +48,30 @@ const GOBLIN: Creature = {
|
||||
skills: "Stealth +6",
|
||||
senses: "darkvision 60 ft., passive Perception 9",
|
||||
languages: "Common, Goblin",
|
||||
traits: [{ name: "Nimble Escape", text: "Disengage or Hide as bonus." }],
|
||||
actions: [{ name: "Scimitar", text: "Melee: +4 to hit, 5 slashing." }],
|
||||
bonusActions: [{ name: "Nimble", text: "Disengage or Hide." }],
|
||||
reactions: [{ name: "Redirect", text: "Redirect attack to ally." }],
|
||||
traits: [
|
||||
{
|
||||
name: "Nimble Escape",
|
||||
segments: [{ type: "text", value: "Disengage or Hide as bonus." }],
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
name: "Scimitar",
|
||||
segments: [{ type: "text", value: "Melee: +4 to hit, 5 slashing." }],
|
||||
},
|
||||
],
|
||||
bonusActions: [
|
||||
{
|
||||
name: "Nimble",
|
||||
segments: [{ type: "text", value: "Disengage or Hide." }],
|
||||
},
|
||||
],
|
||||
reactions: [
|
||||
{
|
||||
name: "Redirect",
|
||||
segments: [{ type: "text", value: "Redirect attack to ally." }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const DRAGON: Creature = {
|
||||
@@ -75,17 +97,31 @@ const DRAGON: Creature = {
|
||||
legendaryActions: {
|
||||
preamble: "The dragon can take 3 legendary actions.",
|
||||
entries: [
|
||||
{ name: "Detect", text: "Wisdom (Perception) check." },
|
||||
{ name: "Tail Attack", text: "Tail attack." },
|
||||
{
|
||||
name: "Detect",
|
||||
segments: [
|
||||
{ type: "text" as const, value: "Wisdom (Perception) check." },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Tail Attack",
|
||||
segments: [{ type: "text" as const, value: "Tail attack." }],
|
||||
},
|
||||
],
|
||||
},
|
||||
spellcasting: [
|
||||
{
|
||||
name: "Innate Spellcasting",
|
||||
headerText: "The dragon's spellcasting ability is Charisma.",
|
||||
atWill: ["detect magic", "suggestion"],
|
||||
daily: [{ uses: 3, each: true, spells: ["fireball", "wall of fire"] }],
|
||||
restLong: [{ uses: 1, each: false, spells: ["wish"] }],
|
||||
atWill: [{ name: "detect magic" }, { name: "suggestion" }],
|
||||
daily: [
|
||||
{
|
||||
uses: 3,
|
||||
each: true,
|
||||
spells: [{ name: "fireball" }, { name: "wall of fire" }],
|
||||
},
|
||||
],
|
||||
restLong: [{ uses: 1, each: false, spells: [{ name: "wish" }] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -94,6 +130,19 @@ function renderStatBlock(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("header", () => {
|
||||
it("renders creature name", () => {
|
||||
@@ -141,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", () => {
|
||||
it("renders all 6 ability labels", () => {
|
||||
renderStatBlock(GOBLIN);
|
||||
|
||||
@@ -3,23 +3,30 @@ import { useId, useState } from "react";
|
||||
import { useAdapters } from "../contexts/adapter-context.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
|
||||
const DEFAULT_BASE_URL =
|
||||
const DND_BASE_URL =
|
||||
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/";
|
||||
|
||||
const PF2E_BASE_URL =
|
||||
"https://raw.githubusercontent.com/foundryvtt/pf2e/v13-dev/packs/pf2e/";
|
||||
|
||||
export function BulkImportPrompt() {
|
||||
const { bestiaryIndex } = useAdapters();
|
||||
const { bestiaryIndex, pf2eBestiaryIndex } = useAdapters();
|
||||
const { fetchAndCacheSource, isSourceCached, refreshCache } =
|
||||
useBestiaryContext();
|
||||
const { state: importState, startImport, reset } = useBulkImportContext();
|
||||
const { dismissPanel } = useSidePanelContext();
|
||||
const { edition } = useRulesEditionContext();
|
||||
|
||||
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
|
||||
const indexPort = edition === "pf2e" ? pf2eBestiaryIndex : bestiaryIndex;
|
||||
const defaultUrl = edition === "pf2e" ? PF2E_BASE_URL : DND_BASE_URL;
|
||||
const [baseUrl, setBaseUrl] = useState(defaultUrl);
|
||||
const baseUrlId = useId();
|
||||
const totalSources = bestiaryIndex.getAllSourceCodes().length;
|
||||
const totalSources = indexPort.getAllSourceCodes().length;
|
||||
|
||||
const handleStart = (url: string) => {
|
||||
startImport(url, fetchAndCacheSource, isSourceCached, refreshCache);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import {
|
||||
type CombatantId,
|
||||
type ConditionId,
|
||||
type ConditionEntry,
|
||||
type CreatureId,
|
||||
deriveHpStatus,
|
||||
type PersistentDamageEntry,
|
||||
type PlayerIcon,
|
||||
type RollMode,
|
||||
} from "@initiative/domain";
|
||||
@@ -10,6 +11,7 @@ import { Brain, Pencil, X } from "lucide-react";
|
||||
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useEncounterContext } from "../contexts/encounter-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 { useLongPress } from "../hooks/use-long-press.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
@@ -18,6 +20,7 @@ import { ConditionPicker } from "./condition-picker.js";
|
||||
import { ConditionTags } from "./condition-tags.js";
|
||||
import { D20Icon } from "./d20-icon.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 { RollModeMenu } from "./roll-mode-menu.js";
|
||||
import { ConfirmButton } from "./ui/confirm-button.js";
|
||||
@@ -31,7 +34,8 @@ interface Combatant {
|
||||
readonly currentHp?: number;
|
||||
readonly tempHp?: number;
|
||||
readonly ac?: number;
|
||||
readonly conditions?: readonly ConditionId[];
|
||||
readonly conditions?: readonly ConditionEntry[];
|
||||
readonly persistentDamage?: readonly PersistentDamageEntry[];
|
||||
readonly isConcentrating?: boolean;
|
||||
readonly color?: string;
|
||||
readonly icon?: string;
|
||||
@@ -198,6 +202,7 @@ function ClickableHp({
|
||||
onSetTempHp: (value: number) => void;
|
||||
}>) {
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
const status = deriveHpStatus(currentHp, maxHp);
|
||||
|
||||
if (maxHp === undefined) {
|
||||
@@ -205,7 +210,7 @@ function ClickableHp({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center">
|
||||
<div ref={anchorRef} className="relative flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPopoverOpen(true)}
|
||||
@@ -226,6 +231,7 @@ function ClickableHp({
|
||||
)}
|
||||
{!!popoverOpen && (
|
||||
<HpAdjustPopover
|
||||
anchorRef={anchorRef}
|
||||
onAdjust={onAdjust}
|
||||
onSetTempHp={onSetTempHp}
|
||||
onClose={() => setPopoverOpen(false)}
|
||||
@@ -415,12 +421,14 @@ function InitiativeDisplay({
|
||||
function rowBorderClass(
|
||||
isActive: boolean,
|
||||
isConcentrating: boolean | undefined,
|
||||
isPf2e: boolean,
|
||||
): 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";
|
||||
if (isActive)
|
||||
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";
|
||||
}
|
||||
@@ -448,14 +456,26 @@ export function CombatantRow({
|
||||
setTempHp,
|
||||
setAc,
|
||||
toggleCondition,
|
||||
setConditionValue,
|
||||
decrementCondition,
|
||||
toggleConcentration,
|
||||
addPersistentDamage,
|
||||
removePersistentDamage,
|
||||
} = useEncounterContext();
|
||||
const { selectedCreatureId, showCreature, toggleCollapse } =
|
||||
useSidePanelContext();
|
||||
const {
|
||||
selectedCreatureId,
|
||||
selectedCombatantId,
|
||||
showCreature,
|
||||
toggleCollapse,
|
||||
} = useSidePanelContext();
|
||||
const { handleRollInitiative } = useInitiativeRollsContext();
|
||||
const { edition } = useRulesEditionContext();
|
||||
const isPf2e = edition === "pf2e";
|
||||
|
||||
// Derive what was previously conditional props
|
||||
const isStatBlockOpen = combatant.creatureId === selectedCreatureId;
|
||||
const isStatBlockOpen =
|
||||
combatant.creatureId === selectedCreatureId &&
|
||||
combatant.id === selectedCombatantId;
|
||||
const { creatureId } = combatant;
|
||||
const hasStatBlock = !!creatureId;
|
||||
const onToggleStatBlock = hasStatBlock
|
||||
@@ -463,7 +483,7 @@ export function CombatantRow({
|
||||
if (isStatBlockOpen) {
|
||||
toggleCollapse();
|
||||
} else {
|
||||
showCreature(creatureId);
|
||||
showCreature(creatureId, combatant.id);
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
@@ -493,12 +513,16 @@ export function CombatantRow({
|
||||
const tempHpDropped =
|
||||
prevTempHp !== undefined && (combatant.tempHp ?? 0) < prevTempHp;
|
||||
|
||||
if ((realHpDropped || tempHpDropped) && combatant.isConcentrating) {
|
||||
if (
|
||||
(realHpDropped || tempHpDropped) &&
|
||||
combatant.isConcentrating &&
|
||||
!isPf2e
|
||||
) {
|
||||
setIsPulsing(true);
|
||||
clearTimeout(pulseTimerRef.current);
|
||||
pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200);
|
||||
}
|
||||
}, [currentHp, combatant.tempHp, combatant.isConcentrating]);
|
||||
}, [currentHp, combatant.tempHp, combatant.isConcentrating, isPf2e]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!combatant.isConcentrating) {
|
||||
@@ -516,24 +540,33 @@ export function CombatantRow({
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group rounded-lg pr-3 transition-colors",
|
||||
rowBorderClass(isActive, combatant.isConcentrating),
|
||||
rowBorderClass(isActive, combatant.isConcentrating, isPf2e),
|
||||
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">
|
||||
{/* Concentration */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleConcentration(id)}
|
||||
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>
|
||||
<div
|
||||
className={cn(
|
||||
"grid items-center gap-1.5 py-3 sm:gap-3 sm:py-2",
|
||||
isPf2e
|
||||
? "grid-cols-[3rem_auto_1fr_auto_2rem] pl-3 sm:grid-cols-[3.5rem_auto_1fr_auto_2rem]"
|
||||
: "grid-cols-[2rem_3rem_auto_1fr_auto_2rem] sm:grid-cols-[2rem_3.5rem_auto_1fr_auto_2rem]",
|
||||
)}
|
||||
>
|
||||
{/* Concentration — hidden in PF2e mode */}
|
||||
{!isPf2e && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleConcentration(id)}
|
||||
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 */}
|
||||
<div className="rounded-md bg-muted/30 px-1">
|
||||
@@ -585,14 +618,31 @@ export function CombatantRow({
|
||||
<ConditionTags
|
||||
conditions={combatant.conditions}
|
||||
onRemove={(conditionId) => toggleCondition(id, conditionId)}
|
||||
onDecrement={(conditionId) => decrementCondition(id, conditionId)}
|
||||
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
||||
/>
|
||||
>
|
||||
{isPf2e && (
|
||||
<PersistentDamageTags
|
||||
entries={combatant.persistentDamage}
|
||||
onRemove={(damageType) =>
|
||||
removePersistentDamage(id, damageType)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</ConditionTags>
|
||||
</div>
|
||||
{!!pickerOpen && (
|
||||
<ConditionPicker
|
||||
anchorRef={conditionAnchorRef}
|
||||
activeConditions={combatant.conditions}
|
||||
activePersistentDamage={combatant.persistentDamage}
|
||||
onToggle={(conditionId) => toggleCondition(id, conditionId)}
|
||||
onSetValue={(conditionId, value) =>
|
||||
setConditionValue(id, conditionId, value)
|
||||
}
|
||||
onAddPersistentDamage={(damageType, formula) =>
|
||||
addPersistentDamage(id, damageType, formula)
|
||||
}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import {
|
||||
type ConditionEntry,
|
||||
type ConditionId,
|
||||
getConditionDescription,
|
||||
getConditionsForEdition,
|
||||
type PersistentDamageEntry,
|
||||
type PersistentDamageType,
|
||||
} from "@initiative/domain";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { Check, Flame, Minus, Plus } from "lucide-react";
|
||||
import React, { useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||
@@ -12,19 +16,29 @@ import {
|
||||
CONDITION_COLOR_CLASSES,
|
||||
CONDITION_ICON_MAP,
|
||||
} from "./condition-styles.js";
|
||||
import { PersistentDamagePicker } from "./persistent-damage-picker.js";
|
||||
import { Tooltip } from "./ui/tooltip.js";
|
||||
|
||||
interface ConditionPickerProps {
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
activeConditions: readonly ConditionId[] | undefined;
|
||||
activeConditions: readonly ConditionEntry[] | undefined;
|
||||
activePersistentDamage?: readonly PersistentDamageEntry[];
|
||||
onToggle: (conditionId: ConditionId) => void;
|
||||
onSetValue: (conditionId: ConditionId, value: number) => void;
|
||||
onAddPersistentDamage?: (
|
||||
damageType: PersistentDamageType,
|
||||
formula: string,
|
||||
) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ConditionPicker({
|
||||
anchorRef,
|
||||
activeConditions,
|
||||
activePersistentDamage,
|
||||
onToggle,
|
||||
onSetValue,
|
||||
onAddPersistentDamage,
|
||||
onClose,
|
||||
}: Readonly<ConditionPickerProps>) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -34,6 +48,12 @@ export function ConditionPicker({
|
||||
maxHeight: number;
|
||||
} | null>(null);
|
||||
|
||||
const [editing, setEditing] = useState<{
|
||||
id: ConditionId;
|
||||
value: number;
|
||||
} | null>(null);
|
||||
const [showPersistentDamage, setShowPersistentDamage] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const anchor = anchorRef.current;
|
||||
const el = ref.current;
|
||||
@@ -59,7 +79,54 @@ export function ConditionPicker({
|
||||
|
||||
const { edition } = useRulesEditionContext();
|
||||
const conditions = getConditionsForEdition(edition);
|
||||
const active = new Set(activeConditions ?? []);
|
||||
const activeMap = new Map(
|
||||
(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(
|
||||
<div
|
||||
@@ -71,41 +138,138 @@ export function ConditionPicker({
|
||||
: { visibility: "hidden" as const }
|
||||
}
|
||||
>
|
||||
{conditions.map((def) => {
|
||||
{conditions.map((def, index) => {
|
||||
const Icon = CONDITION_ICON_MAP[def.iconName];
|
||||
if (!Icon) return null;
|
||||
const isActive = active.has(def.id);
|
||||
const isActive = activeMap.has(def.id);
|
||||
const activeValue = activeMap.get(def.id);
|
||||
const isEditing = editing?.id === def.id;
|
||||
const colorClass =
|
||||
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||
|
||||
const handleClick = () => {
|
||||
if (def.valued && edition === "pf2e") {
|
||||
const current = activeMap.get(def.id);
|
||||
setEditing({
|
||||
id: def.id,
|
||||
value: current ?? 1,
|
||||
});
|
||||
} else {
|
||||
onToggle(def.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={def.id}
|
||||
content={getConditionDescription(def, edition)}
|
||||
className="block"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors hover:bg-hover-neutral-bg",
|
||||
isActive && "bg-card/50",
|
||||
)}
|
||||
onClick={() => onToggle(def.id)}
|
||||
<React.Fragment key={def.id}>
|
||||
{index === persistentDamageInsertIndex && persistentDamageEntry}
|
||||
<Tooltip
|
||||
content={getConditionDescription(def, edition)}
|
||||
className="block"
|
||||
>
|
||||
<Icon
|
||||
size={14}
|
||||
className={isActive ? colorClass : "text-muted-foreground"}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
isActive ? "text-foreground" : "text-muted-foreground"
|
||||
}
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors",
|
||||
(isActive || isEditing) && "bg-card/50",
|
||||
)}
|
||||
>
|
||||
{def.label}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-1 items-center gap-2"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Icon
|
||||
size={14}
|
||||
className={
|
||||
isActive || isEditing
|
||||
? colorClass
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
isActive || isEditing
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{def.label}
|
||||
</span>
|
||||
</button>
|
||||
{isActive && def.valued && edition === "pf2e" && !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">
|
||||
{activeValue}
|
||||
</span>
|
||||
)}
|
||||
{isEditing && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-0.5 text-foreground hover:bg-accent/40"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (editing.value > 1) {
|
||||
setEditing({
|
||||
...editing,
|
||||
value: editing.value - 1,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</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>
|
||||
{(() => {
|
||||
const atMax =
|
||||
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>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
@@ -1,42 +1,92 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
Anchor,
|
||||
ArrowDown,
|
||||
Ban,
|
||||
BatteryLow,
|
||||
BrainCog,
|
||||
CircleHelp,
|
||||
CloudFog,
|
||||
Drama,
|
||||
Droplet,
|
||||
Droplets,
|
||||
EarOff,
|
||||
Eclipse,
|
||||
Eye,
|
||||
EyeClosed,
|
||||
EyeOff,
|
||||
Flame,
|
||||
FlaskConical,
|
||||
Footprints,
|
||||
Gem,
|
||||
Ghost,
|
||||
Hand,
|
||||
Heart,
|
||||
HeartCrack,
|
||||
HeartPulse,
|
||||
Link,
|
||||
Moon,
|
||||
Orbit,
|
||||
PersonStanding,
|
||||
ShieldMinus,
|
||||
ShieldOff,
|
||||
Siren,
|
||||
Skull,
|
||||
Snail,
|
||||
Snowflake,
|
||||
Sparkle,
|
||||
Sparkles,
|
||||
Sun,
|
||||
Sword,
|
||||
TrendingDown,
|
||||
Wind,
|
||||
Zap,
|
||||
ZapOff,
|
||||
} from "lucide-react";
|
||||
|
||||
export const CONDITION_ICON_MAP: Record<string, LucideIcon> = {
|
||||
EyeOff,
|
||||
Heart,
|
||||
EarOff,
|
||||
BatteryLow,
|
||||
Siren,
|
||||
Hand,
|
||||
Ban,
|
||||
Ghost,
|
||||
ZapOff,
|
||||
Gem,
|
||||
Droplet,
|
||||
Anchor,
|
||||
ArrowDown,
|
||||
Ban,
|
||||
BatteryLow,
|
||||
BrainCog,
|
||||
CircleHelp,
|
||||
CloudFog,
|
||||
Drama,
|
||||
Droplet,
|
||||
Droplets,
|
||||
EarOff,
|
||||
Eclipse,
|
||||
Eye,
|
||||
EyeClosed,
|
||||
EyeOff,
|
||||
Flame,
|
||||
FlaskConical,
|
||||
Footprints,
|
||||
Gem,
|
||||
Ghost,
|
||||
Hand,
|
||||
Heart,
|
||||
HeartCrack,
|
||||
HeartPulse,
|
||||
Link,
|
||||
ShieldMinus,
|
||||
Snail,
|
||||
Sparkles,
|
||||
Moon,
|
||||
Orbit,
|
||||
PersonStanding,
|
||||
ShieldMinus,
|
||||
ShieldOff,
|
||||
Siren,
|
||||
Skull,
|
||||
Snail,
|
||||
Snowflake,
|
||||
Sparkle,
|
||||
Sparkles,
|
||||
Sun,
|
||||
Sword,
|
||||
TrendingDown,
|
||||
Wind,
|
||||
Zap,
|
||||
ZapOff,
|
||||
};
|
||||
|
||||
export const CONDITION_COLOR_CLASSES: Record<string, string> = {
|
||||
@@ -44,11 +94,14 @@ export const CONDITION_COLOR_CLASSES: Record<string, string> = {
|
||||
pink: "text-pink-400",
|
||||
amber: "text-amber-400",
|
||||
orange: "text-orange-400",
|
||||
purple: "text-purple-400",
|
||||
gray: "text-gray-400",
|
||||
violet: "text-violet-400",
|
||||
yellow: "text-yellow-400",
|
||||
slate: "text-slate-400",
|
||||
green: "text-green-400",
|
||||
lime: "text-lime-400",
|
||||
indigo: "text-indigo-400",
|
||||
sky: "text-sky-400",
|
||||
red: "text-red-400",
|
||||
};
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
CONDITION_DEFINITIONS,
|
||||
type ConditionEntry,
|
||||
type ConditionId,
|
||||
getConditionDescription,
|
||||
} from "@initiative/domain";
|
||||
import { Plus } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import {
|
||||
@@ -13,48 +15,64 @@ import {
|
||||
import { Tooltip } from "./ui/tooltip.js";
|
||||
|
||||
interface ConditionTagsProps {
|
||||
conditions: readonly ConditionId[] | undefined;
|
||||
conditions: readonly ConditionEntry[] | undefined;
|
||||
onRemove: (conditionId: ConditionId) => void;
|
||||
onDecrement: (conditionId: ConditionId) => void;
|
||||
onOpenPicker: () => void;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function ConditionTags({
|
||||
conditions,
|
||||
onRemove,
|
||||
onDecrement,
|
||||
onOpenPicker,
|
||||
children,
|
||||
}: Readonly<ConditionTagsProps>) {
|
||||
const { edition } = useRulesEditionContext();
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-0.5">
|
||||
{conditions?.map((condId) => {
|
||||
const def = CONDITION_DEFINITIONS.find((d) => d.id === condId);
|
||||
{conditions?.map((entry) => {
|
||||
const def = CONDITION_DEFINITIONS.find((d) => d.id === entry.id);
|
||||
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";
|
||||
const tooltipLabel =
|
||||
entry.value === undefined ? def.label : `${def.label} ${entry.value}`;
|
||||
return (
|
||||
<Tooltip
|
||||
key={condId}
|
||||
content={`${def.label}:\n${getConditionDescription(def, edition)}`}
|
||||
key={entry.id}
|
||||
content={`${tooltipLabel}:\n${getConditionDescription(def, edition)}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${def.label}`}
|
||||
className={cn(
|
||||
"inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
|
||||
"inline-flex items-center gap-0.5 rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
|
||||
colorClass,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(condId);
|
||||
if (entry.value === undefined) {
|
||||
onRemove(entry.id);
|
||||
} else {
|
||||
onDecrement(entry.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon size={14} />
|
||||
{entry.value !== undefined && (
|
||||
<span className="font-medium text-xs leading-none">
|
||||
{entry.value}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
{children}
|
||||
<button
|
||||
type="button"
|
||||
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);
|
||||
}
|
||||
@@ -11,21 +11,29 @@ import {
|
||||
import { CrPicker } from "./cr-picker.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
|
||||
const TIER_LABEL_MAP: Record<
|
||||
RulesEdition,
|
||||
Record<DifficultyTier, { label: string; color: string }>
|
||||
const TIER_LABEL_MAP: Partial<
|
||||
Record<RulesEdition, Record<DifficultyTier, { label: string; color: string }>>
|
||||
> = {
|
||||
"5.5e": {
|
||||
0: { label: "Trivial", color: "text-muted-foreground" },
|
||||
1: { label: "Low", color: "text-green-500" },
|
||||
2: { label: "Moderate", color: "text-yellow-500" },
|
||||
3: { label: "High", color: "text-red-500" },
|
||||
4: { label: "High", color: "text-red-500" },
|
||||
},
|
||||
"5e": {
|
||||
0: { label: "Easy", color: "text-muted-foreground" },
|
||||
1: { label: "Medium", color: "text-green-500" },
|
||||
2: { label: "Hard", color: "text-yellow-500" },
|
||||
3: { label: "Deadly", color: "text-red-500" },
|
||||
4: { label: "Deadly", color: "text-red-500" },
|
||||
},
|
||||
pf2e: {
|
||||
0: { label: "Trivial", color: "text-muted-foreground" },
|
||||
1: { label: "Low", color: "text-green-500" },
|
||||
2: { label: "Moderate", color: "text-yellow-500" },
|
||||
3: { label: "Severe", color: "text-orange-500" },
|
||||
4: { label: "Extreme", color: "text-red-500" },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -33,6 +41,9 @@ const TIER_LABEL_MAP: Record<
|
||||
const SHORT_LABELS: Readonly<Record<string, string>> = {
|
||||
Moderate: "Mod",
|
||||
Medium: "Med",
|
||||
Trivial: "Triv",
|
||||
Severe: "Sev",
|
||||
Extreme: "Ext",
|
||||
};
|
||||
|
||||
function shortLabel(label: string): string {
|
||||
@@ -108,6 +119,54 @@ function NpcRow({
|
||||
);
|
||||
}
|
||||
|
||||
function Pf2eNpcRow({
|
||||
entry,
|
||||
onToggleSide,
|
||||
}: {
|
||||
entry: BreakdownCombatant;
|
||||
onToggleSide: () => void;
|
||||
}) {
|
||||
const isParty = entry.side === "party";
|
||||
const targetSide = isParty ? "enemy" : "party";
|
||||
|
||||
let xpDisplay: string;
|
||||
if (entry.xp == null) {
|
||||
xpDisplay = "\u2014";
|
||||
} else if (isParty) {
|
||||
xpDisplay = `\u2212${formatXp(entry.xp)}`;
|
||||
} else {
|
||||
xpDisplay = formatXp(entry.xp);
|
||||
}
|
||||
|
||||
let levelDisplay: string;
|
||||
if (entry.creatureLevel === undefined) {
|
||||
levelDisplay = "\u2014";
|
||||
} else if (entry.levelDifference === undefined) {
|
||||
levelDisplay = `Lv ${entry.creatureLevel}`;
|
||||
} else {
|
||||
const sign = entry.levelDifference >= 0 ? "+" : "";
|
||||
levelDisplay = `Lv ${entry.creatureLevel} (${sign}${entry.levelDifference})`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="col-span-4 grid grid-cols-subgrid items-center text-xs">
|
||||
<span className="min-w-0 truncate" title={entry.combatant.name}>
|
||||
{entry.combatant.name}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onToggleSide}
|
||||
aria-label={`Move ${entry.combatant.name} to ${targetSide} side`}
|
||||
>
|
||||
<ArrowLeftRight className="h-3 w-3" />
|
||||
</Button>
|
||||
<span className="text-muted-foreground">{levelDisplay}</span>
|
||||
<span className="text-right tabular-nums">{xpDisplay}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, onClose);
|
||||
@@ -117,7 +176,9 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
const breakdown = useDifficultyBreakdown();
|
||||
if (!breakdown) return null;
|
||||
|
||||
const tierConfig = TIER_LABEL_MAP[edition][breakdown.tier];
|
||||
const tierLabels = TIER_LABEL_MAP[edition];
|
||||
if (!tierLabels) return null;
|
||||
const tierConfig = tierLabels[breakdown.tier];
|
||||
|
||||
const handleToggle = (entry: BreakdownCombatant) => {
|
||||
const newSide = entry.side === "party" ? "enemy" : "party";
|
||||
@@ -127,6 +188,8 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
const isPC = (entry: BreakdownCombatant) =>
|
||||
entry.combatant.playerCharacterId != null;
|
||||
|
||||
const CreatureRow = edition === "pf2e" ? Pf2eNpcRow : NpcRow;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -141,6 +204,9 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
<div className="mb-1 text-muted-foreground text-xs">
|
||||
Party Budget ({breakdown.pcCount}{" "}
|
||||
{breakdown.pcCount === 1 ? "PC" : "PCs"})
|
||||
{breakdown.partyLevel !== undefined && (
|
||||
<> · Party Level: {breakdown.partyLevel}</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3 text-xs">
|
||||
{breakdown.thresholds.map((t) => (
|
||||
@@ -165,7 +231,7 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
isPC(entry) ? (
|
||||
<PcRow key={entry.combatant.id} entry={entry} />
|
||||
) : (
|
||||
<NpcRow
|
||||
<CreatureRow
|
||||
key={entry.combatant.id}
|
||||
entry={entry}
|
||||
onToggleSide={() => handleToggle(entry)}
|
||||
@@ -185,7 +251,7 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
isPC(entry) ? (
|
||||
<PcRow key={entry.combatant.id} entry={entry} />
|
||||
) : (
|
||||
<NpcRow
|
||||
<CreatureRow
|
||||
key={entry.combatant.id}
|
||||
entry={entry}
|
||||
onToggleSide={() => handleToggle(entry)}
|
||||
@@ -217,7 +283,9 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex justify-between border-border border-t pt-2 font-medium text-xs">
|
||||
<span>Net Monster XP</span>
|
||||
<span>
|
||||
{edition === "pf2e" ? "Net Creature XP" : "Net Monster XP"}
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{formatXp(breakdown.totalMonsterXp)}
|
||||
</span>
|
||||
|
||||
@@ -6,6 +6,7 @@ export const TIER_LABELS_5_5E: Record<DifficultyTier, string> = {
|
||||
1: "Low",
|
||||
2: "Moderate",
|
||||
3: "High",
|
||||
4: "High",
|
||||
};
|
||||
|
||||
export const TIER_LABELS_2014: Record<DifficultyTier, string> = {
|
||||
@@ -13,30 +14,49 @@ export const TIER_LABELS_2014: Record<DifficultyTier, string> = {
|
||||
1: "Medium",
|
||||
2: "Hard",
|
||||
3: "Deadly",
|
||||
4: "Deadly",
|
||||
};
|
||||
|
||||
const TIER_COLORS: Record<
|
||||
DifficultyTier,
|
||||
{ filledBars: number; color: string }
|
||||
> = {
|
||||
0: { filledBars: 0, color: "" },
|
||||
1: { filledBars: 1, color: "bg-green-500" },
|
||||
2: { filledBars: 2, color: "bg-yellow-500" },
|
||||
3: { filledBars: 3, color: "bg-red-500" },
|
||||
export const TIER_LABELS_PF2E: Record<DifficultyTier, string> = {
|
||||
0: "Trivial",
|
||||
1: "Low",
|
||||
2: "Moderate",
|
||||
3: "Severe",
|
||||
4: "Extreme",
|
||||
};
|
||||
|
||||
const BAR_HEIGHTS = ["h-2", "h-3", "h-4"] as const;
|
||||
const BAR_HEIGHTS_3 = ["h-2", "h-3", "h-4"] as const;
|
||||
const BAR_HEIGHTS_4 = ["h-1.5", "h-2", "h-3", "h-4"] as const;
|
||||
|
||||
/** Color for the Nth filled bar (1-indexed) in 4-bar mode. */
|
||||
const BAR_COLORS: Record<number, string> = {
|
||||
1: "bg-green-500",
|
||||
2: "bg-yellow-500",
|
||||
3: "bg-orange-500",
|
||||
4: "bg-red-500",
|
||||
};
|
||||
|
||||
/** For 3-bar mode, bar 3 uses red directly (skip orange). */
|
||||
const BAR_COLORS_3: Record<number, string> = {
|
||||
1: "bg-green-500",
|
||||
2: "bg-yellow-500",
|
||||
3: "bg-red-500",
|
||||
};
|
||||
|
||||
export function DifficultyIndicator({
|
||||
result,
|
||||
labels,
|
||||
barCount = 3,
|
||||
onClick,
|
||||
}: {
|
||||
result: DifficultyResult;
|
||||
labels: Record<DifficultyTier, string>;
|
||||
barCount?: 3 | 4;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const config = TIER_COLORS[result.tier];
|
||||
const barHeights = barCount === 4 ? BAR_HEIGHTS_4 : BAR_HEIGHTS_3;
|
||||
const colorMap = barCount === 4 ? BAR_COLORS : BAR_COLORS_3;
|
||||
const filledBars = result.tier;
|
||||
const label = labels[result.tier];
|
||||
const tooltip = `${label} encounter difficulty`;
|
||||
|
||||
@@ -54,13 +74,13 @@ export function DifficultyIndicator({
|
||||
onClick={onClick}
|
||||
type={onClick ? "button" : undefined}
|
||||
>
|
||||
{BAR_HEIGHTS.map((height, i) => (
|
||||
{barHeights.map((height, i) => (
|
||||
<div
|
||||
key={height}
|
||||
className={cn(
|
||||
"w-1 rounded-sm",
|
||||
height,
|
||||
i < config.filledBars ? config.color : "bg-muted",
|
||||
i < filledBars ? colorMap[i + 1] : "bg-muted",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
|
||||
+71
-53
@@ -1,11 +1,34 @@
|
||||
import type {
|
||||
CombatantId,
|
||||
Creature,
|
||||
HpRange,
|
||||
HpVariant,
|
||||
} from "@initiative/domain";
|
||||
import {
|
||||
type Creature,
|
||||
calculateInitiative,
|
||||
formatInitiativeModifier,
|
||||
hpForVariant,
|
||||
hpRange,
|
||||
} from "@initiative/domain";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import {
|
||||
PropertyLine,
|
||||
SectionDivider,
|
||||
TraitEntry,
|
||||
TraitSection,
|
||||
} from "./stat-block-parts.js";
|
||||
import type { SegmentedOption } from "./ui/segmented-control.js";
|
||||
import { SegmentedControl } from "./ui/segmented-control.js";
|
||||
|
||||
interface StatBlockProps {
|
||||
interface DndStatBlockProps {
|
||||
creature: Creature;
|
||||
combatantId?: CombatantId;
|
||||
hpVariant?: HpVariant;
|
||||
onSetHpVariant?: (
|
||||
id: CombatantId,
|
||||
variant: HpVariant | undefined,
|
||||
range: HpRange,
|
||||
) => void;
|
||||
}
|
||||
|
||||
function abilityMod(score: number): string {
|
||||
@@ -13,53 +36,25 @@ function abilityMod(score: number): string {
|
||||
return mod >= 0 ? `+${mod}` : `${mod}`;
|
||||
}
|
||||
|
||||
function PropertyLine({
|
||||
label,
|
||||
value,
|
||||
}: Readonly<{
|
||||
label: string;
|
||||
value: string | undefined;
|
||||
}>) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<span className="font-semibold">{label}</span> {value}
|
||||
</div>
|
||||
);
|
||||
/** 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 "";
|
||||
}
|
||||
|
||||
function SectionDivider() {
|
||||
return (
|
||||
<div className="my-2 h-px bg-gradient-to-r from-stat-divider-from via-stat-divider-via to-transparent" />
|
||||
);
|
||||
}
|
||||
const HP_VARIANT_OPTIONS: SegmentedOption<HpVariant | undefined>[] = [
|
||||
{ value: "min", label: "Min" },
|
||||
{ value: undefined, label: "Avg" },
|
||||
{ value: "max", label: "Max" },
|
||||
];
|
||||
|
||||
function TraitSection({
|
||||
entries,
|
||||
heading,
|
||||
}: Readonly<{
|
||||
entries: readonly { name: string; text: string }[] | undefined;
|
||||
heading?: string;
|
||||
}>) {
|
||||
if (!entries || entries.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
<SectionDivider />
|
||||
{heading ? (
|
||||
<h3 className="font-bold text-base text-stat-heading">{heading}</h3>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
{entries.map((e) => (
|
||||
<div key={e.name} className="text-sm">
|
||||
<span className="font-semibold italic">{e.name}.</span> {e.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
||||
export function DndStatBlock({
|
||||
creature,
|
||||
combatantId,
|
||||
hpVariant,
|
||||
onSetHpVariant,
|
||||
}: Readonly<DndStatBlockProps>) {
|
||||
const abilities = [
|
||||
{ label: "STR", score: creature.abilities.str },
|
||||
{ label: "DEX", score: creature.abilities.dex },
|
||||
@@ -75,6 +70,17 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
||||
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 (
|
||||
<div className="space-y-1 text-foreground">
|
||||
{/* Header */}
|
||||
@@ -108,8 +114,22 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
{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>
|
||||
<span className="font-semibold">Speed</span> {creature.speed}
|
||||
@@ -174,7 +194,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
||||
{sc.atWill && sc.atWill.length > 0 && (
|
||||
<div className="pl-2">
|
||||
<span className="font-semibold">At Will:</span>{" "}
|
||||
{sc.atWill.join(", ")}
|
||||
{sc.atWill.map((s) => s.name).join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{sc.daily?.map((d) => (
|
||||
@@ -183,7 +203,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
||||
{d.uses}/day
|
||||
{d.each ? " each" : ""}:
|
||||
</span>{" "}
|
||||
{d.spells.join(", ")}
|
||||
{d.spells.map((s) => s.name).join(", ")}
|
||||
</div>
|
||||
))}
|
||||
{sc.restLong?.map((d) => (
|
||||
@@ -195,7 +215,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
||||
{d.uses}/long rest
|
||||
{d.each ? " each" : ""}:
|
||||
</span>{" "}
|
||||
{d.spells.join(", ")}
|
||||
{d.spells.map((s) => s.name).join(", ")}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -219,9 +239,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{creature.legendaryActions.entries.map((a) => (
|
||||
<div key={a.name} className="text-sm">
|
||||
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
||||
</div>
|
||||
<TraitEntry key={a.name} trait={a} />
|
||||
))}
|
||||
</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,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||
import { Input } from "./ui/input";
|
||||
|
||||
const DIGITS_ONLY_REGEX = /^\d+$/;
|
||||
|
||||
interface HpAdjustPopoverProps {
|
||||
readonly anchorRef: React.RefObject<HTMLElement | null>;
|
||||
readonly onAdjust: (delta: number) => void;
|
||||
readonly onSetTempHp: (value: number) => void;
|
||||
readonly onClose: () => void;
|
||||
}
|
||||
|
||||
export function HpAdjustPopover({
|
||||
anchorRef,
|
||||
onAdjust,
|
||||
onSetTempHp,
|
||||
onClose,
|
||||
@@ -29,10 +32,9 @@ export function HpAdjustPopover({
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const parent = el.parentElement;
|
||||
if (!parent) return;
|
||||
const trigger = parent.getBoundingClientRect();
|
||||
const anchor = anchorRef.current;
|
||||
if (!el || !anchor) return;
|
||||
const trigger = anchor.getBoundingClientRect();
|
||||
const popover = el.getBoundingClientRect();
|
||||
const vw = document.documentElement.clientWidth;
|
||||
let left = trigger.left;
|
||||
@@ -43,7 +45,7 @@ export function HpAdjustPopover({
|
||||
left = 8;
|
||||
}
|
||||
setPos({ top: trigger.bottom + 4, left });
|
||||
}, []);
|
||||
}, [anchorRef]);
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
@@ -82,10 +84,10 @@ export function HpAdjustPopover({
|
||||
[applyDelta, onClose],
|
||||
);
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
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={
|
||||
pos
|
||||
? { top: pos.top, left: pos.left }
|
||||
@@ -144,6 +146,7 @@ export function HpAdjustPopover({
|
||||
<ShieldPlus size={14} />
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import type {
|
||||
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 {
|
||||
PropertyLine,
|
||||
SectionDivider,
|
||||
TraitSection,
|
||||
} from "./stat-block-parts.js";
|
||||
import type { SegmentedOption } from "./ui/segmented-control.js";
|
||||
import { SegmentedControl } from "./ui/segmented-control.js";
|
||||
|
||||
interface Pf2eStatBlockProps {
|
||||
creature: Pf2eCreature;
|
||||
adjustment?: "weak" | "elite";
|
||||
combatantId?: CombatantId;
|
||||
baseCreature?: Pf2eCreature;
|
||||
onSetAdjustment?: (
|
||||
id: CombatantId,
|
||||
adj: "weak" | "elite" | undefined,
|
||||
base: Pf2eCreature,
|
||||
) => void;
|
||||
}
|
||||
|
||||
const ALIGNMENTS = new Set([
|
||||
"lg",
|
||||
"ng",
|
||||
"cg",
|
||||
"ln",
|
||||
"n",
|
||||
"cn",
|
||||
"le",
|
||||
"ne",
|
||||
"ce",
|
||||
]);
|
||||
|
||||
function capitalize(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
function displayTraits(traits: readonly string[]): string[] {
|
||||
return traits.filter((t) => !ALIGNMENTS.has(t)).map(capitalize);
|
||||
}
|
||||
|
||||
function formatMod(mod: number): string {
|
||||
return mod >= 0 ? `+${mod}` : `${mod}`;
|
||||
}
|
||||
|
||||
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 = [
|
||||
{ label: "Str", mod: creature.abilityMods.str },
|
||||
{ label: "Dex", mod: creature.abilityMods.dex },
|
||||
{ label: "Con", mod: creature.abilityMods.con },
|
||||
{ label: "Int", mod: creature.abilityMods.int },
|
||||
{ label: "Wis", mod: creature.abilityMods.wis },
|
||||
{ label: "Cha", mod: creature.abilityMods.cha },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-1 text-foreground">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<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}
|
||||
</h2>
|
||||
<span className={cn("shrink-0 font-semibold text-sm", adjColor)}>
|
||||
Level {creature.level}
|
||||
</span>
|
||||
</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">
|
||||
{displayTraits(creature.traits).map((trait) => (
|
||||
<span
|
||||
key={trait}
|
||||
className="rounded border border-border bg-card px-1.5 py-0.5 text-foreground text-xs"
|
||||
>
|
||||
{trait}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-1 text-muted-foreground text-xs">
|
||||
{creature.sourceDisplayName}
|
||||
</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>
|
||||
|
||||
<SectionDivider />
|
||||
|
||||
{/* Perception, Languages, Skills */}
|
||||
<div className="space-y-0.5 text-sm">
|
||||
<div className={adjColor}>
|
||||
<span className="font-semibold">Perception</span>{" "}
|
||||
{formatInitiativeModifier(creature.perception)}
|
||||
{creature.senses || creature.perceptionDetails
|
||||
? `; ${[creature.senses, creature.perceptionDetails].filter(Boolean).join(", ")}`
|
||||
: ""}
|
||||
</div>
|
||||
<PropertyLine label="Languages" value={creature.languages} />
|
||||
<PropertyLine label="Skills" value={creature.skills} />
|
||||
</div>
|
||||
|
||||
{/* Ability Modifiers */}
|
||||
<div className="grid grid-cols-6 gap-1 text-center text-sm">
|
||||
{abilityEntries.map((a) => (
|
||||
<div key={a.label}>
|
||||
<div className="font-semibold text-muted-foreground text-xs">
|
||||
{a.label}
|
||||
</div>
|
||||
<div>{formatMod(a.mod)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<PropertyLine label="Items" value={creature.items} />
|
||||
|
||||
{/* Top abilities (before defenses) */}
|
||||
<TraitSection entries={creature.abilitiesTop} />
|
||||
|
||||
<SectionDivider />
|
||||
|
||||
{/* Defenses */}
|
||||
<div className="space-y-0.5 text-sm">
|
||||
<div className={adjColor}>
|
||||
<span className="font-semibold">AC</span> {creature.ac}
|
||||
{creature.acConditional ? ` (${creature.acConditional})` : ""};{" "}
|
||||
<span className="font-semibold">Fort</span>{" "}
|
||||
{formatMod(creature.saveFort)},{" "}
|
||||
<span className="font-semibold">Ref</span>{" "}
|
||||
{formatMod(creature.saveRef)},{" "}
|
||||
<span className="font-semibold">Will</span>{" "}
|
||||
{formatMod(creature.saveWill)}
|
||||
{creature.saveConditional ? `; ${creature.saveConditional}` : ""}
|
||||
</div>
|
||||
<div className={adjColor}>
|
||||
<span className="font-semibold">HP</span> {creature.hp}
|
||||
{creature.hpDetails ? ` (${creature.hpDetails})` : ""}
|
||||
</div>
|
||||
<PropertyLine label="Immunities" value={creature.immunities} />
|
||||
<PropertyLine label="Resistances" value={creature.resistances} />
|
||||
<PropertyLine label="Weaknesses" value={creature.weaknesses} />
|
||||
</div>
|
||||
|
||||
{/* Mid abilities (reactions, auras) */}
|
||||
<TraitSection entries={creature.abilitiesMid} />
|
||||
|
||||
<SectionDivider />
|
||||
|
||||
{/* Speed */}
|
||||
<div className="text-sm">
|
||||
<span className="font-semibold">Speed</span> {creature.speed}
|
||||
</div>
|
||||
|
||||
{/* Attacks */}
|
||||
<TraitSection entries={creature.attacks} />
|
||||
|
||||
{/* Bottom abilities (active abilities) */}
|
||||
<TraitSection entries={creature.abilitiesBot} />
|
||||
|
||||
{/* Spellcasting */}
|
||||
{creature.spellcasting && creature.spellcasting.length > 0 && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
{creature.spellcasting.map((sc) => (
|
||||
<div key={sc.name} className="space-y-1 text-sm">
|
||||
<div>
|
||||
<span className="font-semibold italic">{sc.name}.</span>{" "}
|
||||
{sc.headerText}
|
||||
</div>
|
||||
{sc.daily?.map((d) => (
|
||||
<SpellListLine
|
||||
key={d.uses}
|
||||
label={d.uses === 0 ? "Cantrips" : `Rank ${d.uses}`}
|
||||
spells={d.spells}
|
||||
onOpen={handleOpenSpell}
|
||||
/>
|
||||
))}
|
||||
{sc.atWill && sc.atWill.length > 0 && (
|
||||
<SpellListLine
|
||||
label="Cantrips"
|
||||
spells={sc.atWill}
|
||||
onOpen={handleOpenSpell}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{creature.equipment && creature.equipment.length > 0 && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
<h3 className="font-bold text-base text-stat-heading">Equipment</h3>
|
||||
<div className="space-y-1 text-sm">
|
||||
{creature.equipment.map((item) => (
|
||||
<div key={item.name}>
|
||||
<EquipmentLink item={item} onOpen={handleOpenEquipment} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{openSpell ? (
|
||||
<SpellDetailPopover
|
||||
spell={openSpell.spell}
|
||||
anchorRect={openSpell.rect}
|
||||
onClose={handleCloseSpell}
|
||||
/>
|
||||
) : null}
|
||||
{openEquipment ? (
|
||||
<EquipmentDetailPopover
|
||||
item={openEquipment.item}
|
||||
anchorRect={openEquipment.rect}
|
||||
onClose={handleCloseEquipment}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import { type RefObject, useImperativeHandle, useState } from "react";
|
||||
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||
import { type RefObject, useImperativeHandle, useMemo, useState } from "react";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||
import { CreatePlayerModal } from "./create-player-modal.js";
|
||||
import { PlayerManagement } from "./player-management.js";
|
||||
@@ -15,6 +16,15 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
||||
}) {
|
||||
const { characters, createCharacter, editCharacter, deleteCharacter } =
|
||||
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 [createOpen, setCreateOpen] = useState(false);
|
||||
@@ -66,6 +76,11 @@ export const PlayerCharacterSection = function PlayerCharacterSectionInner({
|
||||
setCreateOpen(true);
|
||||
setManagementOpen(false);
|
||||
}}
|
||||
inEncounterIds={inEncounterIds}
|
||||
onAddParty={() => {
|
||||
addParty(characters);
|
||||
setManagementOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { Button } from "./ui/button";
|
||||
import { ConfirmButton } from "./ui/confirm-button";
|
||||
@@ -12,8 +12,12 @@ interface PlayerManagementProps {
|
||||
onEdit: (pc: PlayerCharacter) => void;
|
||||
onDelete: (id: PlayerCharacterId) => void;
|
||||
onCreate: () => void;
|
||||
inEncounterIds: ReadonlySet<PlayerCharacterId>;
|
||||
onAddParty: () => void;
|
||||
}
|
||||
|
||||
const IN_ENCOUNTER_LABEL = "Already in encounter";
|
||||
|
||||
export function PlayerManagement({
|
||||
open,
|
||||
onClose,
|
||||
@@ -21,7 +25,13 @@ export function PlayerManagement({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onCreate,
|
||||
inEncounterIds,
|
||||
onAddParty,
|
||||
}: Readonly<PlayerManagementProps>) {
|
||||
const addableCount = characters.filter(
|
||||
(pc) => !inEncounterIds.has(pc.id),
|
||||
).length;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-md">
|
||||
<DialogHeader title="Player Characters" onClose={onClose} />
|
||||
@@ -61,6 +71,16 @@ export function PlayerManagement({
|
||||
Lv {pc.level}
|
||||
</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
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
@@ -80,7 +100,19 @@ export function PlayerManagement({
|
||||
</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">
|
||||
<Plus size={16} />
|
||||
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,27 +2,51 @@ import type { RulesEdition } from "@initiative/domain";
|
||||
import { Monitor, Moon, Sun } from "lucide-react";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { useThemeContext } from "../contexts/theme-context.js";
|
||||
import { cn } from "../lib/utils.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 {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const EDITION_OPTIONS: { value: RulesEdition; label: string }[] = [
|
||||
const EDITION_OPTIONS: SegmentedOption<RulesEdition>[] = [
|
||||
{ value: "5e", label: "5e (2014)" },
|
||||
{ value: "5.5e", label: "5.5e (2024)" },
|
||||
{ value: "pf2e", label: "Pathfinder 2e" },
|
||||
];
|
||||
|
||||
const THEME_OPTIONS: {
|
||||
value: "system" | "light" | "dark";
|
||||
label: string;
|
||||
icon: typeof Sun;
|
||||
}[] = [
|
||||
{ value: "system", label: "System", icon: Monitor },
|
||||
{ value: "light", label: "Light", icon: Sun },
|
||||
{ value: "dark", label: "Dark", icon: Moon },
|
||||
type ThemePreference = "system" | "light" | "dark";
|
||||
|
||||
const THEME_OPTIONS: SegmentedOption<ThemePreference>[] = [
|
||||
{
|
||||
value: "system",
|
||||
label: (
|
||||
<>
|
||||
<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>) {
|
||||
@@ -36,52 +60,28 @@ export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
|
||||
<div className="flex flex-col gap-5">
|
||||
<div>
|
||||
<span className="mb-2 block font-medium text-muted-foreground text-sm">
|
||||
Rules Edition
|
||||
Game System
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{EDITION_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
"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>
|
||||
<SegmentedControl
|
||||
options={EDITION_OPTIONS}
|
||||
value={edition}
|
||||
onChange={setEdition}
|
||||
stretch
|
||||
label="Game System"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="mb-2 block font-medium text-muted-foreground text-sm">
|
||||
Theme
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{THEME_OPTIONS.map((opt) => {
|
||||
const Icon = opt.icon;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
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>
|
||||
<SegmentedControl
|
||||
options={THEME_OPTIONS}
|
||||
value={preference}
|
||||
onChange={setPreference}
|
||||
stretch
|
||||
label="Theme"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
@@ -2,23 +2,26 @@ import { Download, Loader2, Upload } from "lucide-react";
|
||||
import { useId, useRef, useState } from "react";
|
||||
import { useAdapters } from "../contexts/adapter-context.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
|
||||
interface SourceFetchPromptProps {
|
||||
sourceCode: string;
|
||||
onSourceLoaded: () => void;
|
||||
onSourceLoaded: (skippedNames: string[]) => void;
|
||||
}
|
||||
|
||||
export function SourceFetchPrompt({
|
||||
sourceCode,
|
||||
onSourceLoaded,
|
||||
}: Readonly<SourceFetchPromptProps>) {
|
||||
const { bestiaryIndex } = useAdapters();
|
||||
const { bestiaryIndex, pf2eBestiaryIndex } = useAdapters();
|
||||
const { fetchAndCacheSource, uploadAndCacheSource } = useBestiaryContext();
|
||||
const sourceDisplayName = bestiaryIndex.getSourceDisplayName(sourceCode);
|
||||
const { edition } = useRulesEditionContext();
|
||||
const indexPort = edition === "pf2e" ? pf2eBestiaryIndex : bestiaryIndex;
|
||||
const sourceDisplayName = indexPort.getSourceDisplayName(sourceCode);
|
||||
const [url, setUrl] = useState(() =>
|
||||
bestiaryIndex.getDefaultFetchUrl(sourceCode),
|
||||
indexPort.getDefaultFetchUrl(sourceCode),
|
||||
);
|
||||
const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle");
|
||||
const [error, setError] = useState<string>("");
|
||||
@@ -29,8 +32,9 @@ export function SourceFetchPrompt({
|
||||
setStatus("fetching");
|
||||
setError("");
|
||||
try {
|
||||
await fetchAndCacheSource(sourceCode, url);
|
||||
onSourceLoaded();
|
||||
const { skippedNames } = await fetchAndCacheSource(sourceCode, url);
|
||||
setStatus("idle");
|
||||
onSourceLoaded(skippedNames);
|
||||
} catch (e) {
|
||||
setStatus("error");
|
||||
setError(e instanceof Error ? e.message : "Failed to fetch source data");
|
||||
@@ -48,7 +52,7 @@ export function SourceFetchPrompt({
|
||||
const text = await file.text();
|
||||
const json = JSON.parse(text);
|
||||
await uploadAndCacheSource(sourceCode, json);
|
||||
onSourceLoaded();
|
||||
onSourceLoaded([]);
|
||||
} catch (err) {
|
||||
setStatus("error");
|
||||
setError(
|
||||
|
||||
@@ -9,12 +9,15 @@ import {
|
||||
import type { CachedSourceInfo } from "../adapters/ports.js";
|
||||
import { useAdapters } from "../contexts/adapter-context.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
|
||||
export function SourceManager() {
|
||||
const { bestiaryCache } = useAdapters();
|
||||
const { refreshCache } = useBestiaryContext();
|
||||
const { edition } = useRulesEditionContext();
|
||||
const system = edition === "pf2e" ? "pf2e" : "dnd";
|
||||
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [optimisticSources, applyOptimistic] = useOptimistic(
|
||||
@@ -29,9 +32,9 @@ export function SourceManager() {
|
||||
);
|
||||
|
||||
const loadSources = useCallback(async () => {
|
||||
const cached = await bestiaryCache.getCachedSources();
|
||||
const cached = await bestiaryCache.getCachedSources(system);
|
||||
setSources(cached);
|
||||
}, [bestiaryCache]);
|
||||
}, [bestiaryCache, system]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSources();
|
||||
@@ -39,7 +42,7 @@ export function SourceManager() {
|
||||
|
||||
const handleClearSource = async (sourceCode: string) => {
|
||||
applyOptimistic({ type: "remove", sourceCode });
|
||||
await bestiaryCache.clearSource(sourceCode);
|
||||
await bestiaryCache.clearSource(system, sourceCode);
|
||||
await loadSources();
|
||||
void refreshCache();
|
||||
};
|
||||
|
||||
@@ -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,15 +1,28 @@
|
||||
import type { 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 type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import { BulkImportPrompt } from "./bulk-import-prompt.js";
|
||||
import { DndStatBlock } from "./dnd-stat-block.js";
|
||||
import { Pf2eStatBlock } from "./pf2e-stat-block.js";
|
||||
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
|
||||
import { SourceManager } from "./source-manager.js";
|
||||
import { StatBlock } from "./stat-block.js";
|
||||
import { Toast } from "./toast.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
|
||||
interface StatBlockPanelProps {
|
||||
@@ -20,7 +33,10 @@ interface StatBlockPanelProps {
|
||||
function extractSourceCode(cId: CreatureId): string {
|
||||
const colonIndex = cId.indexOf(":");
|
||||
if (colonIndex === -1) return "";
|
||||
return cId.slice(0, colonIndex).toUpperCase();
|
||||
const prefix = cId.slice(0, colonIndex);
|
||||
// D&D source codes are short uppercase (e.g. "mm" from "MM").
|
||||
// PF2e source codes use hyphens (e.g. "pathfinder-monster-core").
|
||||
return prefix.includes("-") ? prefix : prefix.toUpperCase();
|
||||
}
|
||||
|
||||
function CollapsedTab({
|
||||
@@ -211,6 +227,8 @@ function MobileDrawer({
|
||||
function usePanelRole(panelRole: "browse" | "pinned") {
|
||||
const sidePanel = useSidePanelContext();
|
||||
const { getCreature } = useBestiaryContext();
|
||||
const { encounter, setCreatureAdjustment, setHpVariant } =
|
||||
useEncounterContext();
|
||||
|
||||
const creatureId =
|
||||
panelRole === "browse"
|
||||
@@ -218,10 +236,19 @@ function usePanelRole(panelRole: "browse" | "pinned") {
|
||||
: sidePanel.pinnedCreatureId;
|
||||
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";
|
||||
return {
|
||||
creatureId,
|
||||
creature,
|
||||
combatant,
|
||||
setCreatureAdjustment,
|
||||
setHpVariant,
|
||||
isCollapsed: isBrowse ? sidePanel.isRightPanelCollapsed : false,
|
||||
onToggleCollapse: isBrowse ? sidePanel.toggleCollapse : () => {},
|
||||
onDismiss: isBrowse ? sidePanel.dismissPanel : () => {},
|
||||
@@ -233,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({
|
||||
panelRole,
|
||||
side,
|
||||
}: Readonly<StatBlockPanelProps>) {
|
||||
const { isSourceCached } = useBestiaryContext();
|
||||
const {
|
||||
creatureId,
|
||||
creature,
|
||||
combatant,
|
||||
setCreatureAdjustment,
|
||||
setHpVariant,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
onDismiss,
|
||||
@@ -256,6 +328,7 @@ export function StatBlockPanel({
|
||||
);
|
||||
const [needsFetch, setNeedsFetch] = useState(false);
|
||||
const [checkingCache, setCheckingCache] = useState(false);
|
||||
const [skippedToast, setSkippedToast] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = globalThis.matchMedia("(min-width: 1024px)");
|
||||
@@ -276,19 +349,23 @@ export function StatBlockPanel({
|
||||
return;
|
||||
}
|
||||
|
||||
setCheckingCache(true);
|
||||
void isSourceCached(sourceCode).then((cached) => {
|
||||
setNeedsFetch(!cached);
|
||||
setCheckingCache(false);
|
||||
});
|
||||
}, [creatureId, creature, isSourceCached]);
|
||||
// Show fetch prompt both when source is uncached AND when the source is
|
||||
// cached but this specific creature is missing (e.g. skipped by ad blocker).
|
||||
setNeedsFetch(true);
|
||||
setCheckingCache(false);
|
||||
}, [creatureId, creature]);
|
||||
|
||||
if (!creatureId && !bulkImportMode && !sourceManagerMode) return null;
|
||||
|
||||
const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
|
||||
|
||||
const handleSourceLoaded = () => {
|
||||
setNeedsFetch(false);
|
||||
const handleSourceLoaded = (skippedNames: string[]) => {
|
||||
if (skippedNames.length > 0) {
|
||||
const names = skippedNames.join(", ");
|
||||
setSkippedToast(
|
||||
`${skippedNames.length} creature(s) skipped (ad blocker?): ${names}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
@@ -307,7 +384,10 @@ export function StatBlockPanel({
|
||||
}
|
||||
|
||||
if (creature) {
|
||||
return <StatBlock creature={creature} />;
|
||||
return renderStatBlock(creature, combatant, {
|
||||
setCreatureAdjustment,
|
||||
setHpVariant,
|
||||
});
|
||||
}
|
||||
|
||||
if (needsFetch && sourceCode) {
|
||||
@@ -331,24 +411,36 @@ export function StatBlockPanel({
|
||||
else if (bulkImportMode) fallbackName = "Import All Sources";
|
||||
const creatureName = creature?.name ?? fallbackName;
|
||||
|
||||
const toast = skippedToast ? (
|
||||
<Toast message={skippedToast} onDismiss={() => setSkippedToast(null)} />
|
||||
) : null;
|
||||
|
||||
if (isDesktop) {
|
||||
return (
|
||||
<DesktopPanel
|
||||
isCollapsed={isCollapsed}
|
||||
side={side}
|
||||
creatureName={creatureName}
|
||||
panelRole={panelRole}
|
||||
showPinButton={showPinButton}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
onPin={onPin}
|
||||
onUnpin={onUnpin}
|
||||
>
|
||||
{renderContent()}
|
||||
</DesktopPanel>
|
||||
<>
|
||||
<DesktopPanel
|
||||
isCollapsed={isCollapsed}
|
||||
side={side}
|
||||
creatureName={creatureName}
|
||||
panelRole={panelRole}
|
||||
showPinButton={showPinButton}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
onPin={onPin}
|
||||
onUnpin={onUnpin}
|
||||
>
|
||||
{renderContent()}
|
||||
</DesktopPanel>
|
||||
{toast}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (panelRole === "pinned" || isCollapsed) return null;
|
||||
|
||||
return <MobileDrawer onDismiss={onDismiss}>{renderContent()}</MobileDrawer>;
|
||||
return (
|
||||
<>
|
||||
<MobileDrawer onDismiss={onDismiss}>{renderContent()}</MobileDrawer>
|
||||
{toast}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import type {
|
||||
ActivityCost,
|
||||
TraitBlock,
|
||||
TraitSegment,
|
||||
} from "@initiative/domain";
|
||||
import { RichDescription } from "./rich-description.js";
|
||||
|
||||
export function PropertyLine({
|
||||
label,
|
||||
value,
|
||||
}: Readonly<{
|
||||
label: string;
|
||||
value: string | undefined;
|
||||
}>) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<span className="font-semibold">{label}</span> {value}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionDivider() {
|
||||
return (
|
||||
<div className="my-2 h-px bg-gradient-to-r from-stat-divider-from via-stat-divider-via to-transparent" />
|
||||
);
|
||||
}
|
||||
|
||||
function segmentKey(seg: TraitSegment): string {
|
||||
return seg.type === "text"
|
||||
? seg.value.slice(0, 40)
|
||||
: seg.items.map((i) => i.label ?? i.text.slice(0, 20)).join(",");
|
||||
}
|
||||
|
||||
function TraitSegments({
|
||||
segments,
|
||||
}: Readonly<{ segments: readonly TraitSegment[] }>) {
|
||||
return (
|
||||
<>
|
||||
{segments.map((seg, i) => {
|
||||
if (seg.type === "text") {
|
||||
return (
|
||||
<RichDescription
|
||||
key={segmentKey(seg)}
|
||||
text={i === 0 ? ` ${seg.value}` : seg.value}
|
||||
className="inline whitespace-pre-line"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={segmentKey(seg)} className="mt-1 space-y-0.5">
|
||||
{seg.items.map((item) => (
|
||||
<div key={item.label ?? item.text}>
|
||||
{item.label != null && (
|
||||
<span className="font-semibold">{item.label}. </span>
|
||||
)}
|
||||
<RichDescription text={item.text} className="inline" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const ACTION_DIAMOND = "M50 2 L96 50 L50 98 L4 50 Z M48 27 L71 50 L48 73 Z";
|
||||
const ACTION_DIAMOND_SOLID = "M50 2 L96 50 L50 98 L4 50 Z";
|
||||
const ACTION_DIAMOND_OUTLINE =
|
||||
"M90 2 L136 50 L90 98 L44 50 Z M90 29 L111 50 L90 71 L69 50 Z";
|
||||
const FREE_ACTION_DIAMOND =
|
||||
"M50 2 L96 50 L50 98 L4 50 Z M50 12 L12 50 L50 88 L88 50 Z";
|
||||
const FREE_ACTION_CHEVRON = "M48 27 L71 50 L48 73 Z";
|
||||
const REACTION_ARROW =
|
||||
"M75 15 A42 42 0 1 0 85 55 L72 55 A30 30 0 1 1 65 25 L65 40 L92 20 L65 0 L65 15 Z";
|
||||
|
||||
export function ActivityIcon({
|
||||
activity,
|
||||
}: Readonly<{ activity: ActivityCost }>) {
|
||||
const cls = "inline-block h-[1em] align-[-0.1em]";
|
||||
if (activity.unit === "free") {
|
||||
return (
|
||||
<svg aria-hidden="true" className={cls} viewBox="0 0 100 100">
|
||||
<path d={FREE_ACTION_DIAMOND} fill="currentColor" fillRule="evenodd" />
|
||||
<path d={FREE_ACTION_CHEVRON} fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (activity.unit === "reaction") {
|
||||
return (
|
||||
<svg aria-hidden="true" className={cls} viewBox="0 0 100 100">
|
||||
<g transform="translate(100,100) rotate(180)">
|
||||
<path d={REACTION_ARROW} fill="currentColor" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
const count = activity.number;
|
||||
if (count === 1) {
|
||||
return (
|
||||
<svg aria-hidden="true" className={cls} viewBox="0 0 100 100">
|
||||
<path d={ACTION_DIAMOND} fill="currentColor" fillRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (count === 2) {
|
||||
return (
|
||||
<svg aria-hidden="true" className={cls} viewBox="0 0 140 100">
|
||||
<path d={ACTION_DIAMOND_SOLID} fill="currentColor" />
|
||||
<path
|
||||
d={ACTION_DIAMOND_OUTLINE}
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg aria-hidden="true" className={cls} viewBox="0 0 180 100">
|
||||
<path d={ACTION_DIAMOND_SOLID} fill="currentColor" />
|
||||
<path d="M90 2 L136 50 L90 98 L44 50 Z" fill="currentColor" />
|
||||
<path
|
||||
d="M130 2 L176 50 L130 98 L84 50 Z M130 29 L151 50 L130 71 L109 50 Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TraitEntry({ trait }: Readonly<{ trait: TraitBlock }>) {
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<span className="font-semibold italic">
|
||||
{trait.name}
|
||||
{trait.activity ? null : "."}
|
||||
{trait.activity ? (
|
||||
<>
|
||||
{" "}
|
||||
<ActivityIcon activity={trait.activity} />
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
{trait.frequency ? ` (${trait.frequency})` : null}
|
||||
{trait.trigger ? (
|
||||
<>
|
||||
{" "}
|
||||
<span className="font-semibold">Trigger</span> {trait.trigger}
|
||||
{trait.segments.length > 0 ? (
|
||||
<>
|
||||
{" "}
|
||||
<span className="font-semibold">Effect</span>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
<TraitSegments segments={trait.segments} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TraitSection({
|
||||
entries,
|
||||
heading,
|
||||
}: Readonly<{
|
||||
entries: readonly TraitBlock[] | undefined;
|
||||
heading?: string;
|
||||
}>) {
|
||||
if (!entries || entries.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
<SectionDivider />
|
||||
{heading ? (
|
||||
<h3 className="font-bold text-base text-stat-heading">{heading}</h3>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
{entries.map((e) => (
|
||||
<TraitEntry key={e.name} trait={e} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
DifficultyIndicator,
|
||||
TIER_LABELS_5_5E,
|
||||
TIER_LABELS_2014,
|
||||
TIER_LABELS_PF2E,
|
||||
} from "./difficulty-indicator.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { ConfirmButton } from "./ui/confirm-button.js";
|
||||
@@ -26,7 +27,13 @@ export function TurnNavigation() {
|
||||
|
||||
const difficulty = useDifficulty();
|
||||
const { edition } = useRulesEditionContext();
|
||||
const tierLabels = edition === "5e" ? TIER_LABELS_2014 : TIER_LABELS_5_5E;
|
||||
const TIER_LABELS_BY_EDITION = {
|
||||
pf2e: TIER_LABELS_PF2E,
|
||||
"5e": TIER_LABELS_2014,
|
||||
"5.5e": TIER_LABELS_5_5E,
|
||||
} as const;
|
||||
const tierLabels = TIER_LABELS_BY_EDITION[edition];
|
||||
const barCount = edition === "pf2e" ? 4 : 3;
|
||||
const [showBreakdown, setShowBreakdown] = useState(false);
|
||||
const hasCombatants = encounter.combatants.length > 0;
|
||||
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
||||
@@ -87,6 +94,7 @@ export function TurnNavigation() {
|
||||
<DifficultyIndicator
|
||||
result={difficulty}
|
||||
labels={tierLabels}
|
||||
barCount={barCount}
|
||||
onClick={() => setShowBreakdown((prev) => !prev)}
|
||||
/>
|
||||
{showBreakdown ? (
|
||||
|
||||
@@ -46,7 +46,7 @@ export function Dialog({ open, onClose, className, children }: DialogProps) {
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="p-6">{children}</div>
|
||||
{open ? <div className="p-6">{children}</div> : null}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
BestiaryCachePort,
|
||||
BestiaryIndexPort,
|
||||
EncounterPersistence,
|
||||
Pf2eBestiaryIndexPort,
|
||||
PlayerCharacterPersistence,
|
||||
UndoRedoPersistence,
|
||||
} from "../adapters/ports.js";
|
||||
@@ -13,6 +14,7 @@ export interface Adapters {
|
||||
playerCharacterPersistence: PlayerCharacterPersistence;
|
||||
bestiaryCache: BestiaryCachePort;
|
||||
bestiaryIndex: BestiaryIndexPort;
|
||||
pf2eBestiaryIndex: Pf2eBestiaryIndexPort;
|
||||
}
|
||||
|
||||
const AdapterContext = createContext<Adapters | null>(null);
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import type {
|
||||
BestiaryIndexEntry,
|
||||
ConditionId,
|
||||
PlayerCharacter,
|
||||
} from "@initiative/domain";
|
||||
import type { ConditionId, PlayerCharacter } from "@initiative/domain";
|
||||
import {
|
||||
combatantId,
|
||||
createEncounter,
|
||||
@@ -11,6 +7,7 @@ import {
|
||||
playerCharacterId,
|
||||
} from "@initiative/domain";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SearchResult } from "../use-bestiary.js";
|
||||
import { type EncounterState, encounterReducer } from "../use-encounter.js";
|
||||
|
||||
function emptyState(): EncounterState {
|
||||
@@ -45,9 +42,11 @@ function stateWithHp(name: string, maxHp: number): EncounterState {
|
||||
});
|
||||
}
|
||||
|
||||
const BESTIARY_ENTRY: BestiaryIndexEntry = {
|
||||
const BESTIARY_ENTRY: SearchResult = {
|
||||
system: "dnd",
|
||||
name: "Goblin",
|
||||
source: "MM",
|
||||
sourceDisplayName: "Monster Manual",
|
||||
ac: 15,
|
||||
hp: 7,
|
||||
dex: 14,
|
||||
@@ -57,6 +56,19 @@ const BESTIARY_ENTRY: BestiaryIndexEntry = {
|
||||
type: "humanoid",
|
||||
};
|
||||
|
||||
const PF2E_BESTIARY_ENTRY: SearchResult = {
|
||||
system: "pf2e",
|
||||
name: "Goblin Warrior",
|
||||
source: "B1",
|
||||
sourceDisplayName: "Bestiary",
|
||||
level: -1,
|
||||
ac: 16,
|
||||
hp: 6,
|
||||
perception: 5,
|
||||
size: "small",
|
||||
type: "humanoid",
|
||||
};
|
||||
|
||||
describe("encounterReducer", () => {
|
||||
describe("add-combatant", () => {
|
||||
it("adds a combatant and pushes undo", () => {
|
||||
@@ -236,7 +248,9 @@ describe("encounterReducer", () => {
|
||||
conditionId: "blinded" as ConditionId,
|
||||
});
|
||||
|
||||
expect(next.encounter.combatants[0].conditions).toContain("blinded");
|
||||
expect(next.encounter.combatants[0].conditions).toContainEqual({
|
||||
id: "blinded",
|
||||
});
|
||||
});
|
||||
|
||||
it("toggles concentration", () => {
|
||||
@@ -327,6 +341,19 @@ describe("encounterReducer", () => {
|
||||
expect(names).toContain("Goblin 1");
|
||||
expect(names).toContain("Goblin 2");
|
||||
});
|
||||
|
||||
it("adds PF2e creature with HP, AC, and creatureId", () => {
|
||||
const next = encounterReducer(emptyState(), {
|
||||
type: "add-from-bestiary",
|
||||
entry: PF2E_BESTIARY_ENTRY,
|
||||
});
|
||||
|
||||
const c = next.encounter.combatants[0];
|
||||
expect(c.name).toBe("Goblin Warrior");
|
||||
expect(c.maxHp).toBe(6);
|
||||
expect(c.ac).toBe(16);
|
||||
expect(c.creatureId).toBe("b1:goblin-warrior");
|
||||
});
|
||||
});
|
||||
|
||||
describe("add-multiple-from-bestiary", () => {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||
import type {
|
||||
AnyCreature,
|
||||
CreatureId,
|
||||
PlayerCharacter,
|
||||
} from "@initiative/domain";
|
||||
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
@@ -9,6 +13,7 @@ import {
|
||||
buildCombatant,
|
||||
buildCreature,
|
||||
buildEncounter,
|
||||
buildPf2eCreature,
|
||||
} from "../../__tests__/factories/index.js";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import { useDifficultyBreakdown } from "../use-difficulty-breakdown.js";
|
||||
@@ -42,7 +47,7 @@ const goblinCreature = buildCreature({
|
||||
function makeWrapper(options: {
|
||||
encounter: ReturnType<typeof buildEncounter>;
|
||||
playerCharacters?: PlayerCharacter[];
|
||||
creatures?: Map<CreatureId, Creature>;
|
||||
creatures?: Map<CreatureId, AnyCreature>;
|
||||
}) {
|
||||
const adapters = createTestAdapters({
|
||||
encounter: options.encounter,
|
||||
@@ -345,4 +350,115 @@ describe("useDifficultyBreakdown", () => {
|
||||
editionResult.current.setEdition("5.5e");
|
||||
}
|
||||
});
|
||||
|
||||
describe("PF2e edition", () => {
|
||||
const orcWarrior = buildPf2eCreature({
|
||||
id: creatureId("pf2e:orc-warrior"),
|
||||
name: "Orc Warrior",
|
||||
level: 3,
|
||||
source: "crb",
|
||||
sourceDisplayName: "Core Rulebook",
|
||||
});
|
||||
|
||||
it("returns breakdown with creatureLevel, levelDifference, and XP for PF2e creatures", async () => {
|
||||
const wrapper = makeWrapper({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c-1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId1,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c-2"),
|
||||
name: "Orc Warrior",
|
||||
creatureId: orcWarrior.id,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||
],
|
||||
creatures: new Map([[orcWarrior.id, orcWarrior]]),
|
||||
});
|
||||
|
||||
const { result: editionResult } = renderHook(() => useRulesEdition(), {
|
||||
wrapper,
|
||||
});
|
||||
editionResult.current.setEdition("pf2e");
|
||||
|
||||
try {
|
||||
const { result } = renderHook(() => useDifficultyBreakdown(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const breakdown = result.current;
|
||||
expect(breakdown).not.toBeNull();
|
||||
|
||||
// Party level should be 5
|
||||
expect(breakdown?.partyLevel).toBe(5);
|
||||
|
||||
// Orc Warrior: level 3, party level 5 → diff −2 → 20 XP
|
||||
const orc = breakdown?.enemyCombatants[0];
|
||||
expect(orc?.creatureLevel).toBe(3);
|
||||
expect(orc?.levelDifference).toBe(-2);
|
||||
expect(orc?.xp).toBe(20);
|
||||
expect(orc?.cr).toBeNull();
|
||||
expect(orc?.source).toBe("Core Rulebook");
|
||||
|
||||
// PC should have no creature level
|
||||
const pc = breakdown?.partyCombatants[0];
|
||||
expect(pc?.creatureLevel).toBeUndefined();
|
||||
expect(pc?.levelDifference).toBeUndefined();
|
||||
});
|
||||
} finally {
|
||||
editionResult.current.setEdition("5.5e");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns partyLevel in result", async () => {
|
||||
const wrapper = makeWrapper({
|
||||
encounter: buildEncounter({
|
||||
combatants: [
|
||||
buildCombatant({
|
||||
id: combatantId("c-1"),
|
||||
name: "Hero",
|
||||
playerCharacterId: pcId1,
|
||||
}),
|
||||
buildCombatant({
|
||||
id: combatantId("c-2"),
|
||||
name: "Orc Warrior",
|
||||
creatureId: orcWarrior.id,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
playerCharacters: [
|
||||
{ id: pcId1, name: "Hero", ac: 15, maxHp: 30, level: 5 },
|
||||
],
|
||||
creatures: new Map([[orcWarrior.id, orcWarrior]]),
|
||||
});
|
||||
|
||||
const { result: editionResult } = renderHook(() => useRulesEdition(), {
|
||||
wrapper,
|
||||
});
|
||||
editionResult.current.setEdition("pf2e");
|
||||
|
||||
try {
|
||||
const { result } = renderHook(() => useDifficultyBreakdown(), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).not.toBeNull();
|
||||
expect(result.current?.partyLevel).toBe(5);
|
||||
// 5 thresholds for PF2e
|
||||
expect(result.current?.thresholds).toHaveLength(5);
|
||||
expect(result.current?.thresholds[0].label).toBe("Trivial");
|
||||
});
|
||||
} finally {
|
||||
editionResult.current.setEdition("5.5e");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { Creature, CreatureId, PlayerCharacter } from "@initiative/domain";
|
||||
import type {
|
||||
AnyCreature,
|
||||
CreatureId,
|
||||
PlayerCharacter,
|
||||
} from "@initiative/domain";
|
||||
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
@@ -9,6 +13,7 @@ import {
|
||||
buildCombatant,
|
||||
buildCreature,
|
||||
buildEncounter,
|
||||
buildPf2eCreature,
|
||||
} from "../../__tests__/factories/index.js";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import { useDifficulty } from "../use-difficulty.js";
|
||||
@@ -43,7 +48,7 @@ const goblinCreature = buildCreature({
|
||||
function makeWrapper(options: {
|
||||
encounter: ReturnType<typeof buildEncounter>;
|
||||
playerCharacters?: PlayerCharacter[];
|
||||
creatures?: Map<CreatureId, Creature>;
|
||||
creatures?: Map<CreatureId, AnyCreature>;
|
||||
}) {
|
||||
const adapters = createTestAdapters({
|
||||
encounter: options.encounter,
|
||||
@@ -336,10 +341,10 @@ describe("useDifficulty", () => {
|
||||
await waitFor(() => {
|
||||
expect(result.current).not.toBeNull();
|
||||
// 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?.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);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,13 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { BestiaryIndexEntry, PlayerCharacter } from "@initiative/domain";
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import { combatantId, creatureId, playerCharacterId } from "@initiative/domain";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||
import { buildPlayerCharacter } from "../../__tests__/factories/index.js";
|
||||
import { AllProviders } from "../../__tests__/test-providers.js";
|
||||
import type { SearchResult } from "../use-bestiary.js";
|
||||
import { useEncounter } from "../use-encounter.js";
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -152,9 +154,11 @@ describe("useEncounter", () => {
|
||||
expect(result.current.canRollAllInitiative).toBe(false);
|
||||
|
||||
// Add from bestiary to get a creature combatant
|
||||
const entry: BestiaryIndexEntry = {
|
||||
const entry: SearchResult = {
|
||||
system: "dnd",
|
||||
name: "Goblin",
|
||||
source: "MM",
|
||||
sourceDisplayName: "Monster Manual",
|
||||
ac: 15,
|
||||
hp: 7,
|
||||
dex: 14,
|
||||
@@ -175,9 +179,11 @@ describe("useEncounter", () => {
|
||||
it("addFromBestiary adds combatant with HP, AC, creatureId", () => {
|
||||
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||
|
||||
const entry: BestiaryIndexEntry = {
|
||||
const entry: SearchResult = {
|
||||
system: "dnd",
|
||||
name: "Goblin",
|
||||
source: "MM",
|
||||
sourceDisplayName: "Monster Manual",
|
||||
ac: 15,
|
||||
hp: 7,
|
||||
dex: 14,
|
||||
@@ -202,9 +208,11 @@ describe("useEncounter", () => {
|
||||
it("addFromBestiary auto-numbers duplicate names", () => {
|
||||
const { result } = renderHook(() => useEncounter(), { wrapper });
|
||||
|
||||
const entry: BestiaryIndexEntry = {
|
||||
const entry: SearchResult = {
|
||||
system: "dnd",
|
||||
name: "Goblin",
|
||||
source: "MM",
|
||||
sourceDisplayName: "Monster Manual",
|
||||
ac: 15,
|
||||
hp: 7,
|
||||
dex: 14,
|
||||
@@ -249,4 +257,54 @@ describe("useEncounter", () => {
|
||||
expect(combatant.icon).toBe("sword");
|
||||
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");
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const { result } = renderHook(() => usePlayerCharacters(), { wrapper });
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useRulesEdition } from "../use-rules-edition.js";
|
||||
|
||||
const STORAGE_KEY = "initiative:rules-edition";
|
||||
const STORAGE_KEY = "initiative:game-system";
|
||||
const OLD_STORAGE_KEY = "initiative:rules-edition";
|
||||
|
||||
describe("useRulesEdition", () => {
|
||||
afterEach(() => {
|
||||
@@ -11,6 +12,7 @@ describe("useRulesEdition", () => {
|
||||
const { result } = renderHook(() => useRulesEdition());
|
||||
act(() => result.current.setEdition("5.5e"));
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
localStorage.removeItem(OLD_STORAGE_KEY);
|
||||
});
|
||||
|
||||
it("defaults to 5.5e", () => {
|
||||
@@ -42,4 +44,31 @@ describe("useRulesEdition", () => {
|
||||
|
||||
expect(r2.current.edition).toBe("5e");
|
||||
});
|
||||
|
||||
it("accepts pf2e as a valid game system", () => {
|
||||
const { result } = renderHook(() => useRulesEdition());
|
||||
|
||||
act(() => result.current.setEdition("pf2e"));
|
||||
|
||||
expect(result.current.edition).toBe("pf2e");
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe("pf2e");
|
||||
});
|
||||
|
||||
it("migrates from old storage key on fresh module load", async () => {
|
||||
// Set up old key before re-importing the module
|
||||
localStorage.setItem(OLD_STORAGE_KEY, "5e");
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
|
||||
// Force a fresh module so loadEdition() re-runs at init time
|
||||
vi.resetModules();
|
||||
const { useRulesEdition: freshHook } = await import(
|
||||
"../use-rules-edition.js"
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => freshHook());
|
||||
|
||||
expect(result.current.edition).toBe("5e");
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe("5e");
|
||||
expect(localStorage.getItem(OLD_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,8 +6,8 @@ export function useAutoStatBlock(): void {
|
||||
const { encounter } = useEncounterContext();
|
||||
const { panelView, updateCreature } = useSidePanelContext();
|
||||
|
||||
const activeCreatureId =
|
||||
encounter.combatants[encounter.activeIndex]?.creatureId;
|
||||
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
||||
const activeCreatureId = activeCombatant?.creatureId;
|
||||
const prevActiveIndexRef = useRef(encounter.activeIndex);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -21,7 +21,13 @@ export function useAutoStatBlock(): void {
|
||||
activeCreatureId &&
|
||||
panelView.mode === "creature"
|
||||
) {
|
||||
updateCreature(activeCreatureId);
|
||||
updateCreature(activeCreatureId, activeCombatant.id);
|
||||
}
|
||||
}, [encounter.activeIndex, activeCreatureId, panelView.mode, updateCreature]);
|
||||
}, [
|
||||
encounter.activeIndex,
|
||||
activeCreatureId,
|
||||
activeCombatant?.id,
|
||||
panelView.mode,
|
||||
updateCreature,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,38 @@
|
||||
import type {
|
||||
AnyCreature,
|
||||
BestiaryIndexEntry,
|
||||
Creature,
|
||||
CreatureId,
|
||||
Pf2eBestiaryIndexEntry,
|
||||
} from "@initiative/domain";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
normalizeBestiary,
|
||||
setSourceDisplayNames,
|
||||
} from "../adapters/bestiary-adapter.js";
|
||||
import { loadBundledDndCreatures } from "../adapters/dnd-bundled-adapter.js";
|
||||
import { normalizeFoundryCreatures } from "../adapters/pf2e-bestiary-adapter.js";
|
||||
import { useAdapters } from "../contexts/adapter-context.js";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
|
||||
export interface SearchResult extends BestiaryIndexEntry {
|
||||
readonly sourceDisplayName: string;
|
||||
}
|
||||
export type SearchResult =
|
||||
| (BestiaryIndexEntry & {
|
||||
readonly system: "dnd";
|
||||
readonly sourceDisplayName: string;
|
||||
})
|
||||
| (Pf2eBestiaryIndexEntry & {
|
||||
readonly system: "pf2e";
|
||||
readonly sourceDisplayName: string;
|
||||
});
|
||||
|
||||
interface BestiaryHook {
|
||||
search: (query: string) => SearchResult[];
|
||||
getCreature: (id: CreatureId) => Creature | undefined;
|
||||
getCreature: (id: CreatureId) => AnyCreature | undefined;
|
||||
isLoaded: boolean;
|
||||
isSourceCached: (sourceCode: string) => Promise<boolean>;
|
||||
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
|
||||
fetchAndCacheSource: (
|
||||
sourceCode: string,
|
||||
url: string,
|
||||
) => Promise<{ skippedNames: string[] }>;
|
||||
uploadAndCacheSource: (
|
||||
sourceCode: string,
|
||||
jsonData: unknown,
|
||||
@@ -27,29 +40,153 @@ interface BestiaryHook {
|
||||
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 {
|
||||
const { bestiaryCache, bestiaryIndex } = useAdapters();
|
||||
const { bestiaryCache, bestiaryIndex, pf2eBestiaryIndex } = useAdapters();
|
||||
const { edition } = useRulesEditionContext();
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [creatureMap, setCreatureMap] = useState(
|
||||
() => new Map<CreatureId, Creature>(),
|
||||
() => new Map<CreatureId, AnyCreature>(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const index = bestiaryIndex.loadIndex();
|
||||
setSourceDisplayNames(index.sources as Record<string, string>);
|
||||
if (index.creatures.length > 0) {
|
||||
|
||||
const pf2eIndex = pf2eBestiaryIndex.loadIndex();
|
||||
|
||||
if (index.creatures.length > 0 || pf2eIndex.creatures.length > 0) {
|
||||
setIsLoaded(true);
|
||||
}
|
||||
|
||||
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]);
|
||||
}, [bestiaryCache, bestiaryIndex, pf2eBestiaryIndex]);
|
||||
|
||||
const search = useCallback(
|
||||
(query: string): SearchResult[] => {
|
||||
if (query.length < 2) return [];
|
||||
const lower = query.toLowerCase();
|
||||
|
||||
if (edition === "pf2e") {
|
||||
const index = pf2eBestiaryIndex.loadIndex();
|
||||
return index.creatures
|
||||
.filter((c) => c.name.toLowerCase().includes(lower))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.slice(0, 10)
|
||||
.map((c) => ({
|
||||
...c,
|
||||
system: "pf2e" as const,
|
||||
sourceDisplayName: pf2eBestiaryIndex.getSourceDisplayName(c.source),
|
||||
}));
|
||||
}
|
||||
|
||||
const index = bestiaryIndex.loadIndex();
|
||||
return index.creatures
|
||||
.filter((c) => c.name.toLowerCase().includes(lower))
|
||||
@@ -57,38 +194,70 @@ export function useBestiary(): BestiaryHook {
|
||||
.slice(0, 10)
|
||||
.map((c) => ({
|
||||
...c,
|
||||
system: "dnd" as const,
|
||||
sourceDisplayName: bestiaryIndex.getSourceDisplayName(c.source),
|
||||
}));
|
||||
},
|
||||
[bestiaryIndex],
|
||||
[bestiaryIndex, pf2eBestiaryIndex, edition],
|
||||
);
|
||||
|
||||
const getCreature = useCallback(
|
||||
(id: CreatureId): Creature | undefined => {
|
||||
(id: CreatureId): AnyCreature | undefined => {
|
||||
return creatureMap.get(id);
|
||||
},
|
||||
[creatureMap],
|
||||
);
|
||||
|
||||
const system = edition === "pf2e" ? "pf2e" : "dnd";
|
||||
|
||||
const isSourceCachedFn = useCallback(
|
||||
(sourceCode: string): Promise<boolean> => {
|
||||
return bestiaryCache.isSourceCached(sourceCode);
|
||||
return bestiaryCache.isSourceCached(system, sourceCode);
|
||||
},
|
||||
[bestiaryCache],
|
||||
[bestiaryCache, system],
|
||||
);
|
||||
|
||||
const fetchAndCacheSource = useCallback(
|
||||
async (sourceCode: string, url: string): Promise<void> => {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch: ${response.status} ${response.statusText}`,
|
||||
async (
|
||||
sourceCode: string,
|
||||
url: string,
|
||||
): Promise<{ skippedNames: string[] }> => {
|
||||
let creatures: AnyCreature[];
|
||||
let skippedNames: string[] = [];
|
||||
|
||||
if (edition === "pf2e") {
|
||||
const paths = pf2eBestiaryIndex.getCreaturePathsForSource(sourceCode);
|
||||
const displayName = pf2eBestiaryIndex.getSourceDisplayName(sourceCode);
|
||||
const result = await fetchPf2eSource(
|
||||
paths,
|
||||
url,
|
||||
sourceCode,
|
||||
displayName,
|
||||
pf2eBestiaryIndex.getCreatureNamesByPaths,
|
||||
);
|
||||
creatures = result.creatures;
|
||||
skippedNames = result.skippedNames;
|
||||
} else {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
const json = await response.json();
|
||||
creatures = normalizeBestiary(json);
|
||||
}
|
||||
const json = await response.json();
|
||||
const creatures = normalizeBestiary(json);
|
||||
const displayName = bestiaryIndex.getSourceDisplayName(sourceCode);
|
||||
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
||||
|
||||
const displayName =
|
||||
edition === "pf2e"
|
||||
? pf2eBestiaryIndex.getSourceDisplayName(sourceCode)
|
||||
: bestiaryIndex.getSourceDisplayName(sourceCode);
|
||||
await bestiaryCache.cacheSource(
|
||||
system,
|
||||
sourceCode,
|
||||
displayName,
|
||||
creatures,
|
||||
);
|
||||
setCreatureMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const c of creatures) {
|
||||
@@ -96,16 +265,33 @@ export function useBestiary(): BestiaryHook {
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return { skippedNames };
|
||||
},
|
||||
[bestiaryCache, bestiaryIndex],
|
||||
[bestiaryCache, bestiaryIndex, pf2eBestiaryIndex, edition, system],
|
||||
);
|
||||
|
||||
const uploadAndCacheSource = useCallback(
|
||||
async (sourceCode: string, jsonData: unknown): Promise<void> => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: raw JSON shape varies
|
||||
const creatures = normalizeBestiary(jsonData as any);
|
||||
const displayName = bestiaryIndex.getSourceDisplayName(sourceCode);
|
||||
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
||||
const creatures =
|
||||
edition === "pf2e"
|
||||
? normalizeFoundryCreatures(
|
||||
Array.isArray(jsonData) ? jsonData : [jsonData],
|
||||
sourceCode,
|
||||
pf2eBestiaryIndex.getSourceDisplayName(sourceCode),
|
||||
)
|
||||
: normalizeBestiary(
|
||||
jsonData as Parameters<typeof normalizeBestiary>[0],
|
||||
);
|
||||
const displayName =
|
||||
edition === "pf2e"
|
||||
? pf2eBestiaryIndex.getSourceDisplayName(sourceCode)
|
||||
: bestiaryIndex.getSourceDisplayName(sourceCode);
|
||||
await bestiaryCache.cacheSource(
|
||||
system,
|
||||
sourceCode,
|
||||
displayName,
|
||||
creatures,
|
||||
);
|
||||
setCreatureMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const c of creatures) {
|
||||
@@ -114,11 +300,14 @@ export function useBestiary(): BestiaryHook {
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[bestiaryCache, bestiaryIndex],
|
||||
[bestiaryCache, bestiaryIndex, pf2eBestiaryIndex, edition, system],
|
||||
);
|
||||
|
||||
const refreshCache = useCallback(async (): Promise<void> => {
|
||||
const map = await bestiaryCache.loadAllCachedCreatures();
|
||||
for (const c of loadBundledDndCreatures()) {
|
||||
map.set(c.id, c);
|
||||
}
|
||||
setCreatureMap(map);
|
||||
}, [bestiaryCache]);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useAdapters } from "../contexts/adapter-context.js";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
|
||||
const BATCH_SIZE = 6;
|
||||
|
||||
@@ -21,7 +22,10 @@ interface BulkImportHook {
|
||||
state: BulkImportState;
|
||||
startImport: (
|
||||
baseUrl: string,
|
||||
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>,
|
||||
fetchAndCacheSource: (
|
||||
sourceCode: string,
|
||||
url: string,
|
||||
) => Promise<{ skippedNames: string[] }>,
|
||||
isSourceCached: (sourceCode: string) => Promise<boolean>,
|
||||
refreshCache: () => Promise<void>,
|
||||
) => void;
|
||||
@@ -29,18 +33,23 @@ interface BulkImportHook {
|
||||
}
|
||||
|
||||
export function useBulkImport(): BulkImportHook {
|
||||
const { bestiaryIndex } = useAdapters();
|
||||
const { bestiaryIndex, pf2eBestiaryIndex } = useAdapters();
|
||||
const { edition } = useRulesEditionContext();
|
||||
const indexPort = edition === "pf2e" ? pf2eBestiaryIndex : bestiaryIndex;
|
||||
const [state, setState] = useState<BulkImportState>(IDLE_STATE);
|
||||
const countersRef = useRef({ completed: 0, failed: 0 });
|
||||
|
||||
const startImport = useCallback(
|
||||
(
|
||||
baseUrl: string,
|
||||
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>,
|
||||
fetchAndCacheSource: (
|
||||
sourceCode: string,
|
||||
url: string,
|
||||
) => Promise<{ skippedNames: string[] }>,
|
||||
isSourceCached: (sourceCode: string) => Promise<boolean>,
|
||||
refreshCache: () => Promise<void>,
|
||||
) => {
|
||||
const allCodes = bestiaryIndex.getAllSourceCodes();
|
||||
const allCodes = indexPort.getAllSourceCodes();
|
||||
const total = allCodes.length;
|
||||
|
||||
countersRef.current = { completed: 0, failed: 0 };
|
||||
@@ -81,7 +90,7 @@ export function useBulkImport(): BulkImportHook {
|
||||
chain.then(() =>
|
||||
Promise.allSettled(
|
||||
batch.map(async ({ code }) => {
|
||||
const url = bestiaryIndex.getDefaultFetchUrl(code, baseUrl);
|
||||
const url = indexPort.getDefaultFetchUrl(code, baseUrl);
|
||||
try {
|
||||
await fetchAndCacheSource(code, url);
|
||||
countersRef.current.completed++;
|
||||
@@ -115,7 +124,7 @@ export function useBulkImport(): BulkImportHook {
|
||||
});
|
||||
})();
|
||||
},
|
||||
[bestiaryIndex],
|
||||
[indexPort],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import type {
|
||||
AnyCreature,
|
||||
Combatant,
|
||||
CreatureId,
|
||||
DifficultyThreshold,
|
||||
DifficultyTier,
|
||||
PlayerCharacter,
|
||||
} from "@initiative/domain";
|
||||
import { calculateEncounterDifficulty, crToXp } from "@initiative/domain";
|
||||
import {
|
||||
calculateEncounterDifficulty,
|
||||
crToXp,
|
||||
derivePartyLevel,
|
||||
pf2eCreatureXp,
|
||||
} from "@initiative/domain";
|
||||
import { useMemo } from "react";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
@@ -21,6 +27,10 @@ export interface BreakdownCombatant {
|
||||
readonly editable: boolean;
|
||||
readonly side: "party" | "enemy";
|
||||
readonly level: number | undefined;
|
||||
/** PF2e only: the creature's level from bestiary data. */
|
||||
readonly creatureLevel: number | undefined;
|
||||
/** PF2e only: creature level minus party level. */
|
||||
readonly levelDifference: number | undefined;
|
||||
}
|
||||
|
||||
interface DifficultyBreakdown {
|
||||
@@ -30,6 +40,7 @@ interface DifficultyBreakdown {
|
||||
readonly encounterMultiplier: number | undefined;
|
||||
readonly adjustedXp: number | undefined;
|
||||
readonly partySizeAdjusted: boolean | undefined;
|
||||
readonly partyLevel: number | undefined;
|
||||
readonly pcCount: number;
|
||||
readonly partyCombatants: readonly BreakdownCombatant[];
|
||||
readonly enemyCombatants: readonly BreakdownCombatant[];
|
||||
@@ -48,9 +59,16 @@ export function useDifficultyBreakdown(): DifficultyBreakdown | null {
|
||||
const hasPartyLevel = descriptors.some(
|
||||
(d) => d.side === "party" && d.level !== undefined,
|
||||
);
|
||||
const hasCr = descriptors.some((d) => d.cr !== undefined);
|
||||
|
||||
if (!hasPartyLevel || !hasCr) return null;
|
||||
if (edition === "pf2e") {
|
||||
const hasCreatureLevel = descriptors.some(
|
||||
(d) => d.creatureLevel !== undefined,
|
||||
);
|
||||
if (!hasPartyLevel || !hasCreatureLevel) return null;
|
||||
} else {
|
||||
const hasCr = descriptors.some((d) => d.cr !== undefined);
|
||||
if (!hasPartyLevel || !hasCr) return null;
|
||||
}
|
||||
|
||||
const result = calculateEncounterDifficulty(descriptors, edition);
|
||||
|
||||
@@ -64,7 +82,8 @@ export function useDifficultyBreakdown(): DifficultyBreakdown | null {
|
||||
}
|
||||
|
||||
type CreatureInfo = {
|
||||
cr: string;
|
||||
cr?: string;
|
||||
creatureLevel?: number;
|
||||
source: string;
|
||||
sourceDisplayName: string;
|
||||
};
|
||||
@@ -74,6 +93,7 @@ function buildBreakdownEntry(
|
||||
side: "party" | "enemy",
|
||||
level: number | undefined,
|
||||
creature: CreatureInfo | undefined,
|
||||
partyLevel: number | undefined,
|
||||
): BreakdownCombatant {
|
||||
if (c.playerCharacterId) {
|
||||
return {
|
||||
@@ -84,17 +104,43 @@ function buildBreakdownEntry(
|
||||
editable: false,
|
||||
side,
|
||||
level,
|
||||
creatureLevel: undefined,
|
||||
levelDifference: undefined,
|
||||
};
|
||||
}
|
||||
if (creature) {
|
||||
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: creature.cr,
|
||||
xp: crToXp(creature.cr),
|
||||
cr: null,
|
||||
xp,
|
||||
source: creature.sourceDisplayName ?? creature.source,
|
||||
editable: false,
|
||||
side,
|
||||
level: undefined,
|
||||
creatureLevel: creature.creatureLevel,
|
||||
levelDifference: levelDiff,
|
||||
};
|
||||
}
|
||||
if (creature) {
|
||||
const cr = creature.cr ?? null;
|
||||
return {
|
||||
combatant: c,
|
||||
cr,
|
||||
xp: cr ? crToXp(cr) : null,
|
||||
source: creature.sourceDisplayName ?? creature.source,
|
||||
editable: false,
|
||||
side,
|
||||
level: undefined,
|
||||
creatureLevel: undefined,
|
||||
levelDifference: undefined,
|
||||
};
|
||||
}
|
||||
if (c.cr) {
|
||||
@@ -106,6 +152,8 @@ function buildBreakdownEntry(
|
||||
editable: true,
|
||||
side,
|
||||
level: undefined,
|
||||
creatureLevel: undefined,
|
||||
levelDifference: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -116,6 +164,8 @@ function buildBreakdownEntry(
|
||||
editable: !c.creatureId,
|
||||
side,
|
||||
level: undefined,
|
||||
creatureLevel: undefined,
|
||||
levelDifference: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -127,41 +177,91 @@ function resolveLevel(
|
||||
return characters.find((p) => p.id === c.playerCharacterId)?.level;
|
||||
}
|
||||
|
||||
function resolveCr(
|
||||
function resolveCreatureInfo(
|
||||
c: Combatant,
|
||||
getCreature: (id: CreatureId) => CreatureInfo | undefined,
|
||||
): { cr: string | null; creature: CreatureInfo | undefined } {
|
||||
const creature = c.creatureId ? getCreature(c.creatureId) : undefined;
|
||||
const cr = creature ? creature.cr : (c.cr ?? null);
|
||||
return { cr, creature };
|
||||
getCreature: (id: CreatureId) => AnyCreature | undefined,
|
||||
): {
|
||||
cr: string | null;
|
||||
creatureLevel: number | undefined;
|
||||
creature: CreatureInfo | undefined;
|
||||
} {
|
||||
const rawCreature = c.creatureId ? getCreature(c.creatureId) : undefined;
|
||||
if (!rawCreature) {
|
||||
return {
|
||||
cr: c.cr ?? null,
|
||||
creatureLevel: undefined,
|
||||
creature: undefined,
|
||||
};
|
||||
}
|
||||
if ("system" in rawCreature && rawCreature.system === "pf2e") {
|
||||
return {
|
||||
cr: null,
|
||||
creatureLevel: rawCreature.level,
|
||||
creature: {
|
||||
creatureLevel: rawCreature.level,
|
||||
source: rawCreature.source,
|
||||
sourceDisplayName: rawCreature.sourceDisplayName,
|
||||
},
|
||||
};
|
||||
}
|
||||
const cr = "cr" in rawCreature ? rawCreature.cr : undefined;
|
||||
return {
|
||||
cr: cr ?? c.cr ?? null,
|
||||
creatureLevel: undefined,
|
||||
creature: {
|
||||
cr,
|
||||
source: rawCreature.source,
|
||||
sourceDisplayName: rawCreature.sourceDisplayName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function collectPartyLevel(
|
||||
combatants: readonly Combatant[],
|
||||
characters: readonly PlayerCharacter[],
|
||||
): number | undefined {
|
||||
const partyLevels: number[] = [];
|
||||
for (const c of combatants) {
|
||||
if (resolveSide(c) !== "party") continue;
|
||||
const level = resolveLevel(c, characters);
|
||||
if (level !== undefined) partyLevels.push(level);
|
||||
}
|
||||
return partyLevels.length > 0 ? derivePartyLevel(partyLevels) : undefined;
|
||||
}
|
||||
|
||||
function classifyCombatants(
|
||||
combatants: readonly Combatant[],
|
||||
characters: readonly PlayerCharacter[],
|
||||
getCreature: (id: CreatureId) => CreatureInfo | undefined,
|
||||
getCreature: (id: CreatureId) => AnyCreature | undefined,
|
||||
) {
|
||||
const partyCombatants: BreakdownCombatant[] = [];
|
||||
const enemyCombatants: BreakdownCombatant[] = [];
|
||||
const descriptors: {
|
||||
level?: number;
|
||||
cr?: string;
|
||||
creatureLevel?: number;
|
||||
side: "party" | "enemy";
|
||||
}[] = [];
|
||||
let pcCount = 0;
|
||||
const partyLevel = collectPartyLevel(combatants, characters);
|
||||
|
||||
for (const c of combatants) {
|
||||
const side = resolveSide(c);
|
||||
const level = resolveLevel(c, characters);
|
||||
if (level !== undefined) pcCount++;
|
||||
|
||||
const { cr, creature } = resolveCr(c, getCreature);
|
||||
const { cr, creatureLevel, creature } = resolveCreatureInfo(c, getCreature);
|
||||
|
||||
if (level !== undefined || cr != null) {
|
||||
descriptors.push({ level, cr: cr ?? undefined, side });
|
||||
if (level !== undefined || cr != null || creatureLevel !== undefined) {
|
||||
descriptors.push({
|
||||
level,
|
||||
cr: cr ?? undefined,
|
||||
creatureLevel,
|
||||
side,
|
||||
});
|
||||
}
|
||||
|
||||
const entry = buildBreakdownEntry(c, side, level, creature);
|
||||
const entry = buildBreakdownEntry(c, side, level, creature, partyLevel);
|
||||
const target = side === "party" ? partyCombatants : enemyCombatants;
|
||||
target.push(entry);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AnyCreature,
|
||||
Combatant,
|
||||
CombatantDescriptor,
|
||||
CreatureId,
|
||||
@@ -20,7 +21,7 @@ export function resolveSide(c: Combatant): "party" | "enemy" {
|
||||
function buildDescriptors(
|
||||
combatants: readonly Combatant[],
|
||||
characters: readonly PlayerCharacter[],
|
||||
getCreature: (id: CreatureId) => { cr: string } | undefined,
|
||||
getCreature: (id: CreatureId) => AnyCreature | undefined,
|
||||
): CombatantDescriptor[] {
|
||||
const descriptors: CombatantDescriptor[] = [];
|
||||
for (const c of combatants) {
|
||||
@@ -28,12 +29,21 @@ function buildDescriptors(
|
||||
const level = c.playerCharacterId
|
||||
? characters.find((p) => p.id === c.playerCharacterId)?.level
|
||||
: undefined;
|
||||
const cr = c.creatureId
|
||||
? getCreature(c.creatureId)?.cr
|
||||
: (c.cr ?? undefined);
|
||||
const creature = c.creatureId ? getCreature(c.creatureId) : undefined;
|
||||
const creatureCr =
|
||||
creature && !("system" in creature) ? creature.cr : undefined;
|
||||
const cr = creatureCr ?? c.cr ?? undefined;
|
||||
const creatureLevel =
|
||||
creature && "system" in creature && creature.system === "pf2e"
|
||||
? creature.level
|
||||
: undefined;
|
||||
|
||||
if (level !== undefined || cr !== undefined) {
|
||||
descriptors.push({ level, cr, side });
|
||||
if (
|
||||
level !== undefined ||
|
||||
cr !== undefined ||
|
||||
creatureLevel !== undefined
|
||||
) {
|
||||
descriptors.push({ level, cr, creatureLevel, side });
|
||||
}
|
||||
}
|
||||
return descriptors;
|
||||
@@ -55,9 +65,16 @@ export function useDifficulty(): DifficultyResult | null {
|
||||
const hasPartyLevel = descriptors.some(
|
||||
(d) => d.side === "party" && d.level !== undefined,
|
||||
);
|
||||
const hasCr = descriptors.some((d) => d.cr !== undefined);
|
||||
|
||||
if (!hasPartyLevel || !hasCr) return null;
|
||||
if (edition === "pf2e") {
|
||||
const hasCreatureLevel = descriptors.some(
|
||||
(d) => d.creatureLevel !== undefined,
|
||||
);
|
||||
if (!hasPartyLevel || !hasCreatureLevel) return null;
|
||||
} else {
|
||||
const hasCr = descriptors.some((d) => d.cr !== undefined);
|
||||
if (!hasPartyLevel || !hasCr) return null;
|
||||
}
|
||||
|
||||
return calculateEncounterDifficulty(descriptors, edition);
|
||||
}, [encounter.combatants, characters, getCreature, edition]);
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import type { EncounterStore, UndoRedoStore } from "@initiative/application";
|
||||
import {
|
||||
addCombatantUseCase,
|
||||
addPersistentDamageUseCase,
|
||||
adjustHpUseCase,
|
||||
advanceTurnUseCase,
|
||||
clearEncounterUseCase,
|
||||
decrementConditionUseCase,
|
||||
editCombatantUseCase,
|
||||
redoUseCase,
|
||||
removeCombatantUseCase,
|
||||
removePersistentDamageUseCase,
|
||||
retreatTurnUseCase,
|
||||
setAcUseCase,
|
||||
setConditionValueUseCase,
|
||||
setCrUseCase,
|
||||
setHpUseCase,
|
||||
setHpVariantUseCase,
|
||||
setInitiativeUseCase,
|
||||
setSideUseCase,
|
||||
setTempHpUseCase,
|
||||
@@ -19,7 +24,6 @@ import {
|
||||
undoUseCase,
|
||||
} from "@initiative/application";
|
||||
import type {
|
||||
BestiaryIndexEntry,
|
||||
CombatantId,
|
||||
CombatantInit,
|
||||
ConditionId,
|
||||
@@ -27,12 +31,18 @@ import type {
|
||||
DomainError,
|
||||
DomainEvent,
|
||||
Encounter,
|
||||
HpRange,
|
||||
HpVariant,
|
||||
PersistentDamageType,
|
||||
Pf2eCreature,
|
||||
PlayerCharacter,
|
||||
UndoRedoState,
|
||||
} from "@initiative/domain";
|
||||
import {
|
||||
acDelta,
|
||||
clearHistory,
|
||||
combatantId,
|
||||
hpDelta,
|
||||
isDomainError,
|
||||
creatureId as makeCreatureId,
|
||||
pushUndo,
|
||||
@@ -40,6 +50,7 @@ import {
|
||||
} from "@initiative/domain";
|
||||
import { useCallback, useEffect, useReducer, useRef } from "react";
|
||||
import { useAdapters } from "../contexts/adapter-context.js";
|
||||
import type { SearchResult } from "./use-bestiary.js";
|
||||
|
||||
// -- Types --
|
||||
|
||||
@@ -51,6 +62,12 @@ type EncounterAction =
|
||||
| { type: "edit-combatant"; id: CombatantId; newName: string }
|
||||
| { type: "set-initiative"; id: CombatantId; value: 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: "set-temp-hp"; id: CombatantId; tempHp: number | undefined }
|
||||
| { type: "set-ac"; id: CombatantId; value: number | undefined }
|
||||
@@ -61,17 +78,46 @@ type EncounterAction =
|
||||
id: CombatantId;
|
||||
conditionId: ConditionId;
|
||||
}
|
||||
| {
|
||||
type: "set-condition-value";
|
||||
id: CombatantId;
|
||||
conditionId: ConditionId;
|
||||
value: number;
|
||||
}
|
||||
| {
|
||||
type: "decrement-condition";
|
||||
id: CombatantId;
|
||||
conditionId: ConditionId;
|
||||
}
|
||||
| { 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: "undo" }
|
||||
| { type: "redo" }
|
||||
| { type: "add-from-bestiary"; entry: BestiaryIndexEntry }
|
||||
| { type: "add-from-bestiary"; entry: SearchResult }
|
||||
| {
|
||||
type: "add-multiple-from-bestiary";
|
||||
entry: BestiaryIndexEntry;
|
||||
entry: SearchResult;
|
||||
count: number;
|
||||
}
|
||||
| {
|
||||
type: "set-creature-adjustment";
|
||||
id: CombatantId;
|
||||
adjustment: "weak" | "elite" | undefined;
|
||||
baseCreature: Pf2eCreature;
|
||||
}
|
||||
| { type: "add-from-player-character"; pc: PlayerCharacter }
|
||||
| { type: "add-party"; pcs: readonly PlayerCharacter[] }
|
||||
| {
|
||||
type: "import";
|
||||
encounter: Encounter;
|
||||
@@ -156,7 +202,7 @@ function resolveAndRename(store: EncounterStore, name: string): string {
|
||||
|
||||
function addOneFromBestiary(
|
||||
store: EncounterStore,
|
||||
entry: BestiaryIndexEntry,
|
||||
entry: SearchResult,
|
||||
nextId: number,
|
||||
): {
|
||||
cId: CreatureId;
|
||||
@@ -215,7 +261,7 @@ function handleUndoRedo(
|
||||
|
||||
function handleAddFromBestiary(
|
||||
state: EncounterState,
|
||||
entry: BestiaryIndexEntry,
|
||||
entry: SearchResult,
|
||||
count: number,
|
||||
): EncounterState {
|
||||
const { store, getEncounter } = makeStoreFromState(state);
|
||||
@@ -241,13 +287,13 @@ function handleAddFromBestiary(
|
||||
};
|
||||
}
|
||||
|
||||
function handleAddFromPlayerCharacter(
|
||||
state: EncounterState,
|
||||
function addOneFromPlayerCharacter(
|
||||
store: EncounterStore,
|
||||
pc: PlayerCharacter,
|
||||
): EncounterState {
|
||||
const { store, getEncounter } = makeStoreFromState(state);
|
||||
nextId: number,
|
||||
): DomainEvent[] | null {
|
||||
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, {
|
||||
maxHp: pc.maxHp,
|
||||
ac: pc.ac > 0 ? pc.ac : undefined,
|
||||
@@ -255,17 +301,128 @@ function handleAddFromPlayerCharacter(
|
||||
icon: pc.icon,
|
||||
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 {
|
||||
...state,
|
||||
encounter: getEncounter(),
|
||||
undoRedoState: pushUndo(state.undoRedoState, state.encounter),
|
||||
events: [...state.events, ...result],
|
||||
events: [...state.events, ...events],
|
||||
nextId: state.nextId + 1,
|
||||
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 --
|
||||
|
||||
export function encounterReducer(
|
||||
@@ -297,12 +454,21 @@ export function encounterReducer(
|
||||
lastCreatureId: null,
|
||||
};
|
||||
}
|
||||
case "set-creature-adjustment":
|
||||
return handleSetCreatureAdjustment(
|
||||
state,
|
||||
action.id,
|
||||
action.adjustment,
|
||||
action.baseCreature,
|
||||
);
|
||||
case "add-from-bestiary":
|
||||
return handleAddFromBestiary(state, action.entry, 1);
|
||||
case "add-multiple-from-bestiary":
|
||||
return handleAddFromBestiary(state, action.entry, action.count);
|
||||
case "add-from-player-character":
|
||||
return handleAddFromPlayerCharacter(state, action.pc);
|
||||
case "add-party":
|
||||
return handleAddParty(state, action.pcs);
|
||||
default:
|
||||
return dispatchEncounterAction(state, action);
|
||||
}
|
||||
@@ -319,13 +485,18 @@ function dispatchEncounterAction(
|
||||
| { type: "edit-combatant" }
|
||||
| { type: "set-initiative" }
|
||||
| { type: "set-hp" }
|
||||
| { type: "set-hp-variant" }
|
||||
| { type: "adjust-hp" }
|
||||
| { type: "set-temp-hp" }
|
||||
| { type: "set-ac" }
|
||||
| { type: "set-cr" }
|
||||
| { type: "set-side" }
|
||||
| { type: "toggle-condition" }
|
||||
| { type: "set-condition-value" }
|
||||
| { type: "decrement-condition" }
|
||||
| { type: "toggle-concentration" }
|
||||
| { type: "add-persistent-damage" }
|
||||
| { type: "remove-persistent-damage" }
|
||||
>,
|
||||
): EncounterState {
|
||||
const { store, getEncounter } = makeStoreFromState(state);
|
||||
@@ -355,6 +526,14 @@ function dispatchEncounterAction(
|
||||
case "set-hp":
|
||||
result = setHpUseCase(store, action.id, action.maxHp);
|
||||
break;
|
||||
case "set-hp-variant":
|
||||
result = setHpVariantUseCase(
|
||||
store,
|
||||
action.id,
|
||||
action.variant,
|
||||
action.range,
|
||||
);
|
||||
break;
|
||||
case "adjust-hp":
|
||||
result = adjustHpUseCase(store, action.id, action.delta);
|
||||
break;
|
||||
@@ -373,9 +552,35 @@ function dispatchEncounterAction(
|
||||
case "toggle-condition":
|
||||
result = toggleConditionUseCase(store, action.id, action.conditionId);
|
||||
break;
|
||||
case "set-condition-value":
|
||||
result = setConditionValueUseCase(
|
||||
store,
|
||||
action.id,
|
||||
action.conditionId,
|
||||
action.value,
|
||||
);
|
||||
break;
|
||||
case "decrement-condition":
|
||||
result = decrementConditionUseCase(store, action.id, action.conditionId);
|
||||
break;
|
||||
case "toggle-concentration":
|
||||
result = toggleConcentrationUseCase(store, action.id);
|
||||
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;
|
||||
@@ -395,7 +600,10 @@ function dispatchEncounterAction(
|
||||
export function useEncounter() {
|
||||
const { encounterPersistence, undoRedoPersistence } = useAdapters();
|
||||
const [state, dispatch] = useReducer(encounterReducer, null, () =>
|
||||
initializeState(encounterPersistence.load, undoRedoPersistence.load),
|
||||
initializeState(
|
||||
() => encounterPersistence.load(),
|
||||
() => undoRedoPersistence.load(),
|
||||
),
|
||||
);
|
||||
const { encounter, undoRedoState, events } = state;
|
||||
|
||||
@@ -522,23 +730,59 @@ export function useEncounter() {
|
||||
dispatch({ type: "toggle-condition", id, conditionId }),
|
||||
[],
|
||||
),
|
||||
setConditionValue: useCallback(
|
||||
(id: CombatantId, conditionId: ConditionId, value: number) =>
|
||||
dispatch({ type: "set-condition-value", id, conditionId, value }),
|
||||
[],
|
||||
),
|
||||
decrementCondition: useCallback(
|
||||
(id: CombatantId, conditionId: ConditionId) =>
|
||||
dispatch({ type: "decrement-condition", id, conditionId }),
|
||||
[],
|
||||
),
|
||||
toggleConcentration: useCallback(
|
||||
(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(
|
||||
() => dispatch({ type: "clear-encounter" }),
|
||||
[],
|
||||
),
|
||||
addFromBestiary: useCallback(
|
||||
(entry: BestiaryIndexEntry): CreatureId | null => {
|
||||
dispatch({ type: "add-from-bestiary", entry });
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
),
|
||||
addFromBestiary: useCallback((entry: SearchResult): CreatureId | null => {
|
||||
dispatch({ type: "add-from-bestiary", entry });
|
||||
return null;
|
||||
}, []),
|
||||
addMultipleFromBestiary: useCallback(
|
||||
(entry: BestiaryIndexEntry, count: number): CreatureId | null => {
|
||||
(entry: SearchResult, count: number): CreatureId | null => {
|
||||
dispatch({
|
||||
type: "add-multiple-from-bestiary",
|
||||
entry,
|
||||
@@ -553,6 +797,10 @@ export function useEncounter() {
|
||||
dispatch({ type: "add-from-player-character", pc }),
|
||||
[],
|
||||
),
|
||||
addParty: useCallback(
|
||||
(pcs: readonly PlayerCharacter[]) => dispatch({ type: "add-party", pcs }),
|
||||
[],
|
||||
),
|
||||
undo: useCallback(() => dispatch({ type: "undo" }), []),
|
||||
redo: useCallback(() => dispatch({ type: "redo" }), []),
|
||||
setEncounter: useCallback(
|
||||
|
||||
@@ -9,10 +9,18 @@ import { isDomainError, playerCharacterId } from "@initiative/domain";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useAdapters } from "../contexts/adapter-context.js";
|
||||
|
||||
let nextPcId = 0;
|
||||
const PC_ID_PATTERN = /^pc-(\d+)$/;
|
||||
|
||||
function generatePcId(): PlayerCharacterId {
|
||||
return playerCharacterId(`pc-${++nextPcId}`);
|
||||
function generatePcId(existing: readonly PlayerCharacter[]): PlayerCharacterId {
|
||||
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 {
|
||||
@@ -55,7 +63,7 @@ export function usePlayerCharacters() {
|
||||
icon: string | undefined,
|
||||
level: number | undefined,
|
||||
) => {
|
||||
const id = generatePcId();
|
||||
const id = generatePcId(charactersRef.current);
|
||||
const result = createPlayerCharacterUseCase(
|
||||
makeStore(),
|
||||
id,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { RulesEdition } from "@initiative/domain";
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
|
||||
const STORAGE_KEY = "initiative:rules-edition";
|
||||
const STORAGE_KEY = "initiative:game-system";
|
||||
const OLD_STORAGE_KEY = "initiative:rules-edition";
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
let currentEdition: RulesEdition = loadEdition();
|
||||
@@ -9,7 +10,14 @@ let currentEdition: RulesEdition = loadEdition();
|
||||
function loadEdition(): RulesEdition {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw === "5e" || raw === "5.5e") return raw;
|
||||
if (raw === "5e" || raw === "5.5e" || raw === "pf2e") return raw;
|
||||
// Migrate from old key
|
||||
const old = localStorage.getItem(OLD_STORAGE_KEY);
|
||||
if (old === "5e" || old === "5.5e") {
|
||||
localStorage.setItem(STORAGE_KEY, old);
|
||||
localStorage.removeItem(OLD_STORAGE_KEY);
|
||||
return old;
|
||||
}
|
||||
} catch {
|
||||
// storage unavailable
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import type { CreatureId } from "@initiative/domain";
|
||||
import type { CombatantId, CreatureId } from "@initiative/domain";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
type PanelView =
|
||||
| { mode: "closed" }
|
||||
| { mode: "creature"; creatureId: CreatureId }
|
||||
| { mode: "creature"; creatureId: CreatureId; combatantId?: CombatantId }
|
||||
| { mode: "bulk-import" }
|
||||
| { mode: "source-manager" };
|
||||
|
||||
interface SidePanelState {
|
||||
panelView: PanelView;
|
||||
selectedCreatureId: CreatureId | null;
|
||||
selectedCombatantId: CombatantId | null;
|
||||
bulkImportMode: boolean;
|
||||
sourceManagerMode: boolean;
|
||||
isRightPanelCollapsed: boolean;
|
||||
@@ -18,8 +19,8 @@ interface SidePanelState {
|
||||
}
|
||||
|
||||
interface SidePanelActions {
|
||||
showCreature: (creatureId: CreatureId) => void;
|
||||
updateCreature: (creatureId: CreatureId) => void;
|
||||
showCreature: (creatureId: CreatureId, combatantId?: CombatantId) => void;
|
||||
updateCreature: (creatureId: CreatureId, combatantId?: CombatantId) => void;
|
||||
showBulkImport: () => void;
|
||||
showSourceManager: () => void;
|
||||
dismissPanel: () => void;
|
||||
@@ -48,14 +49,23 @@ export function useSidePanelState(): SidePanelState & SidePanelActions {
|
||||
const selectedCreatureId =
|
||||
panelView.mode === "creature" ? panelView.creatureId : null;
|
||||
|
||||
const showCreature = useCallback((creatureId: CreatureId) => {
|
||||
setPanelView({ mode: "creature", creatureId });
|
||||
setIsRightPanelCollapsed(false);
|
||||
}, []);
|
||||
const selectedCombatantId =
|
||||
panelView.mode === "creature" ? (panelView.combatantId ?? null) : null;
|
||||
|
||||
const updateCreature = useCallback((creatureId: CreatureId) => {
|
||||
setPanelView({ mode: "creature", creatureId });
|
||||
}, []);
|
||||
const showCreature = useCallback(
|
||||
(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(() => {
|
||||
setPanelView({ mode: "bulk-import" });
|
||||
@@ -90,6 +100,7 @@ export function useSidePanelState(): SidePanelState & SidePanelActions {
|
||||
return {
|
||||
panelView,
|
||||
selectedCreatureId,
|
||||
selectedCombatantId,
|
||||
bulkImportMode: panelView.mode === "bulk-import",
|
||||
sourceManagerMode: panelView.mode === "source-manager",
|
||||
isRightPanelCollapsed,
|
||||
|
||||
@@ -70,3 +70,72 @@ export function useSwipeToDismiss(onDismiss: () => void) {
|
||||
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;
|
||||
}
|
||||
|
||||
@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 {
|
||||
0% {
|
||||
scale: 1;
|
||||
|
||||
+2
-1
@@ -10,7 +10,8 @@
|
||||
"!coverage",
|
||||
"!.pnpm-store",
|
||||
"!.rodney",
|
||||
"!.agent-tests"
|
||||
"!.agent-tests",
|
||||
"!data"
|
||||
]
|
||||
},
|
||||
"assist": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user