Compare commits
27
Commits
e161645228
...
0.9.45
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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.
|
||||
+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.5"
|
||||
"vite": "^8.0.16"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ export function createTestAdapters(options?: {
|
||||
`https://example.com/creatures-${sourceCode.toLowerCase()}.json`,
|
||||
getSourceDisplayName: (sourceCode) => sourceCode,
|
||||
getCreaturePathsForSource: () => [],
|
||||
getCreatureNamesByPaths: () => new Map(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export { buildCombatant } from "./build-combatant.js";
|
||||
export { buildCreature } from "./build-creature.js";
|
||||
export { buildEncounter } from "./build-encounter.js";
|
||||
export { buildPf2eCreature } from "./build-pf2e-creature.js";
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -131,6 +131,39 @@ describe("normalizeFoundryCreature", () => {
|
||||
);
|
||||
expect(creature.senses).toBe("Scent 60 feet");
|
||||
});
|
||||
|
||||
it("extracts perception details", () => {
|
||||
const creature = normalizeFoundryCreature(
|
||||
minimalCreature({
|
||||
system: {
|
||||
...minimalCreature().system,
|
||||
perception: {
|
||||
mod: 35,
|
||||
details: "smoke vision",
|
||||
senses: [{ type: "darkvision" }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(creature.perceptionDetails).toBe("smoke vision");
|
||||
expect(creature.senses).toBe("Darkvision");
|
||||
});
|
||||
|
||||
it("omits perception details when empty", () => {
|
||||
const creature = normalizeFoundryCreature(
|
||||
minimalCreature({
|
||||
system: {
|
||||
...minimalCreature().system,
|
||||
perception: {
|
||||
mod: 8,
|
||||
details: "",
|
||||
senses: [{ type: "darkvision" }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(creature.perceptionDetails).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("languages formatting", () => {
|
||||
@@ -386,6 +419,101 @@ describe("normalizeFoundryCreature", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes attack effects in damage text", () => {
|
||||
const creature = normalizeFoundryCreature(
|
||||
minimalCreature({
|
||||
items: [
|
||||
{
|
||||
_id: "atk1",
|
||||
name: "talon",
|
||||
type: "melee",
|
||||
system: {
|
||||
bonus: { value: 14 },
|
||||
damageRolls: {
|
||||
abc: {
|
||||
damage: "1d10+6",
|
||||
damageType: "piercing",
|
||||
},
|
||||
},
|
||||
traits: { value: [] },
|
||||
attackEffects: { value: ["grab"] },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const attack = creature.attacks?.[0];
|
||||
expect(attack?.segments[0]).toEqual({
|
||||
type: "text",
|
||||
value: "+14, 1d10+6 piercing plus Grab",
|
||||
});
|
||||
});
|
||||
|
||||
it("joins multiple attack effects with 'and'", () => {
|
||||
const creature = normalizeFoundryCreature(
|
||||
minimalCreature({
|
||||
items: [
|
||||
{
|
||||
_id: "atk1",
|
||||
name: "claw",
|
||||
type: "melee",
|
||||
system: {
|
||||
bonus: { value: 18 },
|
||||
damageRolls: {
|
||||
abc: {
|
||||
damage: "2d8+6",
|
||||
damageType: "slashing",
|
||||
},
|
||||
},
|
||||
traits: { value: [] },
|
||||
attackEffects: {
|
||||
value: ["grab", "knockdown"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const attack = creature.attacks?.[0];
|
||||
expect(attack?.segments[0]).toEqual({
|
||||
type: "text",
|
||||
value: "+18, 2d8+6 slashing plus Grab and Knockdown",
|
||||
});
|
||||
});
|
||||
|
||||
it("strips creature-name prefix from attack effect slugs", () => {
|
||||
const creature = normalizeFoundryCreature(
|
||||
minimalCreature({
|
||||
name: "Lich",
|
||||
items: [
|
||||
{
|
||||
_id: "atk1",
|
||||
name: "hand",
|
||||
type: "melee",
|
||||
system: {
|
||||
bonus: { value: 24 },
|
||||
damageRolls: {
|
||||
abc: {
|
||||
damage: "2d12+7",
|
||||
damageType: "negative",
|
||||
},
|
||||
},
|
||||
traits: { value: [] },
|
||||
attackEffects: {
|
||||
value: ["lich-siphon-life"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const attack = creature.attacks?.[0];
|
||||
expect(attack?.segments[0]).toEqual({
|
||||
type: "text",
|
||||
value: "+24, 2d12+7 negative plus Siphon Life",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ability normalization", () => {
|
||||
@@ -539,6 +667,396 @@ describe("normalizeFoundryCreature", () => {
|
||||
: undefined,
|
||||
).toBe("(Concentrate, Polymorph) Takes a new form.");
|
||||
});
|
||||
|
||||
it("extracts frequency from ability", () => {
|
||||
const creature = normalizeFoundryCreature(
|
||||
minimalCreature({
|
||||
items: [
|
||||
{
|
||||
_id: "a1",
|
||||
name: "Drain Soul Cage",
|
||||
type: "action",
|
||||
system: {
|
||||
category: "offensive",
|
||||
actionType: { value: "free" },
|
||||
actions: { value: null },
|
||||
traits: { value: [] },
|
||||
description: { value: "<p>Drains the soul.</p>" },
|
||||
frequency: { max: 1, per: "day" },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(creature.abilitiesBot?.[0]?.frequency).toBe("1/day");
|
||||
});
|
||||
|
||||
it("strips redundant frequency line from description", () => {
|
||||
const creature = normalizeFoundryCreature(
|
||||
minimalCreature({
|
||||
items: [
|
||||
{
|
||||
_id: "a1",
|
||||
name: "Consult the Text",
|
||||
type: "action",
|
||||
system: {
|
||||
category: "offensive",
|
||||
actionType: { value: "action" },
|
||||
actions: { value: 1 },
|
||||
traits: { value: [] },
|
||||
description: {
|
||||
value:
|
||||
"<p><strong>Frequency</strong> once per day</p>\n<hr />\n<p><strong>Effect</strong> The lich opens their spell tome.</p>",
|
||||
},
|
||||
frequency: { max: 1, per: "day" },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const text =
|
||||
creature.abilitiesBot?.[0]?.segments[0]?.type === "text"
|
||||
? creature.abilitiesBot[0].segments[0].value
|
||||
: "";
|
||||
expect(text).not.toContain("Frequency");
|
||||
expect(text).toContain("The lich opens their spell tome.");
|
||||
});
|
||||
|
||||
it("strips frequency line even when preceded by other text", () => {
|
||||
const creature = normalizeFoundryCreature(
|
||||
minimalCreature({
|
||||
items: [
|
||||
{
|
||||
_id: "a1",
|
||||
name: "Drain Soul Cage",
|
||||
type: "action",
|
||||
system: {
|
||||
category: "offensive",
|
||||
actionType: { value: "free" },
|
||||
actions: { value: null },
|
||||
traits: { value: [] },
|
||||
description: {
|
||||
value:
|
||||
"<p>6th rank</p>\n<hr />\n<p><strong>Frequency</strong> once per day</p>\n<hr />\n<p><strong>Effect</strong> The lich taps into their soul cage.</p>",
|
||||
},
|
||||
frequency: { max: 1, per: "day" },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const text =
|
||||
creature.abilitiesBot?.[0]?.segments[0]?.type === "text"
|
||||
? creature.abilitiesBot[0].segments[0].value
|
||||
: "";
|
||||
expect(text).not.toContain("Frequency");
|
||||
expect(text).toContain("6th rank");
|
||||
expect(text).toContain("The lich taps into their soul cage.");
|
||||
});
|
||||
|
||||
it("omits frequency when not present", () => {
|
||||
const creature = normalizeFoundryCreature(
|
||||
minimalCreature({
|
||||
items: [
|
||||
{
|
||||
_id: "a1",
|
||||
name: "Strike",
|
||||
type: "action",
|
||||
system: {
|
||||
category: "offensive",
|
||||
actionType: { value: "action" },
|
||||
actions: { value: 1 },
|
||||
traits: { value: [] },
|
||||
description: { value: "<p>Strikes.</p>" },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(creature.abilitiesBot?.[0]?.frequency).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("equipment normalization", () => {
|
||||
it("normalizes a weapon with traits and description", () => {
|
||||
const creature = normalizeFoundryCreature(
|
||||
minimalCreature({
|
||||
items: [
|
||||
{
|
||||
_id: "w1",
|
||||
name: "Flaming Longsword",
|
||||
type: "weapon",
|
||||
system: {
|
||||
level: { value: 5 },
|
||||
traits: { value: ["magical", "fire"] },
|
||||
description: {
|
||||
value: "<p>This sword blazes with fire.</p>",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(creature.equipment).toHaveLength(1);
|
||||
const item = creature.equipment?.[0];
|
||||
expect(item?.name).toBe("Flaming Longsword");
|
||||
expect(item?.level).toBe(5);
|
||||
expect(item?.traits).toEqual(["magical", "fire"]);
|
||||
expect(item?.description).toBe("This sword blazes with fire.");
|
||||
});
|
||||
|
||||
it("normalizes a consumable potion with description", () => {
|
||||
const creature = normalizeFoundryCreature(
|
||||
minimalCreature({
|
||||
items: [
|
||||
{
|
||||
_id: "c1",
|
||||
name: "Healing Potion (Moderate)",
|
||||
type: "consumable",
|
||||
system: {
|
||||
level: { value: 6 },
|
||||
traits: { value: ["consumable", "healing", "magical"] },
|
||||
description: {
|
||||
value: "<p>Restores 3d8+10 Hit Points.</p>",
|
||||
},
|
||||
category: "potion",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(creature.equipment).toHaveLength(1);
|
||||
const item = creature.equipment?.[0];
|
||||
expect(item?.name).toBe("Healing Potion (Moderate)");
|
||||
expect(item?.category).toBe("potion");
|
||||
expect(item?.description).toBe("Restores 3d8+10 Hit Points.");
|
||||
});
|
||||
|
||||
it("extracts scroll embedded spell name and rank", () => {
|
||||
const creature = normalizeFoundryCreature(
|
||||
minimalCreature({
|
||||
items: [
|
||||
{
|
||||
_id: "s1",
|
||||
name: "Scroll of Teleport (Rank 6)",
|
||||
type: "consumable",
|
||||
system: {
|
||||
level: { value: 11 },
|
||||
traits: { value: ["consumable", "magical", "scroll"] },
|
||||
description: { value: "<p>A scroll.</p>" },
|
||||
category: "scroll",
|
||||
spell: {
|
||||
name: "Teleport",
|
||||
system: { level: { value: 6 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const item = creature.equipment?.[0];
|
||||
expect(item?.spellName).toBe("Teleport");
|
||||
expect(item?.spellRank).toBe(6);
|
||||
});
|
||||
|
||||
it("extracts wand embedded spell name and rank", () => {
|
||||
const creature = normalizeFoundryCreature(
|
||||
minimalCreature({
|
||||
items: [
|
||||
{
|
||||
_id: "w1",
|
||||
name: "Wand of Dispel Magic (Rank 2)",
|
||||
type: "consumable",
|
||||
system: {
|
||||
level: { value: 5 },
|
||||
traits: { value: ["consumable", "magical", "wand"] },
|
||||
description: { value: "<p>A wand.</p>" },
|
||||
category: "wand",
|
||||
spell: {
|
||||
name: "Dispel Magic",
|
||||
system: { level: { value: 2 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const item = creature.equipment?.[0];
|
||||
expect(item?.spellName).toBe("Dispel Magic");
|
||||
expect(item?.spellRank).toBe(2);
|
||||
});
|
||||
|
||||
it("filters magical equipment into equipment field", () => {
|
||||
const creature = normalizeFoundryCreature(
|
||||
minimalCreature({
|
||||
items: [
|
||||
{
|
||||
_id: "e1",
|
||||
name: "Ring of Energy Resistance (Fire)",
|
||||
type: "equipment",
|
||||
system: {
|
||||
level: { value: 6 },
|
||||
traits: { value: ["magical", "invested"] },
|
||||
description: {
|
||||
value: "<p>Grants fire resistance 5.</p>",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(creature.equipment).toHaveLength(1);
|
||||
expect(creature.equipment?.[0]?.name).toBe(
|
||||
"Ring of Energy Resistance (Fire)",
|
||||
);
|
||||
expect(creature.items).toBeUndefined();
|
||||
});
|
||||
|
||||
it("filters mundane items into items string", () => {
|
||||
const creature = normalizeFoundryCreature(
|
||||
minimalCreature({
|
||||
items: [
|
||||
{
|
||||
_id: "w1",
|
||||
name: "Longsword",
|
||||
type: "weapon",
|
||||
system: {
|
||||
level: { value: 0 },
|
||||
traits: { value: [] },
|
||||
description: { value: "" },
|
||||
},
|
||||
},
|
||||
{
|
||||
_id: "a1",
|
||||
name: "Leather Armor",
|
||||
type: "armor",
|
||||
system: {
|
||||
level: { value: 0 },
|
||||
traits: { value: [] },
|
||||
description: { value: "" },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(creature.items).toBe("Longsword, Leather Armor");
|
||||
expect(creature.equipment).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits equipment when no detailed items exist", () => {
|
||||
const creature = normalizeFoundryCreature(
|
||||
minimalCreature({
|
||||
items: [
|
||||
{
|
||||
_id: "w1",
|
||||
name: "Dagger",
|
||||
type: "weapon",
|
||||
system: {
|
||||
level: { value: 0 },
|
||||
traits: { value: [] },
|
||||
description: { value: "" },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(creature.equipment).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits items when no mundane items exist", () => {
|
||||
const creature = normalizeFoundryCreature(
|
||||
minimalCreature({
|
||||
items: [
|
||||
{
|
||||
_id: "c1",
|
||||
name: "Giant Wasp Venom",
|
||||
type: "consumable",
|
||||
system: {
|
||||
level: { value: 7 },
|
||||
traits: { value: ["consumable", "poison"] },
|
||||
description: {
|
||||
value: "<p>A deadly poison.</p>",
|
||||
},
|
||||
category: "poison",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(creature.items).toBeUndefined();
|
||||
expect(creature.equipment).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("includes armor with special material in equipment", () => {
|
||||
const creature = normalizeFoundryCreature(
|
||||
minimalCreature({
|
||||
items: [
|
||||
{
|
||||
_id: "a1",
|
||||
name: "Adamantine Full Plate",
|
||||
type: "armor",
|
||||
system: {
|
||||
level: { value: 0 },
|
||||
traits: { value: [] },
|
||||
description: {
|
||||
value: "<p>Full plate made of adamantine.</p>",
|
||||
},
|
||||
material: { type: "adamantine", grade: "standard" },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(creature.equipment).toHaveLength(1);
|
||||
expect(creature.equipment?.[0]?.name).toBe("Adamantine Full Plate");
|
||||
});
|
||||
|
||||
it("excludes mundane armor from equipment (goes to items)", () => {
|
||||
const creature = normalizeFoundryCreature(
|
||||
minimalCreature({
|
||||
items: [
|
||||
{
|
||||
_id: "a1",
|
||||
name: "Chain Mail",
|
||||
type: "armor",
|
||||
system: {
|
||||
level: { value: 0 },
|
||||
traits: { value: [] },
|
||||
description: { value: "" },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(creature.equipment).toBeUndefined();
|
||||
expect(creature.items).toBe("Chain Mail");
|
||||
});
|
||||
|
||||
it("strips Foundry HTML tags from equipment descriptions", () => {
|
||||
const creature = normalizeFoundryCreature(
|
||||
minimalCreature({
|
||||
items: [
|
||||
{
|
||||
_id: "c1",
|
||||
name: "Potion of Speed",
|
||||
type: "consumable",
|
||||
system: {
|
||||
level: { value: 10 },
|
||||
traits: { value: ["consumable", "magical"] },
|
||||
description: {
|
||||
value:
|
||||
"<p>Gain @UUID[Compendium.pf2e.conditionitems.Item.Quickened]{quickened} for 1 minute.</p>",
|
||||
},
|
||||
category: "potion",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const desc = creature.equipment?.[0]?.description;
|
||||
expect(desc).toBe("Gain quickened for 1 minute.");
|
||||
expect(desc).not.toContain("@UUID");
|
||||
});
|
||||
});
|
||||
|
||||
describe("spellcasting normalization", () => {
|
||||
@@ -597,7 +1115,8 @@ describe("normalizeFoundryCreature", () => {
|
||||
expect(sc?.daily?.[0]?.spells.map((s) => s.name)).toEqual(["Earthquake"]);
|
||||
expect(sc?.daily?.[1]?.spells.map((s) => s.name)).toEqual(["Heal"]);
|
||||
expect(sc?.atWill?.map((s) => s.name)).toEqual(["Detect Magic"]);
|
||||
expect(sc?.atWill?.[0]?.rank).toBe(1);
|
||||
// Cantrip rank auto-heightens to ceil(creatureLevel / 2) = ceil(3/2) = 2
|
||||
expect(sc?.atWill?.[0]?.rank).toBe(2);
|
||||
});
|
||||
|
||||
it("normalizes innate spells with uses", () => {
|
||||
|
||||
@@ -99,9 +99,15 @@ describe("stripFoundryTags", () => {
|
||||
expect(stripFoundryTags("before<hr />after")).toBe("before\nafter");
|
||||
});
|
||||
|
||||
it("strips strong and em tags", () => {
|
||||
it("preserves strong and em tags", () => {
|
||||
expect(stripFoundryTags("<strong>bold</strong> <em>italic</em>")).toBe(
|
||||
"bold italic",
|
||||
"<strong>bold</strong> <em>italic</em>",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves list tags", () => {
|
||||
expect(stripFoundryTags("<ul><li>first</li><li>second</li></ul>")).toBe(
|
||||
"<ul><li>first</li><li>second</li></ul>",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import { type IDBPDatabase, openDB } from "idb";
|
||||
|
||||
const DB_NAME = "initiative-bestiary";
|
||||
const STORE_NAME = "sources";
|
||||
// v6 (2026-04-09): SpellReference per-spell data added; old caches are cleared
|
||||
const DB_VERSION = 6;
|
||||
// v8 (2026-04-10): Attack effects, ability frequency, perception details added to PF2e creatures
|
||||
const DB_VERSION = 8;
|
||||
|
||||
interface CachedSourceInfo {
|
||||
readonly sourceCode: string;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { BestiaryIndex, BestiaryIndexEntry } from "@initiative/domain";
|
||||
|
||||
import rawIndex from "../../../../data/bestiary/index.json";
|
||||
import {
|
||||
getBundledDndSources,
|
||||
loadBundledDndIndexEntries,
|
||||
} from "./dnd-bundled-adapter.js";
|
||||
|
||||
interface CompactCreature {
|
||||
readonly n: string;
|
||||
@@ -55,23 +59,32 @@ export function loadBestiaryIndex(): BestiaryIndex {
|
||||
if (cachedIndex) return cachedIndex;
|
||||
|
||||
const compact = rawIndex as unknown as CompactIndex;
|
||||
const sources = Object.fromEntries(
|
||||
const sources: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(compact.sources).filter(
|
||||
([code]) => !EXCLUDED_SOURCES.has(code),
|
||||
),
|
||||
);
|
||||
for (const [code, name] of getBundledDndSources()) {
|
||||
sources[code] = name;
|
||||
}
|
||||
cachedIndex = {
|
||||
sources,
|
||||
creatures: compact.creatures
|
||||
.filter((c) => !EXCLUDED_SOURCES.has(c.s))
|
||||
.map(mapCreature),
|
||||
creatures: [
|
||||
...compact.creatures
|
||||
.filter((c) => !EXCLUDED_SOURCES.has(c.s))
|
||||
.map(mapCreature),
|
||||
...loadBundledDndIndexEntries(),
|
||||
],
|
||||
};
|
||||
return cachedIndex;
|
||||
}
|
||||
|
||||
export function getAllSourceCodes(): string[] {
|
||||
const index = loadBestiaryIndex();
|
||||
return Object.keys(index.sources).filter((c) => !EXCLUDED_SOURCES.has(c));
|
||||
const bundled = getBundledDndSources();
|
||||
return Object.keys(index.sources).filter(
|
||||
(c) => !EXCLUDED_SOURCES.has(c) && !bundled.has(c),
|
||||
);
|
||||
}
|
||||
|
||||
function sourceCodeToFilename(sourceCode: string): string {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { BestiaryIndexEntry, Creature } from "@initiative/domain";
|
||||
import { creatureId } from "@initiative/domain";
|
||||
|
||||
import rawBundled from "../../../../data/bestiary/dnd-bundled.json";
|
||||
|
||||
type RawBundledCreature = Omit<Creature, "id"> & { id: string };
|
||||
|
||||
const SIZE_TO_CODE: Record<string, string> = {
|
||||
Tiny: "T",
|
||||
Small: "S",
|
||||
Medium: "M",
|
||||
Large: "L",
|
||||
Huge: "H",
|
||||
Gargantuan: "G",
|
||||
};
|
||||
|
||||
/** Full normalized stat blocks for bundled D&D creatures. */
|
||||
export function loadBundledDndCreatures(): Creature[] {
|
||||
return (rawBundled as RawBundledCreature[]).map((c) => ({
|
||||
...c,
|
||||
id: creatureId(c.id),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Index entries derived from the bundled creatures, in the compact shape
|
||||
* used by the search index. */
|
||||
export function loadBundledDndIndexEntries(): BestiaryIndexEntry[] {
|
||||
return (rawBundled as RawBundledCreature[]).map((c) => ({
|
||||
name: c.name,
|
||||
source: c.source,
|
||||
ac: c.ac,
|
||||
hp: c.hp.average,
|
||||
dex: c.abilities.dex,
|
||||
cr: c.cr,
|
||||
initiativeProficiency: c.initiativeProficiency,
|
||||
size: SIZE_TO_CODE[c.size.split(" ")[0]] ?? "M",
|
||||
type: c.type.split(" ")[0].toLowerCase(),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Source codes → display names, derived from the bundled creatures' own
|
||||
* `source` and `sourceDisplayName` fields. Adding a new book just means
|
||||
* appending creatures with the right `source` field to dnd-bundled.json;
|
||||
* no code change is required here. */
|
||||
export function getBundledDndSources(): ReadonlyMap<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
for (const c of rawBundled as RawBundledCreature[]) {
|
||||
if (!map.has(c.source)) {
|
||||
map.set(c.source, c.sourceDisplayName);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
CreatureId,
|
||||
EquipmentItem,
|
||||
Pf2eCreature,
|
||||
SpellcastingBlock,
|
||||
SpellReference,
|
||||
@@ -62,6 +63,7 @@ interface MeleeSystem {
|
||||
bonus?: { value: number };
|
||||
damageRolls?: Record<string, { damage: string; damageType: string }>;
|
||||
traits?: { value: string[] };
|
||||
attackEffects?: { value: string[] };
|
||||
}
|
||||
|
||||
interface ActionSystem {
|
||||
@@ -70,6 +72,7 @@ interface ActionSystem {
|
||||
actions?: { value: number | null };
|
||||
traits?: { value: string[] };
|
||||
description?: { value: string };
|
||||
frequency?: { max: number; per: string };
|
||||
}
|
||||
|
||||
interface SpellcastingEntrySystem {
|
||||
@@ -114,6 +117,73 @@ interface SpellSystem {
|
||||
>;
|
||||
}
|
||||
|
||||
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",
|
||||
@@ -274,7 +344,17 @@ function formatSpeed(speed: {
|
||||
|
||||
// -- Attack normalization --
|
||||
|
||||
function normalizeAttack(item: RawFoundryItem): TraitBlock {
|
||||
/** Format an attack effect slug to display text: "grab" → "Grab", "lich-siphon-life" → "Siphon Life". */
|
||||
function formatAttackEffect(slug: string, creatureName: string): string {
|
||||
const prefix = `${creatureName.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-")}-`;
|
||||
const stripped = slug.startsWith(prefix) ? slug.slice(prefix.length) : slug;
|
||||
return stripped.split("-").map(capitalize).join(" ");
|
||||
}
|
||||
|
||||
function normalizeAttack(
|
||||
item: RawFoundryItem,
|
||||
creatureName: string,
|
||||
): TraitBlock {
|
||||
const sys = item.system as unknown as MeleeSystem;
|
||||
const bonus = sys.bonus?.value ?? 0;
|
||||
const traits = sys.traits?.value ?? [];
|
||||
@@ -284,13 +364,18 @@ function normalizeAttack(item: RawFoundryItem): TraitBlock {
|
||||
.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}`,
|
||||
value: `+${bonus}${traitStr}, ${damage}${effectStr}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -314,15 +399,31 @@ function parseActivity(
|
||||
|
||||
// -- 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;
|
||||
const description = stripFoundryTags(sys.description?.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(", ")}) `
|
||||
@@ -333,7 +434,7 @@ function normalizeAbility(item: RawFoundryItem): TraitBlock {
|
||||
? [{ type: "text", value: text }]
|
||||
: [];
|
||||
|
||||
return { name: item.name, activity, segments };
|
||||
return { name: item.name, activity, frequency, segments };
|
||||
}
|
||||
|
||||
// -- Spellcasting normalization --
|
||||
@@ -402,16 +503,25 @@ function formatOverlays(overlays: SpellSystem["overlays"]): string | undefined {
|
||||
*/
|
||||
const HEIGHTENED_SUFFIX = /\s*Heightened\s*\([^)]*\)[\s\S]*$/;
|
||||
|
||||
function normalizeSpell(item: RawFoundryItem): SpellReference {
|
||||
function normalizeSpell(
|
||||
item: RawFoundryItem,
|
||||
creatureLevel: number,
|
||||
): SpellReference {
|
||||
const sys = item.system as unknown as SpellSystem;
|
||||
const usesMax = sys.location?.uses?.max;
|
||||
const rank = sys.location?.heightenedLevel ?? sys.level?.value ?? 0;
|
||||
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();
|
||||
}
|
||||
@@ -439,6 +549,7 @@ function normalizeSpell(item: RawFoundryItem): SpellReference {
|
||||
function normalizeSpellcastingEntry(
|
||||
entry: RawFoundryItem,
|
||||
allSpells: readonly RawFoundryItem[],
|
||||
creatureLevel: number,
|
||||
): SpellcastingBlock {
|
||||
const sys = entry.system as unknown as SpellcastingEntrySystem;
|
||||
const tradition = capitalize(sys.tradition?.value ?? "");
|
||||
@@ -457,7 +568,7 @@ function normalizeSpellcastingEntry(
|
||||
const cantrips: SpellReference[] = [];
|
||||
|
||||
for (const spell of linkedSpells) {
|
||||
const ref = normalizeSpell(spell);
|
||||
const ref = normalizeSpell(spell, creatureLevel);
|
||||
const isCantrip =
|
||||
(spell.system as unknown as SpellSystem).traits?.value?.includes(
|
||||
"cantrip",
|
||||
@@ -490,10 +601,13 @@ function normalizeSpellcastingEntry(
|
||||
|
||||
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));
|
||||
return entries.map((entry) =>
|
||||
normalizeSpellcastingEntry(entry, spells, creatureLevel),
|
||||
);
|
||||
}
|
||||
|
||||
// -- Main normalization --
|
||||
@@ -603,6 +717,7 @@ export function normalizeFoundryCreature(
|
||||
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),
|
||||
@@ -620,7 +735,9 @@ export function normalizeFoundryCreature(
|
||||
weaknesses: formatWeaknesses(sys.attributes.weaknesses),
|
||||
speed: formatSpeed(sys.attributes.speed),
|
||||
attacks: orUndefined(
|
||||
items.filter((i) => i.type === "melee").map(normalizeAttack),
|
||||
items
|
||||
.filter((i) => i.type === "melee")
|
||||
.map((i) => normalizeAttack(i, r.name)),
|
||||
),
|
||||
abilitiesTop: orUndefined(actionsByCategory(items, "interaction")),
|
||||
abilitiesMid: orUndefined(
|
||||
@@ -632,7 +749,17 @@ export function normalizeFoundryCreature(
|
||||
),
|
||||
),
|
||||
abilitiesBot: orUndefined(actionsByCategory(items, "offensive")),
|
||||
spellcasting: orUndefined(normalizeSpellcasting(items)),
|
||||
spellcasting: orUndefined(
|
||||
normalizeSpellcasting(items, sys.details?.level?.value ?? 0),
|
||||
),
|
||||
items:
|
||||
items
|
||||
.filter(isMundaneItem)
|
||||
.map((i) => i.name)
|
||||
.join(", ") || undefined,
|
||||
equipment: orUndefined(
|
||||
items.filter(isDetailedEquipment).map(normalizeEquipmentItem),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,18 @@ export function getCreaturePathsForSource(sourceCode: string): string[] {
|
||||
return compact.creatures.filter((c) => c.s === sourceCode).map((c) => c.f);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -9,18 +9,18 @@ import type {
|
||||
} 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 {
|
||||
@@ -31,30 +31,34 @@ export interface CachedSourceInfo {
|
||||
}
|
||||
|
||||
export interface BestiaryCachePort {
|
||||
cacheSource(
|
||||
readonly cacheSource: (
|
||||
system: string,
|
||||
sourceCode: string,
|
||||
displayName: string,
|
||||
creatures: AnyCreature[],
|
||||
): Promise<void>;
|
||||
isSourceCached(system: string, sourceCode: string): Promise<boolean>;
|
||||
getCachedSources(system?: string): Promise<CachedSourceInfo[]>;
|
||||
clearSource(system: string, sourceCode: string): Promise<void>;
|
||||
clearAll(): Promise<void>;
|
||||
loadAllCachedCreatures(): Promise<Map<CreatureId, 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 {
|
||||
loadIndex(): Pf2eBestiaryIndex;
|
||||
getAllSourceCodes(): string[];
|
||||
getDefaultFetchUrl(sourceCode: string, baseUrl?: string): string;
|
||||
getSourceDisplayName(sourceCode: string): string;
|
||||
getCreaturePathsForSource(sourceCode: string): string[];
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -48,5 +48,6 @@ export const productionAdapters: Adapters = {
|
||||
getDefaultFetchUrl: pf2eBestiaryIndex.getDefaultPf2eFetchUrl,
|
||||
getSourceDisplayName: pf2eBestiaryIndex.getPf2eSourceDisplayName,
|
||||
getCreaturePathsForSource: pf2eBestiaryIndex.getCreaturePathsForSource,
|
||||
getCreatureNamesByPaths: pf2eBestiaryIndex.getCreatureNamesByPaths,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -8,7 +8,13 @@
|
||||
|
||||
function formatDamage(params: string): string {
|
||||
// "3d6+10[fire]" → "3d6+10 fire"
|
||||
return params.replaceAll(/\[([^\]]*)\]/g, " $1").trim();
|
||||
// "d4[persistent,fire]" → "d4 persistent fire"
|
||||
return params
|
||||
.replaceAll(
|
||||
/\[([^\]]*)\]/g,
|
||||
(_, type: string) => ` ${type.replaceAll(",", " ")}`,
|
||||
)
|
||||
.trim();
|
||||
}
|
||||
|
||||
function formatCheck(params: string): string {
|
||||
@@ -80,11 +86,11 @@ export function stripFoundryTags(html: string): string {
|
||||
// Strip action-glyph spans (content is a number the renderer handles)
|
||||
result = result.replaceAll(/<span class="action-glyph">[^<]*<\/span>/gi, "");
|
||||
|
||||
// Strip HTML tags
|
||||
// Strip HTML tags (preserve <strong> for UI rendering)
|
||||
result = result.replaceAll(/<br\s*\/?>/gi, "\n");
|
||||
result = result.replaceAll(/<hr\s*\/?>/gi, "\n");
|
||||
result = result.replaceAll(/<\/p>\s*<p[^>]*>/gi, "\n");
|
||||
result = result.replaceAll(/<[^>]+>/g, "");
|
||||
result = result.replaceAll(/<(?!\/?(?:strong|em|ul|ol|li)\b)[^>]+>/g, "");
|
||||
|
||||
// Decode common HTML entities
|
||||
result = result.replaceAll("&", "&");
|
||||
@@ -92,6 +98,11 @@ export function stripFoundryTags(html: string): string {
|
||||
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");
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -5,43 +5,73 @@ 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 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}
|
||||
onSetValue={onSetValue}
|
||||
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, onSetValue, onClose };
|
||||
return { ...result, onToggle, onSetValue, onAddPersistentDamage, onClose };
|
||||
}
|
||||
|
||||
describe("ConditionPicker", () => {
|
||||
@@ -77,4 +107,111 @@ describe("ConditionPicker", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,13 @@ 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",
|
||||
@@ -154,6 +161,53 @@ describe("Pf2eStatBlock", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("recall knowledge", () => {
|
||||
it("renders Recall Knowledge line for a creature with a recognized type trait", () => {
|
||||
renderStatBlock(GOBLIN_WARRIOR);
|
||||
expect(screen.getByText("Recall Knowledge")).toBeInTheDocument();
|
||||
expect(screen.getByText(RK_DC_13_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(RK_HUMANOID_SOCIETY_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adjusts DC for uncommon rarity", () => {
|
||||
const uncommonCreature: Pf2eCreature = {
|
||||
...GOBLIN_WARRIOR,
|
||||
traits: ["uncommon", "small", "humanoid"],
|
||||
};
|
||||
renderStatBlock(uncommonCreature);
|
||||
expect(screen.getByText(RK_DC_15_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adjusts DC for rare rarity", () => {
|
||||
const rareCreature: Pf2eCreature = {
|
||||
...GOBLIN_WARRIOR,
|
||||
level: 5,
|
||||
traits: ["rare", "medium", "undead"],
|
||||
};
|
||||
renderStatBlock(rareCreature);
|
||||
expect(screen.getByText(RK_DC_25_REGEX)).toBeInTheDocument();
|
||||
expect(screen.getByText(RK_UNDEAD_RELIGION_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows multiple skills for types with dual skill mapping", () => {
|
||||
const beastCreature: Pf2eCreature = {
|
||||
...GOBLIN_WARRIOR,
|
||||
traits: ["small", "beast"],
|
||||
};
|
||||
renderStatBlock(beastCreature);
|
||||
expect(screen.getByText(RK_BEAST_SKILLS_REGEX)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits Recall Knowledge when no type trait is recognized", () => {
|
||||
const noTypeCreature: Pf2eCreature = {
|
||||
...GOBLIN_WARRIOR,
|
||||
traits: ["small", "goblin"],
|
||||
};
|
||||
renderStatBlock(noTypeCreature);
|
||||
expect(screen.queryByText("Recall Knowledge")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("perception and senses", () => {
|
||||
it("renders perception modifier and senses", () => {
|
||||
renderStatBlock(GOBLIN_WARRIOR);
|
||||
@@ -285,6 +339,79 @@ describe("Pf2eStatBlock", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
|
||||
@@ -63,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 () => {
|
||||
@@ -72,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 () => {
|
||||
|
||||
@@ -65,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();
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// @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 { DndStatBlock as StatBlock } from "../dnd-stat-block.js";
|
||||
|
||||
@@ -128,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", () => {
|
||||
@@ -175,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,6 +3,7 @@ import {
|
||||
type ConditionEntry,
|
||||
type CreatureId,
|
||||
deriveHpStatus,
|
||||
type PersistentDamageEntry,
|
||||
type PlayerIcon,
|
||||
type RollMode,
|
||||
} from "@initiative/domain";
|
||||
@@ -19,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";
|
||||
@@ -33,6 +35,7 @@ interface Combatant {
|
||||
readonly tempHp?: number;
|
||||
readonly ac?: number;
|
||||
readonly conditions?: readonly ConditionEntry[];
|
||||
readonly persistentDamage?: readonly PersistentDamageEntry[];
|
||||
readonly isConcentrating?: boolean;
|
||||
readonly color?: string;
|
||||
readonly icon?: string;
|
||||
@@ -199,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) {
|
||||
@@ -206,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)}
|
||||
@@ -227,6 +231,7 @@ function ClickableHp({
|
||||
)}
|
||||
{!!popoverOpen && (
|
||||
<HpAdjustPopover
|
||||
anchorRef={anchorRef}
|
||||
onAdjust={onAdjust}
|
||||
onSetTempHp={onSetTempHp}
|
||||
onClose={() => setPopoverOpen(false)}
|
||||
@@ -454,15 +459,23 @@ export function CombatantRow({
|
||||
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
|
||||
@@ -470,7 +483,7 @@ export function CombatantRow({
|
||||
if (isStatBlockOpen) {
|
||||
toggleCollapse();
|
||||
} else {
|
||||
showCreature(creatureId);
|
||||
showCreature(creatureId, combatant.id);
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
@@ -607,16 +620,29 @@ export function CombatantRow({
|
||||
onRemove={(conditionId) => toggleCondition(id, conditionId)}
|
||||
onDecrement={(conditionId) => decrementCondition(id, conditionId)}
|
||||
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
||||
/>
|
||||
>
|
||||
{isPf2e && (
|
||||
<PersistentDamageTags
|
||||
entries={combatant.persistentDamage}
|
||||
onRemove={(damageType) =>
|
||||
removePersistentDamage(id, damageType)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</ConditionTags>
|
||||
</div>
|
||||
{!!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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -3,9 +3,11 @@ import {
|
||||
type ConditionId,
|
||||
getConditionDescription,
|
||||
getConditionsForEdition,
|
||||
type PersistentDamageEntry,
|
||||
type PersistentDamageType,
|
||||
} from "@initiative/domain";
|
||||
import { Check, Minus, Plus } from "lucide-react";
|
||||
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";
|
||||
@@ -14,21 +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 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);
|
||||
@@ -42,6 +52,7 @@ export function ConditionPicker({
|
||||
id: ConditionId;
|
||||
value: number;
|
||||
} | null>(null);
|
||||
const [showPersistentDamage, setShowPersistentDamage] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const anchor = anchorRef.current;
|
||||
@@ -71,6 +82,51 @@ export function ConditionPicker({
|
||||
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
|
||||
@@ -82,7 +138,7 @@ 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 = activeMap.has(def.id);
|
||||
@@ -104,111 +160,116 @@ export function ConditionPicker({
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={def.id}
|
||||
content={getConditionDescription(def, edition)}
|
||||
className="block"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors",
|
||||
(isActive || isEditing) && "bg-card/50",
|
||||
)}
|
||||
<React.Fragment key={def.id}>
|
||||
{index === persistentDamageInsertIndex && persistentDamageEntry}
|
||||
<Tooltip
|
||||
content={getConditionDescription(def, edition)}
|
||||
className="block"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-1 items-center gap-2"
|
||||
onClick={handleClick}
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors",
|
||||
(isActive || isEditing) && "bg-card/50",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
size={14}
|
||||
className={
|
||||
isActive || isEditing ? colorClass : "text-muted-foreground"
|
||||
}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
isActive || isEditing
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-1 items-center gap-2"
|
||||
onClick={handleClick}
|
||||
>
|
||||
{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,
|
||||
});
|
||||
}
|
||||
}}
|
||||
<Icon
|
||||
size={14}
|
||||
className={
|
||||
isActive || isEditing
|
||||
? colorClass
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
isActive || isEditing
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
<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}
|
||||
{def.label}
|
||||
</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>
|
||||
</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,
|
||||
);
|
||||
|
||||
@@ -11,8 +11,12 @@ import {
|
||||
Droplet,
|
||||
Droplets,
|
||||
EarOff,
|
||||
Eclipse,
|
||||
Eye,
|
||||
EyeClosed,
|
||||
EyeOff,
|
||||
Flame,
|
||||
FlaskConical,
|
||||
Footprints,
|
||||
Gem,
|
||||
Ghost,
|
||||
@@ -22,15 +26,20 @@ import {
|
||||
HeartPulse,
|
||||
Link,
|
||||
Moon,
|
||||
Orbit,
|
||||
PersonStanding,
|
||||
ShieldMinus,
|
||||
ShieldOff,
|
||||
Siren,
|
||||
Skull,
|
||||
Snail,
|
||||
Snowflake,
|
||||
Sparkle,
|
||||
Sparkles,
|
||||
Sun,
|
||||
Sword,
|
||||
TrendingDown,
|
||||
Wind,
|
||||
Zap,
|
||||
ZapOff,
|
||||
} from "lucide-react";
|
||||
@@ -47,8 +56,12 @@ export const CONDITION_ICON_MAP: Record<string, LucideIcon> = {
|
||||
Droplet,
|
||||
Droplets,
|
||||
EarOff,
|
||||
Eclipse,
|
||||
Eye,
|
||||
EyeClosed,
|
||||
EyeOff,
|
||||
Flame,
|
||||
FlaskConical,
|
||||
Footprints,
|
||||
Gem,
|
||||
Ghost,
|
||||
@@ -58,15 +71,20 @@ export const CONDITION_ICON_MAP: Record<string, LucideIcon> = {
|
||||
HeartPulse,
|
||||
Link,
|
||||
Moon,
|
||||
Orbit,
|
||||
PersonStanding,
|
||||
ShieldMinus,
|
||||
ShieldOff,
|
||||
Siren,
|
||||
Skull,
|
||||
Snail,
|
||||
Snowflake,
|
||||
Sparkle,
|
||||
Sparkles,
|
||||
Sun,
|
||||
Sword,
|
||||
TrendingDown,
|
||||
Wind,
|
||||
Zap,
|
||||
ZapOff,
|
||||
};
|
||||
@@ -76,11 +94,13 @@ 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",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getConditionDescription,
|
||||
} from "@initiative/domain";
|
||||
import { Plus } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import {
|
||||
@@ -18,6 +19,7 @@ interface ConditionTagsProps {
|
||||
onRemove: (conditionId: ConditionId) => void;
|
||||
onDecrement: (conditionId: ConditionId) => void;
|
||||
onOpenPicker: () => void;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function ConditionTags({
|
||||
@@ -25,6 +27,7 @@ export function ConditionTags({
|
||||
onRemove,
|
||||
onDecrement,
|
||||
onOpenPicker,
|
||||
children,
|
||||
}: Readonly<ConditionTagsProps>) {
|
||||
const { edition } = useRulesEditionContext();
|
||||
return (
|
||||
@@ -69,6 +72,7 @@ export function ConditionTags({
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
{children}
|
||||
<button
|
||||
type="button"
|
||||
title="Add condition"
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||
import { useSwipeToDismissDown } from "../hooks/use-swipe-to-dismiss.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
|
||||
interface DetailPopoverProps {
|
||||
readonly anchorRect: DOMRect;
|
||||
readonly onClose: () => void;
|
||||
readonly ariaLabel: string;
|
||||
readonly children: ReactNode;
|
||||
}
|
||||
|
||||
function DesktopPanel({
|
||||
anchorRect,
|
||||
onClose,
|
||||
ariaLabel,
|
||||
children,
|
||||
}: Readonly<DetailPopoverProps>) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const popover = el.getBoundingClientRect();
|
||||
const vw = document.documentElement.clientWidth;
|
||||
const vh = document.documentElement.clientHeight;
|
||||
// Prefer placement to the LEFT of the anchor (panel is on the right edge)
|
||||
let left = anchorRect.left - popover.width - 8;
|
||||
if (left < 8) {
|
||||
left = anchorRect.right + 8;
|
||||
}
|
||||
if (left + popover.width > vw - 8) {
|
||||
left = vw - popover.width - 8;
|
||||
}
|
||||
let top = anchorRect.top;
|
||||
if (top + popover.height > vh - 8) {
|
||||
top = vh - popover.height - 8;
|
||||
}
|
||||
if (top < 8) top = 8;
|
||||
setPos({ top, left });
|
||||
}, [anchorRect]);
|
||||
|
||||
useClickOutside(ref, onClose);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="card-glow fixed z-50 max-h-[calc(100vh-16px)] w-80 max-w-[calc(100vw-16px)] overflow-y-auto rounded-lg border border-border bg-card p-4 shadow-lg"
|
||||
style={pos ? { top: pos.top, left: pos.left } : { visibility: "hidden" }}
|
||||
role="dialog"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileSheet({
|
||||
onClose,
|
||||
ariaLabel,
|
||||
children,
|
||||
}: Readonly<Omit<DetailPopoverProps, "anchorRect">>) {
|
||||
const { offsetY, isSwiping, handlers } = useSwipeToDismissDown(onClose);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<button
|
||||
type="button"
|
||||
className="fade-in absolute inset-0 animate-in bg-black/50"
|
||||
onClick={onClose}
|
||||
aria-label="Close details"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"panel-glow absolute right-0 bottom-0 left-0 max-h-[80vh] rounded-t-2xl border-border border-t bg-card",
|
||||
!isSwiping && "animate-slide-in-bottom",
|
||||
)}
|
||||
style={
|
||||
isSwiping ? { transform: `translateY(${offsetY}px)` } : undefined
|
||||
}
|
||||
{...handlers}
|
||||
role="dialog"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<div className="flex justify-center pt-2 pb-1">
|
||||
<div className="h-1 w-10 rounded-full bg-muted-foreground/40" />
|
||||
</div>
|
||||
<div className="max-h-[calc(80vh-24px)] overflow-y-auto p-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailPopover({
|
||||
anchorRect,
|
||||
onClose,
|
||||
ariaLabel,
|
||||
children,
|
||||
}: Readonly<DetailPopoverProps>) {
|
||||
const [isDesktop, setIsDesktop] = useState(
|
||||
() => globalThis.matchMedia("(min-width: 1024px)").matches,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = globalThis.matchMedia("(min-width: 1024px)");
|
||||
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
// Portal to document.body to escape any CSS transforms on ancestors
|
||||
// (the side panel uses translate-x for collapse animation, which would
|
||||
// otherwise become the containing block for fixed-positioned children).
|
||||
const content = isDesktop ? (
|
||||
<DesktopPanel
|
||||
anchorRect={anchorRect}
|
||||
onClose={onClose}
|
||||
ariaLabel={ariaLabel}
|
||||
>
|
||||
{children}
|
||||
</DesktopPanel>
|
||||
) : (
|
||||
<MobileSheet onClose={onClose} ariaLabel={ariaLabel}>
|
||||
{children}
|
||||
</MobileSheet>
|
||||
);
|
||||
return createPortal(content, document.body);
|
||||
}
|
||||
@@ -19,12 +19,21 @@ const TIER_LABEL_MAP: Partial<
|
||||
1: { label: "Low", color: "text-green-500" },
|
||||
2: { label: "Moderate", color: "text-yellow-500" },
|
||||
3: { label: "High", color: "text-red-500" },
|
||||
4: { label: "High", color: "text-red-500" },
|
||||
},
|
||||
"5e": {
|
||||
0: { label: "Easy", color: "text-muted-foreground" },
|
||||
1: { label: "Medium", color: "text-green-500" },
|
||||
2: { label: "Hard", color: "text-yellow-500" },
|
||||
3: { label: "Deadly", color: "text-red-500" },
|
||||
4: { label: "Deadly", color: "text-red-500" },
|
||||
},
|
||||
pf2e: {
|
||||
0: { label: "Trivial", color: "text-muted-foreground" },
|
||||
1: { label: "Low", color: "text-green-500" },
|
||||
2: { label: "Moderate", color: "text-yellow-500" },
|
||||
3: { label: "Severe", color: "text-orange-500" },
|
||||
4: { label: "Extreme", color: "text-red-500" },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -32,6 +41,9 @@ const TIER_LABEL_MAP: Partial<
|
||||
const SHORT_LABELS: Readonly<Record<string, string>> = {
|
||||
Moderate: "Mod",
|
||||
Medium: "Med",
|
||||
Trivial: "Triv",
|
||||
Severe: "Sev",
|
||||
Extreme: "Ext",
|
||||
};
|
||||
|
||||
function shortLabel(label: string): string {
|
||||
@@ -107,6 +119,54 @@ function NpcRow({
|
||||
);
|
||||
}
|
||||
|
||||
function Pf2eNpcRow({
|
||||
entry,
|
||||
onToggleSide,
|
||||
}: {
|
||||
entry: BreakdownCombatant;
|
||||
onToggleSide: () => void;
|
||||
}) {
|
||||
const isParty = entry.side === "party";
|
||||
const targetSide = isParty ? "enemy" : "party";
|
||||
|
||||
let xpDisplay: string;
|
||||
if (entry.xp == null) {
|
||||
xpDisplay = "\u2014";
|
||||
} else if (isParty) {
|
||||
xpDisplay = `\u2212${formatXp(entry.xp)}`;
|
||||
} else {
|
||||
xpDisplay = formatXp(entry.xp);
|
||||
}
|
||||
|
||||
let levelDisplay: string;
|
||||
if (entry.creatureLevel === undefined) {
|
||||
levelDisplay = "\u2014";
|
||||
} else if (entry.levelDifference === undefined) {
|
||||
levelDisplay = `Lv ${entry.creatureLevel}`;
|
||||
} else {
|
||||
const sign = entry.levelDifference >= 0 ? "+" : "";
|
||||
levelDisplay = `Lv ${entry.creatureLevel} (${sign}${entry.levelDifference})`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="col-span-4 grid grid-cols-subgrid items-center text-xs">
|
||||
<span className="min-w-0 truncate" title={entry.combatant.name}>
|
||||
{entry.combatant.name}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onToggleSide}
|
||||
aria-label={`Move ${entry.combatant.name} to ${targetSide} side`}
|
||||
>
|
||||
<ArrowLeftRight className="h-3 w-3" />
|
||||
</Button>
|
||||
<span className="text-muted-foreground">{levelDisplay}</span>
|
||||
<span className="text-right tabular-nums">{xpDisplay}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, onClose);
|
||||
@@ -128,6 +188,8 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
const isPC = (entry: BreakdownCombatant) =>
|
||||
entry.combatant.playerCharacterId != null;
|
||||
|
||||
const CreatureRow = edition === "pf2e" ? Pf2eNpcRow : NpcRow;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -142,6 +204,9 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
<div className="mb-1 text-muted-foreground text-xs">
|
||||
Party Budget ({breakdown.pcCount}{" "}
|
||||
{breakdown.pcCount === 1 ? "PC" : "PCs"})
|
||||
{breakdown.partyLevel !== undefined && (
|
||||
<> · Party Level: {breakdown.partyLevel}</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3 text-xs">
|
||||
{breakdown.thresholds.map((t) => (
|
||||
@@ -166,7 +231,7 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
isPC(entry) ? (
|
||||
<PcRow key={entry.combatant.id} entry={entry} />
|
||||
) : (
|
||||
<NpcRow
|
||||
<CreatureRow
|
||||
key={entry.combatant.id}
|
||||
entry={entry}
|
||||
onToggleSide={() => handleToggle(entry)}
|
||||
@@ -186,7 +251,7 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
isPC(entry) ? (
|
||||
<PcRow key={entry.combatant.id} entry={entry} />
|
||||
) : (
|
||||
<NpcRow
|
||||
<CreatureRow
|
||||
key={entry.combatant.id}
|
||||
entry={entry}
|
||||
onToggleSide={() => handleToggle(entry)}
|
||||
@@ -218,7 +283,9 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex justify-between border-border border-t pt-2 font-medium text-xs">
|
||||
<span>Net Monster XP</span>
|
||||
<span>
|
||||
{edition === "pf2e" ? "Net Creature XP" : "Net Monster XP"}
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{formatXp(breakdown.totalMonsterXp)}
|
||||
</span>
|
||||
|
||||
@@ -6,6 +6,7 @@ export const TIER_LABELS_5_5E: Record<DifficultyTier, string> = {
|
||||
1: "Low",
|
||||
2: "Moderate",
|
||||
3: "High",
|
||||
4: "High",
|
||||
};
|
||||
|
||||
export const TIER_LABELS_2014: Record<DifficultyTier, string> = {
|
||||
@@ -13,30 +14,49 @@ export const TIER_LABELS_2014: Record<DifficultyTier, string> = {
|
||||
1: "Medium",
|
||||
2: "Hard",
|
||||
3: "Deadly",
|
||||
4: "Deadly",
|
||||
};
|
||||
|
||||
const TIER_COLORS: Record<
|
||||
DifficultyTier,
|
||||
{ filledBars: number; color: string }
|
||||
> = {
|
||||
0: { filledBars: 0, color: "" },
|
||||
1: { filledBars: 1, color: "bg-green-500" },
|
||||
2: { filledBars: 2, color: "bg-yellow-500" },
|
||||
3: { filledBars: 3, color: "bg-red-500" },
|
||||
export const TIER_LABELS_PF2E: Record<DifficultyTier, string> = {
|
||||
0: "Trivial",
|
||||
1: "Low",
|
||||
2: "Moderate",
|
||||
3: "Severe",
|
||||
4: "Extreme",
|
||||
};
|
||||
|
||||
const BAR_HEIGHTS = ["h-2", "h-3", "h-4"] as const;
|
||||
const BAR_HEIGHTS_3 = ["h-2", "h-3", "h-4"] as const;
|
||||
const BAR_HEIGHTS_4 = ["h-1.5", "h-2", "h-3", "h-4"] as const;
|
||||
|
||||
/** Color for the Nth filled bar (1-indexed) in 4-bar mode. */
|
||||
const BAR_COLORS: Record<number, string> = {
|
||||
1: "bg-green-500",
|
||||
2: "bg-yellow-500",
|
||||
3: "bg-orange-500",
|
||||
4: "bg-red-500",
|
||||
};
|
||||
|
||||
/** For 3-bar mode, bar 3 uses red directly (skip orange). */
|
||||
const BAR_COLORS_3: Record<number, string> = {
|
||||
1: "bg-green-500",
|
||||
2: "bg-yellow-500",
|
||||
3: "bg-red-500",
|
||||
};
|
||||
|
||||
export function DifficultyIndicator({
|
||||
result,
|
||||
labels,
|
||||
barCount = 3,
|
||||
onClick,
|
||||
}: {
|
||||
result: DifficultyResult;
|
||||
labels: Record<DifficultyTier, string>;
|
||||
barCount?: 3 | 4;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const config = TIER_COLORS[result.tier];
|
||||
const barHeights = barCount === 4 ? BAR_HEIGHTS_4 : BAR_HEIGHTS_3;
|
||||
const colorMap = barCount === 4 ? BAR_COLORS : BAR_COLORS_3;
|
||||
const filledBars = result.tier;
|
||||
const label = labels[result.tier];
|
||||
const tooltip = `${label} encounter difficulty`;
|
||||
|
||||
@@ -54,13 +74,13 @@ export function DifficultyIndicator({
|
||||
onClick={onClick}
|
||||
type={onClick ? "button" : undefined}
|
||||
>
|
||||
{BAR_HEIGHTS.map((height, i) => (
|
||||
{barHeights.map((height, i) => (
|
||||
<div
|
||||
key={height}
|
||||
className={cn(
|
||||
"w-1 rounded-sm",
|
||||
height,
|
||||
i < config.filledBars ? config.color : "bg-muted",
|
||||
i < filledBars ? colorMap[i + 1] : "bg-muted",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,17 +1,34 @@
|
||||
import type { Creature } from "@initiative/domain";
|
||||
import type {
|
||||
CombatantId,
|
||||
Creature,
|
||||
HpRange,
|
||||
HpVariant,
|
||||
} from "@initiative/domain";
|
||||
import {
|
||||
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 DndStatBlockProps {
|
||||
creature: Creature;
|
||||
combatantId?: CombatantId;
|
||||
hpVariant?: HpVariant;
|
||||
onSetHpVariant?: (
|
||||
id: CombatantId,
|
||||
variant: HpVariant | undefined,
|
||||
range: HpRange,
|
||||
) => void;
|
||||
}
|
||||
|
||||
function abilityMod(score: number): string {
|
||||
@@ -19,7 +36,25 @@ function abilityMod(score: number): string {
|
||||
return mod >= 0 ? `+${mod}` : `${mod}`;
|
||||
}
|
||||
|
||||
export function DndStatBlock({ creature }: Readonly<DndStatBlockProps>) {
|
||||
/** Text color for a max HP that no longer shows the printed average. */
|
||||
function hpVariantColor(variant: HpVariant | undefined): string {
|
||||
if (variant === "max") return "text-blue-400";
|
||||
if (variant === "min") return "text-red-400";
|
||||
return "";
|
||||
}
|
||||
|
||||
const HP_VARIANT_OPTIONS: SegmentedOption<HpVariant | undefined>[] = [
|
||||
{ value: "min", label: "Min" },
|
||||
{ value: undefined, label: "Avg" },
|
||||
{ value: "max", label: "Max" },
|
||||
];
|
||||
|
||||
export function DndStatBlock({
|
||||
creature,
|
||||
combatantId,
|
||||
hpVariant,
|
||||
onSetHpVariant,
|
||||
}: Readonly<DndStatBlockProps>) {
|
||||
const abilities = [
|
||||
{ label: "STR", score: creature.abilities.str },
|
||||
{ label: "DEX", score: creature.abilities.dex },
|
||||
@@ -35,6 +70,17 @@ export function DndStatBlock({ creature }: Readonly<DndStatBlockProps>) {
|
||||
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 */}
|
||||
@@ -68,8 +114,22 @@ export function DndStatBlock({ creature }: Readonly<DndStatBlockProps>) {
|
||||
</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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,33 @@
|
||||
import type { Pf2eCreature, SpellReference } from "@initiative/domain";
|
||||
import { formatInitiativeModifier } from "@initiative/domain";
|
||||
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([
|
||||
@@ -36,6 +54,19 @@ 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;
|
||||
@@ -102,7 +133,42 @@ function SpellListLine({
|
||||
);
|
||||
}
|
||||
|
||||
export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
||||
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;
|
||||
@@ -112,6 +178,18 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
||||
[],
|
||||
);
|
||||
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 },
|
||||
@@ -127,13 +205,33 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<h2 className="font-bold text-stat-heading text-xl">
|
||||
<h2 className="flex items-center gap-1.5 font-bold text-stat-heading text-xl">
|
||||
{adjustment === "elite" && (
|
||||
<ChevronUp className="h-5 w-5 shrink-0 text-blue-400" />
|
||||
)}
|
||||
{adjustment === "weak" && (
|
||||
<ChevronDown className="h-5 w-5 shrink-0 text-red-400" />
|
||||
)}
|
||||
{creature.name}
|
||||
</h2>
|
||||
<span className="shrink-0 font-semibold text-sm">
|
||||
<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
|
||||
@@ -147,16 +245,24 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
||||
<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>
|
||||
<div className={adjColor}>
|
||||
<span className="font-semibold">Perception</span>{" "}
|
||||
{formatInitiativeModifier(creature.perception)}
|
||||
{creature.senses ? `; ${creature.senses}` : ""}
|
||||
{creature.senses || creature.perceptionDetails
|
||||
? `; ${[creature.senses, creature.perceptionDetails].filter(Boolean).join(", ")}`
|
||||
: ""}
|
||||
</div>
|
||||
<PropertyLine label="Languages" value={creature.languages} />
|
||||
<PropertyLine label="Skills" value={creature.skills} />
|
||||
@@ -183,7 +289,7 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
||||
|
||||
{/* Defenses */}
|
||||
<div className="space-y-0.5 text-sm">
|
||||
<div>
|
||||
<div className={adjColor}>
|
||||
<span className="font-semibold">AC</span> {creature.ac}
|
||||
{creature.acConditional ? ` (${creature.acConditional})` : ""};{" "}
|
||||
<span className="font-semibold">Fort</span>{" "}
|
||||
@@ -194,7 +300,7 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
||||
{formatMod(creature.saveWill)}
|
||||
{creature.saveConditional ? `; ${creature.saveConditional}` : ""}
|
||||
</div>
|
||||
<div>
|
||||
<div className={adjColor}>
|
||||
<span className="font-semibold">HP</span> {creature.hp}
|
||||
{creature.hpDetails ? ` (${creature.hpDetails})` : ""}
|
||||
</div>
|
||||
@@ -248,6 +354,19 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{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}
|
||||
@@ -255,6 +374,13 @@ export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
||||
onClose={handleCloseSpell}
|
||||
/>
|
||||
) : null}
|
||||
{openEquipment ? (
|
||||
<EquipmentDetailPopover
|
||||
item={openEquipment.item}
|
||||
anchorRect={openEquipment.rect}
|
||||
onClose={handleCloseEquipment}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { cn } from "../lib/utils.js";
|
||||
|
||||
/**
|
||||
* Renders text containing safe HTML formatting tags (strong, em, ul, ol, li)
|
||||
* preserved by the stripFoundryTags pipeline. All other HTML is already
|
||||
* stripped before reaching this component.
|
||||
*/
|
||||
export function RichDescription({
|
||||
text,
|
||||
className,
|
||||
}: Readonly<{ text: string; className?: string }>) {
|
||||
const props = {
|
||||
className: cn(
|
||||
"[&_ol]:list-decimal [&_ol]:pl-4 [&_ul]:list-disc [&_ul]:pl-4",
|
||||
className,
|
||||
),
|
||||
dangerouslySetInnerHTML: { __html: text },
|
||||
};
|
||||
return <div {...props} />;
|
||||
}
|
||||
@@ -2,28 +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>) {
|
||||
@@ -39,50 +62,26 @@ export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
|
||||
<span className="mb-2 block font-medium text-muted-foreground text-sm">
|
||||
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>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Input } from "./ui/input.js";
|
||||
|
||||
interface SourceFetchPromptProps {
|
||||
sourceCode: string;
|
||||
onSourceLoaded: () => void;
|
||||
onSourceLoaded: (skippedNames: string[]) => void;
|
||||
}
|
||||
|
||||
export function SourceFetchPrompt({
|
||||
@@ -32,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");
|
||||
@@ -51,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(
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { ActivityCost, SpellReference } from "@initiative/domain";
|
||||
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";
|
||||
import { DetailPopover } from "./detail-popover.js";
|
||||
import { RichDescription } from "./rich-description.js";
|
||||
import { ActivityIcon } from "./stat-block-parts.js";
|
||||
|
||||
interface SpellDetailPopoverProps {
|
||||
@@ -138,24 +135,6 @@ function SpellMeta({ spell }: Readonly<{ spell: SpellReference }>) {
|
||||
);
|
||||
}
|
||||
|
||||
const SAVE_OUTCOME_REGEX =
|
||||
/(Critical Success|Critical Failure|Success|Failure)/g;
|
||||
|
||||
function SpellDescription({ text }: Readonly<{ text: string }>) {
|
||||
const parts = text.split(SAVE_OUTCOME_REGEX);
|
||||
const elements: React.ReactNode[] = [];
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
if (SAVE_OUTCOME_REGEX.test(part)) {
|
||||
elements.push(<strong key={`b-${offset}`}>{part}</strong>);
|
||||
} else if (part) {
|
||||
elements.push(<span key={`t-${offset}`}>{part}</span>);
|
||||
}
|
||||
offset += part.length;
|
||||
}
|
||||
return <p className="whitespace-pre-line text-foreground">{elements}</p>;
|
||||
}
|
||||
|
||||
function SpellDetailContent({ spell }: Readonly<{ spell: SpellReference }>) {
|
||||
return (
|
||||
<div className="space-y-2 text-sm">
|
||||
@@ -163,134 +142,37 @@ function SpellDetailContent({ spell }: Readonly<{ spell: SpellReference }>) {
|
||||
<SpellTraits traits={spell.traits ?? []} />
|
||||
<SpellMeta spell={spell} />
|
||||
{spell.description ? (
|
||||
<SpellDescription text={spell.description} />
|
||||
<RichDescription
|
||||
text={spell.description}
|
||||
className="whitespace-pre-line text-foreground"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted-foreground italic">
|
||||
No description available.
|
||||
</p>
|
||||
)}
|
||||
{spell.heightening ? (
|
||||
<p className="whitespace-pre-line text-foreground text-xs">
|
||||
{spell.heightening}
|
||||
</p>
|
||||
<RichDescription
|
||||
text={spell.heightening}
|
||||
className="whitespace-pre-line text-foreground text-xs"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopPopover({
|
||||
spell,
|
||||
anchorRect,
|
||||
onClose,
|
||||
}: Readonly<SpellDetailPopoverProps>) {
|
||||
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={`Spell details: ${spell.name}`}
|
||||
>
|
||||
<SpellDetailContent spell={spell} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileSheet({
|
||||
spell,
|
||||
onClose,
|
||||
}: Readonly<{ spell: SpellReference; onClose: () => void }>) {
|
||||
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 spell 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={`Spell details: ${spell.name}`}
|
||||
>
|
||||
<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">
|
||||
<SpellDetailContent spell={spell} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpellDetailPopover({
|
||||
spell,
|
||||
anchorRect,
|
||||
onClose,
|
||||
}: Readonly<SpellDetailPopoverProps>) {
|
||||
const [isDesktop, setIsDesktop] = useState(
|
||||
() => globalThis.matchMedia("(min-width: 1024px)").matches,
|
||||
return (
|
||||
<DetailPopover
|
||||
anchorRect={anchorRect}
|
||||
onClose={onClose}
|
||||
ariaLabel={`Spell details: ${spell.name}`}
|
||||
>
|
||||
<SpellDetailContent spell={spell} />
|
||||
</DetailPopover>
|
||||
);
|
||||
|
||||
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 ? (
|
||||
<DesktopPopover spell={spell} anchorRect={anchorRect} onClose={onClose} />
|
||||
) : (
|
||||
<MobileSheet spell={spell} onClose={onClose} />
|
||||
);
|
||||
return createPortal(content, document.body);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import type { Creature, CreatureId } from "@initiative/domain";
|
||||
import type {
|
||||
AnyCreature,
|
||||
Combatant,
|
||||
CombatantId,
|
||||
Creature,
|
||||
CreatureId,
|
||||
HpRange,
|
||||
HpVariant,
|
||||
Pf2eCreature,
|
||||
} from "@initiative/domain";
|
||||
import { applyPf2eAdjustment } from "@initiative/domain";
|
||||
import { PanelRightClose, Pin, PinOff } from "lucide-react";
|
||||
import 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";
|
||||
@@ -11,6 +22,7 @@ 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 { Toast } from "./toast.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
|
||||
interface StatBlockPanelProps {
|
||||
@@ -215,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"
|
||||
@@ -222,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 : () => {},
|
||||
@@ -237,14 +260,59 @@ function usePanelRole(panelRole: "browse" | "pinned") {
|
||||
};
|
||||
}
|
||||
|
||||
interface StatBlockHandlers {
|
||||
setCreatureAdjustment: (
|
||||
id: CombatantId,
|
||||
adj: "weak" | "elite" | undefined,
|
||||
base: Pf2eCreature,
|
||||
) => void;
|
||||
setHpVariant: (
|
||||
id: CombatantId,
|
||||
variant: HpVariant | undefined,
|
||||
range: HpRange,
|
||||
) => void;
|
||||
}
|
||||
|
||||
function renderStatBlock(
|
||||
creature: AnyCreature,
|
||||
combatant: Combatant | null,
|
||||
handlers: StatBlockHandlers,
|
||||
) {
|
||||
if ("system" in creature && creature.system === "pf2e") {
|
||||
const baseCreature = creature;
|
||||
const adjusted = combatant?.creatureAdjustment
|
||||
? applyPf2eAdjustment(baseCreature, combatant.creatureAdjustment)
|
||||
: baseCreature;
|
||||
return (
|
||||
<Pf2eStatBlock
|
||||
creature={adjusted}
|
||||
adjustment={combatant?.creatureAdjustment}
|
||||
combatantId={combatant?.id}
|
||||
baseCreature={baseCreature}
|
||||
onSetAdjustment={handlers.setCreatureAdjustment}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DndStatBlock
|
||||
creature={creature as Creature}
|
||||
combatantId={combatant?.id}
|
||||
hpVariant={combatant?.hpVariant}
|
||||
onSetHpVariant={handlers.setHpVariant}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatBlockPanel({
|
||||
panelRole,
|
||||
side,
|
||||
}: Readonly<StatBlockPanelProps>) {
|
||||
const { isSourceCached } = useBestiaryContext();
|
||||
const {
|
||||
creatureId,
|
||||
creature,
|
||||
combatant,
|
||||
setCreatureAdjustment,
|
||||
setHpVariant,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
onDismiss,
|
||||
@@ -260,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)");
|
||||
@@ -280,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 = () => {
|
||||
@@ -311,10 +384,10 @@ export function StatBlockPanel({
|
||||
}
|
||||
|
||||
if (creature) {
|
||||
if ("system" in creature && creature.system === "pf2e") {
|
||||
return <Pf2eStatBlock creature={creature} />;
|
||||
}
|
||||
return <DndStatBlock creature={creature as Creature} />;
|
||||
return renderStatBlock(creature, combatant, {
|
||||
setCreatureAdjustment,
|
||||
setHpVariant,
|
||||
});
|
||||
}
|
||||
|
||||
if (needsFetch && sourceCode) {
|
||||
@@ -338,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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
TraitBlock,
|
||||
TraitSegment,
|
||||
} from "@initiative/domain";
|
||||
import { RichDescription } from "./rich-description.js";
|
||||
|
||||
export function PropertyLine({
|
||||
label,
|
||||
@@ -39,20 +40,22 @@ function TraitSegments({
|
||||
{segments.map((seg, i) => {
|
||||
if (seg.type === "text") {
|
||||
return (
|
||||
<span key={segmentKey(seg)}>
|
||||
{i === 0 ? ` ${seg.value}` : seg.value}
|
||||
</span>
|
||||
<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) => (
|
||||
<p key={item.label ?? item.text}>
|
||||
<div key={item.label ?? item.text}>
|
||||
{item.label != null && (
|
||||
<span className="font-semibold">{item.label}. </span>
|
||||
)}
|
||||
{item.text}
|
||||
</p>
|
||||
<RichDescription text={item.text} className="inline" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -138,6 +141,7 @@ export function TraitEntry({ trait }: Readonly<{ trait: TraitBlock }>) {
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
{trait.frequency ? ` (${trait.frequency})` : null}
|
||||
{trait.trigger ? (
|
||||
<>
|
||||
{" "}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
normalizeBestiary,
|
||||
setSourceDisplayNames,
|
||||
} from "../adapters/bestiary-adapter.js";
|
||||
import { loadBundledDndCreatures } from "../adapters/dnd-bundled-adapter.js";
|
||||
import { normalizeFoundryCreatures } from "../adapters/pf2e-bestiary-adapter.js";
|
||||
import { useAdapters } from "../contexts/adapter-context.js";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
@@ -28,7 +29,10 @@ interface BestiaryHook {
|
||||
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,
|
||||
@@ -36,6 +40,108 @@ 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, pf2eBestiaryIndex } = useAdapters();
|
||||
const { edition } = useRulesEditionContext();
|
||||
@@ -55,7 +161,11 @@ export function useBestiary(): BestiaryHook {
|
||||
}
|
||||
|
||||
void bestiaryCache.loadAllCachedCreatures().then((map) => {
|
||||
setCreatureMap(map);
|
||||
const merged = new Map(map);
|
||||
for (const c of loadBundledDndCreatures()) {
|
||||
merged.set(c.id, c);
|
||||
}
|
||||
setCreatureMap(merged);
|
||||
});
|
||||
}, [bestiaryCache, bestiaryIndex, pf2eBestiaryIndex]);
|
||||
|
||||
@@ -108,30 +218,25 @@ export function useBestiary(): BestiaryHook {
|
||||
);
|
||||
|
||||
const fetchAndCacheSource = useCallback(
|
||||
async (sourceCode: string, url: string): Promise<void> => {
|
||||
async (
|
||||
sourceCode: string,
|
||||
url: string,
|
||||
): Promise<{ skippedNames: string[] }> => {
|
||||
let creatures: AnyCreature[];
|
||||
let skippedNames: string[] = [];
|
||||
|
||||
if (edition === "pf2e") {
|
||||
// PF2e: url is a base URL; fetch each creature file in parallel
|
||||
const paths = pf2eBestiaryIndex.getCreaturePathsForSource(sourceCode);
|
||||
const baseUrl = url.endsWith("/") ? url : `${url}/`;
|
||||
const responses = await Promise.all(
|
||||
paths.map(async (path) => {
|
||||
const response = await fetch(`${baseUrl}${path}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch ${path}: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
return response.json();
|
||||
}),
|
||||
);
|
||||
const displayName = pf2eBestiaryIndex.getSourceDisplayName(sourceCode);
|
||||
creatures = normalizeFoundryCreatures(
|
||||
responses,
|
||||
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) {
|
||||
@@ -160,6 +265,7 @@ export function useBestiary(): BestiaryHook {
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return { skippedNames };
|
||||
},
|
||||
[bestiaryCache, bestiaryIndex, pf2eBestiaryIndex, edition, system],
|
||||
);
|
||||
@@ -199,6 +305,9 @@ export function useBestiary(): BestiaryHook {
|
||||
|
||||
const refreshCache = useCallback(async (): Promise<void> => {
|
||||
const map = await bestiaryCache.loadAllCachedCreatures();
|
||||
for (const c of loadBundledDndCreatures()) {
|
||||
map.set(c.id, c);
|
||||
}
|
||||
setCreatureMap(map);
|
||||
}, [bestiaryCache]);
|
||||
|
||||
|
||||
@@ -22,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;
|
||||
@@ -39,7 +42,10 @@ export function useBulkImport(): BulkImportHook {
|
||||
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>,
|
||||
) => {
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import type {
|
||||
AnyCreature,
|
||||
Combatant,
|
||||
CreatureId,
|
||||
DifficultyThreshold,
|
||||
DifficultyTier,
|
||||
PlayerCharacter,
|
||||
} from "@initiative/domain";
|
||||
import { calculateEncounterDifficulty, crToXp } from "@initiative/domain";
|
||||
import {
|
||||
calculateEncounterDifficulty,
|
||||
crToXp,
|
||||
derivePartyLevel,
|
||||
pf2eCreatureXp,
|
||||
} from "@initiative/domain";
|
||||
import { useMemo } from "react";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
@@ -21,6 +27,10 @@ export interface BreakdownCombatant {
|
||||
readonly editable: boolean;
|
||||
readonly side: "party" | "enemy";
|
||||
readonly level: number | undefined;
|
||||
/** PF2e only: the creature's level from bestiary data. */
|
||||
readonly creatureLevel: number | undefined;
|
||||
/** PF2e only: creature level minus party level. */
|
||||
readonly levelDifference: number | undefined;
|
||||
}
|
||||
|
||||
interface DifficultyBreakdown {
|
||||
@@ -30,6 +40,7 @@ interface DifficultyBreakdown {
|
||||
readonly encounterMultiplier: number | undefined;
|
||||
readonly adjustedXp: number | undefined;
|
||||
readonly partySizeAdjusted: boolean | undefined;
|
||||
readonly partyLevel: number | undefined;
|
||||
readonly pcCount: number;
|
||||
readonly partyCombatants: readonly BreakdownCombatant[];
|
||||
readonly enemyCombatants: readonly BreakdownCombatant[];
|
||||
@@ -48,9 +59,16 @@ export function useDifficultyBreakdown(): DifficultyBreakdown | null {
|
||||
const hasPartyLevel = descriptors.some(
|
||||
(d) => d.side === "party" && d.level !== undefined,
|
||||
);
|
||||
const hasCr = descriptors.some((d) => d.cr !== undefined);
|
||||
|
||||
if (!hasPartyLevel || !hasCr) return null;
|
||||
if (edition === "pf2e") {
|
||||
const hasCreatureLevel = descriptors.some(
|
||||
(d) => d.creatureLevel !== undefined,
|
||||
);
|
||||
if (!hasPartyLevel || !hasCreatureLevel) return null;
|
||||
} else {
|
||||
const hasCr = descriptors.some((d) => d.cr !== undefined);
|
||||
if (!hasPartyLevel || !hasCr) return null;
|
||||
}
|
||||
|
||||
const result = calculateEncounterDifficulty(descriptors, edition);
|
||||
|
||||
@@ -65,6 +83,7 @@ export function useDifficultyBreakdown(): DifficultyBreakdown | null {
|
||||
|
||||
type CreatureInfo = {
|
||||
cr?: string;
|
||||
creatureLevel?: number;
|
||||
source: string;
|
||||
sourceDisplayName: string;
|
||||
};
|
||||
@@ -74,6 +93,7 @@ function buildBreakdownEntry(
|
||||
side: "party" | "enemy",
|
||||
level: number | undefined,
|
||||
creature: CreatureInfo | undefined,
|
||||
partyLevel: number | undefined,
|
||||
): BreakdownCombatant {
|
||||
if (c.playerCharacterId) {
|
||||
return {
|
||||
@@ -84,6 +104,29 @@ function buildBreakdownEntry(
|
||||
editable: false,
|
||||
side,
|
||||
level,
|
||||
creatureLevel: undefined,
|
||||
levelDifference: undefined,
|
||||
};
|
||||
}
|
||||
if (creature && creature.creatureLevel !== undefined) {
|
||||
const levelDiff =
|
||||
partyLevel === undefined
|
||||
? undefined
|
||||
: creature.creatureLevel - partyLevel;
|
||||
const xp =
|
||||
partyLevel === undefined
|
||||
? null
|
||||
: pf2eCreatureXp(creature.creatureLevel, partyLevel);
|
||||
return {
|
||||
combatant: c,
|
||||
cr: null,
|
||||
xp,
|
||||
source: creature.sourceDisplayName ?? creature.source,
|
||||
editable: false,
|
||||
side,
|
||||
level: undefined,
|
||||
creatureLevel: creature.creatureLevel,
|
||||
levelDifference: levelDiff,
|
||||
};
|
||||
}
|
||||
if (creature) {
|
||||
@@ -96,6 +139,8 @@ function buildBreakdownEntry(
|
||||
editable: false,
|
||||
side,
|
||||
level: undefined,
|
||||
creatureLevel: undefined,
|
||||
levelDifference: undefined,
|
||||
};
|
||||
}
|
||||
if (c.cr) {
|
||||
@@ -107,6 +152,8 @@ function buildBreakdownEntry(
|
||||
editable: true,
|
||||
side,
|
||||
level: undefined,
|
||||
creatureLevel: undefined,
|
||||
levelDifference: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -117,6 +164,8 @@ function buildBreakdownEntry(
|
||||
editable: !c.creatureId,
|
||||
side,
|
||||
level: undefined,
|
||||
creatureLevel: undefined,
|
||||
levelDifference: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,41 +177,91 @@ function resolveLevel(
|
||||
return characters.find((p) => p.id === c.playerCharacterId)?.level;
|
||||
}
|
||||
|
||||
function resolveCr(
|
||||
function resolveCreatureInfo(
|
||||
c: Combatant,
|
||||
getCreature: (id: CreatureId) => CreatureInfo | undefined,
|
||||
): { cr: string | null; creature: CreatureInfo | undefined } {
|
||||
const creature = c.creatureId ? getCreature(c.creatureId) : undefined;
|
||||
const cr = creature?.cr ?? c.cr ?? null;
|
||||
return { cr, creature };
|
||||
getCreature: (id: CreatureId) => AnyCreature | undefined,
|
||||
): {
|
||||
cr: string | null;
|
||||
creatureLevel: number | undefined;
|
||||
creature: CreatureInfo | undefined;
|
||||
} {
|
||||
const rawCreature = c.creatureId ? getCreature(c.creatureId) : undefined;
|
||||
if (!rawCreature) {
|
||||
return {
|
||||
cr: c.cr ?? null,
|
||||
creatureLevel: undefined,
|
||||
creature: undefined,
|
||||
};
|
||||
}
|
||||
if ("system" in rawCreature && rawCreature.system === "pf2e") {
|
||||
return {
|
||||
cr: null,
|
||||
creatureLevel: rawCreature.level,
|
||||
creature: {
|
||||
creatureLevel: rawCreature.level,
|
||||
source: rawCreature.source,
|
||||
sourceDisplayName: rawCreature.sourceDisplayName,
|
||||
},
|
||||
};
|
||||
}
|
||||
const cr = "cr" in rawCreature ? rawCreature.cr : undefined;
|
||||
return {
|
||||
cr: cr ?? c.cr ?? null,
|
||||
creatureLevel: undefined,
|
||||
creature: {
|
||||
cr,
|
||||
source: rawCreature.source,
|
||||
sourceDisplayName: rawCreature.sourceDisplayName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function collectPartyLevel(
|
||||
combatants: readonly Combatant[],
|
||||
characters: readonly PlayerCharacter[],
|
||||
): number | undefined {
|
||||
const partyLevels: number[] = [];
|
||||
for (const c of combatants) {
|
||||
if (resolveSide(c) !== "party") continue;
|
||||
const level = resolveLevel(c, characters);
|
||||
if (level !== undefined) partyLevels.push(level);
|
||||
}
|
||||
return partyLevels.length > 0 ? derivePartyLevel(partyLevels) : undefined;
|
||||
}
|
||||
|
||||
function classifyCombatants(
|
||||
combatants: readonly Combatant[],
|
||||
characters: readonly PlayerCharacter[],
|
||||
getCreature: (id: CreatureId) => CreatureInfo | undefined,
|
||||
getCreature: (id: CreatureId) => AnyCreature | undefined,
|
||||
) {
|
||||
const partyCombatants: BreakdownCombatant[] = [];
|
||||
const enemyCombatants: BreakdownCombatant[] = [];
|
||||
const descriptors: {
|
||||
level?: number;
|
||||
cr?: string;
|
||||
creatureLevel?: number;
|
||||
side: "party" | "enemy";
|
||||
}[] = [];
|
||||
let pcCount = 0;
|
||||
const partyLevel = collectPartyLevel(combatants, characters);
|
||||
|
||||
for (const c of combatants) {
|
||||
const side = resolveSide(c);
|
||||
const level = resolveLevel(c, characters);
|
||||
if (level !== undefined) pcCount++;
|
||||
|
||||
const { cr, creature } = resolveCr(c, getCreature);
|
||||
const { cr, creatureLevel, creature } = resolveCreatureInfo(c, getCreature);
|
||||
|
||||
if (level !== undefined || cr != null) {
|
||||
descriptors.push({ level, cr: cr ?? undefined, side });
|
||||
if (level !== undefined || cr != null || creatureLevel !== undefined) {
|
||||
descriptors.push({
|
||||
level,
|
||||
cr: cr ?? undefined,
|
||||
creatureLevel,
|
||||
side,
|
||||
});
|
||||
}
|
||||
|
||||
const entry = buildBreakdownEntry(c, side, level, creature);
|
||||
const entry = buildBreakdownEntry(c, side, level, creature, partyLevel);
|
||||
const target = side === "party" ? partyCombatants : enemyCombatants;
|
||||
target.push(entry);
|
||||
}
|
||||
|
||||
@@ -33,9 +33,17 @@ function buildDescriptors(
|
||||
const creatureCr =
|
||||
creature && !("system" in creature) ? creature.cr : undefined;
|
||||
const cr = creatureCr ?? c.cr ?? undefined;
|
||||
const creatureLevel =
|
||||
creature && "system" in creature && creature.system === "pf2e"
|
||||
? creature.level
|
||||
: undefined;
|
||||
|
||||
if (level !== undefined || cr !== undefined) {
|
||||
descriptors.push({ level, cr, side });
|
||||
if (
|
||||
level !== undefined ||
|
||||
cr !== undefined ||
|
||||
creatureLevel !== undefined
|
||||
) {
|
||||
descriptors.push({ level, cr, creatureLevel, side });
|
||||
}
|
||||
}
|
||||
return descriptors;
|
||||
@@ -48,8 +56,6 @@ export function useDifficulty(): DifficultyResult | null {
|
||||
const { edition } = useRulesEditionContext();
|
||||
|
||||
return useMemo(() => {
|
||||
if (edition === "pf2e") return null;
|
||||
|
||||
const descriptors = buildDescriptors(
|
||||
encounter.combatants,
|
||||
characters,
|
||||
@@ -59,9 +65,16 @@ export function useDifficulty(): DifficultyResult | null {
|
||||
const hasPartyLevel = descriptors.some(
|
||||
(d) => d.side === "party" && d.level !== undefined,
|
||||
);
|
||||
const hasCr = descriptors.some((d) => d.cr !== undefined);
|
||||
|
||||
if (!hasPartyLevel || !hasCr) return null;
|
||||
if (edition === "pf2e") {
|
||||
const hasCreatureLevel = descriptors.some(
|
||||
(d) => d.creatureLevel !== undefined,
|
||||
);
|
||||
if (!hasPartyLevel || !hasCreatureLevel) return null;
|
||||
} else {
|
||||
const hasCr = descriptors.some((d) => d.cr !== undefined);
|
||||
if (!hasPartyLevel || !hasCr) return null;
|
||||
}
|
||||
|
||||
return calculateEncounterDifficulty(descriptors, edition);
|
||||
}, [encounter.combatants, characters, getCreature, edition]);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { EncounterStore, UndoRedoStore } from "@initiative/application";
|
||||
import {
|
||||
addCombatantUseCase,
|
||||
addPersistentDamageUseCase,
|
||||
adjustHpUseCase,
|
||||
advanceTurnUseCase,
|
||||
clearEncounterUseCase,
|
||||
@@ -8,11 +9,13 @@ import {
|
||||
editCombatantUseCase,
|
||||
redoUseCase,
|
||||
removeCombatantUseCase,
|
||||
removePersistentDamageUseCase,
|
||||
retreatTurnUseCase,
|
||||
setAcUseCase,
|
||||
setConditionValueUseCase,
|
||||
setCrUseCase,
|
||||
setHpUseCase,
|
||||
setHpVariantUseCase,
|
||||
setInitiativeUseCase,
|
||||
setSideUseCase,
|
||||
setTempHpUseCase,
|
||||
@@ -28,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,
|
||||
@@ -53,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 }
|
||||
@@ -75,6 +90,17 @@ type EncounterAction =
|
||||
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" }
|
||||
@@ -84,6 +110,12 @@ type EncounterAction =
|
||||
entry: SearchResult;
|
||||
count: number;
|
||||
}
|
||||
| {
|
||||
type: "set-creature-adjustment";
|
||||
id: CombatantId;
|
||||
adjustment: "weak" | "elite" | undefined;
|
||||
baseCreature: Pf2eCreature;
|
||||
}
|
||||
| { type: "add-from-player-character"; pc: PlayerCharacter }
|
||||
| {
|
||||
type: "import";
|
||||
@@ -279,6 +311,76 @@ function handleAddFromPlayerCharacter(
|
||||
};
|
||||
}
|
||||
|
||||
function applyNamePrefix(
|
||||
name: string,
|
||||
oldAdj: "weak" | "elite" | undefined,
|
||||
newAdj: "weak" | "elite" | undefined,
|
||||
): string {
|
||||
let base = name;
|
||||
if (oldAdj === "weak" && name.startsWith("Weak ")) base = name.slice(5);
|
||||
else if (oldAdj === "elite" && name.startsWith("Elite "))
|
||||
base = name.slice(6);
|
||||
if (newAdj === "weak") return `Weak ${base}`;
|
||||
if (newAdj === "elite") return `Elite ${base}`;
|
||||
return base;
|
||||
}
|
||||
|
||||
function handleSetCreatureAdjustment(
|
||||
state: EncounterState,
|
||||
id: CombatantId,
|
||||
adjustment: "weak" | "elite" | undefined,
|
||||
baseCreature: Pf2eCreature,
|
||||
): EncounterState {
|
||||
const combatant = state.encounter.combatants.find((c) => c.id === id);
|
||||
if (!combatant) return state;
|
||||
|
||||
const oldAdj = combatant.creatureAdjustment;
|
||||
if (oldAdj === adjustment) return state;
|
||||
|
||||
const baseLevel = baseCreature.level;
|
||||
const oldHpDelta = oldAdj ? hpDelta(baseLevel, oldAdj) : 0;
|
||||
const newHpDelta = adjustment ? hpDelta(baseLevel, adjustment) : 0;
|
||||
const netHpDelta = newHpDelta - oldHpDelta;
|
||||
|
||||
const oldAcDelta = oldAdj ? acDelta(oldAdj) : 0;
|
||||
const newAcDelta = adjustment ? acDelta(adjustment) : 0;
|
||||
const netAcDelta = newAcDelta - oldAcDelta;
|
||||
|
||||
const newMaxHp =
|
||||
combatant.maxHp === undefined ? undefined : combatant.maxHp + netHpDelta;
|
||||
const newCurrentHp =
|
||||
combatant.currentHp === undefined || newMaxHp === undefined
|
||||
? undefined
|
||||
: Math.max(0, Math.min(combatant.currentHp + netHpDelta, newMaxHp));
|
||||
const newAc =
|
||||
combatant.ac === undefined ? undefined : combatant.ac + netAcDelta;
|
||||
const newName = applyNamePrefix(combatant.name, oldAdj, adjustment);
|
||||
|
||||
const updatedCombatant: typeof combatant = {
|
||||
...combatant,
|
||||
name: newName,
|
||||
...(newMaxHp !== undefined && { maxHp: newMaxHp }),
|
||||
...(newCurrentHp !== undefined && { currentHp: newCurrentHp }),
|
||||
...(newAc !== undefined && { ac: newAc }),
|
||||
...(adjustment === undefined
|
||||
? { creatureAdjustment: undefined }
|
||||
: { creatureAdjustment: adjustment }),
|
||||
};
|
||||
|
||||
const combatants = state.encounter.combatants.map((c) =>
|
||||
c.id === id ? updatedCombatant : c,
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
encounter: { ...state.encounter, combatants },
|
||||
events: [
|
||||
...state.events,
|
||||
{ type: "CreatureAdjustmentSet", combatantId: id, adjustment },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// -- Reducer --
|
||||
|
||||
export function encounterReducer(
|
||||
@@ -310,6 +412,13 @@ 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":
|
||||
@@ -332,6 +441,7 @@ 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" }
|
||||
@@ -341,6 +451,8 @@ function dispatchEncounterAction(
|
||||
| { type: "set-condition-value" }
|
||||
| { type: "decrement-condition" }
|
||||
| { type: "toggle-concentration" }
|
||||
| { type: "add-persistent-damage" }
|
||||
| { type: "remove-persistent-damage" }
|
||||
>,
|
||||
): EncounterState {
|
||||
const { store, getEncounter } = makeStoreFromState(state);
|
||||
@@ -370,6 +482,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;
|
||||
@@ -402,6 +522,21 @@ function dispatchEncounterAction(
|
||||
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;
|
||||
@@ -565,6 +700,35 @@ export function useEncounter() {
|
||||
(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" }),
|
||||
[],
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@5/schema.json",
|
||||
"$schema": "https://unpkg.com/knip@6/schema.json",
|
||||
"workspaces": {
|
||||
".": {
|
||||
"entry": ["scripts/*.mjs"]
|
||||
|
||||
+7
-3
@@ -3,8 +3,12 @@ pre-commit:
|
||||
jobs:
|
||||
- name: audit
|
||||
run: pnpm audit --audit-level=high
|
||||
# Must go through the npm script, not `pnpm exec knip`: the script sets
|
||||
# KNIP_DISABLE_RAW_TRANSFER=1. Knip 6's default oxc-parser raw-transfer path
|
||||
# allocates a 6 GiB ArrayBuffer, which Linux refuses under heuristic
|
||||
# overcommit on the CI runner (3.7 GB RAM, no swap). See issue #11.
|
||||
- name: knip
|
||||
run: pnpm exec knip
|
||||
run: pnpm knip
|
||||
- name: biome
|
||||
run: pnpm exec biome check .
|
||||
- name: check-ignores
|
||||
@@ -14,7 +18,7 @@ pre-commit:
|
||||
- name: check-props
|
||||
run: node scripts/check-component-props.mjs
|
||||
- name: jscpd
|
||||
run: pnpm exec jscpd
|
||||
run: pnpm jscpd
|
||||
- name: jsinspect
|
||||
run: pnpm jsinspect
|
||||
- name: typecheck-oxlint-test
|
||||
@@ -24,6 +28,6 @@ pre-commit:
|
||||
- name: typecheck
|
||||
run: pnpm exec tsc --build
|
||||
- name: oxlint
|
||||
run: pnpm oxlint -- --deny warnings
|
||||
run: pnpm oxlint
|
||||
- name: test
|
||||
run: pnpm vitest run --reporter=dot --coverage.reporter=text-summary
|
||||
|
||||
+5
-11
@@ -1,18 +1,12 @@
|
||||
{
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"undici": ">=7.24.0",
|
||||
"picomatch": ">=4.0.4"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.8",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"jscpd": "^4.0.8",
|
||||
"jsinspect-plus": "^3.1.3",
|
||||
"knip": "^5.88.1",
|
||||
"knip": "^6.31.0",
|
||||
"lefthook": "^2.1.4",
|
||||
"oxlint": "^1.56.0",
|
||||
"oxlint-tsgolint": "^0.17.1",
|
||||
@@ -28,13 +22,13 @@
|
||||
"typecheck": "tsc --build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"knip": "knip",
|
||||
"jscpd": "jscpd",
|
||||
"knip": "KNIP_DISABLE_RAW_TRANSFER=1 knip",
|
||||
"jscpd": "jscpd apps/web/src packages/domain/src packages/application/src",
|
||||
"jsinspect": "jsinspect -c .jsinspectrc apps/web/src packages/domain/src packages/application/src",
|
||||
"oxlint": "oxlint --tsconfig apps/web/tsconfig.json --type-aware --deny-warnings",
|
||||
"oxlint": "oxlint --tsconfig tsconfig.json --type-aware --deny-warnings",
|
||||
"check:ignores": "node scripts/check-lint-ignores.mjs",
|
||||
"check:classnames": "node scripts/check-cn-classnames.mjs",
|
||||
"check:props": "node scripts/check-component-props.mjs",
|
||||
"check": "pnpm audit --audit-level=high && knip && biome check . && node scripts/check-lint-ignores.mjs && node scripts/check-cn-classnames.mjs && node scripts/check-component-props.mjs && jscpd && pnpm jsinspect && tsc --build && oxlint --tsconfig apps/web/tsconfig.json --type-aware --deny warnings && vitest run"
|
||||
"check": "pnpm audit --audit-level=high && pnpm knip && biome check . && node scripts/check-lint-ignores.mjs && node scripts/check-cn-classnames.mjs && node scripts/check-component-props.mjs && pnpm jscpd && pnpm jsinspect && tsc --build && pnpm oxlint && vitest run"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
addPersistentDamage,
|
||||
type CombatantId,
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
type PersistentDamageType,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore } from "./ports.js";
|
||||
import { runEncounterAction } from "./run-encounter-action.js";
|
||||
|
||||
export function addPersistentDamageUseCase(
|
||||
store: EncounterStore,
|
||||
combatantId: CombatantId,
|
||||
damageType: PersistentDamageType,
|
||||
formula: string,
|
||||
): DomainEvent[] | DomainError {
|
||||
return runEncounterAction(store, (encounter) =>
|
||||
addPersistentDamage(encounter, combatantId, damageType, formula),
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { addCombatantUseCase } from "./add-combatant-use-case.js";
|
||||
export { addPersistentDamageUseCase } from "./add-persistent-damage-use-case.js";
|
||||
export { adjustHpUseCase } from "./adjust-hp-use-case.js";
|
||||
export { advanceTurnUseCase } from "./advance-turn-use-case.js";
|
||||
export { clearEncounterUseCase } from "./clear-encounter-use-case.js";
|
||||
@@ -15,6 +16,7 @@ export type {
|
||||
} from "./ports.js";
|
||||
export { redoUseCase } from "./redo-use-case.js";
|
||||
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
|
||||
export { removePersistentDamageUseCase } from "./remove-persistent-damage-use-case.js";
|
||||
export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
|
||||
export {
|
||||
type RollAllResult,
|
||||
@@ -25,6 +27,7 @@ export { setAcUseCase } from "./set-ac-use-case.js";
|
||||
export { setConditionValueUseCase } from "./set-condition-value-use-case.js";
|
||||
export { setCrUseCase } from "./set-cr-use-case.js";
|
||||
export { setHpUseCase } from "./set-hp-use-case.js";
|
||||
export { setHpVariantUseCase } from "./set-hp-variant-use-case.js";
|
||||
export { setInitiativeUseCase } from "./set-initiative-use-case.js";
|
||||
export { setSideUseCase } from "./set-side-use-case.js";
|
||||
export { setTempHpUseCase } from "./set-temp-hp-use-case.js";
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import {
|
||||
type CombatantId,
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
type PersistentDamageType,
|
||||
removePersistentDamage,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore } from "./ports.js";
|
||||
import { runEncounterAction } from "./run-encounter-action.js";
|
||||
|
||||
export function removePersistentDamageUseCase(
|
||||
store: EncounterStore,
|
||||
combatantId: CombatantId,
|
||||
damageType: PersistentDamageType,
|
||||
): DomainEvent[] | DomainError {
|
||||
return runEncounterAction(store, (encounter) =>
|
||||
removePersistentDamage(encounter, combatantId, damageType),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
type CombatantId,
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
type HpRange,
|
||||
type HpVariant,
|
||||
setHpVariant,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore } from "./ports.js";
|
||||
import { runEncounterAction } from "./run-encounter-action.js";
|
||||
|
||||
export function setHpVariantUseCase(
|
||||
store: EncounterStore,
|
||||
combatantId: CombatantId,
|
||||
variant: HpVariant | undefined,
|
||||
range: HpRange,
|
||||
): DomainEvent[] | DomainError {
|
||||
return runEncounterAction(store, (encounter) =>
|
||||
setHpVariant(encounter, combatantId, variant, range),
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
calculateEncounterDifficulty,
|
||||
crToXp,
|
||||
derivePartyLevel,
|
||||
pf2eCreatureXp,
|
||||
} from "../encounter-difficulty.js";
|
||||
|
||||
describe("crToXp", () => {
|
||||
@@ -47,9 +49,9 @@ function enemy(cr: string) {
|
||||
}
|
||||
|
||||
describe("calculateEncounterDifficulty — 5.5e edition", () => {
|
||||
it("returns tier 0 when monster XP is below Low threshold", () => {
|
||||
it("returns tier 0 when monster XP is 0", () => {
|
||||
// 4x level 1: Low = 200, Moderate = 300, High = 400
|
||||
// 1x CR 0 = 0 XP -> tier 0
|
||||
// 1x CR 0 = 0 XP -> tier 0 (5.5e has no Trivial tier; 0 bars only at 0 XP)
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), party(1), party(1), party(1), enemy("0")],
|
||||
"5.5e",
|
||||
@@ -75,9 +77,29 @@ describe("calculateEncounterDifficulty — 5.5e edition", () => {
|
||||
expect(result.totalMonsterXp).toBe(200);
|
||||
});
|
||||
|
||||
it("returns tier 2 for 5x level 3 vs 1150 XP", () => {
|
||||
it("returns tier 2 when XP spends exactly the Moderate budget", () => {
|
||||
// 5x level 3: Low = 750, Moderate = 1125, High = 2000
|
||||
// CR 3 (700) + CR 2 (450) = 1150 XP >= 1125 Moderate
|
||||
// CR 4 (1100) + CR 1/8 (25) = 1125 XP = Moderate budget
|
||||
const result = calculateEncounterDifficulty(
|
||||
[
|
||||
party(3),
|
||||
party(3),
|
||||
party(3),
|
||||
party(3),
|
||||
party(3),
|
||||
enemy("4"),
|
||||
enemy("1/8"),
|
||||
],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.tier).toBe(2);
|
||||
expect(result.totalMonsterXp).toBe(1125);
|
||||
expect(result.thresholds[1].value).toBe(1125);
|
||||
});
|
||||
|
||||
it("returns tier 3 when XP exceeds the Moderate budget", () => {
|
||||
// 5x level 3: Low = 750, Moderate = 1125, High = 2000
|
||||
// CR 3 (700) + CR 2 (450) = 1150 XP > 1125 Moderate → High
|
||||
const result = calculateEncounterDifficulty(
|
||||
[
|
||||
party(3),
|
||||
@@ -90,14 +112,24 @@ describe("calculateEncounterDifficulty — 5.5e edition", () => {
|
||||
],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.tier).toBe(2);
|
||||
expect(result.tier).toBe(3);
|
||||
expect(result.totalMonsterXp).toBe(1150);
|
||||
expect(result.thresholds[1].value).toBe(1125);
|
||||
});
|
||||
|
||||
it("returns tier 3 when XP meets High threshold", () => {
|
||||
// 4x level 1: High = 400
|
||||
// 2x CR 1 = 400 XP -> tier 3
|
||||
it("returns tier 3 for 5x level 5 vs 5000 XP (over Moderate budget)", () => {
|
||||
// 5x level 5: Low = 2500, Moderate = 3750, High = 5500
|
||||
// CR 9 = 5000 XP > 3750 Moderate → High, even though below the High budget
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(5), party(5), party(5), party(5), party(5), enemy("9")],
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.tier).toBe(3);
|
||||
expect(result.totalMonsterXp).toBe(5000);
|
||||
});
|
||||
|
||||
it("returns tier 3 when XP exceeds the Moderate budget", () => {
|
||||
// 4x level 1: Moderate = 300
|
||||
// 2x CR 1 = 400 XP > 300 -> tier 3
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(1), party(1), party(1), party(1), enemy("1"), enemy("1")],
|
||||
"5.5e",
|
||||
@@ -118,6 +150,7 @@ describe("calculateEncounterDifficulty — 5.5e edition", () => {
|
||||
it("handles mixed party levels", () => {
|
||||
// 3x level 3 + 1x level 2
|
||||
// Total: low=550, mod=825, high=1400
|
||||
// 700 XP > 550 Low, ≤ 825 Moderate → Moderate
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(3), party(3), party(3), party(2), enemy("3")],
|
||||
"5.5e",
|
||||
@@ -128,7 +161,7 @@ describe("calculateEncounterDifficulty — 5.5e edition", () => {
|
||||
{ label: "High", value: 1400 },
|
||||
]);
|
||||
expect(result.totalMonsterXp).toBe(700);
|
||||
expect(result.tier).toBe(1);
|
||||
expect(result.tier).toBe(2);
|
||||
});
|
||||
|
||||
it("returns tier 0 with no enemies", () => {
|
||||
@@ -162,7 +195,7 @@ describe("calculateEncounterDifficulty — 5.5e edition", () => {
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.totalMonsterXp).toBe(175); // 25 + 50 + 100
|
||||
expect(result.tier).toBe(0); // 175 < 200 Low
|
||||
expect(result.tier).toBe(1); // 175 ≤ 200 Low budget → Low
|
||||
});
|
||||
|
||||
it("ignores unknown CRs (0 XP)", () => {
|
||||
@@ -189,7 +222,7 @@ describe("calculateEncounterDifficulty — 5.5e edition", () => {
|
||||
"5.5e",
|
||||
);
|
||||
expect(result.totalMonsterXp).toBe(250);
|
||||
expect(result.tier).toBe(1); // 250 >= 200 Low, < 300 Moderate
|
||||
expect(result.tier).toBe(2); // 250 > 200 Low budget, ≤ 300 Moderate → Moderate
|
||||
});
|
||||
|
||||
it("floors net monster XP at 0", () => {
|
||||
@@ -252,7 +285,7 @@ describe("calculateEncounterDifficulty — 5.5e edition", () => {
|
||||
{ label: "High", value: 800 },
|
||||
]);
|
||||
expect(result.totalMonsterXp).toBe(700);
|
||||
expect(result.tier).toBe(2); // 700 >= 450 Moderate, < 800 High
|
||||
expect(result.tier).toBe(3); // 700 > 450 Moderate budget → High
|
||||
});
|
||||
});
|
||||
|
||||
@@ -386,3 +419,263 @@ describe("calculateEncounterDifficulty — 2014 edition", () => {
|
||||
expect(result.adjustedXp).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/** Helper to build a PF2e enemy-side descriptor with creature level. */
|
||||
function pf2eEnemy(creatureLevel: number) {
|
||||
return { creatureLevel, side: "enemy" as const };
|
||||
}
|
||||
|
||||
/** Helper to build a PF2e party-side creature descriptor. */
|
||||
function pf2eAlly(creatureLevel: number) {
|
||||
return { creatureLevel, side: "party" as const };
|
||||
}
|
||||
|
||||
describe("derivePartyLevel", () => {
|
||||
it("returns 0 for empty array", () => {
|
||||
expect(derivePartyLevel([])).toBe(0);
|
||||
});
|
||||
|
||||
it("returns the level for a single PC", () => {
|
||||
expect(derivePartyLevel([7])).toBe(7);
|
||||
});
|
||||
|
||||
it("returns the unanimous level", () => {
|
||||
expect(derivePartyLevel([5, 5, 5, 5])).toBe(5);
|
||||
});
|
||||
|
||||
it("returns the mode when one level is most common", () => {
|
||||
expect(derivePartyLevel([3, 3, 3, 5])).toBe(3);
|
||||
});
|
||||
|
||||
it("returns rounded average when mode is tied", () => {
|
||||
// 3,3,5,5 → average 4
|
||||
expect(derivePartyLevel([3, 3, 5, 5])).toBe(4);
|
||||
});
|
||||
|
||||
it("returns rounded average when all levels are different", () => {
|
||||
// 2,4,6,8 → average 5
|
||||
expect(derivePartyLevel([2, 4, 6, 8])).toBe(5);
|
||||
});
|
||||
|
||||
it("rounds average to nearest integer", () => {
|
||||
// 1,2 → average 1.5 → rounds to 2
|
||||
expect(derivePartyLevel([1, 2])).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pf2eCreatureXp", () => {
|
||||
it.each([
|
||||
[-4, 10],
|
||||
[-3, 15],
|
||||
[-2, 20],
|
||||
[-1, 30],
|
||||
[0, 40],
|
||||
[1, 60],
|
||||
[2, 80],
|
||||
[3, 120],
|
||||
[4, 160],
|
||||
])("level diff %i returns %i XP", (diff, expectedXp) => {
|
||||
// partyLevel 5, creatureLevel = 5 + diff
|
||||
expect(pf2eCreatureXp(5 + diff, 5)).toBe(expectedXp);
|
||||
});
|
||||
|
||||
it("returns 0 XP for creatures more than 4 levels below the party", () => {
|
||||
expect(pf2eCreatureXp(0, 10)).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps level diff above +4 to +4 (160 XP)", () => {
|
||||
expect(pf2eCreatureXp(15, 5)).toBe(160);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateEncounterDifficulty — pf2e edition", () => {
|
||||
it("returns Trivial (tier 0) for 40 XP with party of 4", () => {
|
||||
// 1 creature at party level = 40 XP, within the Trivial band (40 or less)
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(5), party(5), party(5), party(5), pf2eEnemy(5)],
|
||||
"pf2e",
|
||||
);
|
||||
expect(result.tier).toBe(0);
|
||||
expect(result.totalMonsterXp).toBe(40);
|
||||
expect(result.partyLevel).toBe(5);
|
||||
expect(result.thresholds).toEqual([
|
||||
{ label: "Trivial", value: 40 },
|
||||
{ label: "Low", value: 60 },
|
||||
{ label: "Moderate", value: 80 },
|
||||
{ label: "Severe", value: 120 },
|
||||
{ label: "Extreme", value: 160 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns Low (tier 1) for 60 XP", () => {
|
||||
// 1 creature at party level +1 = 60 XP
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(5), party(5), party(5), party(5), pf2eEnemy(6)],
|
||||
"pf2e",
|
||||
);
|
||||
expect(result.tier).toBe(1);
|
||||
expect(result.totalMonsterXp).toBe(60);
|
||||
});
|
||||
|
||||
it("returns Moderate (tier 2) for 80 XP", () => {
|
||||
// 1 creature at +2 = 80 XP
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(5), party(5), party(5), party(5), pf2eEnemy(7)],
|
||||
"pf2e",
|
||||
);
|
||||
expect(result.tier).toBe(2);
|
||||
expect(result.totalMonsterXp).toBe(80);
|
||||
});
|
||||
|
||||
it("returns Severe (tier 3) for 120 XP", () => {
|
||||
// 1 creature at +3 = 120 XP
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(5), party(5), party(5), party(5), pf2eEnemy(8)],
|
||||
"pf2e",
|
||||
);
|
||||
expect(result.tier).toBe(3);
|
||||
expect(result.totalMonsterXp).toBe(120);
|
||||
});
|
||||
|
||||
it("returns Extreme (tier 4) for 160 XP", () => {
|
||||
// 1 creature at +4 = 160 XP > Severe budget (120)
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(5), party(5), party(5), party(5), pf2eEnemy(9)],
|
||||
"pf2e",
|
||||
);
|
||||
expect(result.tier).toBe(4);
|
||||
expect(result.totalMonsterXp).toBe(160);
|
||||
});
|
||||
|
||||
it("returns Moderate (tier 2) for XP between the Low and Moderate budgets", () => {
|
||||
// +1 (60) + −4 (10) = 70 XP > Low (60), ≤ Moderate (80)
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(5), party(5), party(5), party(5), pf2eEnemy(6), pf2eEnemy(1)],
|
||||
"pf2e",
|
||||
);
|
||||
expect(result.tier).toBe(2);
|
||||
expect(result.totalMonsterXp).toBe(70);
|
||||
});
|
||||
|
||||
it("returns Extreme (tier 4) for XP just over the Severe budget", () => {
|
||||
// +3 (120) + −4 (10) = 130 XP > Severe (120)
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(5), party(5), party(5), party(5), pf2eEnemy(8), pf2eEnemy(1)],
|
||||
"pf2e",
|
||||
);
|
||||
expect(result.tier).toBe(4);
|
||||
expect(result.totalMonsterXp).toBe(130);
|
||||
});
|
||||
|
||||
it("returns tier 0 when XP is within the Trivial band", () => {
|
||||
// 1 creature at −4 = 10 XP ≤ Trivial (40)
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(5), party(5), party(5), party(5), pf2eEnemy(1)],
|
||||
"pf2e",
|
||||
);
|
||||
expect(result.tier).toBe(0);
|
||||
expect(result.totalMonsterXp).toBe(10);
|
||||
});
|
||||
|
||||
it("counts creatures more than 4 levels below the party as 0 XP", () => {
|
||||
// Party level 6: a level-1 creature is 5 below → 0 XP
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(6), party(6), party(6), party(6), pf2eEnemy(1), pf2eEnemy(6)],
|
||||
"pf2e",
|
||||
);
|
||||
expect(result.totalMonsterXp).toBe(40); // only the at-level creature counts
|
||||
});
|
||||
|
||||
it("adjusts thresholds for 5 PCs (increases by adjustment)", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(5), party(5), party(5), party(5), party(5), pf2eEnemy(5)],
|
||||
"pf2e",
|
||||
);
|
||||
expect(result.thresholds).toEqual([
|
||||
{ label: "Trivial", value: 50 },
|
||||
{ label: "Low", value: 80 },
|
||||
{ label: "Moderate", value: 100 },
|
||||
{ label: "Severe", value: 150 },
|
||||
{ label: "Extreme", value: 200 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("adjusts thresholds for 3 PCs (decreases by adjustment)", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(5), party(5), party(5), pf2eEnemy(5)],
|
||||
"pf2e",
|
||||
);
|
||||
expect(result.thresholds).toEqual([
|
||||
{ label: "Trivial", value: 30 },
|
||||
{ label: "Low", value: 40 },
|
||||
{ label: "Moderate", value: 60 },
|
||||
{ label: "Severe", value: 90 },
|
||||
{ label: "Extreme", value: 120 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("floors thresholds at 0 for very small parties", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(5), pf2eEnemy(5)],
|
||||
"pf2e",
|
||||
);
|
||||
// 1 PC: adjustment = −3
|
||||
// Trivial: 40 + (−3 * 10) = 10
|
||||
// Low: 60 + (−3 * 20) = 0
|
||||
expect(result.thresholds[0].value).toBe(10);
|
||||
expect(result.thresholds[1].value).toBe(0);
|
||||
expect(result.thresholds[2].value).toBe(20); // 80 − 60
|
||||
expect(result.thresholds[3].value).toBe(30); // 120 − 90
|
||||
expect(result.thresholds[4].value).toBe(40); // 160 − 120
|
||||
});
|
||||
|
||||
it("subtracts XP for party-side creatures", () => {
|
||||
// 2 enemies at party level = 80 XP, 1 ally at party level = 40 XP
|
||||
// Net = 80 − 40 = 40 XP
|
||||
const result = calculateEncounterDifficulty(
|
||||
[
|
||||
party(5),
|
||||
party(5),
|
||||
party(5),
|
||||
party(5),
|
||||
pf2eEnemy(5),
|
||||
pf2eEnemy(5),
|
||||
pf2eAlly(5),
|
||||
],
|
||||
"pf2e",
|
||||
);
|
||||
expect(result.totalMonsterXp).toBe(40);
|
||||
});
|
||||
|
||||
it("floors net creature XP at 0", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(5), party(5), party(5), party(5), pf2eEnemy(1), pf2eAlly(9)],
|
||||
"pf2e",
|
||||
);
|
||||
expect(result.totalMonsterXp).toBe(0);
|
||||
});
|
||||
|
||||
it("derives party level using mode", () => {
|
||||
// 3x level 3, 1x level 5 → mode is 3
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(3), party(3), party(3), party(5), pf2eEnemy(3)],
|
||||
"pf2e",
|
||||
);
|
||||
expect(result.partyLevel).toBe(3);
|
||||
});
|
||||
|
||||
it("has no encounterMultiplier, adjustedXp, or partySizeAdjusted", () => {
|
||||
const result = calculateEncounterDifficulty(
|
||||
[party(5), party(5), party(5), party(5), pf2eEnemy(5)],
|
||||
"pf2e",
|
||||
);
|
||||
expect(result.encounterMultiplier).toBeUndefined();
|
||||
expect(result.adjustedXp).toBeUndefined();
|
||||
expect(result.partySizeAdjusted).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns partyLevel undefined for D&D editions", () => {
|
||||
const result = calculateEncounterDifficulty([party(1), enemy("1")], "5.5e");
|
||||
expect(result.partyLevel).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { checkGates } from "../../../../scripts/check-gates.mjs";
|
||||
|
||||
describe("quality gates", () => {
|
||||
it("fail on violations instead of silently passing", () => {
|
||||
const violations = checkGates();
|
||||
if (violations.length > 0) {
|
||||
throw new Error(
|
||||
`Gate integrity violations:\n ${violations.join("\n ")}`,
|
||||
);
|
||||
}
|
||||
expect(violations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hpForVariant, hpRange } from "../hp-range.js";
|
||||
|
||||
describe("hpRange", () => {
|
||||
it("derives min and max from a formula with a positive modifier", () => {
|
||||
const range = hpRange({ average: 16, formula: "3d6 + 6" });
|
||||
expect(range).toEqual({ min: 9, average: 16, max: 24 });
|
||||
});
|
||||
|
||||
it("derives min and max from a formula without a modifier", () => {
|
||||
const range = hpRange({ average: 40, formula: "9d8" });
|
||||
expect(range).toEqual({ min: 9, average: 40, max: 72 });
|
||||
});
|
||||
|
||||
it("subtracts a negative modifier", () => {
|
||||
const range = hpRange({ average: 45, formula: "10d8 - 5" });
|
||||
expect(range).toEqual({ min: 5, average: 45, max: 75 });
|
||||
});
|
||||
|
||||
it("treats an en dash as a minus sign", () => {
|
||||
// Bundled bestiary data contains e.g. "2d6 – 2" (U+2013).
|
||||
const range = hpRange({ average: 5, formula: "2d6 – 2" });
|
||||
expect(range).toEqual({ min: 1, average: 5, max: 10 });
|
||||
});
|
||||
|
||||
it("never drops below 1 HP when the modifier exceeds the dice", () => {
|
||||
const range = hpRange({ average: 1, formula: "1d4 - 10" });
|
||||
expect(range?.min).toBe(1);
|
||||
expect(range?.max).toBe(1);
|
||||
});
|
||||
|
||||
it("handles large pools without whitespace", () => {
|
||||
expect(hpRange({ average: 256, formula: "19d12+133" })).toEqual({
|
||||
min: 152,
|
||||
average: 256,
|
||||
max: 361,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for a missing formula", () => {
|
||||
expect(hpRange({ average: 7, formula: "" })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for prose instead of a dice pool", () => {
|
||||
expect(
|
||||
hpRange({ average: 0, formula: "equal to the summoner's" }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a flat number", () => {
|
||||
expect(hpRange({ average: 50, formula: "50" })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a pool with zero dice or zero sides", () => {
|
||||
expect(hpRange({ average: 0, formula: "0d6" })).toBeNull();
|
||||
expect(hpRange({ average: 0, formula: "2d0" })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for compound formulas it cannot reason about", () => {
|
||||
expect(hpRange({ average: 20, formula: "2d6 + 2d8" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hpForVariant", () => {
|
||||
const range = { min: 9, average: 16, max: 24 };
|
||||
|
||||
it("returns the average when no variant is set", () => {
|
||||
expect(hpForVariant(range, undefined)).toBe(16);
|
||||
});
|
||||
|
||||
it("returns the minimum for the min variant", () => {
|
||||
expect(hpForVariant(range, "min")).toBe(9);
|
||||
});
|
||||
|
||||
it("returns the maximum for the max variant", () => {
|
||||
expect(hpForVariant(range, "max")).toBe(24);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
addPersistentDamage,
|
||||
type PersistentDamageType,
|
||||
removePersistentDamage,
|
||||
} from "../persistent-damage.js";
|
||||
import type { Encounter } from "../types.js";
|
||||
import { combatantId } from "../types.js";
|
||||
|
||||
const goblinId = combatantId("goblin-1");
|
||||
|
||||
function buildEncounter(overrides: Partial<Encounter> = {}): Encounter {
|
||||
return {
|
||||
combatants: [
|
||||
{
|
||||
id: goblinId,
|
||||
name: "Goblin",
|
||||
...overrides.combatants?.[0],
|
||||
},
|
||||
],
|
||||
activeIndex: overrides.activeIndex ?? 0,
|
||||
roundNumber: overrides.roundNumber ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe("addPersistentDamage", () => {
|
||||
it("adds persistent fire damage to combatant", () => {
|
||||
const encounter = buildEncounter();
|
||||
const result = addPersistentDamage(encounter, goblinId, "fire", "2d6");
|
||||
|
||||
expect(result).not.toHaveProperty("kind");
|
||||
if ("kind" in result) return;
|
||||
|
||||
const target = result.encounter.combatants[0];
|
||||
expect(target.persistentDamage).toEqual([{ type: "fire", formula: "2d6" }]);
|
||||
expect(result.events).toEqual([
|
||||
{
|
||||
type: "PersistentDamageAdded",
|
||||
combatantId: goblinId,
|
||||
damageType: "fire",
|
||||
formula: "2d6",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("replaces existing entry of same type with new formula", () => {
|
||||
const encounter = buildEncounter({
|
||||
combatants: [
|
||||
{
|
||||
id: goblinId,
|
||||
name: "Goblin",
|
||||
persistentDamage: [{ type: "fire", formula: "2d6" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = addPersistentDamage(encounter, goblinId, "fire", "3d6");
|
||||
|
||||
expect(result).not.toHaveProperty("kind");
|
||||
if ("kind" in result) return;
|
||||
|
||||
expect(result.encounter.combatants[0].persistentDamage).toEqual([
|
||||
{ type: "fire", formula: "3d6" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("allows multiple different damage types", () => {
|
||||
const encounter = buildEncounter({
|
||||
combatants: [
|
||||
{
|
||||
id: goblinId,
|
||||
name: "Goblin",
|
||||
persistentDamage: [{ type: "fire", formula: "2d6" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = addPersistentDamage(encounter, goblinId, "bleed", "1d4");
|
||||
|
||||
expect(result).not.toHaveProperty("kind");
|
||||
if ("kind" in result) return;
|
||||
|
||||
expect(result.encounter.combatants[0].persistentDamage).toEqual([
|
||||
{ type: "fire", formula: "2d6" },
|
||||
{ type: "bleed", formula: "1d4" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("sorts entries by definition order", () => {
|
||||
const encounter = buildEncounter({
|
||||
combatants: [
|
||||
{
|
||||
id: goblinId,
|
||||
name: "Goblin",
|
||||
persistentDamage: [{ type: "cold", formula: "1d6" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = addPersistentDamage(encounter, goblinId, "fire", "2d6");
|
||||
|
||||
expect(result).not.toHaveProperty("kind");
|
||||
if ("kind" in result) return;
|
||||
|
||||
const types = result.encounter.combatants[0].persistentDamage?.map(
|
||||
(e) => e.type,
|
||||
);
|
||||
expect(types).toEqual(["fire", "cold"]);
|
||||
});
|
||||
|
||||
it("returns domain error for empty formula", () => {
|
||||
const encounter = buildEncounter();
|
||||
const result = addPersistentDamage(encounter, goblinId, "fire", " ");
|
||||
|
||||
expect(result).toHaveProperty("kind", "domain-error");
|
||||
if (!("kind" in result)) return;
|
||||
expect(result.code).toBe("empty-formula");
|
||||
});
|
||||
|
||||
it("returns domain error for unknown damage type", () => {
|
||||
const encounter = buildEncounter();
|
||||
const result = addPersistentDamage(
|
||||
encounter,
|
||||
goblinId,
|
||||
"radiant" as PersistentDamageType,
|
||||
"2d6",
|
||||
);
|
||||
|
||||
expect(result).toHaveProperty("kind", "domain-error");
|
||||
if (!("kind" in result)) return;
|
||||
expect(result.code).toBe("unknown-damage-type");
|
||||
});
|
||||
|
||||
it("returns domain error for unknown combatant", () => {
|
||||
const encounter = buildEncounter();
|
||||
const result = addPersistentDamage(
|
||||
encounter,
|
||||
combatantId("nonexistent"),
|
||||
"fire",
|
||||
"2d6",
|
||||
);
|
||||
|
||||
expect(result).toHaveProperty("kind", "domain-error");
|
||||
if (!("kind" in result)) return;
|
||||
expect(result.code).toBe("combatant-not-found");
|
||||
});
|
||||
|
||||
it("trims formula whitespace", () => {
|
||||
const encounter = buildEncounter();
|
||||
const result = addPersistentDamage(encounter, goblinId, "fire", " 2d6 ");
|
||||
|
||||
expect(result).not.toHaveProperty("kind");
|
||||
if ("kind" in result) return;
|
||||
|
||||
expect(result.encounter.combatants[0].persistentDamage?.[0].formula).toBe(
|
||||
"2d6",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not mutate input encounter", () => {
|
||||
const encounter = buildEncounter();
|
||||
const originalCombatants = encounter.combatants;
|
||||
addPersistentDamage(encounter, goblinId, "fire", "2d6");
|
||||
|
||||
expect(encounter.combatants).toBe(originalCombatants);
|
||||
expect(encounter.combatants[0].persistentDamage).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("removePersistentDamage", () => {
|
||||
it("removes existing persistent damage entry", () => {
|
||||
const encounter = buildEncounter({
|
||||
combatants: [
|
||||
{
|
||||
id: goblinId,
|
||||
name: "Goblin",
|
||||
persistentDamage: [
|
||||
{ type: "fire", formula: "2d6" },
|
||||
{ type: "bleed", formula: "1d4" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = removePersistentDamage(encounter, goblinId, "fire");
|
||||
|
||||
expect(result).not.toHaveProperty("kind");
|
||||
if ("kind" in result) return;
|
||||
|
||||
expect(result.encounter.combatants[0].persistentDamage).toEqual([
|
||||
{ type: "bleed", formula: "1d4" },
|
||||
]);
|
||||
expect(result.events).toEqual([
|
||||
{
|
||||
type: "PersistentDamageRemoved",
|
||||
combatantId: goblinId,
|
||||
damageType: "fire",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("sets persistentDamage to undefined when last entry removed", () => {
|
||||
const encounter = buildEncounter({
|
||||
combatants: [
|
||||
{
|
||||
id: goblinId,
|
||||
name: "Goblin",
|
||||
persistentDamage: [{ type: "fire", formula: "2d6" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = removePersistentDamage(encounter, goblinId, "fire");
|
||||
|
||||
expect(result).not.toHaveProperty("kind");
|
||||
if ("kind" in result) return;
|
||||
|
||||
expect(result.encounter.combatants[0].persistentDamage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns domain error when damage type not active", () => {
|
||||
const encounter = buildEncounter();
|
||||
const result = removePersistentDamage(encounter, goblinId, "fire");
|
||||
|
||||
expect(result).toHaveProperty("kind", "domain-error");
|
||||
if (!("kind" in result)) return;
|
||||
expect(result.code).toBe("persistent-damage-not-active");
|
||||
});
|
||||
|
||||
it("returns domain error for unknown combatant", () => {
|
||||
const encounter = buildEncounter();
|
||||
const result = removePersistentDamage(
|
||||
encounter,
|
||||
combatantId("nonexistent"),
|
||||
"fire",
|
||||
);
|
||||
|
||||
expect(result).toHaveProperty("kind", "domain-error");
|
||||
if (!("kind" in result)) return;
|
||||
expect(result.code).toBe("combatant-not-found");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Pf2eCreature } from "../creature-types.js";
|
||||
import { creatureId } from "../creature-types.js";
|
||||
import {
|
||||
acDelta,
|
||||
adjustedLevel,
|
||||
applyPf2eAdjustment,
|
||||
hpDelta,
|
||||
modDelta,
|
||||
} from "../pf2e-adjustments.js";
|
||||
|
||||
describe("adjustedLevel", () => {
|
||||
it("elite on level 5 → 6", () => {
|
||||
expect(adjustedLevel(5, "elite")).toBe(6);
|
||||
});
|
||||
|
||||
it("elite on level 0 → 2 (double bump)", () => {
|
||||
expect(adjustedLevel(0, "elite")).toBe(2);
|
||||
});
|
||||
|
||||
it("elite on level −1 → 1 (double bump)", () => {
|
||||
expect(adjustedLevel(-1, "elite")).toBe(1);
|
||||
});
|
||||
|
||||
it("weak on level 5 → 4", () => {
|
||||
expect(adjustedLevel(5, "weak")).toBe(4);
|
||||
});
|
||||
|
||||
it("weak on level 1 → −1 (double drop)", () => {
|
||||
expect(adjustedLevel(1, "weak")).toBe(-1);
|
||||
});
|
||||
|
||||
it("weak on level 0 → −1", () => {
|
||||
expect(adjustedLevel(0, "weak")).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hpDelta", () => {
|
||||
it("level 1 elite → +10", () => {
|
||||
expect(hpDelta(1, "elite")).toBe(10);
|
||||
});
|
||||
|
||||
it("level 1 weak → −10", () => {
|
||||
expect(hpDelta(1, "weak")).toBe(-10);
|
||||
});
|
||||
|
||||
it("level 3 elite → +15", () => {
|
||||
expect(hpDelta(3, "elite")).toBe(15);
|
||||
});
|
||||
|
||||
it("level 3 weak → −15", () => {
|
||||
expect(hpDelta(3, "weak")).toBe(-15);
|
||||
});
|
||||
|
||||
it("level 10 elite → +20", () => {
|
||||
expect(hpDelta(10, "elite")).toBe(20);
|
||||
});
|
||||
|
||||
it("level 10 weak → −20", () => {
|
||||
expect(hpDelta(10, "weak")).toBe(-20);
|
||||
});
|
||||
|
||||
it("level 25 elite → +30", () => {
|
||||
expect(hpDelta(25, "elite")).toBe(30);
|
||||
});
|
||||
|
||||
it("level 25 weak → −30", () => {
|
||||
expect(hpDelta(25, "weak")).toBe(-30);
|
||||
});
|
||||
});
|
||||
|
||||
describe("acDelta", () => {
|
||||
it("elite → +2", () => {
|
||||
expect(acDelta("elite")).toBe(2);
|
||||
});
|
||||
|
||||
it("weak → −2", () => {
|
||||
expect(acDelta("weak")).toBe(-2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("modDelta", () => {
|
||||
it("elite → +2", () => {
|
||||
expect(modDelta("elite")).toBe(2);
|
||||
});
|
||||
|
||||
it("weak → −2", () => {
|
||||
expect(modDelta("weak")).toBe(-2);
|
||||
});
|
||||
});
|
||||
|
||||
function baseCreature(overrides?: Partial<Pf2eCreature>): Pf2eCreature {
|
||||
return {
|
||||
system: "pf2e",
|
||||
id: creatureId("test-creature"),
|
||||
name: "Test Creature",
|
||||
source: "test-source",
|
||||
sourceDisplayName: "Test Source",
|
||||
level: 5,
|
||||
traits: ["humanoid"],
|
||||
perception: 12,
|
||||
skills: "Athletics +14",
|
||||
abilityMods: {
|
||||
str: 4,
|
||||
dex: 2,
|
||||
con: 3,
|
||||
int: 0,
|
||||
wis: 1,
|
||||
cha: -1,
|
||||
},
|
||||
ac: 22,
|
||||
saveFort: 14,
|
||||
saveRef: 11,
|
||||
saveWill: 9,
|
||||
hp: 75,
|
||||
speed: "25 feet",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyPf2eAdjustment", () => {
|
||||
it("adjusts all numeric stats for elite", () => {
|
||||
const creature = baseCreature();
|
||||
const result = applyPf2eAdjustment(creature, "elite");
|
||||
|
||||
expect(result.level).toBe(6);
|
||||
expect(result.ac).toBe(24);
|
||||
expect(result.hp).toBe(95); // 75 + 20 (level 5 bracket)
|
||||
expect(result.perception).toBe(14);
|
||||
expect(result.saveFort).toBe(16);
|
||||
expect(result.saveRef).toBe(13);
|
||||
expect(result.saveWill).toBe(11);
|
||||
});
|
||||
|
||||
it("adjusts all numeric stats for weak", () => {
|
||||
const creature = baseCreature();
|
||||
const result = applyPf2eAdjustment(creature, "weak");
|
||||
|
||||
expect(result.level).toBe(4);
|
||||
expect(result.ac).toBe(20);
|
||||
expect(result.hp).toBe(55); // 75 - 20 (level 5 bracket)
|
||||
expect(result.perception).toBe(10);
|
||||
expect(result.saveFort).toBe(12);
|
||||
expect(result.saveRef).toBe(9);
|
||||
expect(result.saveWill).toBe(7);
|
||||
});
|
||||
|
||||
it("adjusts attack bonuses and damage", () => {
|
||||
const creature = baseCreature({
|
||||
attacks: [
|
||||
{
|
||||
name: "Melee",
|
||||
activity: { number: 1, unit: "action" },
|
||||
segments: [
|
||||
{
|
||||
type: "text",
|
||||
value: "+15 [+10/+5] (agile), 2d12+7 piercing plus Grab",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = applyPf2eAdjustment(creature, "elite");
|
||||
const text = result.attacks?.[0].segments[0];
|
||||
expect(text).toEqual({
|
||||
type: "text",
|
||||
value: "+17 [+12/+7] (agile), 2d12+9 piercing plus Grab",
|
||||
});
|
||||
});
|
||||
|
||||
it("adjusts attack damage for weak", () => {
|
||||
const creature = baseCreature({
|
||||
attacks: [
|
||||
{
|
||||
name: "Melee",
|
||||
activity: { number: 1, unit: "action" },
|
||||
segments: [
|
||||
{
|
||||
type: "text",
|
||||
value: "+15 (agile), 2d12+7 piercing plus Grab",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = applyPf2eAdjustment(creature, "weak");
|
||||
const text = result.attacks?.[0].segments[0];
|
||||
expect(text).toEqual({
|
||||
type: "text",
|
||||
value: "+13 (agile), 2d12+5 piercing plus Grab",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles damage bonus becoming zero", () => {
|
||||
const creature = baseCreature({
|
||||
attacks: [
|
||||
{
|
||||
name: "Melee",
|
||||
activity: { number: 1, unit: "action" },
|
||||
segments: [{ type: "text", value: "+10, 1d4+2 slashing" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = applyPf2eAdjustment(creature, "weak");
|
||||
const text = result.attacks?.[0].segments[0];
|
||||
expect(text).toEqual({
|
||||
type: "text",
|
||||
value: "+8, 1d4 slashing",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles damage bonus becoming negative", () => {
|
||||
const creature = baseCreature({
|
||||
attacks: [
|
||||
{
|
||||
name: "Melee",
|
||||
activity: { number: 1, unit: "action" },
|
||||
segments: [{ type: "text", value: "+10, 1d4+1 slashing" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = applyPf2eAdjustment(creature, "weak");
|
||||
const text = result.attacks?.[0].segments[0];
|
||||
expect(text).toEqual({
|
||||
type: "text",
|
||||
value: "+8, 1d4-1 slashing",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not modify non-attack abilities", () => {
|
||||
const creature = baseCreature({
|
||||
abilitiesTop: [
|
||||
{
|
||||
name: "Darkvision",
|
||||
segments: [{ type: "text", value: "Can see in darkness." }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = applyPf2eAdjustment(creature, "elite");
|
||||
expect(result.abilitiesTop).toEqual(creature.abilitiesTop);
|
||||
});
|
||||
|
||||
it("preserves non-text segments in attacks", () => {
|
||||
const creature = baseCreature({
|
||||
attacks: [
|
||||
{
|
||||
name: "Melee",
|
||||
activity: { number: 1, unit: "action" },
|
||||
segments: [
|
||||
{
|
||||
type: "list",
|
||||
items: [{ text: "some list item" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = applyPf2eAdjustment(creature, "elite");
|
||||
expect(result.attacks?.[0].segments[0]).toEqual({
|
||||
type: "list",
|
||||
items: [{ text: "some list item" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { recallKnowledge } from "../recall-knowledge.js";
|
||||
|
||||
describe("recallKnowledge", () => {
|
||||
it("returns null when no type trait is recognized", () => {
|
||||
expect(recallKnowledge(5, ["small", "goblin"])).toBeNull();
|
||||
});
|
||||
|
||||
it("calculates DC for a common creature from the DC-by-level table", () => {
|
||||
const result = recallKnowledge(5, ["humanoid"]);
|
||||
expect(result).toEqual({ dc: 20, type: "humanoid", skills: ["Society"] });
|
||||
});
|
||||
|
||||
it("calculates DC for level -1", () => {
|
||||
const result = recallKnowledge(-1, ["humanoid"]);
|
||||
expect(result).toEqual({ dc: 13, type: "humanoid", skills: ["Society"] });
|
||||
});
|
||||
|
||||
it("calculates DC for level 0", () => {
|
||||
const result = recallKnowledge(0, ["animal"]);
|
||||
expect(result).toEqual({ dc: 14, type: "animal", skills: ["Nature"] });
|
||||
});
|
||||
|
||||
it("calculates DC for level 25 (max table entry)", () => {
|
||||
const result = recallKnowledge(25, ["dragon"]);
|
||||
expect(result).toEqual({ dc: 50, type: "dragon", skills: ["Arcana"] });
|
||||
});
|
||||
|
||||
it("clamps DC for levels beyond the table", () => {
|
||||
const result = recallKnowledge(30, ["dragon"]);
|
||||
expect(result).toEqual({ dc: 50, type: "dragon", skills: ["Arcana"] });
|
||||
});
|
||||
|
||||
it("adjusts DC for uncommon rarity (+2)", () => {
|
||||
const result = recallKnowledge(5, ["uncommon", "medium", "undead"]);
|
||||
expect(result).toEqual({
|
||||
dc: 22,
|
||||
type: "undead",
|
||||
skills: ["Religion"],
|
||||
});
|
||||
});
|
||||
|
||||
it("adjusts DC for rare rarity (+5)", () => {
|
||||
const result = recallKnowledge(5, ["rare", "large", "dragon"]);
|
||||
expect(result).toEqual({ dc: 25, type: "dragon", skills: ["Arcana"] });
|
||||
});
|
||||
|
||||
it("adjusts DC for unique rarity (+10)", () => {
|
||||
const result = recallKnowledge(5, ["unique", "medium", "humanoid"]);
|
||||
expect(result).toEqual({
|
||||
dc: 30,
|
||||
type: "humanoid",
|
||||
skills: ["Society"],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns multiple skills for beast type", () => {
|
||||
const result = recallKnowledge(3, ["beast"]);
|
||||
expect(result).toEqual({
|
||||
dc: 18,
|
||||
type: "beast",
|
||||
skills: ["Arcana", "Nature"],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns multiple skills for construct type", () => {
|
||||
const result = recallKnowledge(1, ["construct"]);
|
||||
expect(result).toEqual({
|
||||
dc: 15,
|
||||
type: "construct",
|
||||
skills: ["Arcana", "Crafting"],
|
||||
});
|
||||
});
|
||||
|
||||
it("matches type traits case-insensitively", () => {
|
||||
const result = recallKnowledge(5, ["Humanoid"]);
|
||||
expect(result).toEqual({ dc: 20, type: "Humanoid", skills: ["Society"] });
|
||||
});
|
||||
|
||||
it("uses the first matching type trait when multiple are present", () => {
|
||||
const result = recallKnowledge(7, ["large", "monitor", "protean"]);
|
||||
expect(result).toEqual({
|
||||
dc: 23,
|
||||
type: "monitor",
|
||||
skills: ["Religion"],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves original trait casing in the returned type", () => {
|
||||
const result = recallKnowledge(1, ["Fey"]);
|
||||
expect(result?.type).toBe("Fey");
|
||||
});
|
||||
|
||||
it("ignores common rarity (no adjustment)", () => {
|
||||
// "common" is not included in traits by the normalization pipeline
|
||||
const result = recallKnowledge(5, ["medium", "humanoid"]);
|
||||
expect(result?.dc).toBe(20);
|
||||
});
|
||||
});
|
||||
@@ -301,6 +301,80 @@ describe("rehydrateCombatant", () => {
|
||||
expect(result?.side).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves valid hpVariant field", () => {
|
||||
for (const hpVariant of ["min", "max"]) {
|
||||
const result = rehydrateCombatant({
|
||||
...minimalCombatant(),
|
||||
hpVariant,
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.hpVariant).toBe(hpVariant);
|
||||
}
|
||||
});
|
||||
|
||||
it("drops invalid hpVariant field", () => {
|
||||
for (const hpVariant of ["average", "", 42, null, true]) {
|
||||
const result = rehydrateCombatant({
|
||||
...minimalCombatant(),
|
||||
hpVariant,
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.hpVariant).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("combatant without hpVariant rehydrates as before", () => {
|
||||
const result = rehydrateCombatant(minimalCombatant());
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.hpVariant).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves valid persistent damage entries", () => {
|
||||
const result = rehydrateCombatant({
|
||||
...minimalCombatant(),
|
||||
persistentDamage: [
|
||||
{ type: "fire", formula: "2d6" },
|
||||
{ type: "bleed", formula: "1d4" },
|
||||
],
|
||||
});
|
||||
expect(result?.persistentDamage).toEqual([
|
||||
{ type: "fire", formula: "2d6" },
|
||||
{ type: "bleed", formula: "1d4" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters out invalid persistent damage entries", () => {
|
||||
const result = rehydrateCombatant({
|
||||
...minimalCombatant(),
|
||||
persistentDamage: [
|
||||
{ type: "fire", formula: "2d6" },
|
||||
{ type: "radiant", formula: "1d4" },
|
||||
{ type: "bleed", formula: "" },
|
||||
{ type: "acid" },
|
||||
{ formula: "1d6" },
|
||||
],
|
||||
});
|
||||
expect(result?.persistentDamage).toEqual([
|
||||
{ type: "fire", formula: "2d6" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns undefined persistentDamage for non-array value", () => {
|
||||
const result = rehydrateCombatant({
|
||||
...minimalCombatant(),
|
||||
persistentDamage: "fire",
|
||||
});
|
||||
expect(result?.persistentDamage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined persistentDamage for empty array", () => {
|
||||
const result = rehydrateCombatant({
|
||||
...minimalCombatant(),
|
||||
persistentDamage: [],
|
||||
});
|
||||
expect(result?.persistentDamage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops invalid tempHp — keeps combatant", () => {
|
||||
for (const tempHp of [-1, 1.5, "3"]) {
|
||||
const result = rehydrateCombatant({
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { HpRange, HpVariant } from "../hp-range.js";
|
||||
import { setHpVariant } from "../set-hp-variant.js";
|
||||
import type { Combatant, Encounter } from "../types.js";
|
||||
import { combatantId, isDomainError } from "../types.js";
|
||||
import { expectDomainError } from "./test-helpers.js";
|
||||
|
||||
// A 3d6 + 6 creature: 9 / 16 / 24.
|
||||
const RANGE: HpRange = { min: 9, average: 16, max: 24 };
|
||||
|
||||
function makeCombatant(opts?: Partial<Combatant>): Combatant {
|
||||
return {
|
||||
id: combatantId("c-1"),
|
||||
name: "Ogre",
|
||||
maxHp: 16,
|
||||
currentHp: 16,
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
|
||||
function enc(combatants: Combatant[]): Encounter {
|
||||
return { combatants, activeIndex: 0, roundNumber: 1 };
|
||||
}
|
||||
|
||||
function apply(
|
||||
encounter: Encounter,
|
||||
variant: HpVariant | undefined,
|
||||
range: HpRange = RANGE,
|
||||
) {
|
||||
const result = setHpVariant(encounter, combatantId("c-1"), variant, range);
|
||||
if (isDomainError(result)) {
|
||||
throw new Error(`Expected success, got error: ${result.message}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("setHpVariant", () => {
|
||||
describe("acceptance scenarios", () => {
|
||||
it("average → max raises maxHp and currentHp to the maximum roll", () => {
|
||||
const { encounter } = apply(enc([makeCombatant()]), "max");
|
||||
const c = encounter.combatants[0];
|
||||
expect(c.maxHp).toBe(24);
|
||||
expect(c.currentHp).toBe(24);
|
||||
expect(c.hpVariant).toBe("max");
|
||||
});
|
||||
|
||||
it("average → min lowers maxHp and currentHp to the minimum roll", () => {
|
||||
const { encounter } = apply(enc([makeCombatant()]), "min");
|
||||
const c = encounter.combatants[0];
|
||||
expect(c.maxHp).toBe(9);
|
||||
expect(c.currentHp).toBe(9);
|
||||
expect(c.hpVariant).toBe("min");
|
||||
});
|
||||
|
||||
it("max → average restores the printed average", () => {
|
||||
const start = enc([
|
||||
makeCombatant({ maxHp: 24, currentHp: 24, hpVariant: "max" }),
|
||||
]);
|
||||
const { encounter } = apply(start, undefined);
|
||||
const c = encounter.combatants[0];
|
||||
expect(c.maxHp).toBe(16);
|
||||
expect(c.currentHp).toBe(16);
|
||||
expect(c.hpVariant).toBeUndefined();
|
||||
});
|
||||
|
||||
it("min → max spans the full range in one step", () => {
|
||||
const start = enc([
|
||||
makeCombatant({ maxHp: 9, currentHp: 9, hpVariant: "min" }),
|
||||
]);
|
||||
const { encounter } = apply(start, "max");
|
||||
expect(encounter.combatants[0].maxHp).toBe(24);
|
||||
});
|
||||
});
|
||||
|
||||
describe("damage already taken", () => {
|
||||
it("keeps the amount of damage taken when raising max HP", () => {
|
||||
// 5 damage taken: 11/16 → 19/24
|
||||
const start = enc([makeCombatant({ maxHp: 16, currentHp: 11 })]);
|
||||
const { encounter } = apply(start, "max");
|
||||
const c = encounter.combatants[0];
|
||||
expect(c.maxHp).toBe(24);
|
||||
expect(c.currentHp).toBe(19);
|
||||
});
|
||||
|
||||
it("floors currentHp at 0 when lowering max HP past the damage taken", () => {
|
||||
const start = enc([makeCombatant({ maxHp: 16, currentHp: 3 })]);
|
||||
const { encounter } = apply(start, "min");
|
||||
const c = encounter.combatants[0];
|
||||
expect(c.maxHp).toBe(9);
|
||||
expect(c.currentHp).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps currentHp to the new maxHp", () => {
|
||||
const start = enc([makeCombatant({ maxHp: 100, currentHp: 100 })]);
|
||||
const { encounter } = apply(start, "min");
|
||||
const c = encounter.combatants[0];
|
||||
expect(c.maxHp).toBe(93);
|
||||
expect(c.currentHp).toBe(93);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("preserves a manual max HP edit as an offset", () => {
|
||||
// User bumped the ogre to 20 HP; max is 8 above average.
|
||||
const start = enc([makeCombatant({ maxHp: 20, currentHp: 20 })]);
|
||||
const { encounter } = apply(start, "max");
|
||||
expect(encounter.combatants[0].maxHp).toBe(28);
|
||||
});
|
||||
|
||||
it("never lets maxHp drop below 1", () => {
|
||||
const start = enc([makeCombatant({ maxHp: 2, currentHp: 2 })]);
|
||||
const { encounter } = apply(start, "min");
|
||||
expect(encounter.combatants[0].maxHp).toBe(1);
|
||||
});
|
||||
|
||||
it("leaves a combatant without HP untouched but records the variant", () => {
|
||||
const start = enc([
|
||||
makeCombatant({ maxHp: undefined, currentHp: undefined }),
|
||||
]);
|
||||
const { encounter } = apply(start, "max");
|
||||
const c = encounter.combatants[0];
|
||||
expect(c.maxHp).toBeUndefined();
|
||||
expect(c.currentHp).toBeUndefined();
|
||||
expect(c.hpVariant).toBe("max");
|
||||
});
|
||||
|
||||
it("is a no-op when the variant is already active", () => {
|
||||
const start = enc([
|
||||
makeCombatant({ maxHp: 24, currentHp: 20, hpVariant: "max" }),
|
||||
]);
|
||||
const { encounter, events } = apply(start, "max");
|
||||
expect(encounter.combatants[0].currentHp).toBe(20);
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns a domain error for an unknown combatant", () => {
|
||||
const result = setHpVariant(
|
||||
enc([makeCombatant()]),
|
||||
combatantId("c-99"),
|
||||
"max",
|
||||
RANGE,
|
||||
);
|
||||
expectDomainError(result, "combatant-not-found");
|
||||
});
|
||||
});
|
||||
|
||||
it("emits an HpVariantSet event carrying the HP change", () => {
|
||||
const { events } = apply(enc([makeCombatant()]), "max");
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "HpVariantSet",
|
||||
combatantId: combatantId("c-1"),
|
||||
variant: "max",
|
||||
previousMaxHp: 16,
|
||||
newMaxHp: 24,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -60,13 +60,13 @@ describe("toggleCondition", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("maintains definition order when adding conditions", () => {
|
||||
it("appends new conditions to the end (insertion order)", () => {
|
||||
const e = enc([makeCombatant("A", [{ id: "poisoned" }])]);
|
||||
const { encounter } = success(e, "A", "blinded");
|
||||
|
||||
expect(encounter.combatants[0].conditions).toEqual([
|
||||
{ id: "blinded" },
|
||||
{ id: "poisoned" },
|
||||
{ id: "blinded" },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -109,15 +109,16 @@ describe("toggleCondition", () => {
|
||||
expect(encounter.combatants[0].conditions).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves order across all conditions", () => {
|
||||
it("preserves insertion order across all conditions", () => {
|
||||
const order = CONDITION_DEFINITIONS.map((d) => d.id);
|
||||
// Add in reverse order
|
||||
// Add in reverse order — result should be reverse order (insertion order)
|
||||
const reversed = [...order].reverse();
|
||||
let e = enc([makeCombatant("A")]);
|
||||
for (const cond of [...order].reverse()) {
|
||||
for (const cond of reversed) {
|
||||
const result = success(e, "A", cond);
|
||||
e = result.encounter;
|
||||
}
|
||||
expect(e.combatants[0].conditions).toEqual(order.map((id) => ({ id })));
|
||||
expect(e.combatants[0].conditions).toEqual(reversed.map((id) => ({ id })));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface CombatantInit {
|
||||
readonly ac?: number;
|
||||
readonly initiative?: number;
|
||||
readonly creatureId?: CreatureId;
|
||||
readonly creatureAdjustment?: "weak" | "elite";
|
||||
readonly color?: string;
|
||||
readonly icon?: string;
|
||||
readonly playerCharacterId?: PlayerCharacterId;
|
||||
@@ -67,6 +68,9 @@ function buildCombatant(
|
||||
...(init?.ac !== undefined && { ac: init.ac }),
|
||||
...(init?.initiative !== undefined && { initiative: init.initiative }),
|
||||
...(init?.creatureId !== undefined && { creatureId: init.creatureId }),
|
||||
...(init?.creatureAdjustment !== undefined && {
|
||||
creatureAdjustment: init.creatureAdjustment,
|
||||
}),
|
||||
...(init?.color !== undefined && { color: init.color }),
|
||||
...(init?.icon !== undefined && { icon: init.icon }),
|
||||
...(init?.playerCharacterId !== undefined && {
|
||||
|
||||
@@ -500,8 +500,8 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
|
||||
description5e: "",
|
||||
descriptionPf2e:
|
||||
"Location unknown. Must pick a square to target; DC 11 flat check. Attacker is off-guard against your attacks.",
|
||||
iconName: "Ghost",
|
||||
color: "violet",
|
||||
iconName: "EyeClosed",
|
||||
color: "slate",
|
||||
systems: ["pf2e"],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface TraitBlock {
|
||||
readonly name: string;
|
||||
readonly activity?: ActivityCost;
|
||||
readonly trigger?: string;
|
||||
readonly frequency?: string;
|
||||
readonly segments: readonly TraitSegment[];
|
||||
}
|
||||
|
||||
@@ -86,6 +87,19 @@ export interface SpellReference {
|
||||
readonly usesPerDay?: number;
|
||||
}
|
||||
|
||||
/** A carried equipment item on a PF2e creature (weapon, consumable, magic item, etc.). */
|
||||
export interface EquipmentItem {
|
||||
readonly name: string;
|
||||
readonly level: number;
|
||||
readonly category?: string;
|
||||
readonly traits?: readonly string[];
|
||||
readonly description?: string;
|
||||
/** For scrolls/wands: the embedded spell name. */
|
||||
readonly spellName?: string;
|
||||
/** For scrolls/wands: the embedded spell rank. */
|
||||
readonly spellRank?: number;
|
||||
}
|
||||
|
||||
export interface DailySpells {
|
||||
readonly uses: number;
|
||||
readonly each: boolean;
|
||||
@@ -172,6 +186,7 @@ export interface Pf2eCreature {
|
||||
readonly level: number;
|
||||
readonly traits: readonly string[];
|
||||
readonly perception: number;
|
||||
readonly perceptionDetails?: string;
|
||||
readonly senses?: string;
|
||||
readonly languages?: string;
|
||||
readonly skills?: string;
|
||||
@@ -201,6 +216,7 @@ export interface Pf2eCreature {
|
||||
readonly abilitiesMid?: readonly TraitBlock[];
|
||||
readonly abilitiesBot?: readonly TraitBlock[];
|
||||
readonly spellcasting?: readonly SpellcastingBlock[];
|
||||
readonly equipment?: readonly EquipmentItem[];
|
||||
}
|
||||
|
||||
export type AnyCreature = Creature | Pf2eCreature;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { RulesEdition } from "./rules-edition.js";
|
||||
|
||||
/** Abstract difficulty severity: 0 = negligible, 3 = maximum. Maps to filled bar count. */
|
||||
export type DifficultyTier = 0 | 1 | 2 | 3;
|
||||
/** Abstract difficulty severity: 0 = negligible, up to 4 (PF2e Extreme). Maps to filled bar count. */
|
||||
export type DifficultyTier = 0 | 1 | 2 | 3 | 4;
|
||||
|
||||
export interface DifficultyThreshold {
|
||||
readonly label: string;
|
||||
@@ -18,6 +18,8 @@ export interface DifficultyResult {
|
||||
readonly adjustedXp: number | undefined;
|
||||
/** 2014 only: true when the multiplier was shifted due to party size (<3 or 6+). */
|
||||
readonly partySizeAdjusted: boolean | undefined;
|
||||
/** PF2e only: the derived party level used for XP calculation. */
|
||||
readonly partyLevel: number | undefined;
|
||||
}
|
||||
|
||||
/** Maps challenge rating strings to XP values (standard 5e). */
|
||||
@@ -160,6 +162,143 @@ function getEncounterMultiplier(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PF2e: XP granted by a creature based on its level relative to party level.
|
||||
* Key is (creature level − party level), clamped to [−4, +4].
|
||||
*/
|
||||
const PF2E_LEVEL_DIFF_XP: Readonly<Record<number, number>> = {
|
||||
[-4]: 10,
|
||||
[-3]: 15,
|
||||
[-2]: 20,
|
||||
[-1]: 30,
|
||||
0: 40,
|
||||
1: 60,
|
||||
2: 80,
|
||||
3: 120,
|
||||
4: 160,
|
||||
};
|
||||
|
||||
/** PF2e base encounter budget thresholds for a party of 4. */
|
||||
const PF2E_THRESHOLDS_BASE = {
|
||||
trivial: 40,
|
||||
low: 60,
|
||||
moderate: 80,
|
||||
severe: 120,
|
||||
extreme: 160,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* PF2e per-PC adjustment to each threshold (added per PC beyond 4, subtracted
|
||||
* per PC fewer). GM Core remaster values — Low is 20 there, not the
|
||||
* pre-remaster CRB's 15.
|
||||
*/
|
||||
const PF2E_THRESHOLD_ADJUSTMENTS = {
|
||||
trivial: 10,
|
||||
low: 20,
|
||||
moderate: 20,
|
||||
severe: 30,
|
||||
extreme: 40,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Derives PF2e party level from PC levels.
|
||||
* Returns the mode (most common level). If no unique mode, returns
|
||||
* the average rounded to the nearest integer.
|
||||
*/
|
||||
export function derivePartyLevel(levels: readonly number[]): number {
|
||||
if (levels.length === 0) return 0;
|
||||
if (levels.length === 1) return levels[0];
|
||||
|
||||
const counts = new Map<number, number>();
|
||||
for (const l of levels) {
|
||||
counts.set(l, (counts.get(l) ?? 0) + 1);
|
||||
}
|
||||
|
||||
let maxCount = 0;
|
||||
let mode: number | undefined;
|
||||
let isTied = false;
|
||||
|
||||
for (const [level, count] of counts) {
|
||||
if (count > maxCount) {
|
||||
maxCount = count;
|
||||
mode = level;
|
||||
isTied = false;
|
||||
} else if (count === maxCount) {
|
||||
isTied = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isTied && mode !== undefined) return mode;
|
||||
|
||||
const sum = levels.reduce((a, b) => a + b, 0);
|
||||
return Math.round(sum / levels.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns PF2e XP for a creature given its level and the party level.
|
||||
* Creatures more than 4 levels below the party have no row in the GM Core
|
||||
* table and pose no meaningful threat — they are worth 0 XP. Creatures more
|
||||
* than 4 levels above are counted as the +4 row (160 XP).
|
||||
*/
|
||||
export function pf2eCreatureXp(
|
||||
creatureLevel: number,
|
||||
partyLevel: number,
|
||||
): number {
|
||||
const diff = creatureLevel - partyLevel;
|
||||
if (diff < -4) return 0;
|
||||
return PF2E_LEVEL_DIFF_XP[Math.min(4, diff)] ?? 0;
|
||||
}
|
||||
|
||||
function calculatePf2eBudget(partySize: number) {
|
||||
const adjustment = partySize - 4;
|
||||
return {
|
||||
trivial: Math.max(
|
||||
0,
|
||||
PF2E_THRESHOLDS_BASE.trivial +
|
||||
adjustment * PF2E_THRESHOLD_ADJUSTMENTS.trivial,
|
||||
),
|
||||
low: Math.max(
|
||||
0,
|
||||
PF2E_THRESHOLDS_BASE.low + adjustment * PF2E_THRESHOLD_ADJUSTMENTS.low,
|
||||
),
|
||||
moderate: Math.max(
|
||||
0,
|
||||
PF2E_THRESHOLDS_BASE.moderate +
|
||||
adjustment * PF2E_THRESHOLD_ADJUSTMENTS.moderate,
|
||||
),
|
||||
severe: Math.max(
|
||||
0,
|
||||
PF2E_THRESHOLDS_BASE.severe +
|
||||
adjustment * PF2E_THRESHOLD_ADJUSTMENTS.severe,
|
||||
),
|
||||
extreme: Math.max(
|
||||
0,
|
||||
PF2E_THRESHOLDS_BASE.extreme +
|
||||
adjustment * PF2E_THRESHOLD_ADJUSTMENTS.extreme,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function scanCombatantsPf2e(
|
||||
combatants: readonly CombatantDescriptor[],
|
||||
partyLevel: number,
|
||||
) {
|
||||
let totalCreatureXp = 0;
|
||||
|
||||
for (const c of combatants) {
|
||||
if (c.creatureLevel !== undefined) {
|
||||
const xp = pf2eCreatureXp(c.creatureLevel, partyLevel);
|
||||
if (c.side === "enemy") {
|
||||
totalCreatureXp += xp;
|
||||
} else {
|
||||
totalCreatureXp -= xp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { totalCreatureXp: Math.max(0, totalCreatureXp) };
|
||||
}
|
||||
|
||||
/** All standard 5e challenge rating strings, in ascending order. */
|
||||
export const VALID_CR_VALUES: readonly string[] = Object.keys(CR_TO_XP);
|
||||
|
||||
@@ -171,10 +310,15 @@ export function crToXp(cr: string): number {
|
||||
export interface CombatantDescriptor {
|
||||
readonly level?: number;
|
||||
readonly cr?: string;
|
||||
readonly creatureLevel?: number;
|
||||
readonly side: "party" | "enemy";
|
||||
}
|
||||
|
||||
function determineTier(
|
||||
/**
|
||||
* 2014 DMG: thresholds are floors — "the closest threshold that is lower
|
||||
* than the adjusted XP value determines the encounter's difficulty".
|
||||
*/
|
||||
function tierFromThresholds(
|
||||
xp: number,
|
||||
tierThresholds: readonly number[],
|
||||
): DifficultyTier {
|
||||
@@ -184,6 +328,23 @@ function determineTier(
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 2024 DMG / PF2e: budgets are ceilings — an encounter belongs to the lowest
|
||||
* tier whose budget it does not exceed ("spend as much of your XP budget as
|
||||
* you can without going over"; PF2e Trivial is "40 XP or less"). XP above the
|
||||
* last budget falls into the tier beyond it (5.5e High / PF2e Extreme have no
|
||||
* upper bound).
|
||||
*/
|
||||
function tierFromBudgets(
|
||||
xp: number,
|
||||
budgets: readonly number[],
|
||||
): DifficultyTier {
|
||||
for (let i = 0; i < budgets.length; i++) {
|
||||
if (xp <= budgets[i]) return i as DifficultyTier;
|
||||
}
|
||||
return budgets.length as DifficultyTier;
|
||||
}
|
||||
|
||||
function accumulateBudget5_5e(levels: readonly number[]) {
|
||||
const budget = { low: 0, moderate: 0, high: 0 };
|
||||
for (const level of levels) {
|
||||
@@ -247,6 +408,41 @@ export function calculateEncounterDifficulty(
|
||||
combatants: readonly CombatantDescriptor[],
|
||||
edition: RulesEdition,
|
||||
): DifficultyResult {
|
||||
if (edition === "pf2e") {
|
||||
const partyLevels: number[] = [];
|
||||
for (const c of combatants) {
|
||||
if (c.level !== undefined && c.side === "party") {
|
||||
partyLevels.push(c.level);
|
||||
}
|
||||
}
|
||||
|
||||
const partyLevel = derivePartyLevel(partyLevels);
|
||||
const { totalCreatureXp } = scanCombatantsPf2e(combatants, partyLevel);
|
||||
const budget = calculatePf2eBudget(partyLevels.length);
|
||||
const thresholds: DifficultyThreshold[] = [
|
||||
{ label: "Trivial", value: budget.trivial },
|
||||
{ label: "Low", value: budget.low },
|
||||
{ label: "Moderate", value: budget.moderate },
|
||||
{ label: "Severe", value: budget.severe },
|
||||
{ label: "Extreme", value: budget.extreme },
|
||||
];
|
||||
|
||||
return {
|
||||
tier: tierFromBudgets(totalCreatureXp, [
|
||||
budget.trivial,
|
||||
budget.low,
|
||||
budget.moderate,
|
||||
budget.severe,
|
||||
]),
|
||||
totalMonsterXp: totalCreatureXp,
|
||||
thresholds,
|
||||
encounterMultiplier: undefined,
|
||||
adjustedXp: undefined,
|
||||
partySizeAdjusted: undefined,
|
||||
partyLevel,
|
||||
};
|
||||
}
|
||||
|
||||
const { totalMonsterXp, monsterCount, partyLevels } =
|
||||
scanCombatants(combatants);
|
||||
|
||||
@@ -258,16 +454,15 @@ export function calculateEncounterDifficulty(
|
||||
{ label: "High", value: budget.high },
|
||||
];
|
||||
return {
|
||||
tier: determineTier(totalMonsterXp, [
|
||||
budget.low,
|
||||
budget.moderate,
|
||||
budget.high,
|
||||
]),
|
||||
// 5.5e has no tier below Low, so tier 0 (empty bars) only at 0 XP.
|
||||
// The High budget is display-only: anything over Moderate is High.
|
||||
tier: tierFromBudgets(totalMonsterXp, [0, budget.low, budget.moderate]),
|
||||
totalMonsterXp,
|
||||
thresholds,
|
||||
encounterMultiplier: undefined,
|
||||
adjustedXp: undefined,
|
||||
partySizeAdjusted: undefined,
|
||||
partyLevel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -284,7 +479,7 @@ export function calculateEncounterDifficulty(
|
||||
];
|
||||
|
||||
return {
|
||||
tier: determineTier(adjustedXp, [
|
||||
tier: tierFromThresholds(adjustedXp, [
|
||||
budget.medium,
|
||||
budget.hard,
|
||||
budget.deadly,
|
||||
@@ -294,5 +489,6 @@ export function calculateEncounterDifficulty(
|
||||
encounterMultiplier,
|
||||
adjustedXp,
|
||||
partySizeAdjusted,
|
||||
partyLevel: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ConditionId } from "./conditions.js";
|
||||
import type { CreatureId } from "./creature-types.js";
|
||||
import type { PersistentDamageType } from "./persistent-damage-types.js";
|
||||
import type { PlayerCharacterId } from "./player-character-types.js";
|
||||
import type { CombatantId } from "./types.js";
|
||||
|
||||
@@ -132,6 +133,33 @@ export interface ConcentrationEnded {
|
||||
readonly combatantId: CombatantId;
|
||||
}
|
||||
|
||||
export interface PersistentDamageAdded {
|
||||
readonly type: "PersistentDamageAdded";
|
||||
readonly combatantId: CombatantId;
|
||||
readonly damageType: PersistentDamageType;
|
||||
readonly formula: string;
|
||||
}
|
||||
|
||||
export interface PersistentDamageRemoved {
|
||||
readonly type: "PersistentDamageRemoved";
|
||||
readonly combatantId: CombatantId;
|
||||
readonly damageType: PersistentDamageType;
|
||||
}
|
||||
|
||||
export interface CreatureAdjustmentSet {
|
||||
readonly type: "CreatureAdjustmentSet";
|
||||
readonly combatantId: CombatantId;
|
||||
readonly adjustment: "weak" | "elite" | undefined;
|
||||
}
|
||||
|
||||
export interface HpVariantSet {
|
||||
readonly type: "HpVariantSet";
|
||||
readonly combatantId: CombatantId;
|
||||
readonly variant: "min" | "max" | undefined;
|
||||
readonly previousMaxHp: number | undefined;
|
||||
readonly newMaxHp: number | undefined;
|
||||
}
|
||||
|
||||
export interface EncounterCleared {
|
||||
readonly type: "EncounterCleared";
|
||||
readonly combatantCount: number;
|
||||
@@ -175,6 +203,10 @@ export type DomainEvent =
|
||||
| ConditionRemoved
|
||||
| ConcentrationStarted
|
||||
| ConcentrationEnded
|
||||
| PersistentDamageAdded
|
||||
| PersistentDamageRemoved
|
||||
| CreatureAdjustmentSet
|
||||
| HpVariantSet
|
||||
| EncounterCleared
|
||||
| PlayerCharacterCreated
|
||||
| PlayerCharacterUpdated
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/** Which end of a creature's Hit Dice range its max HP is taken from. */
|
||||
export type HpVariant = "min" | "max";
|
||||
|
||||
export const VALID_HP_VARIANTS: ReadonlySet<string> = new Set(["min", "max"]);
|
||||
|
||||
export interface HpRange {
|
||||
/** Every Hit Die rolls a 1 (never below 1 HP). */
|
||||
readonly min: number;
|
||||
/** The statblock's printed average. */
|
||||
readonly average: number;
|
||||
/** Every Hit Die rolls its maximum. */
|
||||
readonly max: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a plain Hit Dice pool: "9d8", "3d6 + 6", "2d6 - 2".
|
||||
* Bestiary data uses several dash characters, so hyphen, minus sign, en dash
|
||||
* and em dash all count as a negative modifier.
|
||||
*/
|
||||
const DICE_FORMULA_REGEX =
|
||||
/^\s*(\d+)\s*[dD]\s*(\d+)\s*(?:([-+−–—])\s*(\d+))?\s*$/;
|
||||
|
||||
/**
|
||||
* Derives the min/average/max HP of a creature from its Hit Dice formula.
|
||||
* Returns null when the formula is not a plain dice pool — some sources carry
|
||||
* prose (`hp.special`) or an empty string instead.
|
||||
*/
|
||||
export function hpRange(
|
||||
hp: Readonly<{ average: number; formula: string }>,
|
||||
): HpRange | null {
|
||||
const match = DICE_FORMULA_REGEX.exec(hp.formula);
|
||||
if (!match) return null;
|
||||
|
||||
const count = Number(match[1]);
|
||||
const sides = Number(match[2]);
|
||||
if (count < 1 || sides < 1) return null;
|
||||
|
||||
const modifier =
|
||||
match[4] === undefined
|
||||
? 0
|
||||
: (match[3] === "+" ? 1 : -1) * Number.parseInt(match[4], 10);
|
||||
|
||||
return {
|
||||
min: Math.max(1, count + modifier),
|
||||
average: hp.average,
|
||||
max: Math.max(1, count * sides + modifier),
|
||||
};
|
||||
}
|
||||
|
||||
/** The HP value a variant selects; undefined means the printed average. */
|
||||
export function hpForVariant(
|
||||
range: HpRange,
|
||||
variant: HpVariant | undefined,
|
||||
): number {
|
||||
if (variant === "min") return range.min;
|
||||
if (variant === "max") return range.max;
|
||||
return range.average;
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export {
|
||||
type CreatureId,
|
||||
creatureId,
|
||||
type DailySpells,
|
||||
type EquipmentItem,
|
||||
type LegendaryBlock,
|
||||
type Pf2eBestiaryIndex,
|
||||
type Pf2eBestiaryIndexEntry,
|
||||
@@ -63,6 +64,8 @@ export {
|
||||
type DifficultyResult,
|
||||
type DifficultyThreshold,
|
||||
type DifficultyTier,
|
||||
derivePartyLevel,
|
||||
pf2eCreatureXp,
|
||||
VALID_CR_VALUES,
|
||||
} from "./encounter-difficulty.js";
|
||||
export type {
|
||||
@@ -74,12 +77,16 @@ export type {
|
||||
ConcentrationStarted,
|
||||
ConditionAdded,
|
||||
ConditionRemoved,
|
||||
CreatureAdjustmentSet,
|
||||
CrSet,
|
||||
CurrentHpAdjusted,
|
||||
DomainEvent,
|
||||
EncounterCleared,
|
||||
HpVariantSet,
|
||||
InitiativeSet,
|
||||
MaxHpSet,
|
||||
PersistentDamageAdded,
|
||||
PersistentDamageRemoved,
|
||||
PlayerCharacterCreated,
|
||||
PlayerCharacterDeleted,
|
||||
PlayerCharacterUpdated,
|
||||
@@ -91,6 +98,13 @@ export type {
|
||||
TurnRetreated,
|
||||
} from "./events.js";
|
||||
export type { ExportBundle } from "./export-bundle.js";
|
||||
export {
|
||||
type HpRange,
|
||||
type HpVariant,
|
||||
hpForVariant,
|
||||
hpRange,
|
||||
VALID_HP_VARIANTS,
|
||||
} from "./hp-range.js";
|
||||
export { deriveHpStatus, type HpStatus } from "./hp-status.js";
|
||||
export {
|
||||
calculateInitiative,
|
||||
@@ -98,6 +112,25 @@ export {
|
||||
formatInitiativeModifier,
|
||||
type InitiativeResult,
|
||||
} from "./initiative.js";
|
||||
export {
|
||||
addPersistentDamage,
|
||||
PERSISTENT_DAMAGE_DEFINITIONS,
|
||||
PERSISTENT_DAMAGE_TYPES,
|
||||
type PersistentDamageDefinition,
|
||||
type PersistentDamageEntry,
|
||||
type PersistentDamageSuccess,
|
||||
type PersistentDamageType,
|
||||
removePersistentDamage,
|
||||
VALID_PERSISTENT_DAMAGE_TYPES,
|
||||
} from "./persistent-damage.js";
|
||||
export {
|
||||
acDelta,
|
||||
adjustedLevel,
|
||||
applyPf2eAdjustment,
|
||||
type CreatureAdjustment,
|
||||
hpDelta,
|
||||
modDelta,
|
||||
} from "./pf2e-adjustments.js";
|
||||
export {
|
||||
type PlayerCharacter,
|
||||
type PlayerCharacterId,
|
||||
@@ -108,6 +141,10 @@ export {
|
||||
VALID_PLAYER_COLORS,
|
||||
VALID_PLAYER_ICONS,
|
||||
} from "./player-character-types.js";
|
||||
export {
|
||||
type RecallKnowledge,
|
||||
recallKnowledge,
|
||||
} from "./recall-knowledge.js";
|
||||
export { rehydrateCombatant } from "./rehydrate-combatant.js";
|
||||
export { rehydratePlayerCharacter } from "./rehydrate-player-character.js";
|
||||
export {
|
||||
@@ -124,6 +161,10 @@ export type { RulesEdition } from "./rules-edition.js";
|
||||
export { type SetAcSuccess, setAc } from "./set-ac.js";
|
||||
export { type SetCrSuccess, setCr } from "./set-cr.js";
|
||||
export { type SetHpSuccess, setHp } from "./set-hp.js";
|
||||
export {
|
||||
type SetHpVariantSuccess,
|
||||
setHpVariant,
|
||||
} from "./set-hp-variant.js";
|
||||
export {
|
||||
type SetInitiativeSuccess,
|
||||
setInitiative,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
export const PERSISTENT_DAMAGE_TYPES = [
|
||||
"fire",
|
||||
"bleed",
|
||||
"acid",
|
||||
"cold",
|
||||
"electricity",
|
||||
"poison",
|
||||
"mental",
|
||||
"force",
|
||||
"void",
|
||||
"spirit",
|
||||
"vitality",
|
||||
"piercing",
|
||||
] as const;
|
||||
|
||||
export type PersistentDamageType = (typeof PERSISTENT_DAMAGE_TYPES)[number];
|
||||
|
||||
export const VALID_PERSISTENT_DAMAGE_TYPES: ReadonlySet<string> = new Set(
|
||||
PERSISTENT_DAMAGE_TYPES,
|
||||
);
|
||||
|
||||
export interface PersistentDamageEntry {
|
||||
readonly type: PersistentDamageType;
|
||||
readonly formula: string;
|
||||
}
|
||||
|
||||
export interface PersistentDamageDefinition {
|
||||
readonly type: PersistentDamageType;
|
||||
readonly label: string;
|
||||
readonly iconName: string;
|
||||
readonly color: string;
|
||||
}
|
||||
|
||||
export const PERSISTENT_DAMAGE_DEFINITIONS: readonly PersistentDamageDefinition[] =
|
||||
[
|
||||
{ type: "fire", label: "Fire", iconName: "Flame", color: "orange" },
|
||||
{ type: "bleed", label: "Bleed", iconName: "Droplets", color: "red" },
|
||||
{
|
||||
type: "acid",
|
||||
label: "Acid",
|
||||
iconName: "FlaskConical",
|
||||
color: "lime",
|
||||
},
|
||||
{ type: "cold", label: "Cold", iconName: "Snowflake", color: "sky" },
|
||||
{
|
||||
type: "electricity",
|
||||
label: "Electricity",
|
||||
iconName: "Zap",
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
type: "poison",
|
||||
label: "Poison",
|
||||
iconName: "Droplet",
|
||||
color: "green",
|
||||
},
|
||||
{
|
||||
type: "mental",
|
||||
label: "Mental",
|
||||
iconName: "BrainCog",
|
||||
color: "pink",
|
||||
},
|
||||
{ type: "force", label: "Force", iconName: "Orbit", color: "indigo" },
|
||||
{ type: "void", label: "Void", iconName: "Eclipse", color: "purple" },
|
||||
{ type: "spirit", label: "Spirit", iconName: "Wind", color: "neutral" },
|
||||
{
|
||||
type: "vitality",
|
||||
label: "Vitality",
|
||||
iconName: "Sparkle",
|
||||
color: "amber",
|
||||
},
|
||||
{
|
||||
type: "piercing",
|
||||
label: "Piercing",
|
||||
iconName: "Sword",
|
||||
color: "neutral",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { DomainEvent } from "./events.js";
|
||||
import {
|
||||
PERSISTENT_DAMAGE_DEFINITIONS,
|
||||
type PersistentDamageEntry,
|
||||
type PersistentDamageType,
|
||||
VALID_PERSISTENT_DAMAGE_TYPES,
|
||||
} from "./persistent-damage-types.js";
|
||||
import {
|
||||
type CombatantId,
|
||||
type DomainError,
|
||||
type Encounter,
|
||||
findCombatant,
|
||||
isDomainError,
|
||||
} from "./types.js";
|
||||
|
||||
export {
|
||||
PERSISTENT_DAMAGE_DEFINITIONS,
|
||||
PERSISTENT_DAMAGE_TYPES,
|
||||
type PersistentDamageDefinition,
|
||||
type PersistentDamageEntry,
|
||||
type PersistentDamageType,
|
||||
VALID_PERSISTENT_DAMAGE_TYPES,
|
||||
} from "./persistent-damage-types.js";
|
||||
|
||||
export interface PersistentDamageSuccess {
|
||||
readonly encounter: Encounter;
|
||||
readonly events: DomainEvent[];
|
||||
}
|
||||
|
||||
function applyPersistentDamage(
|
||||
encounter: Encounter,
|
||||
combatantId: CombatantId,
|
||||
newEntries: readonly PersistentDamageEntry[] | undefined,
|
||||
): Encounter {
|
||||
return {
|
||||
combatants: encounter.combatants.map((c) =>
|
||||
c.id === combatantId ? { ...c, persistentDamage: newEntries } : c,
|
||||
),
|
||||
activeIndex: encounter.activeIndex,
|
||||
roundNumber: encounter.roundNumber,
|
||||
};
|
||||
}
|
||||
|
||||
export function addPersistentDamage(
|
||||
encounter: Encounter,
|
||||
combatantId: CombatantId,
|
||||
damageType: PersistentDamageType,
|
||||
formula: string,
|
||||
): PersistentDamageSuccess | DomainError {
|
||||
if (!VALID_PERSISTENT_DAMAGE_TYPES.has(damageType)) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "unknown-damage-type",
|
||||
message: `Unknown persistent damage type "${damageType}"`,
|
||||
};
|
||||
}
|
||||
if (formula.trim().length === 0) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "empty-formula",
|
||||
message: "Persistent damage formula must not be empty",
|
||||
};
|
||||
}
|
||||
|
||||
const found = findCombatant(encounter, combatantId);
|
||||
if (isDomainError(found)) return found;
|
||||
const { combatant: target } = found;
|
||||
const current = target.persistentDamage ?? [];
|
||||
|
||||
// Replace existing entry of same type, or append
|
||||
const filtered = current.filter((e) => e.type !== damageType);
|
||||
const newEntries = [
|
||||
...filtered,
|
||||
{ type: damageType, formula: formula.trim() },
|
||||
];
|
||||
|
||||
// Sort by definition order
|
||||
const order = PERSISTENT_DAMAGE_DEFINITIONS.map((d) => d.type);
|
||||
newEntries.sort((a, b) => order.indexOf(a.type) - order.indexOf(b.type));
|
||||
|
||||
return {
|
||||
encounter: applyPersistentDamage(encounter, combatantId, newEntries),
|
||||
events: [
|
||||
{
|
||||
type: "PersistentDamageAdded",
|
||||
combatantId,
|
||||
damageType,
|
||||
formula: formula.trim(),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function removePersistentDamage(
|
||||
encounter: Encounter,
|
||||
combatantId: CombatantId,
|
||||
damageType: PersistentDamageType,
|
||||
): PersistentDamageSuccess | DomainError {
|
||||
const found = findCombatant(encounter, combatantId);
|
||||
if (isDomainError(found)) return found;
|
||||
const { combatant: target } = found;
|
||||
const current = target.persistentDamage ?? [];
|
||||
|
||||
if (!current.some((e) => e.type === damageType)) {
|
||||
return {
|
||||
kind: "domain-error",
|
||||
code: "persistent-damage-not-active",
|
||||
message: `Persistent ${damageType} damage is not active`,
|
||||
};
|
||||
}
|
||||
|
||||
const filtered = current.filter((e) => e.type !== damageType);
|
||||
return {
|
||||
encounter: applyPersistentDamage(
|
||||
encounter,
|
||||
combatantId,
|
||||
filtered.length > 0 ? filtered : undefined,
|
||||
),
|
||||
events: [{ type: "PersistentDamageRemoved", combatantId, damageType }],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type {
|
||||
Pf2eCreature,
|
||||
TraitBlock,
|
||||
TraitSegment,
|
||||
} from "./creature-types.js";
|
||||
|
||||
export type CreatureAdjustment = "weak" | "elite";
|
||||
|
||||
/** HP bracket delta by creature level (standard PF2e table). */
|
||||
function hpBracketDelta(level: number): number {
|
||||
if (level <= 1) return 10;
|
||||
if (level <= 4) return 15;
|
||||
if (level <= 19) return 20;
|
||||
return 30;
|
||||
}
|
||||
|
||||
/** Level shift: elite +1 (or +2 if level ≤ 0), weak −1 (or −2 if level is 1). */
|
||||
export function adjustedLevel(
|
||||
baseLevel: number,
|
||||
adjustment: CreatureAdjustment,
|
||||
): number {
|
||||
if (adjustment === "elite") {
|
||||
return baseLevel <= 0 ? baseLevel + 2 : baseLevel + 1;
|
||||
}
|
||||
return baseLevel === 1 ? baseLevel - 2 : baseLevel - 1;
|
||||
}
|
||||
|
||||
/** Signed HP delta for a given base level and adjustment. */
|
||||
export function hpDelta(
|
||||
baseLevel: number,
|
||||
adjustment: CreatureAdjustment,
|
||||
): number {
|
||||
const delta = hpBracketDelta(baseLevel);
|
||||
return adjustment === "elite" ? delta : -delta;
|
||||
}
|
||||
|
||||
/** AC delta: +2 for elite, −2 for weak. */
|
||||
export function acDelta(adjustment: CreatureAdjustment): number {
|
||||
return adjustment === "elite" ? 2 : -2;
|
||||
}
|
||||
|
||||
/** Generic ±2 modifier delta. Used for saves, Perception, attacks, damage. */
|
||||
export function modDelta(adjustment: CreatureAdjustment): number {
|
||||
return adjustment === "elite" ? 2 : -2;
|
||||
}
|
||||
|
||||
const ATTACK_BONUS_RE = /^([+-])(\d+)/;
|
||||
const MAP_RE = /\[([+-]\d+)\/([+-]\d+)\]/g;
|
||||
const DAMAGE_BONUS_RE = /(\d+d\d+)([+-])(\d+)/g;
|
||||
|
||||
/**
|
||||
* Adjust attack bonus in a formatted attack string.
|
||||
* "+15 (agile), 2d12+7 piercing plus Grab" → "+17 (agile), 2d12+9 piercing plus Grab"
|
||||
*/
|
||||
function adjustAttackText(text: string, delta: number): string {
|
||||
// Adjust leading attack bonus: "+15" → "+17"
|
||||
let result = text.replace(ATTACK_BONUS_RE, (_, sign, num) => {
|
||||
const adjusted = (sign === "+" ? 1 : -1) * Number(num) + delta;
|
||||
return adjusted >= 0 ? `+${adjusted}` : `${adjusted}`;
|
||||
});
|
||||
// Adjust MAP values in brackets: "[+10/+5]" → "[+12/+7]"
|
||||
result = result.replace(MAP_RE, (_, m1, m2) => {
|
||||
const a1 = Number(m1) + delta;
|
||||
const a2 = Number(m2) + delta;
|
||||
const f = (n: number) => (n >= 0 ? `+${n}` : `${n}`);
|
||||
return `[${f(a1)}/${f(a2)}]`;
|
||||
});
|
||||
// Adjust damage bonus in "NdN+N type" patterns
|
||||
result = result.replace(DAMAGE_BONUS_RE, (_, dice, sign, num) => {
|
||||
const current = (sign === "+" ? 1 : -1) * Number(num);
|
||||
const adjusted = current + delta;
|
||||
if (adjusted === 0) return dice as string;
|
||||
return adjusted > 0 ? `${dice}+${adjusted}` : `${dice}${adjusted}`;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function adjustTraitBlock(block: TraitBlock, delta: number): TraitBlock {
|
||||
return {
|
||||
...block,
|
||||
segments: block.segments.map(
|
||||
(seg): TraitSegment =>
|
||||
seg.type === "text"
|
||||
? { type: "text", value: adjustAttackText(seg.value, delta) }
|
||||
: seg,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a weak or elite adjustment to a full PF2e creature.
|
||||
* Returns a new Pf2eCreature with all numeric stats adjusted.
|
||||
*/
|
||||
export function applyPf2eAdjustment(
|
||||
creature: Pf2eCreature,
|
||||
adjustment: CreatureAdjustment,
|
||||
): Pf2eCreature {
|
||||
const d = modDelta(adjustment);
|
||||
return {
|
||||
...creature,
|
||||
level: adjustedLevel(creature.level, adjustment),
|
||||
ac: creature.ac + d,
|
||||
hp: creature.hp + hpDelta(creature.level, adjustment),
|
||||
perception: creature.perception + d,
|
||||
saveFort: creature.saveFort + d,
|
||||
saveRef: creature.saveRef + d,
|
||||
saveWill: creature.saveWill + d,
|
||||
attacks: creature.attacks?.map((a) => adjustTraitBlock(a, d)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* PF2e Recall Knowledge DC calculation and type-to-skill mapping.
|
||||
*
|
||||
* DC is derived from creature level using the standard DC-by-level table
|
||||
* (Player Core / GM Core), adjusted for rarity.
|
||||
*/
|
||||
|
||||
/** Standard DC-by-level table from PF2e GM Core. Index = level + 1 (level -1 → index 0). */
|
||||
const DC_BY_LEVEL: readonly number[] = [
|
||||
13, // level -1
|
||||
14, // level 0
|
||||
15, // level 1
|
||||
16, // level 2
|
||||
18, // level 3
|
||||
19, // level 4
|
||||
20, // level 5
|
||||
22, // level 6
|
||||
23, // level 7
|
||||
24, // level 8
|
||||
26, // level 9
|
||||
27, // level 10
|
||||
28, // level 11
|
||||
30, // level 12
|
||||
31, // level 13
|
||||
32, // level 14
|
||||
34, // level 15
|
||||
35, // level 16
|
||||
36, // level 17
|
||||
38, // level 18
|
||||
39, // level 19
|
||||
40, // level 20
|
||||
42, // level 21
|
||||
44, // level 22
|
||||
46, // level 23
|
||||
48, // level 24
|
||||
50, // level 25
|
||||
];
|
||||
|
||||
const RARITY_ADJUSTMENT: Readonly<Record<string, number>> = {
|
||||
uncommon: 2,
|
||||
rare: 5,
|
||||
unique: 10,
|
||||
};
|
||||
|
||||
/**
|
||||
* Mapping from PF2e creature type traits to the skill(s) used for
|
||||
* Recall Knowledge. Types that map to multiple skills list all of them.
|
||||
*/
|
||||
const TYPE_TO_SKILLS: Readonly<Record<string, readonly string[]>> = {
|
||||
aberration: ["Occultism"],
|
||||
animal: ["Nature"],
|
||||
astral: ["Occultism"],
|
||||
beast: ["Arcana", "Nature"],
|
||||
celestial: ["Religion"],
|
||||
construct: ["Arcana", "Crafting"],
|
||||
dragon: ["Arcana"],
|
||||
dream: ["Occultism"],
|
||||
elemental: ["Arcana", "Nature"],
|
||||
ethereal: ["Occultism"],
|
||||
fey: ["Nature"],
|
||||
fiend: ["Religion"],
|
||||
fungus: ["Nature"],
|
||||
giant: ["Society"],
|
||||
humanoid: ["Society"],
|
||||
monitor: ["Religion"],
|
||||
ooze: ["Occultism"],
|
||||
plant: ["Nature"],
|
||||
undead: ["Religion"],
|
||||
};
|
||||
|
||||
export interface RecallKnowledge {
|
||||
readonly dc: number;
|
||||
readonly type: string;
|
||||
readonly skills: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Recall Knowledge DC, type, and skill(s) for a PF2e creature.
|
||||
*
|
||||
* Returns `null` when no recognized type trait is found in the creature's
|
||||
* traits array, indicating the Recall Knowledge line should be omitted.
|
||||
*/
|
||||
export function recallKnowledge(
|
||||
level: number,
|
||||
traits: readonly string[],
|
||||
): RecallKnowledge | null {
|
||||
// Find the first type trait that maps to a skill
|
||||
let matchedType: string | undefined;
|
||||
let skills: readonly string[] | undefined;
|
||||
|
||||
for (const trait of traits) {
|
||||
const lower = trait.toLowerCase();
|
||||
const mapped = TYPE_TO_SKILLS[lower];
|
||||
if (mapped) {
|
||||
matchedType = trait;
|
||||
skills = mapped;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedType || !skills) return null;
|
||||
|
||||
// Calculate DC from level
|
||||
const clampedIndex = Math.max(0, Math.min(level + 1, DC_BY_LEVEL.length - 1));
|
||||
let dc = DC_BY_LEVEL[clampedIndex];
|
||||
|
||||
// Apply rarity adjustment (rarity traits are included in the traits array
|
||||
// for non-common creatures by the normalization pipeline)
|
||||
for (const trait of traits) {
|
||||
const adjustment = RARITY_ADJUSTMENT[trait.toLowerCase()];
|
||||
if (adjustment) {
|
||||
dc += adjustment;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { dc, type: matchedType, skills };
|
||||
}
|
||||
@@ -2,6 +2,10 @@ import type { ConditionEntry, ConditionId } from "./conditions.js";
|
||||
import { VALID_CONDITION_IDS } from "./conditions.js";
|
||||
import { creatureId } from "./creature-types.js";
|
||||
import { VALID_CR_VALUES } from "./encounter-difficulty.js";
|
||||
import type { HpVariant } from "./hp-range.js";
|
||||
import { VALID_HP_VARIANTS } from "./hp-range.js";
|
||||
import type { PersistentDamageEntry } from "./persistent-damage-types.js";
|
||||
import { VALID_PERSISTENT_DAMAGE_TYPES } from "./persistent-damage-types.js";
|
||||
import {
|
||||
playerCharacterId,
|
||||
VALID_PLAYER_COLORS,
|
||||
@@ -42,6 +46,32 @@ function validateConditions(value: unknown): ConditionEntry[] | undefined {
|
||||
return entries.length > 0 ? entries : undefined;
|
||||
}
|
||||
|
||||
function validatePersistentDamage(
|
||||
value: unknown,
|
||||
): PersistentDamageEntry[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const entries: PersistentDamageEntry[] = [];
|
||||
for (const item of value) {
|
||||
if (
|
||||
typeof item === "object" &&
|
||||
item !== null &&
|
||||
typeof (item as Record<string, unknown>).type === "string" &&
|
||||
VALID_PERSISTENT_DAMAGE_TYPES.has(
|
||||
(item as Record<string, unknown>).type as string,
|
||||
) &&
|
||||
typeof (item as Record<string, unknown>).formula === "string" &&
|
||||
((item as Record<string, unknown>).formula as string).length > 0
|
||||
) {
|
||||
entries.push({
|
||||
type: (item as Record<string, unknown>)
|
||||
.type as PersistentDamageEntry["type"],
|
||||
formula: (item as Record<string, unknown>).formula as string,
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries.length > 0 ? entries : undefined;
|
||||
}
|
||||
|
||||
function validateHp(
|
||||
rawMaxHp: unknown,
|
||||
rawCurrentHp: unknown,
|
||||
@@ -93,6 +123,7 @@ function validateCr(value: unknown): string | undefined {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const VALID_ADJUSTMENTS = new Set(["weak", "elite"]);
|
||||
const VALID_SIDES = new Set(["party", "enemy"]);
|
||||
|
||||
function validateSide(value: unknown): "party" | "enemy" | undefined {
|
||||
@@ -106,10 +137,18 @@ function parseOptionalFields(entry: Record<string, unknown>) {
|
||||
initiative: validateInteger(entry.initiative),
|
||||
ac: validateAc(entry.ac),
|
||||
conditions: validateConditions(entry.conditions),
|
||||
persistentDamage: validatePersistentDamage(entry.persistentDamage),
|
||||
isConcentrating: entry.isConcentrating === true ? true : undefined,
|
||||
creatureId: validateNonEmptyString(entry.creatureId)
|
||||
? creatureId(entry.creatureId as string)
|
||||
: undefined,
|
||||
creatureAdjustment: validateSetMember(
|
||||
entry.creatureAdjustment,
|
||||
VALID_ADJUSTMENTS,
|
||||
) as "weak" | "elite" | undefined,
|
||||
hpVariant: validateSetMember(entry.hpVariant, VALID_HP_VARIANTS) as
|
||||
| HpVariant
|
||||
| undefined,
|
||||
cr: validateCr(entry.cr),
|
||||
side: validateSide(entry.side),
|
||||
color: validateSetMember(entry.color, VALID_PLAYER_COLORS),
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { DomainEvent } from "./events.js";
|
||||
import { type HpRange, type HpVariant, hpForVariant } from "./hp-range.js";
|
||||
import {
|
||||
type CombatantId,
|
||||
type DomainError,
|
||||
type Encounter,
|
||||
findCombatant,
|
||||
isDomainError,
|
||||
} from "./types.js";
|
||||
|
||||
export interface SetHpVariantSuccess {
|
||||
readonly encounter: Encounter;
|
||||
readonly events: DomainEvent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches a combatant's max HP between the minimum, average and maximum roll
|
||||
* of its Hit Dice.
|
||||
*
|
||||
* The shift is applied as a delta so manual HP edits and damage already taken
|
||||
* survive the switch: a creature missing 5 HP still misses 5 HP afterwards.
|
||||
*/
|
||||
export function setHpVariant(
|
||||
encounter: Encounter,
|
||||
combatantId: CombatantId,
|
||||
variant: HpVariant | undefined,
|
||||
range: HpRange,
|
||||
): SetHpVariantSuccess | DomainError {
|
||||
const found = findCombatant(encounter, combatantId);
|
||||
if (isDomainError(found)) return found;
|
||||
|
||||
const { combatant } = found;
|
||||
if (combatant.hpVariant === variant) {
|
||||
return { encounter, events: [] };
|
||||
}
|
||||
|
||||
const delta =
|
||||
hpForVariant(range, variant) - hpForVariant(range, combatant.hpVariant);
|
||||
|
||||
const newMaxHp =
|
||||
combatant.maxHp === undefined
|
||||
? undefined
|
||||
: Math.max(1, combatant.maxHp + delta);
|
||||
const newCurrentHp =
|
||||
combatant.currentHp === undefined || newMaxHp === undefined
|
||||
? combatant.currentHp
|
||||
: Math.max(0, Math.min(combatant.currentHp + delta, newMaxHp));
|
||||
|
||||
return {
|
||||
encounter: {
|
||||
...encounter,
|
||||
combatants: encounter.combatants.map((c) =>
|
||||
c.id === combatantId
|
||||
? {
|
||||
...c,
|
||||
maxHp: newMaxHp,
|
||||
currentHp: newCurrentHp,
|
||||
hpVariant: variant,
|
||||
}
|
||||
: c,
|
||||
),
|
||||
},
|
||||
events: [
|
||||
{
|
||||
type: "HpVariantSet",
|
||||
combatantId,
|
||||
variant,
|
||||
previousMaxHp: combatant.maxHp,
|
||||
newMaxHp,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -14,12 +14,6 @@ export interface ToggleConditionSuccess {
|
||||
readonly events: DomainEvent[];
|
||||
}
|
||||
|
||||
function sortByDefinitionOrder(entries: ConditionEntry[]): ConditionEntry[] {
|
||||
const order = CONDITION_DEFINITIONS.map((d) => d.id);
|
||||
entries.sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id));
|
||||
return entries;
|
||||
}
|
||||
|
||||
function validateConditionId(conditionId: ConditionId): DomainError | null {
|
||||
if (!VALID_CONDITION_IDS.has(conditionId)) {
|
||||
return {
|
||||
@@ -67,8 +61,7 @@ export function toggleCondition(
|
||||
newConditions = filtered.length > 0 ? filtered : undefined;
|
||||
event = { type: "ConditionRemoved", combatantId, condition: conditionId };
|
||||
} else {
|
||||
const added = sortByDefinitionOrder([...current, { id: conditionId }]);
|
||||
newConditions = added;
|
||||
newConditions = [...current, { id: conditionId }];
|
||||
event = { type: "ConditionAdded", combatantId, condition: conditionId };
|
||||
}
|
||||
|
||||
@@ -125,10 +118,7 @@ export function setConditionValue(
|
||||
};
|
||||
}
|
||||
|
||||
const added = sortByDefinitionOrder([
|
||||
...current,
|
||||
{ id: conditionId, value: clampedValue },
|
||||
]);
|
||||
const added = [...current, { id: conditionId, value: clampedValue }];
|
||||
return {
|
||||
encounter: applyConditions(encounter, combatantId, added),
|
||||
events: [
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user